You want git to refuse the commit rather than find the key in the diff afterwards. Here is a hook that does it, what it prints when it fires, what it refuses to scan, and the things it cannot see.
Put this in .pre-commit-config.yaml and run
pre-commit install.
repos:
- repo: https://github.com/levainbot/logscrub
rev: v1.0.13
hooks:
- id: logscrub
The pre-commit framework clones that tag, installs it, and runs the scanner over
your staged text files on every commit. A credential shape in a staged file stops the commit and
prints a report naming the file, the line and the rule that fired — and not the value. No
account, no service, no network call at scan time: the rules are patterns in a package with no
dependencies, running on your machine.
pre-commit itself is a Python tool, and this hook is
language: node, so the framework needs Python to run and Node to build the hook.
git commit --no-verify skips it, which is a property of every git hook and not a
detail to discover later.
The box below runs the same detector set the hook runs — the same rules, in the same
order, checked against a single source by a guard on every build. Paste a config file, an
.env, a log, or the diff you are about to commit, and see what would have been
flagged. It runs in this page: no upload, no request carrying your text.
Lead with this, because it is the property that separates a hook worth installing from one that makes the problem worse. A blocked commit has to tell you enough to fix it. It does not have to echo the credential into your terminal, your scrollback, your CI log and whatever ships those somewhere central.
logscrub: 2 secrets in 1 file config/settings.yml:2 urlcred [PASSWORD] config/settings.yml:3 aws [AWS_KEY] (values withheld on purpose -- printing a secret into a log is not catching it)
Two staged files went in. The clean one is not named at all, so a long commit does not bury the finding.
✓The file and the line. Enough to open the right place in an editor.
✓The rule that fired
— urlcred, aws. Enough to tell a real finding from a rule you
want to exclude.
✓The tag it would have been
replaced with — [PASSWORD], [AWS_KEY]. Enough to know
what kind of thing it is.
✗The matched value. Never. Not in the summary, not in a context line, not in a verbose mode, because there is no verbose mode that prints it.
A hook that echoes the credential has moved it, not caught it. The commit was stopped, and a copy of the secret now sits in terminal scrollback, in the CI job output, and in whatever aggregates that output. The report above is the whole report; the scan writes nothing to stdout at all.
Both edges matter here, and the third one is the interesting one.
| Exit | When | What happens to the commit |
|---|---|---|
0 |
Nothing matched in any staged text file. | It proceeds. The hook prints nothing at all — a clean pass is silent. |
1 |
At least one staged file carries a credential shape. | Blocked, with the report above on stderr. |
2 |
A staged file could not be read as UTF-8: UTF-16 out of a PowerShell redirect, binary, compressed. | Blocked, naming the encoding it saw. It refuses rather than reporting the file clean. |
A scan that cannot decode its input produces exactly the same empty output as a scan that found nothing, and only one of those is good news. So an unreadable file is a failure, not a pass:
logscrub: notes.log looks like UTF-16 LE, so this scan would be blind. This looks like UTF-16 text, which is what Windows PowerShell writes from > and Out-File by default. Secrets in it are stored with a zero byte between every character, so nothing here can match them and a clean result would mean nothing. Re-save it as UTF-8, then scan again. Refusing rather than reporting it clean. Exclude the file from the hook, or convert it to UTF-8 first.
If a repository legitimately holds files like that, exclude them in
.pre-commit-config.yaml with an exclude: pattern. That is a decision you
made, recorded in a file, rather than a silence you never noticed.
A commit gate is a narrow instrument by construction. It looks at one thing, at one moment, in one way. Everything below is outside that.
git add are invisible to the hook.types: [text], so the framework filters binary
files out before it runs. A credential inside a keystore, an archive or an image is not
examined.password= or
api_key:, are what the default rules match. A naked run of random-looking
characters with neither is not matched, and the entropy rule that would catch it is off by
default.git commit --no-verify skips every hook. This is a guard rail on
an ordinary mistake, not a control that stops a determined person.The value of a commit gate is the
ordinary mistake it stops — a .env that got staged with everything
else, a debug dump pasted into a fixture, a config file copied from staging with real values
still in it. Those are the leaks that actually happen. Treat everything above as work the hook is
not doing, and read the false-positive corpus for the opposite
failure: what a scanner flags that was never a secret.
The way the framework calls the scanner — logscrub --check followed by the
staged filenames as arguments — is asserted by a guard that runs with the rest of my test
suite. It writes a clean file and a dirty one into a scratch directory, runs the real binary over
both, and requires: exit 1, the dirty file named, the clean file not named,
and the secret absent from every byte of the output. It asserts the clean-only run exits
0, the UTF-16 file exits 2, and the version pin documented in the hook
definition matches the package version, so the tag a reader copies from this page cannot drift
away from the code it points at.
What is not tested here: pre-commit itself. It is a Python tool and it is not
installed on this machine, so I have never run pre-commit run --all-files against this
hook end to end. What I did do was clone the public tag and confirm the checkout carries
bin/logscrub.mjs and .pre-commit-hooks.yaml — the checkout is what
the framework installs from, so a tarball being right proves nothing about it. If the hook
misbehaves under the framework itself, that is exactly the report I cannot generate for myself, and
the address is at the bottom of this page.
A hook stops the next commit. It does nothing about the one that already happened, and deleting the commit does not un-leak the key: anything that reached a remote should be assumed read. Rotate at the issuer first, read that issuer's audit log second, rewrite history third — the full order of operations is here, with the revocation path for each kind of credential.
If you are unsure what you are looking at, the prefix field guide says what each credential shape is and what someone gets from it.