SAST scanner · reads git history

Git Secrets Scanner

Deleting a key from the code does not delete it from the repository. This scanner searches the files at HEAD, then the lines added by past commits, then the names of files ever committed. A secret removed long ago is still reported, tagged with the commit that added it.

SASTCritical–HighExposed Secrets3 passes13 key patternsno network

A secret that left HEAD

The scanner works on a clone and uses git directly. It runs three passes in a fixed order and does not de-duplicate between them, so one secret can be reported by more than one pass. The example is the common mistake: a key committed in one commit and deleted in the next.

a1f3 · add config + .env + STRIPE=sk_live_4eC39… + app.js adds two files 9c0d · remove secrets - .env the key looks gone HEAD · working tree app.js no .env, no key 1 · HEAD files 13 patterns over each indexed file; lock files and .min.js skipped no match 2 · history diffs git log --all --diff-filter=A -p added lines only, 500 commits stripe_secret_keycommit a1f3not at HEAD 3 · file names git log --name-only, same filter 5 sensitive name patterns .env matches\.env(\.local|…)?$ Findings none yet CRITICAL · 0.90Stripe secret API key.env · commit a1f3value masked: sk_live_*** HIGH · 0.95Sensitive file in history.env · deleted since HEAD is clean, yet both findings stand

1Commit a1f3 added .env with a live Stripe key. Commit 9c0d deleted it. Anyone who clones the repository still gets a1f3, so the key is one git show away.

2Pass 1 reads the files at HEAD. Every indexed file except lock files, *.min.js and node_modules/ goes through the thirteen patterns. app.js is clean, so nothing is reported yet.

3Pass 2 reads the lines that past commits added. git log --all --diff-filter=A -p hands over the diff of a1f3. The Stripe pattern matches, the finding carries the commit hash, and because the file is not at HEAD the description says the secret "may still be valid if not rotated". Confidence 0.90.

4Pass 3 looks only at file names. .env matches one of five patterns (.env and its .local/.production/.staging variants, .pem, .key, credentials.json, service-account JSON), giving a HIGH finding at 0.95 even if the file held no recognisable key. One finding per file name.

5Nothing at HEAD, two findings. A secret that is still in HEAD would additionally be reported by pass 1 as "currently in the codebase".

Which patterns match

Every pass ends in the same function: run each of the thirteen regexes over a block of text, drop matches shorter than 8 or longer than 500 characters, mask the value and emit a finding. The table of patterns is fixed in the scanner's configuration; there is no entropy scoring in the matching path.

Added lines from one diff AWS_KEY=AKIAIOSFODNN7EXAMPLE DB=postgres://app:p4ss@db.prod/main tok="eyJhbGciOi.eyJzdWIiOi.SflKxw" STRIPE=sk_live_4eC39HqLyjWDarjtT1zdp7dc retries = 3 API_KEY = "change-me" masked evidence kept on the finding AKIAIOSF***MPLEsk_live_***p7dc 13 patterns · red dot critical, amber dot high supabase_service_role stripe_secret_key stripe_restricted_key aws_access_key github_pat github_oauth openai_key anthropic_key database_url firebase_key sendgrid_key twilio_key private_key AKIA + 16 of [0-9A-Z] (postgres(ql)?|mysql|mongodb(+srv)?)://user:pass@… eyJ….eyJ….… JWT, role = service_role sk_live_ + 24 or more [a-zA-Z0-9] length 8 to 500, then masked

1One block of added lines meets the thirteen patterns. Six critical (red) and seven high (amber) shapes are defined, covering Stripe, AWS, GitHub, OpenAI, Anthropic, Firebase, SendGrid, Twilio, database URLs, private keys and Supabase-style JWTs.

2An AWS access key ID is the literal AKIA followed by sixteen uppercase letters or digits. Match: CRITICAL, confidence 0.90.

3A postgres://, postgresql://, mysql://, mongodb:// or mongodb+srv:// URL matches up to the next whitespace or quote, password included. CRITICAL when it embeds a real user:password@ (the password may contain /) or a password=/pwd= query parameter. A URL with no password or a templated one (${DB_PASS}) is not reported; an obvious placeholder password such as password drops to MEDIUM.

4The "Supabase service role key" pattern is eyJ….eyJ….sig, and the payload's role claim is then decoded. service_role is CRITICAL. The public anon key that ships in every frontend is not reported, and a JWT whose role cannot be confirmed is kept at MEDIUM instead of being dropped.

5A Stripe live secret key is sk_live_ plus at least 24 letters or digits. The pattern is live keys only: a sk_test_ key does not match.

6Plain assignments never match: the two ordinary lines stay silent because only these shapes are searched, and a variable named SECRET or PASSWORD with a weak value is invisible. Matches are masked before they are stored: first 8 characters, ***, last 4.

What the history pass sees

The history pass does not ask git for every change. It asks for --diff-filter=A, which keeps only the files a commit adds. That choice decides which commits are readable. This was confirmed on a scratch repository: a key appended to an existing file in a later commit never appeared in the output.

