Builds & investigations Build note

Catching the secret before the commit, not after the audit

A leaked credential is cheapest to stop before git history. This is how leakferret scans staged changes, filters fixtures, verifies keys, and rewrites code.

Share

I've had to rotate credentials after they turned up in a repository's git history. Finding the key was the easy part. I still had to replace it in the services that used it and check that they worked with the replacement.

I wanted to catch those keys before a commit. The scanners I'd used could find secrets, but test fixtures created noise, and a finding didn't always tell me whether the credential still worked.

I built leakferret1 to scan staged changes and help fix what it finds. Fixture detection, provider verification, and replacing hardcoded values came out of those earlier problems.

Why the commit is the expensive place to catch it

A secret in your git history is not a future problem you will get to. It is a present problem you have not noticed. Git history is permanent by default: rewriting it across every clone, fork, and CI cache is its own painful project, and even then you have to assume the value already leaked. So the real cost of a committed secret is never "delete the line." It is "rotate the credential everywhere it is used, prove nothing was accessed, and hope the rotation does not take down a service that read it from an environment you forgot about."

That cost is the same whether the audit finds the key today or a scanner in CI finds it next week. Both are downstream of the commit. CI scanning, push protection, audit grep, incident response, these are all the same move made at different distances to the right, and every one of them is paying the full rotation cost because the secret already exists somewhere it should not.

Catching a secret before commit avoids adding it to Git history. It doesn't prove that the credential was never exposed elsewhere. Logs, shared files, and model prompts can still require rotation. I wanted an earlier check, without treating a blocked commit as evidence that everything was safe.

What the existing scanners got me, and where they stopped

I want to be fair, regex secret scanners are not bad, and I have run several. But two things wore me down.

The first is false-positive fatigue. A plain regex scanner flags AKIAIOSFODNN7EXAMPLE, which is AWS's own documented example key that appears in a thousand tutorials and zero breaches2. It flags the Stripe test key from the docs. It flags a sample JWT. The developer sees ten alerts, nine are noise, and the rational response to a tool that is wrong ninety percent of the time is to stop reading it. A pre-commit hook that cries wolf gets bypassed with --no-verify by the end of the week, and a bypassed hook protects nothing. At the left edge, signal quality is not a nice-to-have. It is the whole game, because the developer can always skip you.

The second is the harder gap. A regex can tell me a string looks like a credential. It cannot tell me whether that credential is real, and it definitely cannot tell me whether it is live right now. During an incident those are the only two questions that matter. "There is a forty character base64 string here" is not actionable. "There is an AWS key here that I just confirmed still authenticates" is the sentence that decides whether somebody is rotating keys tonight.

What I actually wanted

So I wrote down the minimum the tool would have to do to be worth installing, not a wishlist, just the parts that would have saved me on every one of those evenings:

  1. Catch the secret before the commit, not three steps to the right in CI.
  2. Tell me whether a candidate is a real secret or a documented example, without me eyeballing every hit.
  3. Tell me whether it is actually live, by safely asking the provider.
  4. Help me fix it, not just flag it.
  5. Run in all three places a leak is actually born: the editor, the pre-commit hook, and, increasingly, the AI agent writing the code.

That last one turned out to matter more than I expected, and I will come back to it.

Building it in stations

I did not design all of this up front. It grew the way most of my tools grow, one station at a time, each one solving the annoyance the previous one exposed. It is a single Rust binary, which I like because there is no runtime to install and the same engine backs the CLI, the editor extension, and the agent integration.

Scan is the cheap first pass: a regex pre-filter over the working tree. It respects .gitignore but deliberately reads dotfiles like .env, because that is exactly where the interesting values hide. This stage just produces candidates, fast.

Catalog exists because the very first scan reproduced the false-positive problem immediately. So there is a signed catalog of known-public example credentials, the AWS doc key, the Stripe test keys, the jwt.io samples, and a candidate that matches it is marked FIXTURE instead of raising an alarm. This is the single biggest difference between a tool people keep enabled and one they uninstall.

Classify handles the candidates that are not in any list, which is most of them. Here I made the decision I am happiest about. Instead of shipping yet another cloud service with its own API key and its own bill, leakferret asks the language model you already have, your editor's Copilot, the agent's Claude, whatever is in reach, to classify a candidate as REAL, FIXTURE, or UNKNOWN. No extra key, no extra cost, and the code never leaves for a server I run, because I do not run one.

Verify is the step that turns a guess into a fact. For providers that expose a harmless read-only check, leakferret makes one real but safe API call to confirm the key is live: AWS via SigV4, plus GitHub, GitLab, Stripe, OpenAI, Slack, Twilio and more, with a trufflehog fallback for the long tail. This is the difference between "looks like a key" and "this key answered, rotate it," and it is the station I most wanted during incidents.

A small thing from building leakferret makes the point for me. I was writing its tests, and one of them needed a fake Hugging Face key, so I typed some random characters in roughly the right shape. When I went to commit, GitHub stopped me: its scanner was sure my made-up string was a real leaked secret. It was not. Nothing had leaked. The scanner only saw the shape, the shape looked right, and so it sounded the alarm. That is the limit of detection on its own. Verification would have settled it in a second, because one harmless call to Hugging Face shows the key does not work. The tool whose whole job is catching secrets got tripped up by a fake one, which is exactly the gap the verify step closes.

