Catching the secret before the commit, not after the audit

The finding always reads the same. A security review lands, somebody greps the repositories, and there it is on line 14 of a config.py that nobody has touched in a year: an access key, in plaintext, committed. By the time it shows up in the report it has been in the history long enough that the only honest assumption is that the key is compromised. Now it is the platform team's problem, and it is urgent, because the clock did not start when the auditor found it. It started the day it was committed.
I have been on the rotating-the-key end of that finding more times than I would like. Rotating a credential that is wired into six services, three of which nobody remembers configuring, is not a five minute job. It is the thing that eats the evening. And every time, the same thought: this was the most expensive possible place to catch this. The cheapest place was the developer's machine, the second before they typed git commit.
This post is about moving the catch to that second. Not a smaller spike of findings in the next audit. Zero secrets reaching the history in the first place. The tool I ended up building to do it is called leakferret1, and the more interesting part is the design decisions, not the tool.
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.
Shifting left means refusing to pay that. If the secret never enters a commit, there is no history to scrub and no rotation to schedule. The catch happens while the value is still just a string in an unsaved buffer, where deleting it costs nothing. Everyone agrees with this in the abstract. The reason it does not happen in practice is not disagreement, it is that the tooling at that left edge has historically been annoying enough that people turn it off.
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:
- Catch the secret before the commit, not three steps to the right in CI.
- Tell me whether a candidate is a real secret or a documented example, without me eyeballing every hit.
- Tell me whether it is actually live, by safely asking the provider.
- Help me fix it, not just flag it.
- 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.
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. It runs fully offline, no network, and blocks the commit on any non-fixture finding. From the repo root:
cat > .git/hooks/pre-commit <<'HOOK'
#!/bin/sh
# Offline secret scan (no network). Blocks the commit on any finding.
leakferret verify . --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
--verify-mode none keeps it offline, so it is fast and nothing leaves the machine, and --fail-on any exits non-zero the moment a real candidate appears. The secret never reaches the commit, so it never reaches the history, so it never reaches an auditor. That is the entire thesis in three lines.
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.
The second place is the one I did not see coming a year ago. The agent is now the committer. I have coding agents writing code into repositories all day, and they hardcode secrets exactly like people do, except nobody reviews an agent's diff line by line the way they review a human pull request. So leakferret is also an MCP server3, which means the agent can call it to scan, verify, and rewrite before it writes the commit. It self-checks. For Claude Code that is one line:
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 am a platform engineer before I am a tool author, and there was one rule I set before writing any of the scanner: the full secret value never leaves your machine. Not to a log, not to a report, not to a model prompt, not anywhere. The only thing leakferret ever writes out is a redacted preview, first four and last four characters, like AKIA...4XYZ, enough for a human to recognize the key without ever exposing it. Verification calls go straight from your machine to the provider. There is no leakferret backend in the middle, on purpose, because the day a secret scanner becomes the thing that aggregates everyone's secrets is the day it becomes the most attractive target on the internet. I did not want to operate that.
What this gets you, and what it doesn't
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.
Closer
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
-
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. ↩
-
AKIAIOSFODNN7EXAMPLEis 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. ↩ -
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. ↩
Comments