c1 · add cfgA cfg.js+ x=1new file c2 · add keyM cfg.js+ KEY=AKIA…edits an old file c3 · drop keyM cfg.js- KEY=AKIA…edits an old file c4 · add envA .env+ SECRET=AKIA…new file c5 · rm envD .env- SECRET=AKIA…deletes a file read · no match never shown to scanner never shown to scanner read · aws_access_key never shown to scanner The key is committed twice: appended to an existing file in c2, and in a brand-new file in c4. git log --diff-filter=A -p prints diffs only for files a commit adds, so c1 and c4 are read and c2, c3, c5 are not. 1 finding: CRITICAL AWS access key ID in .env, commit c4 The key in c2 is in history and recoverable, yet there is no finding for it. A key that was edited into an existing file and later removed is missed unless it is still at HEAD (pass 1). Bounds on the pass: 500 commits, merges skipped, 1 MB of added text per file diff, 60 s for git log.

1Five commits touch a key. In c2 it is appended to an existing file; in c4 it arrives in a new file. Both were later removed.

2The history pass runs git log --all --diff-filter=A -p --no-merges. Only file additions carry a diff, so the scanner reads c1 and c4. It collects the lines starting with + per file and scans them.

3c4's added lines contain an AKIA… key: one CRITICAL finding with the commit hash. c1 holds only x=1.

4The key in c2 is never read. A modification is not an addition, so the diff is filtered out. Modified files are therefore not covered by the history pass, which looks like a gap in the scanner rather than intended behaviour.

5The pass is also bounded: the last 500 commits, no merge commits, a file diff over 1 MB is skipped whole, and git gets 60 seconds. If git fails or times out the pass logs a warning and returns what it has.

How "not at HEAD" is decided

For a history finding the scanner asks whether the file path exists in the HEAD index, not whether the secret is still in it. If config.js held a key in an old commit and the file still exists today without it, the finding says "this secret is currently in the codebase". The tag is a statement about the file, so read the commit hash before concluding the key is live.

The fix: rotate first

Because a clone carries the whole history, the only repair that works on every copy is to make the leaked value worthless.

leaked in a1f3 sk_live_4eC39…p7dc LIVE · anyone with a clone can charge cards REVOKED · the copy in history is a dead string 1 · Rotate new key in the provider dashboard update deployment env variables process.env.STRIPE_SECRET_KEY 2 · Rewrite history destructive; optional after rotating git filter-repo \ --path .env --invert-paths 3 · Prevent the next one .env in .gitignore a gitleaks pre-commit hook echo ".env" >> .gitignore

1The Stripe key from the earlier example is still live in a1f3. Deleting .env in 9c0d changed nothing for a cloner.

2Rotate it. Issue a new key and put it in the environment. The old string in history now authenticates nothing. This is the one step that cannot be skipped.

3Rewriting history is optional and destructive. git filter-repo --path .env --invert-paths drops the file from every commit, but existing clones and forks keep their copies, which is why rotation comes first.

4Put .env in .gitignore, read secrets from process.env, and run gitleaks as a pre-commit hook so the next key never reaches a commit.

Why it matters

Why it matters

Exposed secrets are the fastest path to a full compromise. A database connection string gives direct read and write access; leaked AWS keys can create resources, read S3 buckets and delete infrastructure; an API key lets an attacker act as your application; a Supabase service role key bypasses every Row Level Security policy. The secret does not need to be in today's code. If it was ever committed, anyone who clones the repo can run git log -p and find it.

Uber (2016) and Samsung (2022)

Uber engineers committed AWS credentials to a private GitHub repository. Attackers found them, opened an S3 bucket and stole 57 million user and driver records; Uber paid $100K to the attackers and concealed the breach for over a year. In 2022, GitGuardian researchers found Samsung engineers had leaked internal source code, private keys and AWS credentials in public GitLab repositories.

Vulnerable vs fixed code

// BAD: hardcoded secret in source code
const STRIPE_SECRET = "sk_live_51abc123def456..."

// BAD: committed .env file
// .env
SUPABASE_SERVICE_ROLE_KEY=eyJhbGciOiJIUzI1NiIs...
DATABASE_URL=postgresql://user:p@ssw0rd@db.example.com/prod

// BAD: "removed" secret, still in git history
// commit abc123 "add config"  then  commit def456 "remove secrets"
// still recoverable with:  git show abc123:.env

Limits and configuration

SettingValueEffect
Commits scanned500, --no-mergesOlder commits and merge commits are never read
Diff size1 MB of added text per file per commitLarger diffs are skipped whole
git log timeout60 sTimeout is logged; findings from other passes remain
Match length8 to 500 charactersShorter or longer matches are dropped
Skipped filespackage-lock.json, yarn.lock, pnpm-lock.yaml, *.min.js, node_modules/Never scanned at HEAD or in history
Confidence0.90 for a pattern match, 0.95 for a sensitive file nameFixed values
SeverityCritical: Supabase service_role JWT, Stripe live secret, AWS, GitHub PAT, database URL, private key. High: Stripe restricted, GitHub OAuth, OpenAI, Anthropic, Firebase, SendGrid, Twilio, sensitive file namesSet by the pattern
Scope note

The pattern table has no Stripe sk_test key, no GitLab token and no "high-entropy string in a variable assignment" detection: only sk_live_ and rk_live_ Stripe keys, no GitLab pattern, and the entropy helper in the scanner is defined but never called. The history pass reads added files only (see above), and the Supabase pattern matches any three-part JWT. There is no de-duplication, so a secret still at HEAD can be reported by pass 1 and again by pass 2. The scanner needs a clone with a .git directory and sends nothing over the network.