Secret Scanning & Remediation Jump to heading
Every repository of any age contains a credential somewhere in its history. Not because people are careless, but because the failure is easy — a config file added before it was gitignored, a test fixture with a real token, a debugging session that committed an environment file — and the consequence is delayed. A credential in history is live until it is rotated, whatever the repository’s visibility, because clones, forks, caches and mirrors are all outside your control. This part of Commit Signing & Git Supply-Chain Security covers detection, the response order that matters, and keeping the signal usable.
Prerequisites Jump to heading
The Response Order That Matters Jump to heading
Almost every mistake in handling a leak comes from doing these in the wrong order. Rotation is first because it is the only step that actually removes the exposure; everything else is cleanup.
Step 1 — Scan History, Not Just the Working Tree Jump to heading
A working-tree scan finds what is there now. Most leaks are in commits from two years ago.
# gitleaks over the full history
gitleaks detect --source . --report-format json --report-path /tmp/leaks.json
jq 'length' /tmp/leaks.json # Summarised by rule and by file, which is more actionable than the raw list
jq -r 'group_by(.RuleID)[] | "\(length)\t\(.[0].RuleID)"' /tmp/leaks.json | sort -rn
jq -r '.[].File' /tmp/leaks.json | sort | uniq -c | sort -rn | head # Verification: each finding names a commit you can inspect
jq -r '.[0] | "\(.Commit) \(.File):\(.StartLine) \(.RuleID)"' /tmp/leaks.json
git show --stat "$(jq -r '.[0].Commit' /tmp/leaks.json)" The choice of scanner matters less than running one at all; the comparison is in choosing a secret scanner for git history.
Step 2 — Triage Findings by Whether They Are Live Jump to heading
A scanner reports candidates, not confirmed leaks. Most first scans return a majority of false positives, and treating all of them as incidents is how scanning gets abandoned.
# Group by whether the value still exists in the working tree
jq -r '.[] | "\(.File)\t\(.Secret[0:12])..."' /tmp/leaks.json | while IFS=$'\t' read -r f s; do
grep -qrF "${s%...}" . --exclude-dir=.git 2>/dev/null \
&& echo "PRESENT $f" || echo "historic $f"
done | sort | uniq -c # Test whether a candidate is actually valid — the only definitive triage
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $CANDIDATE" https://api.example.com/v1/me SAFETY WARNING — do not paste a suspected credential into a chat, a ticket, a search engine or a paste service while triaging. Those are all places it becomes more exposed than it was, and several of them are indexed. Refer to findings by commit and file, test validity against the issuing service directly, and record only that a credential of a given type was found.
Step 3 — Rotate, and Confirm the Old Value Is Dead Jump to heading
# Whatever the credential type, the sequence is the same
# 1. issue a new one, 2. deploy it, 3. revoke the old one, 4. verify
curl -s -o /dev/null -w '%{http_code}\n' \
-H "Authorization: Bearer $OLD_VALUE" https://api.example.com/v1/me # expect 401 # Record what was rotated and when, outside the repository
printf '%s\tapi-token\trotated\t%s\n' "$(date -u +%FT%TZ)" "INC-4471" >> ~/incidents/rotations.tsv # Verification: the new credential works and the old one does not
curl -s -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer $NEW_VALUE" https://api.example.com/v1/me Until that 401 comes back, nothing else you do matters. The full incident sequence, including the case where rotation is slow, is in responding to a leaked credential.
Step 4 — Decide About History Deliberately Jump to heading
Rewriting is expensive and sometimes right. The deciding question is not “is the secret in history” but “what does removing it buy now that the credential is dead”.
# What would a rewrite touch?
git log --all --format='%H' -S"$LEAKED_FRAGMENT" | wc -l
git rev-list --count --all # If rewriting: mirror first, always
git clone --mirror . ../backup-before-rewrite.git
git filter-repo --replace-text /tmp/replacements.txt # Verification: the value is gone and the mapping is published
git log --all -S"$LEAKED_FRAGMENT" --oneline | wc -l # expect 0
wc -l .git/filter-repo/commit-map Rewriting is justified when the repository is about to become public, when a compliance requirement demands it, or when the credential cannot be rotated quickly. It is not justified as a reflex — the mechanics and the fallout are in removing a leaked secret from git history.
Step 5 — Scan Incoming Changes, Where It Is Cheap Jump to heading
Catching a credential before it is pushed costs nothing; catching it afterwards costs an incident.
# A pre-commit hook, on the staged content only
gitleaks protect --staged --redact --verbose # And in the pipeline, over the range the change introduces
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- run: gitleaks detect --log-opts="$(git merge-base origin/main HEAD)..HEAD" --redact # Verification: a test credential is refused locally
printf 'AWS_SECRET_ACCESS_KEY=AKIAIOSFODNN7EXAMPLE\n' > /tmp/t.env
git add -f /tmp/t.env 2>/dev/null; gitleaks protect --staged --redact | tail -3 Keeping the Signal Worth Reading Jump to heading
A scanner that reports sixty findings a week is a scanner nobody reads, and the quickest route to that state is refusing to allowlist anything. Test fixtures need example credentials, documentation needs illustrative values, and a scanner that flags them on every run trains people to approve their way past the whole category — including the day it is real.
The discipline that keeps this working is to allowlist narrowly and to record why. An allowlist entry scoped to one path and one rule, with a comment naming the reason, is auditable; a blanket exclusion of test/** is a place for a real credential to hide forever. The tension is genuine — every allowlist entry is a small hole — and the resolution is that a narrow, documented hole is better than a scanner everyone ignores. The mechanics are in allowlisting test fixtures without blinding the scanner.
The second habit is to treat detection rules as something that changes. Scanners add patterns as new credential formats appear, which means a full-history scan run today can find something that a scan a year ago could not. Running the full scan on a schedule rather than once is what turns that into a benefit rather than a missed opportunity, and it costs nothing because it runs unattended.
Finally, keep the findings somewhere that is not the repository. An issue describing which file and commit contained a credential is a map to it, and if the repository is public or later becomes public that map is available to everyone. A private incident record, referenced by an identifier, gives the same traceability without the exposure.
Configuration Reference Jump to heading
| Layer or setting | Effect | When to use |
|---|---|---|
gitleaks protect --staged | Scans staged content pre-commit | Every developer machine |
gitleaks detect --log-opts | Scans a commit range | Pull request pipelines |
| Full-history scan | Scans every commit | Scheduled, monthly or weekly |
| Allowlist by path and rule | Suppresses a known false positive | Narrowly, with a recorded reason |
--redact | Keeps the value out of logs | Always, in CI |
| Forge-native scanning | Provider-side detection and alerts | In addition to, not instead of |
| Push protection | Rejects a push containing a secret | Where the provider offers it |
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Hundreds of findings on first run | Untuned rules against test fixtures | Triage, then allowlist narrowly |
| Scanner passes but a leak got through | Only the working tree was scanned | Scan history and the commit range |
| Findings contain the secret in CI logs | --redact not used | Add it; rotate anything already logged |
| Nobody reads the report | Too many false positives | Tune first, then re-enable blocking |
| Leak found in a fork’s pull request | Local hooks do not apply to forks | Pipeline scan covers it |
| Rewrite done but the credential still works | Rotation was skipped | Rotate now; the rewrite changed nothing |
Frequently Asked Questions Jump to heading
Is a private repository safe from this? Jump to heading
Less exposed, not safe. Private repositories are cloned to laptops, forked within an organisation, backed up, mirrored into CI caches and occasionally made public by mistake. The response order does not change with visibility, because the credential’s validity does not depend on who can currently read the repository.
Do we have to rewrite history every time? Jump to heading
No, and treating it as mandatory is what makes teams delay reporting leaks. Rotation ends the exposure; a rewrite removes the trace. Rewrite when the repository is going public, when a policy requires it, or when rotation is genuinely impossible — and accept the fallout knowingly when you do.
What about secrets in CI logs rather than in the repository? Jump to heading
Same order: rotate, then clean up. Most providers can delete logs and most cannot guarantee that no copy exists, which is the same reason rotation comes first for repository leaks. Adding --redact and masking values in the pipeline prevents the recurrence.
Should push protection be enabled? Jump to heading
Where the provider offers it, yes — it is the only layer that stops the credential reaching the server at all, which removes the question of what else has a copy. Pair it with a documented bypass path, because it will occasionally block a legitimate value and a silent bypass is worse than a noisy one.
Who should own the response when a scanner fires? Jump to heading
Whoever can rotate the credential, which is frequently not the person who found it. That mismatch is the main reason leaks sit unaddressed: an engineer sees a finding in a repository they do not own, for a credential belonging to a team they cannot name, and the finding becomes a ticket rather than an action.
The fix is unglamorous and effective: record, per credential type, who can rotate it and how quickly. A short table naming the cloud roles, the registry tokens, the third-party API keys and the person or rota responsible for each turns the first ten minutes of a response from an investigation into a lookup. It is worth writing before it is needed, because the moment it is needed is the moment nobody has time to compile it.
The second half is the escalation path for the case where the owner is unavailable. A credential that can only be rotated by one person on holiday is a single point of failure that the scanner has just made visible, and treating that as a finding in its own right is usually more valuable than the leak that exposed it.
Related Jump to heading
- Choosing a Secret Scanner for Git History — comparing the tools on what actually differs.
- Responding to a Leaked Credential — the incident sequence, in order.
- Allowlisting Test Fixtures Without Blinding the Scanner — keeping the findings readable.
- Scanning Pull Requests for Secrets in CI — the layer that covers every contributor.
- Rotating Credentials After a History Rewrite — why the rewrite is not the remediation.