Secret and private-data scanning¶
This repository is public. Two classes of thing must never reach a commit, and they need different tools:
- Credentials — API keys, passwords, tokens. Caught by gitleaks, which recognises them by shape and entropy.
- Private data — a real name, a production hostname, a home directory path, an account number, a personal address. These have no universal shape, so gitleaks cannot see them; they are caught by a denylist scan.
Both run in the same pre-commit hook.
Setup¶
brew install gitleaks # macOS; see the gitleaks README for other platforms
./scripts/setup.sh # sets core.hooksPath to .githooks
cp .private-data-local.example .private-data-local # then fill it in
scripts/setup.sh is what enables the hook — a checked-in hook does nothing
until core.hooksPath points at it, and that is per-clone local config. Run it
once per checkout.
Without gitleaks installed the hook warns and skips the credential half; the private-data half is pure bash and always runs.
What the hook does¶
.githooks/pre-commit runs two scans over staged content:
gitleaks git . --staged -c .gitleaks.tomlscripts/check-private-data.sh --staged
Either one failing aborts the commit. Neither prints the matched value — a
scanner that echoes the secret puts it in your scrollback, and from there into a
chat log or an issue. You get file:line and the class that fired; open the file
to see what it found.
To see gitleaks' detail yourself:
Bypass with git commit --no-verify only when you are certain, and then go fix
whatever made it necessary.
The three pattern sources¶
scripts/check-private-data.sh loads patterns from three places:
| Source | Committed? | Holds |
|---|---|---|
.private-data-patterns |
yes | Generic shapes: payment cards, IBAN, SSN, Nextcloud app passwords, unambiguous API-key prefixes |
.private-data-local |
no, gitignored | Your own literals: real names, production hostnames, account numbers |
| derived at runtime | n/a | Your home directory path, and your git user.email |
The local file is gitignored on purpose. On a public repo, a checked-in list of
the terms you want redacted is the leak — it tells a reader exactly what to
search the history for. Start from .private-data-local.example, which is
comments only.
Runtime derivation covers the two terms nobody should have to configure: an
absolute path into your home directory, and your personal address. The git user
name and the mail domain are deliberately not derived — this project puts
both in LICENSE and README.md on purpose, so denylisting them would block
every commit.
Patterns are POSIX extended regexes, one per line; a plain literal is a valid regex. The comment heading above a block becomes the label a hit reports under.
Running it by hand¶
scripts/check-private-data.sh # staged content (what the hook runs)
scripts/check-private-data.sh --all # every tracked file, ~2s
scripts/check-private-data.sh path/to/file # named files as they are on disk
Staged mode reads the index, not the worktree: a leak that was staged and then edited out on disk is still what the commit would carry.
A run is one batched grep -l over every target at once, and only the files it
names pay for the per-pattern loop that attributes a hit to a line and a label.
A path containing a newline can't go through the batch — grep -l reports
matches by name — so those are scanned one at a time rather than skipped.
False positives¶
Two escape hatches, in order of preference:
- Narrow the pattern. If a shape is matching things it should not, fix it in
.private-data-patterns. - Mark the line. Put
private-data-okanywhere on the offending line. It exempts that line only.
Documentation placeholders are exempt automatically: a line containing xxxx,
<your-token>, CHANGEME or similar is read as an example, not a value. That is
a heuristic, which is part of why gitleaks runs alongside it.
.gitleaks.toml carries its own allowlists for the synthetic values the test
suite uses — 16-hex conversation tokens, key == "..." comparisons. Add a
shape or a path there, never a real secret, not even to allowlist it.
CHANGELOG.md is deliberately not allowlisted. Prose written up from a
terminal session is exactly where a pasted credential or a production hostname
ends up.
Tests¶
tests/test_private_data_scan.py gives every pattern class a positive control,
plus both ways of not matching (the exemption marker, the placeholder
heuristic). A scanner whose regex quietly stops matching reports a clean tree,
which is indistinguishable from having nothing to find — so the patterns are
tested rather than trusted.
Three further tests pin the batching, which fails the same silent way: a file that never reaches the batch is never attributed. They cover a leak among sixty clean files, several leaks in one batch, and a path the batch cannot take.
If something already leaked¶
Assume the credential is compromised and rotate it first; history rewriting is cleanup, not containment.
brew install git-filter-repo
git branch backup-before-cleanup
# Replace a literal (file format: literal:SECRET==>[REDACTED])
git filter-repo --replace-text secrets-to-replace.txt --force
# Or drop a whole file from history
git filter-repo --path path/to/file --invert-paths --force
git remote add origin <url> # filter-repo drops remotes
git push origin main --force
Then force-push to every remote the branch tracks, tell collaborators to
re-clone or
git reset --hard origin/main, and ask the hosting provider to purge cached
views of the affected commits. A force-push does not remove a commit that a fork
or a cached web view still references.
When getting help with remediation, share only file paths, line numbers and rule IDs — never the value.