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.

The order a leak response has to followRotate the credential first, because that is the only action that ends the exposure. Then remove it from the working tree so it cannot be re-committed, then decide whether history needs rewriting, and finally add a check so the same class of leak is caught next time.Rotatethe only real fixminutes, not daysRemove from HEADstop re-committingDecide on historyrewrite or acceptPreventscan incoming changesa history rewrite before rotation protects nothing and takes hours

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.

What a first full-history scan typically returnsMost findings are test fixtures, example values and expired credentials. A minority are real and still live, and those are the ones the process exists for — which is why triage has to happen before anyone starts rewriting history.findings from a first full-history scantest fixtures and examples61real but already rotated18real and still valid4ambiguous, needs an owner9the four red findings are the incident; the rest are tuning work

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
Three scanning layers and what each catchesA pre-commit scan catches a credential before it exists in history at all. A pipeline scan covers every contributor including forks. A scheduled full-history scan catches what the other two missed and what new detection rules now recognise.Pre-commitstaged contentinstantskippablePull requestthe whole rangecovers forksauthoritativeScheduled full scanall historynew rulesfinds the old onesthe third layer is the one that finds the credential from 2021

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 settingEffectWhen to use
gitleaks protect --stagedScans staged content pre-commitEvery developer machine
gitleaks detect --log-optsScans a commit rangePull request pipelines
Full-history scanScans every commitScheduled, monthly or weekly
Allowlist by path and ruleSuppresses a known false positiveNarrowly, with a recorded reason
--redactKeeps the value out of logsAlways, in CI
Forge-native scanningProvider-side detection and alertsIn addition to, not instead of
Push protectionRejects a push containing a secretWhere the provider offers it

Troubleshooting Jump to heading

SymptomLikely causeFix
Hundreds of findings on first runUntuned rules against test fixturesTriage, then allowlist narrowly
Scanner passes but a leak got throughOnly the working tree was scannedScan history and the commit range
Findings contain the secret in CI logs--redact not usedAdd it; rotate anything already logged
Nobody reads the reportToo many false positivesTune first, then re-enable blocking
Leak found in a fork’s pull requestLocal hooks do not apply to forksPipeline scan covers it
Rewrite done but the credential still worksRotation was skippedRotate 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.