You Deleted the Secret - It's Still in Git History

You Deleted the Secret - It's Still in Git History
Photo by Stefan Steinbauer / Unsplash

The questionnaire asks whether you scan for secrets. You do. GitHub secret scanning is on. The last report shows no findings on main. On paper, the control exists.

That report is almost certainly looking at the wrong place.

Secret scanning that only inspects the current tree, or only the tip of the default branch, will miss credentials that were committed, then “fixed” by deleting the file or rewriting the line. Git still has the blob: the stored file content that remains in the object database even after you delete the path from main. Anyone with a clone can git show an old commit and recover the value. If the token was never revoked at the issuer, it still works.

That is the audit gap: a green checkbox on HEAD is weak evidence of remediation. “We removed it from the repo” does not mean “it cannot be used.”

This article goes trough a walk history with a scout, present hits without re-leaking them, rotate anything that ever appeared, then put prevention in front of the next push so the next audit is not a fire drill.

Check history, not just the current tree.


A scout that walks history

HEAD-only scanners answer a convenient question: “Is there a secret in the tree we have checked out right now”? The audit question is harder: “Is there a secret anywhere this repository has ever stored that someone with a clone can still read”?

A history-aware scout is built for the second question. In outline, the Python script does four things:

  1. Enumerate the GitHub repos you care about (your user, an org, or an explicit list).
  2. Clone or fetch enough history to see past commits, not a shallow tip-only checkout if you want real coverage.
  3. Walk commits and the blobs they introduce (or that remain reachable), and run pattern matches against blob content.
  4. Emit hits with enough context to act: repo, commit, path, and a redacted excerpt, never a paste-ready secret in a log you will forward upstream.

A hit means the value is still in the object database, whether or not it sits on main today. Anyone who can clone the repo (or who already did) can recover it with git show <commit>:path or an equivalent. Treat that as public for risk purposes until the credential is dead at the issuer.

The companion code lives in nunombispo/github-scout. It is intentionally small: GitHub API for discovery, local git for history, a short pattern list, redacted table output. The scan loop looks like this (simplified: the real scout reads blobs as bytes so binaries do not crash the run):

# For each reachable commit, open each blob and match patterns.
for commit in run(["git", "rev-list", "--all"], cwd=repo_dir).splitlines():
    for path in run(["git", "ls-tree", "-r", "--name-only", commit], cwd=repo_dir).splitlines():
        blob = run(["git", "show", f"{commit}:{path}"], cwd=repo_dir)
        for pattern in PATTERNS:
            for match in pattern.regex.finditer(blob):
                emit(repo, commit, path, pattern.name, redact(match.group(0)))

Run it against your own repositories (needs git, Python 3.10+, and a read-scoped GITHUB_TOKEN):

git clone https://github.com/nunombispo/github-scout.git
cd github-scout/scout
export GITHUB_TOKEN=...
python scout.py --user YOUR_USER
# or: python scout.py --org YOUR_ORG
# or: python scout.py --repo owner/name --json

You do not need a novel architecture to prove the point on your own repositories. A small script that uses the GitHub API for discovery and local git for history is enough to produce findings an auditor will understand. The craft is in the matchers and in the reporting discipline, not in reinventing git.


Build vs buy

If you are hunting commodity shapes, like AWS keys, GitHub PATs, Slack tokens, generic high-entropy string, prefer what you already pay for or can adopt quickly: GitHub secret scanning and push protection, plus something like gitleaks (or a similar pre-commit/CI scanner) on the developer path.

DIY only earns its keep when you have org-specific formats the defaults miss: internal token prefixes, proprietary API key layouts, partner credentials with a house style.

In that case, a thin custom scout (or custom rules on top of an existing engine) is justified. If you cannot name the pattern gap, buy or reuse; do not maintain a scanner for sport of it.

The output of this step is a list of historical hits you can stand behind. The next problem is showing that list to other humans without leaking the secrets again.


What I found in my repositories

A small demo checkout (demo-leaky-history/ in this article’s repo) committed a fake .env, then deleted it. Current main has no .env. History still does.

Point github-scout at it with python scout.py --path /path/to/demo-leaky-history. The scout output:

Scanning demo-leaky-history …
REPO                         COMMIT       PATTERN                PATH                             REDACTED
------------------------------------------------------------------------------------------------------------------------
demo-leaky-history           ec824970bd74 aws_access_key_id      .env                             AKIA••••MPLE
demo-leaky-history           ec824970bd74 generic_api_assignmen  .env                             a8e0••••52db

2 finding(s). Values are redacted — rotate at the issuer.

Tip clean, history hits. The values are intentional fakes (AWS docs examples plus invented hex), so you can clone and re-run safely.

A scan of my own public GitHub user (nunombispo) produced the usual mix.

Most of the output was noise: README placeholders (your_api_token, <your_api_key>), tutorial .env stubs (YOUR_API_KEY), and false positives inside a committed .venv.

A few hits were hex-looking values next to an API-key assignment, still reachable from old commits. For each, I checked the current default-branch tip as well as history:

