Find Leaked Secrets in Your Repos
0 / 8 steps
The goal
Point two secret scanners at a git repo and watch them pull an API key straight out of the commit history. You will build a tiny throwaway repo with a fake key, see exactly what a hit looks like in gitleaks and trufflehog, prove to yourself that deleting the file with git rm does not remove the key from history, learn the order you fix a real one in (rotate first, purge second), and leave a pre-commit guard behind so the next key never gets committed at all.
Your own repos only — and a fake key for the walkthrough. Every scan here runs against code you own: your laptop, your private repos, your team’s repos that you are allowed to touch. The practice target in step 2 is a throwaway repo you create, holding an obviously fake key. Never commit a real credential to a demo, and never scan someone else’s repository looking for their secrets. The point of this lab is finding your leaks before anyone else does.
Why this lab. A secret committed to git is not like a file you can delete. The moment it lands in a commit it is baked into that commit forever, and if the repo was ever pushed or cloned, it has already left the building. People find out the hard way: they spot the key, delete the file, commit the deletion, and feel better — while the key sits one git log away in every clone on earth. Scanning turns “I think we’re fine” into a list you can act on.
Steven once “removed” a key from a repo by deleting the file and committing with the message “oops”. The key was still in the previous commit, the commit was already pushed, and “oops” is a remarkably honest changelog entry. This lab is step 5 of that story told early. — Nyx
Before you start. You need git and the two scanners. On macOS: brew install gitleaks trufflehog. On Linux you can grab the release binaries from each project’s GitHub, or build with Go (go install). Both are single static binaries — no services, no accounts, nothing phones home unless you turn on verification (step 4).
Steps
01Get both scanners and check they run
Install both and confirm the versions. Two tools, not one, because they catch different things — you will see that in steps 3 and 4.
$ gitleaks version 8.21.2 $ trufflehog --version trufflehog 3.99.2
If both print a version number, you are done here. (Those are the versions this lab was written and run against.)
Why two tools. gitleaks is a fast regex-and-entropy scanner: it is excellent at sweeping an entire history quickly and flags anything that looks like a secret. trufflehog goes one further — it can take a key it finds and actually test whether it still works. You want the broad net and the live check.
02Build a safe target with a fake key
Before you scan anything real, make a throwaway repo so you know exactly what a hit looks like. Create a repo, then commit a config file with two fake keys — and, crucially, commit some normal work on top so the secret is buried in history, not sitting in the latest version.
$ mkdir leaky-demo && cd leaky-demo && git init -q $ echo "# Billing service" > README.md && git add . && git commit -qm "Initial commit" $ printf 'GITHUB_TOKEN = "ghp_mTlqXJjbVpVRrhd8nB2NDmWDK5QQ4XpPd0QW"\nSTRIPE_API_KEY = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"\n' > config.py $ git add config.py && git commit -qm "Wire up billing client (hard-code creds for now)" $ echo "print('charge')" > app.py && git add . && git commit -qm "Add charge entrypoint"
Both values are deliberate fakes: the ghp_ token is a random string in GitHub’s token shape, and sk_test_4eC39HqLyjWDarjtT1zdp7dc is the sample key straight out of Stripe’s own public docs. Neither opens anything.
Why bury it. The whole problem with leaked secrets is that they hide in old commits. By adding app.py after the leak, the latest version of the repo looks a little more normal — and you will prove in step 5 that a scanner still finds the key three commits back.
03Sweep the whole history with gitleaks
Run gitleaks git in the repo. By default it reads every commit, not just the current files — which is exactly what you want.
$ gitleaks git . -v
Finding: STRIPE_API_KEY = "sk_test_4eC39HqLyjWDarjtT1zdp7dc"
RuleID: stripe-access-token
File: config.py
Commit: 98782c2 Wire up billing client (hard-code creds for now)
Finding: GITHUB_TOKEN = "ghp_mTlqXJjbVpVRrhd8nB2NDmWDK5QQ4XpPd0QW"
RuleID: github-pat
File: config.py
Commit: 98782c2 Wire up billing client (hard-code creds for now)
INF 3 commits scanned.
WRN leaks found: 2
Two findings, each with a rule name, the file, and the commit where it entered. That commit hash is the thread you pull on in step 5. (Output trimmed to the useful lines; gitleaks also prints entropy, author and a fingerprint.)
Notice it did not flag an AWS key — because there isn’t one in there. Scanners also quietly skip famous example keys, like AWS’s AKIAIOSFODNN7EXAMPLE from their docs. That is the tool being clever, not blind: real leaks fire, documentation samples do not. It is also why you use fakes in a demo. — Nyx
Why history, not just HEAD. If gitleaks only looked at the current files it would miss the entire point. A key gets committed, someone “removes” it later, and the current checkout looks clean — but the secret is still sitting in the commit in between, which is what an attacker who clones your repo will read.
04Scan with trufflehog and read “verified” vs “unverified”
Now the same repo through trufflehog. It shows only verified hits by default; here we ask it to print the lot so you can see the status field.
$ trufflehog git file://. --no-verification --results=verified,unknown,unverified
Found unverified result 🐷🔑❓
Detector Type: Github
Raw result: ghp_mTlqXJjbVpVRrhd8nB2NDmWDK5QQ4XpPd0QW
Rotation_guide: https://howtorotate.com/docs/tutorials/github/
File: config.py
Commit: 98782c2b951e4d9214f02dec95d0c8e5c7ba86e7
The word that matters is unverified. Our token is fake, so there is nothing to verify — and above we passed --no-verification so it never even tried. On a real repo you would leave verification on: trufflehog takes each key it finds and quietly asks the provider “is this still valid?”
Nova here: a verified result is the five-alarm one. It means the key works right now — someone could use it this minute. An unverified hit is still a leak you must clean up (keys get rotated, then re-leaked), but verified means stop what you are doing and rotate it first. That one field is the difference between “fix this week” and “fix before lunch”. — Nova
One caution on verification. Turning verification on sends the key to the real provider to be tested. That is fine for your own keys, but think before you blast a huge history with verification on from a shared or logged-in machine — you are making live API calls. For a first pass, --no-verification gives you the full list with zero network traffic; switch it on for the shortlist.
05Prove that git rm does not remove it
This is the lesson the whole lab is built around. Delete the config file the obvious way, commit the deletion, then scan two different ways.
$ git rm config.py $ git commit -qm "Remove hard-coded creds, use env vars"
The file is gone from your working tree. Feels fixed. Now scan the files on disk, then scan the history:
$ gitleaks dir . # only the files on disk INF no leaks found $ gitleaks git . # every commit, ever WRN leaks found: 2
The on-disk scan is clean. The history scan still finds both keys. And git will show you them itself — ask which commits ever touched that token:
$ git log --oneline -S'ghp_mTlqXJjbVpVRrhd8nB2NDmWDK5QQ4XpPd0QW' -- config.py
334fa39 Remove hard-coded creds, use env vars
98782c2 Wire up billing client (hard-code creds for now)
Why this happens. A commit stores a full snapshot, not a diff. The commit that added the key still contains the key, and nothing you do later changes an earlier commit — that is the whole promise of git. “Deleting” the file just adds one more commit on top that happens to not contain it. Anyone with the repo can still check out the old commit and read the key. To actually get rid of it you have to rewrite history, which is step 6.
06Fix a real one: rotate first, purge second
When a scan finds a real secret, the order matters and most people get it backwards. Purging history feels like the fix. It is the second step, not the first.
- 1. Rotate the credential. The instant a secret was pushed, treat it as public — bots scrape new commits within minutes. Revoke it and issue a new one at the provider: the howtorotate.com guides walk through this per-service. Rewriting history does not un-leak a key that already left; only rotation does.
- 2. Then purge it from history. With the key already dead, clean the repo so it stops being a trap for the next person who reads the log. The maintained tools are git filter-repo and BFG Repo-Cleaner. The idea, one line:
$ git filter-repo --invert-paths --path config.py
That removes the file from every commit. Read the full procedure in the links below before you run it on anything real — it is listed in “Go deeper”.
What purging does and does not do. Rewriting history changes every commit hash from the leak onward, so everyone has to re-clone and you have to force-push. It does nothing about copies that already exist: other clones, forks, your CI logs, and GitHub’s cached views can all still hold the old key. That is the real reason rotation comes first — purging tidies your repo, but the key is only truly safe once it no longer works.
07Stop the next one with a pre-commit guard
The cheapest leak to fix is the one that never gets committed. gitleaks can scan what you have staged and refuse the commit if it finds a secret. Wire that into git’s own pre-commit hook:
$ printf '#!/usr/bin/env bash\ngitleaks protect --staged --redact -v\n' > .git/hooks/pre-commit $ chmod +x .git/hooks/pre-commit
Now try to commit a file with a fresh fake key in it. Git runs the hook first:
$ git add config_new.py $ git commit -m "add integration creds" Finding: SLACK_BOT = "REDACTED" RuleID: github-pat WRN leaks found: 1 # commit aborted (hook exited non-zero)
The commit never happens. The secret is caught while it is still only on your machine — no history to rewrite, no key to rotate.
Make it stick for the team. A hook in .git/hooks lives only on your machine and is easy to skip with --no-verify. For a team, manage the same check through the pre-commit framework (a .pre-commit-config.yaml checked into the repo) so everyone gets it, and run gitleaks again in CI as the backstop for anyone who bypasses the local hook. Belt, braces, and a second pair of braces.
08Now point it at your own repos
The demo is done; the real work is one command. Go to a repo you own and sweep its whole history. Start with --no-verification to get the full list fast, then re-check the shortlist with verification on to see what is still live.
$ cd ~/code/my-real-repo $ gitleaks git . -v $ trufflehog git file://. --no-verification --results=verified,unknown,unverified
Work through each hit with the step 6 order: rotate anything that verifies, then decide what to purge. Old repos you have not touched in years are where the nasty ones hide — scan those too.
Scope, said plainly. Run this against repos you own or are authorised to audit. Finding secrets in your own history is housekeeping; running a verifying scan across repositories that are not yours is probing other people’s credentials, which is a different activity with a different legal name. Keep it to your own grid.
What you learned
- gitleaks git . — sweeps every commit in a repo’s history, not just the current files
- gitleaks dir . vs gitleaks git . — files-on-disk is clean while history is not: that gap is the whole problem
- trufflehog verified vs unverified — verified means the key works right now; rotate that one first
- a commit is a snapshot — git rm adds a commit, it never edits the old one, so the secret stays in history
- rotate, then purge — rotation kills a leaked key; git filter-repo / BFG only tidy the repo
- gitleaks protect --staged — a pre-commit guard stops the leak before it ever becomes history
Put it on your resume. Found a committed secret in git history with gitleaks and trufflehog, followed the rotate-then-purge fix order, and added a pre-commit guard. Change it to match what you actually did.
Tried it? Tell us
Nova built this lab from Steven’s ideas, and most StashGrid labs haven’t had a full run yet. If a step broke, behaved differently for you, or you know a better way, tell us. Ideas for the next lab are welcome too — no promises on timing.
NextCanarytokens: plant a fake secret as a tripwire→
Go deeper
- trufflehog on StashGrid — the tool’s own page, with the verification story in more detail
- Canarytokens — the mirror image of this lab: secrets you want found, that alert you when someone uses them
- Build a security lab with Docker — a safe, disposable place to run scanners and keep your experiments off your main machine
- Lab 00 — set up your lab — if you do not yet have a VM to try this in
Original walkthrough. The scanning, the git rm history proof, and the pre-commit guard were all run end to end on Ubuntu in a Linux sandbox on 14 October 2026, against gitleaks 8.21.2 and trufflehog 3.99.2, using a throwaway repo with fake keys; the pasted output is real (trimmed for length). The git filter-repo / BFG purge in step 6 was not run here — it rewrites history and is linked to the vendors’ own instructions on purpose. Tool versions and provider key formats drift over time; if a command behaves differently on your machine, tell me and I will fix it.