Unpushed isn't the same as safe: auditing git history for leaked secrets
Secrets deleted from your repo still live in git history and backups.

There is a sentence that shows up in code reviews and diligence calls with remarkable regularity: "It's fine, it's fine. It hasn't been pushed yet."
When the thing in question is a leaked secret or hardcoded credential, that sentence is not reassurance. It is a scope reduction. Those are different things, and treating them as the same is one of the more reliable ways to leave a real vulnerability unaddressed while believing the work is done.
"Not Pushed" Bounds the Problem. That's It.
Picture a developer who spots a hardcoded API key sitting in a config file the morning before a big code review. Stomach drops, then lifts. She exhales, deletes the line, commits the fix, and walks into the review feeling clean.
She is wrong. She just feels right. That gap is the actual problem.
Here is what "not pushed to remote" actually guarantees: the credential is not currently on the remote at that specific ref. That is the whole guarantee. It is about as complete as locking your front door while leaving every window open.
It does not account for:
- File-sync tools. Dropbox, OneDrive, Google Drive, iCloud. These sync
.gitdirectories. The full object store, including every commit that has ever existed on that machine, goes with them. - Local backups. Time Machine, rsync jobs, disk images. If a commit ever touched a credential, that commit object lives in
.git/objectsand gets swept into any backup that includes the repo directory. - Clone chains. If one engineer cloned from another engineer's local copy at any point (more common in early-stage environments than people admit), that history travels.
- Future pushes from unexpected commands. A
git push --mirror, agit push --all, a force-push during a rebase cleanup. Watching for one specific push command is not the same as watching for all of them.
The blast radius is smaller when something has not been pushed to a shared remote. Smaller is not zero.
Two Claims That Are Not the Same Claim
"The working tree is clean" means: right now, at HEAD, the file does not contain the credential.
"The history is clean" means: the credential has never appeared in any commit ever recorded in this repository.
A diligence process that checks the first and reports the second is giving false assurance. This happens more than it should. Checking HEAD is fast and obvious. Checking full history takes a deliberate extra step, and that step is easy to skip when you are moving quickly and everything looks fine on the surface.
The credential that got removed in a follow-up commit is still in the history. It is in the diff of the commit that introduced it. It is accessible via git log -p. It will be cloned by anyone who clones the repository, now or later. That deleted line did not disappear. It just moved somewhere nobody kept looking.
What Checking This Correctly Actually Looks Like
When auditing a codebase for secret hygiene, the working tree is the last place to look, not the only place.
- Full history scan. Run a tool like
gitleaks,trufflehog, or a well-constructedgit log -p | greppattern across every commit in the log. Every branch, not just main. - Stash inspection.
git stash listfollowed bygit stash show -pfor each entry. Stashes do not get pushed automatically, but they live in the object store. - Reflog awareness. Commits that were "deleted" via rebase or reset are often still reachable through the reflog. Until garbage collection runs, and GC timing is not something an auditor should assume, those objects still exist.
- The
.gitdirectory itself. Config files, credential helpers configured locally, anything written to.git/configor adjacent files.
The current diff or HEAD tree state is, at most, one input into this process. It is not the conclusion.
The Failure Mode Worth Naming
The failure mode here is not malice. Nobody is trying to cut corners. It is narrowed scope getting treated as full clearance.
Someone checks that the current version of the file looks fine, notes it in the review, and moves on. The credential stays in the history undetected. The repository gets pushed, cloned, forked, or backed up, and the credential goes with it. Clean-looking surface, dirty history underneath.
The tools to catch this are free, widely available, and take minutes to run. gitleaks and trufflehog are not obscure, and they are not hard to use. Full-history scanning gets skipped not because it is technically difficult, but because the phrase "it hasn't been pushed yet" arrives early in the conversation and quietly closes it.
A clean working tree with a dirty history is still a dirty repository. Any review process that cannot tell the difference between those two states is not checking secret hygiene. It is checking the surface and calling it a day.

