Tuning Secret Scanner False Positives With Allowlists
The gitleaks pre-commit hook blocks a commit because tests/fixtures/jwt.json contains a sample token, CI fails on a lockfile's integrity hashes flagged as generic-api-key, and after the third false alarm in a week someone adds --no-verify to their commit alias. A secret scanner that cries wolf gets bypassed, and a bypassed scanner catches nothing. The fix is not to loosen detection globally but to allowlist exactly the known-safe matches — narrowly enough that a real secret in the same file still fails. This page does that with gitleaks, as part of secret scanning and leak prevention.
Every allowlist entry is a small, deliberate hole in detection; the goal is to make each one as small and as reviewable as possible.
Diagnostic
Run the scanner over the repository and group findings by rule and path to see what the noise is:
#!/usr/bin/env bash
set -euo pipefail
gitleaks detect --source . --redact --report-format json --report-path /tmp/leaks.json --exit-code 0
jq -r '.[] | "\(.RuleID)\t\(.File | split("/")[0:2] | join("/"))"' /tmp/leaks.json | sort | uniq -c | sort -rn | head -8
jq -r '.[0] | "fingerprint: \(.Fingerprint)"' /tmp/leaks.json
git config --get-regexp 'alias\.' | grep -- '--no-verify' || true
Expected bad output:
14 generic-api-key package-lock.json
6 jwt tests/fixtures
3 generic-api-key docs/examples
1 aws-access-token scripts/seed.sh
fingerprint: 3f2a91c7b4e8d1a2:package-lock.json:generic-api-key:1842
alias.ci commit --no-verify
Twenty-three findings are noise from lockfile hashes, test fixtures and documentation; one in scripts/seed.sh may be real — and it is buried. Someone has already aliased around the hook.
Root cause
Secret scanners combine precise rules for known formats (AWS keys, GitHub tokens, Stripe keys) with generic high-entropy rules for anything that looks like a credential. Generic rules are what catch custom tokens, and they also match integrity hashes, UUIDs, base64 test data and example keys in documentation. When the configuration has no allowlists, every such match fails the hook, and developers learn that failures are usually noise. The typical overreaction — disabling a rule, excluding a whole directory, or bypassing the hook — removes real protection along with the noise. Precise tuning is possible because false positives are predictable: they come from specific files, specific value formats or specific, reviewed values.
The social dynamics matter as much as the regexes. A hook that fails on noise teaches developers that the fix for a red hook is to get around it, and that lesson generalises: the day a real key is staged, the reflex is the same bypass. Every false positive therefore costs more than the minute it takes to dismiss — it erodes the one behaviour the scanner depends on. Treating false positives as bugs in the configuration, fixed promptly and narrowly by the team that owns the scanner, keeps the hook credible. The measure of success is not zero findings but a hook whose failures people take seriously.
Resolution
- Extend the default rules instead of replacing them, and allowlist predictable safe formats with narrow regexes:
title = "acme gitleaks config"
[extend]
useDefault = true
[allowlist]
description = "Known-safe formats and generated files"
paths = [
'''(^|/)package-lock\.json$''',
'''(^|/)pnpm-lock\.yaml$''',
'''(^|/)go\.sum$''',
]
regexes = [
'''sha(256|384|512)-[A-Za-z0-9+/=]{40,}''',
'''AKIAIOSFODNN7EXAMPLE''',
]
Save as .gitleaks.toml. Lockfiles contain only integrity hashes and package URLs, so excluding them is safe; AKIAIOSFODNN7EXAMPLE is AWS's documented example key.
- Scope test fixtures narrowly — by path and rule — so a real key accidentally pasted into a fixture of a different type still fails:
[[rules]]
id = "jwt"
[rules.allowlist]
paths = ['''^tests/fixtures/.*\.json$''']
regexes = ['''eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9\.eyJzdWIiOiJ0ZXN0[^"]*''']
The regex matches only tokens whose payload starts with {"sub":"test, the convention the test suite uses for fixture tokens.
- Ignore individual reviewed findings by fingerprint when a one-off safe match does not fit a pattern:
#!/usr/bin/env bash
set -euo pipefail
jq -r '.[] | select(.File | startswith("docs/examples/")) | .Fingerprint' /tmp/leaks.json >> .gitleaksignore
sort -u -o .gitleaksignore .gitleaksignore
wc -l .gitleaksignore
Fingerprints include commit, file, rule and line, so the ignore applies to that exact occurrence only.
- Mark intentional inline values with the
gitleaks:allowcomment, which is visible in review:
const exampleKey = 'sk_test_EXAMPLE0000000000000000'; // gitleaks:allow — placeholder used in the README example
- Investigate the remaining real finding — here
scripts/seed.sh— and rotate it if it is a live credential, as in removing a leaked API key from git history.
Expected output
$ gitleaks detect --source . --redact --config .gitleaks.toml
INF 1 commits scanned.
INF scan completed in 1.2s
WRN leaks found: 1
Finding: AWS_KEY=REDACTED
RuleID: aws-access-token
File: scripts/seed.sh
The twenty-three false positives are gone and the one genuine-looking finding stands alone, where it gets attention instead of being dismissed with the rest.
With noise near zero, the hook's failures regain their meaning: a blocked commit almost always means something worth looking at. That is when developers stop bypassing it, and the --no-verify alias can be removed without resistance. Track the false-positive rate over time; if it creeps back up, tune again rather than tolerating it.
Prevention
Require review for allowlist changes with a CODEOWNERS entry for
.gitleaks.tomland.gitleaksignore, owned by the security or platform team.Test the configuration with a known real-looking secret in a scratch file to confirm allowlists did not open a hole:
#!/usr/bin/env bash
set -euo pipefail
tmp=$(mktemp -d); printf 'token = "ghp_%s"\n' "$(head -c 30 /dev/urandom | base64 | tr -dc 'A-Za-z0-9' | head -c 36)" > "$tmp/probe.txt"
gitleaks detect --no-git --source "$tmp" --config .gitleaks.toml --exit-code 1 >/dev/null 2>&1 && echo "PROBLEM: probe not detected" || echo "probe detected: config still catches real tokens"
rm -rf "$tmp"
- Never allowlist whole directories of application code. Test and generated paths only; application code gets fingerprint or inline allowances, each visible in review.
Platform caveats
Pre-commit and CI parity: point both the pre-commit hook and the CI job at the same
.gitleaks.toml, or local and CI results diverge; see running the same lint checks in pre-commit and CI.
Monorepos: path allowlists are relative to the scan root; run gitleaks from the repository root so patterns like
^tests/fixtures/match consistently.
Other scanners: TruffleHog, detect-secrets and GitHub secret scanning have equivalent mechanisms (
--exclude-paths, baseline files, push-protection bypass reasons); the same narrowest-first principle applies.
Rollback
Revert the configuration and ignore files to return to default rules; expect the old noise to return:
#!/usr/bin/env bash
set -euo pipefail
git checkout HEAD~1 -- .gitleaks.toml .gitleaksignore
Frequently Asked Questions
How do I stop gitleaks flagging lockfile hashes?
Add the lockfiles to the global paths allowlist, or add a regex for sha512- integrity strings. Lockfiles contain only hashes and package URLs, so excluding them does not hide real secrets.
Is it safe to exclude the whole tests/ directory?
Usually not. Real credentials do end up in test files. Allow specific fixture paths for specific rules, or match the fixture convention with a regex.
What is the difference between .gitleaksignore and gitleaks:allow?
.gitleaksignore lists fingerprints of specific findings and is edited separately from the code. gitleaks:allow is an inline comment on the line itself, visible in the diff that introduces the value. Both apply to one occurrence.
How do I know the allowlist did not hide a real secret?
Run a probe with a realistic fake token through the same configuration in CI; if it is not detected, the allowlist is too broad.