What showed up Where? On current main? Notes
API key assignment 315a••••52db nunombispo/Unstract-AI-Powered-Classification · app.py · e.g. 6496456c9e03… No - tip now uses os.getenv("API_KEY") Same redacted value across older commits; scrubbing the tip did not remove those blobs
Config-looking b03f••••0a3a nunombispo/iTextPdfSdk · iTextPdfSdk/App.config · e.g. e375c6e28c3a… Yes - still on tip A .NET publicKeyToken (assembly identity), not an API key
API key assignment a8e0••••e660 nunombispo/GuideTesseractOCR · llmwhispherer_*.py · 4b36aece17c5 No - tip shows api_key='<api_key>' History still has the hex; tip only has a placeholder

Ship a sample, paste a real key “just for the screenshot,” maybe scrub the working tree later. main can look fine. History still has the blob.

Until that value is revoked at the issuer, treat it as usable by anyone who cloned the repo. Tip status tells you whether it is still in the tree.

When you report findings to Slack, a ticket, or an auditor, report handles, not secrets:

  • Repo and commit SHA
  • Path and (if useful) line range
  • Secret type or pattern name (e.g. “AWS access key id”, “GitHub PAT”)
  • A redacted excerpt (AKIA••••, ghp_••••) if you must show shape
  • Status: revoked / rotation in progress / false positive

Do not paste the raw value into the channel that is supposed to be fixing the leak. That turns an incident report into a second distribution event. Your scout’s default output should make the safe report the easy report.

Findings without follow-through are an awkward inventory. The next step is making every real hit unusable.


After a hit - rotate and invalidate

Once a secret has appeared in git history, treat it as compromised. The remediation that matters happens at the issuer: revoke the key, rotate the token, invalidate the webhook, disable the credential. Until that is done, every clone, including ones you do not control, is a possible copy.

Deleting the file from main, amending the tip commit, or opening a PR that removes the line does not change that fact. Those steps reduce future accidental use of a bad tree. They do not retire a credential that already leaked into an object someone can still fetch.

Rewriting history (filter-repo, BFG, force-push) is sometimes useful for hygiene on a private repo with a small, coordinated set of clones. On a public repo, or anywhere forks and CI caches exist, it is mostly theater. People who already cloned keep the old objects. Search indexes and mirrors may keep them too. Do the rewrite if your policy requires a cleaner default branch; do not confuse it with invalidation.

What an auditor should accept as remediation evidence:

  • Confirmation the credential was revoked or rotated at the issuer (ticket, console screenshot policy, API audit log: whatever your org uses)
  • A short note that historical copies may still exist in clones, and why that is acceptable because the secret no longer works
  • The controls you will put in front of the next push (next section)

What is weak evidence: “we deleted it from the repo” with a clean HEAD and no issuer-side action.

Inventory without revocation is unfinished work. Prevention without revocation leaves the already-leaked value live. Revoke first, then harden the path so the next leak is harder.

Next steps are to assign an owner per hit, open the issuer console (AWS IAM, GitHub token settings, Slack app credentials: wherever that credential was minted), revoke or rotate there, and only then mark the ticket done.

A clean git tree is a follow-up chore, not the finish line. For how keys should be stored after that (env vars, secrets managers, never in source), see How to Hash Passwords in Python and Encrypt Sensitive Data the Right Way.


Prevention - so the next audit isn't a fire drill

Discovery and rotation clean up what already leaked. Prevention is how you stop paying that tax on every release.

Put controls on three layers, in this order of preference:

  1. Before the push leaves the laptop. A pre-commit (or pre-push) hook running something in the gitleaks / TruffleHog class catches commodity secrets while the author still has context. Failures here are cheap. Failures in production CI are expensive and often ignored under deadline pressure.
  2. At the Git host. Turn on GitHub secret scanning and push protection (or the equivalent on your forge). Rejecting a push that contains a known secret pattern is the control auditors understand. It also closes the gap for people who skipped the local hook.
  3. In CI, as a backstop. Scan pull requests and default-branch builds so a misconfigured laptop or a bot account cannot quietly land secrets. CI should not be your only line: by the time the pipeline runs, the secret may already be in a fork, a cache, or a teammate’s fetch.

None of this replaces history-aware discovery. Push protection does not erase yesterday’s blob. Schedule a periodic scout (or an equivalent history scan) against the repos that matter, especially after incidents and before audits.

Pair it with a rotation playbook so a hit has an owner and a clock. Keeping AWS and host credentials in a .env loaded at runtime (never committed) is the boring half of that discipline; a worked example is in Automating Long-Term Backup — Hetzner Storage Box to AWS S3 Glacier Deep Archive.

For leadership, the system that holds up under questioning is simple to state: we can find secrets in history, we revoke what we find, and we block the common cases on the way in. For engineers, that system is the scout, the issuer console, and the hooks :not a green checkbox on main.

If the next questionnaire asks whether you scan for secrets, answer yes only if that includes history, revocation, and the controls above.


Follow me on Twitter: https://twitter.com/DevAsService

Follow me on Instagram: https://www.instagram.com/devasservice/

Follow me on TikTok: https://www.tiktok.com/@devasservice/

Follow me on YouTube: https://www.youtube.com/@DevAsService

Nuno Bispo

Nuno Bispo

Solutions Architect · Senior Python & AI Engineer · AI Audits · Helping teams fix what they shipped too fast
Netherlands