Engineering

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:

  1. Copy hook scripts from templates to .git/hooks/
  2. Run bash .git/hooks/pre-push to see raw findings
  3. Create .security-allowlist with verified false positives only (exact scanner labels)
  4. 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 -F for 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
Stay updated

Get new articles in your inbox.

No spam, just technical articles and product updates.