CI is a great place to catch a leaked secret — unless the secret has already been committed. By the time the pipeline fails, the value is in git history, and "just delete the file and push again" does not un-ring that bell.
The earlier line of defense is the pre-commit hook. It runs on your machine, before the commit exists, and a 30-line hook can stop the most common leak class dead.
Here's the whole setup.
The hook
npx @wuchunjie/dotguard scans every .env* file in a directory and exits 1 if it finds anything that looks like a real credential. That exit code is the entire integration:
#!/bin/sh
# .git/hooks/pre-commit
# 1. Did the user stage a .env file? If so, it must be .env.example.
git diff --cached --name-only | grep -E '(^|/)\.env($|\.)' | grep -v '\.env\.example$' > /dev/null && {
echo "BLOCKED: you staged a .env file. Use .env.example instead."
echo " If you really need to commit it (you don't), run: git commit --no-verify"
exit 1
}
# 2. Scan the working tree for env files that contain real values.
npx @wuchunjie/dotguard . > /dev/null 2>&1 || {
echo "BLOCKED: dotguard found potential secrets in .env files."
echo " Run 'npx @wuchunjie/dotguard .' to see details."
exit 1
}
exit 0
That's the whole thing. Two checks, both fast, both local:
- Staged-file check — the classic "I accidentally added .env" case. Blocked before the object ever enters the repo.
-
Content check — the
.envsitting in your working directory (correctly untracked, correctly in.gitignore) still gets scanned, because it's the file you're about to think is safe. If it contains a real key, you'll know before you push a branch, open a PR, or copy-paste it into a config file elsewhere.
Why not just CI?
Because the value of catching a secret is a function of how far back in history it goes:
| Caught by | Damage already done |
|---|---|
| Pre-commit hook | None. The value never entered git. |
| CI on push | It's in the remote history. Rotate, then scrub. |
| A dependency audit, weeks later | It's in forks, caches, and possibly a public mirror. Rotate, scrub, assume compromise. |
CI still deserves a scanner — it's the last line, and it catches what the hook can't (secrets in non-env files, secrets in PRs from forks). But the hook is the one that keeps your own history clean, and that's the one teams skip because "I'm careful."
Careful is not a control. The hook is.
Making the team actually use it
Two practical notes from rolling this out:
Install it in the repo, not the laptop. A hook that lives in ~/.git-hooks only exists on the person who set it up. Instead, commit a hooks/ directory and a one-liner setup:
git config core.hooksPath .githooks
Now the hook travels with the repo, shows up in the onboarding doc, and can't silently rot.
Make the failure message teach. The hook above tells you why it blocked you and how to fix it (or how to override with --no-verify, which leaves an audit trail in the commit message if you document it). A hook that just says error gets bypassed by the third week.
The tradeoff, honestly
Pre-commit hooks are per-machine, so a fork without the hook setup has no protection — CI is still mandatory. And npx means the first run downloads the package (it's a single zero-dependency file, but the first run takes a few seconds). Cache it with a local npm install -D if the latency bothers you.
Worth it? The hook has stopped real commits on my machines more times than I can count — usually a .env.local with a staging key, usually two seconds from a git add .. Each stop is a five-minute incident that never happened.
git config core.hooksPath .githooks
npx @wuchunjie/dotguard
More Tools
| Tool | What it does | Command |
|---|---|---|
| scaffoldx-cli | Production-ready project templates in seconds | npx scaffoldx-cli |
| dotguard | Scan .env files for exposed secrets | npx @wuchunjie/dotguard |
| gitpulse | Git repo analytics in your terminal | npx @wuchunjie/gitpulse |
| snippetx | Terminal code snippet manager | npx @wuchunjie/snippetx |
If these save you time, consider buying me a coffee. All tools are MIT-licensed, zero-dependency, and run fully offline.
Top comments (0)