Rewrite is the fix, because finding a problem and walking away felt wrong. It swaps the hardcoded literal for an environment-variable lookup in the right idiom for the language (process.env, os.environ, ENV.fetch), adds a line to .env.example, and prints the seed commands for your secret manager. Find it, prove it, fix it, in one pass.

You fixed the file. What’s in the commit?

Editor and index are separate snapshots. Clear both.

Working file config.rb
14token = "SECRET_EXAMPLE"
Staged snapshot index
14token = "SECRET_EXAMPLE"
Staged secret detected

Removing text in your editor won’t change what Git commits.

Shifting it left, concretely

Five stations in one binary is only useful if it runs early enough. The whole point was to move the catch, so it wires into the two places leaks are actually created.

The first is the pre-commit hook. The important detail is that it must scan the index, not whatever happens to be in the working tree. Otherwise git add secret.env, followed by a cleaned-up local file, can leave the staged secret invisible to leakferret verify .. This hook materializes the staged snapshot in a temporary directory, scans it fully offline, and blocks the commit on any finding:

cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/sh
set -eu
scan_dir="$(mktemp -d)"
trap 'rm -rf "$scan_dir"' EXIT HUP INT TERM

# checkout-index writes exactly what is staged for the next commit.
git checkout-index --all --prefix="$scan_dir/"
leakferret verify "$scan_dir" --verify-mode none --fail-on any || {
  echo "leakferret blocked this commit. Bypass: git commit --no-verify"
  exit 1
}
HOOK
chmod +x .git/hooks/pre-commit

git checkout-index makes the thing being scanned match the thing being committed. --verify-mode none keeps the scan offline, and --fail-on any exits non-zero when a candidate appears. The local hook gives fast feedback before the secret reaches history.

One honest caveat: the local hook is a seatbelt, not a gate. Anyone can run git commit --no-verify and skip it, and a hook only lives on the machines that installed it. So the same check belongs in CI as the thing that actually blocks the merge. Local hook for fast feedback, CI for enforcement, both rather than either.

Coding agents can introduce hardcoded credentials too. I added an MCP server3 so an agent can call the scanner and propose a rewrite before committing. Human review still matters. The tool adds a check, not permission to skip the diff. Claude Code can register it with:

claude mcp add leakferret -- npx -y @leakferret/mcp

or, as an .mcp.json entry for any MCP client (Cursor, Continue, Claude Desktop):

{
  "mcpServers": {
    "leakferret": {
      "command": "npx",
      "args": ["@leakferret/mcp"]
    }
  }
}

That, to me, is what shifting left actually looks like now. Not just earlier in the pipeline, but earlier than the human, inside the thing generating the code.

The line I would not cross

I did not build a service that collects credentials. Ordinary scanning runs locally. Verification sends the credential directly to its provider, and that authenticated request can appear in the provider's audit trail. It belongs only in repositories whose credentials you have permission to test.

Output still needs care. Long matches use a shortened preview in finding reports, but short values and source context in rewrite diffs can contain sensitive data. A dry-run rewrite avoids editing files, not provider requests. I keep those outputs out of public logs and use the offline scan when network access is inappropriate.

What improved and what stayed unsolved

What it gets you is secrets caught while they are still free to delete, with signal good enough that the hook stays installed instead of getting muted. The operating philosophy underneath is deliberate: a false positive is an annoyance a human dismisses in two seconds, a false negative is a breach. So leakferret leans toward flagging, and then spends its cleverness on explaining what each flag actually is (fixture, real, live) rather than staying quiet to keep the count low.

What it does not get you is a clean run on a repository that already has secrets in its history. The pre-commit hook stops new ones, it does not retroactively scrub old commits, and on a legacy repo the first scan can be loud. The fix there is to run leakferret baseline init once, which fingerprints the existing findings so you fail only on new ones, and then work the backlog down deliberately instead of drowning in it on day one. The agent integration has an honest limit too: it is a self-check the agent can call, not a guarantee it always will, which is exactly why the offline pre-commit hook and the CI gate still sit underneath it. Defense in depth, not a single magic layer.

Why I kept building it

This started as a finding I kept writing in other people's reports and a key I kept rotating in my own evenings. At some point the cheaper thing was to build the catch I wished existed at the left edge, and then keep sharpening it every time it annoyed me. It is open source and free, and it installs from wherever you already live:

cargo install leakferret-cli      # Rust
npm i -g @leakferret/cli          # Node
gem install leakferret            # Ruby

then leakferret verify . and see what it finds. There is a VS Code extension and a GitHub Action too, if that fits your flow better than the CLI.

If you have worked through the shift-left version of this with a different shape, or you have a secret-rotation war story that still makes you wince, I'd love to hear about it. [email protected]. The "found it the expensive way" stories are the part I read first.

Footnotes

  1. leakferret is the scanner described here. One Rust binary that acts as the CLI, the editor extension's engine, and an MCP server. Source and the provider verifier list are linked from the site. ↩

  2. AKIAIOSFODNN7EXAMPLE is the access key id AWS uses throughout its own documentation. It is the canonical example of a string that matches every AWS-key regex and is never a real finding, which is exactly why a scanner that cannot tell example keys from real ones generates noise that gets it ignored. ↩

  3. The Model Context Protocol is the open standard for giving coding agents callable tools. leakferret is listed in the MCP registry as io.github.leakferrethq/leakferret, so registry-aware clients can discover it. The tools it exposes let an agent scan a path, classify and verify candidates, and propose a rewrite before it produces a commit. ↩

Share

Comments