I audited 138 of my own GitHub repos
A token that had been live for nine months, a secret that a path-list scan missed and a pattern scan caught, and why rotating the credential matters more than scrubbing the history.
- Security
- Tooling
I have 138 repositories. Most are small, many are years old, and a few were written in an evening to test one idea and never touched again. I audited all of them for committed secrets, expecting to find nothing, mostly to justify the tooling.
I found things.
The nine-month-old token
The worst one was an API token in a .env file, committed to a private repository, still valid. It had been there for nine months.
Private, so nobody outside had read it. But "private" is a property of the repository today, not of every place that repository has ever been. Between the commit and the audit, that history had been on a laptop I no longer own, in a CI cache, and inside at least two clones I made and forgot.
The token was for a service with a free tier and no billing attached, which is exactly why it survived. Nothing broke. No alert fired. There is no feedback loop that tells you a credential has leaked when the credential is not doing anything expensive.
The one a path scan missed
My first pass was the obvious one: list every file that has ever existed in history, match the paths against a list of things that should not be there.
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(rest)' \
| awk '$1 == "blob" { print $3 }' \
| sort -u \
| grep -E '(^|/)(\.env|\.env\..*|id_rsa|.*\.pem|.*\.p12|credentials\.json)#x27;That found several .env files and gave me a satisfying number to report. It also missed the most interesting finding entirely.
The miss was a file named .env.production. My pattern had \.env\..* in it, so it should have matched — but the file was committed inside a config/ directory in one repo and at the root in another, and my earlier, narrower version of this pattern was anchored to the root. I had already "fixed" the pattern by the time I ran the content scan, but the content scan is what actually told me the file mattered, because the path scan only tells you a file has a suspicious name.
That is the real lesson: a path scan tells you where to look, a content scan tells you what you have. A file called .env.example matches every path heuristic and contains nothing. A file called config/settings.local.js matches nothing and contains a live key.
So the second pass reads blobs and matches against value shapes:
git rev-list --objects --all \
| git cat-file --batch-check='%(objecttype) %(objectname) %(rest)' \
| awk '$1 == "blob" { print $2, $3 }' \
| while read -r sha path; do
git cat-file blob "$sha" 2>/dev/null \
| grep -nHE '(AKIA[0-9A-Z]{16}|gh[pousr]_[A-Za-z0-9]{36,}|sk-[A-Za-z0-9]{32,}|-----BEGIN [A-Z ]*PRIVATE KEY-----)' \
| sed "s|^(standard input)|$path@$sha|"
doneIt is slow and it produces false positives — a base64 blob in a fixture file will trip the entropy-shaped patterns, and every sk- in a README example gets flagged. That is the correct trade. A false positive costs thirty seconds to dismiss; a false negative is a live credential.
Rotate first. Scrubbing history is the part that does not matter.
My instinct on finding the token was to rewrite history and make it never have happened. git filter-repo, force-push, done.
That instinct is backwards, and following it wastes the only time that matters.
The credential is compromised the moment it is committed. Not when the repo goes public, not when someone finds it. You cannot know who has cloned, forked, cached or mirrored that history. GitHub keeps unreachable objects accessible by SHA for a while after a force-push. Rewriting history changes what is convenient to find. It does not change what is out.
So the order is:
- Rotate the credential. Now, before anything else. This is the only step that changes the security situation.
- Check the logs, if the service keeps them, for use you do not recognise.
- Then decide about history — and it is now a housekeeping decision with no deadline, not an incident response.
I did rewrite the history afterwards, for the same reason I clean up dead code: the next person to read this should not find a secret-shaped string and have to work out whether it is live. But it was an hour of tidying, not a fix.
Prevention, ranked by how much it actually helped
A pre-commit hook that reads staged content. The only control that stops the commit rather than reporting it later. Cheap and it has caught me twice since.
.gitignore as a template, not per-project. Every one of these leaks came from a repo where I did not bother, because I was moving fast and it was "just a test". A global gitignore covering .env* (except .env.example) removes the decision.
A scheduled scan. Runs weekly, opens an issue when it finds something. Low value compared to the hook — by the time it fires the secret is already committed — but it is the thing that catches the repos you are not actively working in, which are exactly the ones that hide a nine-month-old token.
Short-lived credentials wherever the service offers them. The most effective control by far, because it makes the whole class of mistake self-healing. A token that expires in an hour is not much of a finding.
The number
Across 138 repositories: 4 real secrets, 3 of them in repos I had not opened in over a year, 1 still valid.
One in 138 is a low rate and it is also completely irrelevant. The distribution does not matter. One live credential is the finding, and it was in the repository I would have been least likely to check by hand.