A pre-commit hook that blocks secrets

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.

Try the rules before you install anything

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.

The report never prints the secret

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.

What a blocked commit prints, and what it withholds
stderr, verbatim
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.

Each field, and why

The file and the line. Enough to open the right place in an editor.

The rule that firedurlcred, 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.

What each exit code means

Both edges matter here, and the third one is the interesting one.

ExitWhenWhat 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.

The refusal, in full blocks rather than guesses

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.

What the hook cannot see

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.

The scan, and what falls outside it
Every commit: the staged text files, scanned by shape
Anything already committed History is not scanned. A key that landed last month is still there, and a hook installed today will never mention it.
Anything not staged Ignored files, untracked files, and the half of a file you did not git add are invisible to the hook.
Anything not text The hook declares 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.
Anything encoded Base64 is an encoding, not encryption, and it defeats every pattern here. A Kubernetes Secret manifest comes back clean because the scanner is blind to it, not because it is safe.
A bare high-entropy string Shapes with a prefix, and values behind a name like 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.
Anyone in a hurry 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.

How this is tested, and what is not tested

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.

If it fires on something you already pushed

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.

The same rules, in the other places you need them

The full Log Redactor Every detector with a switch next to it, including the entropy rule that is off by default, a custom terms box for your own host and bucket names, and the written account of what it misses. Browser only. The command-line redactor, for logs rather than commits Pipe a file through it, keep a local key map so the placeholders are reversible, and turn the replies back into the real thing. No network, no telemetry, no dependencies. Before you paste a log anywhere The short check for what leaks out of a stack trace, and why rotation rather than deletion is the fix once something is out.