Git Security Hooks That Actually Work
How we built pre-commit and pre-push hooks that catch real secrets without drowning in false positives — with per-project allowlists and ANSI-safe filtering.
Every project we ship gets two git hooks that scan for secrets and vulnerabilities before code leaves the machine. Sounds simple — until your scanner flags 47 "findings" that are all false positives, or worse, passes silently because invisible ANSI color codes break your grep. Here's how we built hooks that catch real problems and stay out of the way.
The Problem
Security scanners are noisy. Run one on any Node.js project and you'll get a wall of findings — some critical, most irrelevant. The default response is either to ignore the scanner entirely or to build a global allowlist that silently suppresses real vulnerabilities across all projects.
We needed hooks that:
- Block commits and pushes on actual secrets (API keys, private keys, credentials)
- Skip files already in
.gitignore(temp keys, local configs) - Allow per-project exceptions without a global suppression list
- Work reliably — no silent failures
What We Built
Two hooks installed at .git/hooks/ in every project:
| Hook | Trigger | What It Scans | Blocks? |
|---|---|---|---|
| pre-commit | Every git commit |
Staged files only — hardcoded secrets, AWS keys, private keys | Yes, on critical secrets |
| pre-push | Every git push |
Full project — npm deps (CVEs), secrets, code patterns | Yes, on critical findings |
The pre-commit hook is fast — it only looks at what you're about to commit. The pre-push hook is thorough — it scans the entire project for dependency vulnerabilities and code-level security patterns before anything reaches the remote.
How Filtering Works
Raw scanner output goes through three stages before we decide to block or pass. Each stage removes a category of noise:
Scanner Output (raw)
│
▼
┌─────────────────────┐
│ Strip ANSI colors │ sed 's/\x1b\[[0-9;]*m//g'
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Skip .gitignore'd │ git check-ignore
│ files │ e.g. temp-key.json, .temp/pooler-url
└─────────────────────┘
│
▼
┌─────────────────────┐
│ Per-project │ .security-allowlist (fixed strings)
│ allowlist filter │ Only if file exists
└─────────────────────┘
│
▼
Real findings only → count → block or pass
Stage 1 strips terminal color codes so downstream grep can match text patterns reliably. Stage 2 filters out findings in files that git already ignores — no point flagging a secret in a file that will never be committed. Stage 3 checks a per-project allowlist for verified false positives.
Why Per-Project Allowlists (Not Global)
We considered three approaches to handling false positives:
| Approach | Risk | Decision |
|---|---|---|
| Built-in global allowlist | Silently hides real vulns in other projects — e.g., actual SQL injection flagged as "SQL string concatenation" | Rejected |
| Regex-based allowlist | .* or [ can suppress everything or crash grep |
Rejected |
Per-project .security-allowlist |
Explicit, auditable, project-scoped | Adopted |
A global allowlist is dangerous because a suppression rule that's correct for one project can mask a real vulnerability in another. And regex-based matching is a footgun — one bad pattern and your entire scanner is silenced.
Our .security-allowlist file uses fixed-string matching (grep -F). Each line is an exact scanner label. You can read the file and know exactly what's suppressed. No wildcards, no regular expressions, no surprises.
Three Bugs That Broke Everything
The initial implementation looked correct but failed silently in production. Here's what went wrong and how we fixed each one.
Bug 1: ANSI Color Codes Broke Grep
The scanner outputs colored text to make terminal output readable. But those invisible ANSI escape sequences (\x1b[31m, \x1b[0m) get embedded in the text. When our hook ran grep "^\[CRITICAL\]", it matched zero lines — because the actual text started with \x1b[31m[CRITICAL], not [CRITICAL].
The hook always passed. Every scan looked clean.
Fix: Strip ANSI codes first with sed 's/\x1b\[[0-9;]*m//g' before any grep operations.
Bug 2: Finding-to-File Mapping Was Fragile
We used grep -A1 -F "$finding" to grab each finding plus the file path on the next line. This broke when the same finding appeared in multiple files — grep would grab the wrong "File:" line.
Fix: Switch to record-aware sequential parsing that processes finding and File: lines as pairs, maintaining correct associations even with duplicates.
Bug 3: Allowlist Used Regex by Default
The .security-allowlist entries were passed to grep without the -F flag, so they were interpreted as extended regular expressions. A line containing . or [ could match far more than intended.
Fix: Use grep -F -f .security-allowlist — the -F flag treats every line as a literal fixed string.
| Bug | Before | After |
|---|---|---|
| ANSI in output | grep matched 0 lines — hook always passed |
sed strips ANSI first |
| Finding-to-file mapping | grep -A1 matched wrong file on duplicates |
Record-aware sequential parsing |
| Allowlist regex | Entries interpreted as ERE — could overmatch | grep -F fixed string matching |
Setup for New Projects
Every new project gets these hooks through our project initialization process:
- Copy hook scripts from templates to
.git/hooks/ - Run
bash .git/hooks/pre-pushto see raw findings - Create
.security-allowlistwith verified false positives only (exact scanner labels) - Re-run the hook — should show 0 critical findings
The allowlist starts empty. You only add entries after manually verifying each finding is a false positive. This keeps the default secure — new projects block everything until you explicitly say otherwise.
Key Takeaways
- Two hooks, two speeds: pre-commit scans staged files (fast), pre-push scans the full project (thorough)
- Strip ANSI before grep: invisible terminal color codes silently break text matching
- Per-project allowlists only: global suppression rules are a security risk across multiple codebases
- Fixed strings, not regex: use
grep -Ffor allowlists to prevent accidental over-matching - Test your hooks by breaking them: run the hook manually, check the output, and verify it actually blocks when it should
- Silent passes are worse than false positives: a hook that never blocks is a hook that doesn't work