--- name: grep-secret-gate-hook-traps description: "Write a zero-dependency grep-based pre-commit secret gate that fails closed: the GNU grep -- one-shot trap, POSIX ERE limits, bracket collating elements, and rc=2-as-success in command substitution." --- # Fail-closed grep secret gate (pre-commit hook) Use when writing/auditing a bash pre-commit hook (or any CI tripwire) that scans staged content with `grep -E` for credential shapes — no gitleaks available. ## The four traps (all personally earned; each silently PASSES bad content) 1. **`--` is a ONE-SHOT end-of-options marker.** `grep -nE -- 'p1' -- 'p2' 'p3'` takes the later `--` and patterns as FILE names → rc=2, errors on stderr. Correct multi-pattern form: repeat `-e` for every pattern; never `--`. 2. **`if hits=$(... | grep ...)` treats rc=2 as success.** Any non-zero-but-not-1 (broken pattern, unreadable file) enters the `then` branch with EMPTY output. Never branch on the substitution's truthiness. Capture rc explicitly: `hits=$(...); rc=$?` then: `rc>=2` → BLOCK (fail closed — a dead regex is worse than none), `rc==1` → clean, `rc==0` → hits. 3. **POSIX ERE (`grep -E`) has no `(?:...)` groups.** Use plain `(...)`. 4. **`[:-]` inside a bracket expression starts a collating element** → `grep: repetition-operator operand invalid` (rc=2, see trap 2). Put `-` FIRST in every class: `[-A-Za-z0-9/+=_.:+]`. ## Gate structure that works - Scan **staged content**, not the working tree: `git show ":$f"` over `git diff --cached --name-only --diff-filter=ACM`. - Denylist doc/fixture paths (`README`, `docs?/`, `tests?/`, `*.example`) — else the hook blocks its own patterns and every test fixture. - Report `BLOCKED :` only — never echo the matched secret. - Wire with tracked `hooks/pre-commit` + `git config core.hooksPath hooks` (lives in the COMMON git dir config → one set in the main checkout covers every worktree). Fresh clones are unprotected until it's set; document it. - Worktrees lacking `hooks/` while `core.hooksPath` is set: git dies on commit (`cannot run ... No such file or directory`) — merge main in, don't unset. - Do NOT ship the hook in an npm plugin artifact (`files`): node_modules never runs git; pin its ABSENCE in the pack test instead. ## Test it red-first through the real seam (bats) `git init` a fixture repo, `cp` the real hook in, `git config core.hooksPath hooks`, then drive REAL `git commit` and assert: - credential-shaped `.env` (RFC-2606 domains / all-zero ids as inert fixtures) → non-zero AND `git log` count unchanged (blocks, not warns); - PEM header line → blocked; - clean prose → passes; - the hook committing ITSELF → passes (trap 1/3/4 usually die here); - exit-code masking: `bats ... | tail` returns tail's rc — never `&& commit` behind a piped suite, or you commit on red.