|
| 1 | +#!/usr/bin/env sh |
| 2 | +# ───────────────────────────────────────────────────────────────────────────── |
| 3 | +# Staged-content secret scanner. |
| 4 | +# |
| 5 | +# Why this exists: GitHub's own secret scanning and push protection are not |
| 6 | +# available here — they are free for public repositories only, and this is a |
| 7 | +# private repo on a free-tier org ("Secret scanning is not available for this |
| 8 | +# repository", HTTP 422). This hook is the substitute, in the same spirit as the |
| 9 | +# pre-push guard in the sibling repositories. |
| 10 | +# |
| 11 | +# What it is guarding against specifically: this repository's whole subject is a |
| 12 | +# password vault. A `bw list items` dump is every password, TOTP seed and note in |
| 13 | +# PLAINTEXT, and git keeps it in history even after a later delete. That is the |
| 14 | +# accident worth spending a hook on. |
| 15 | +# |
| 16 | +# Design rule: NO NOISE. A hook that cries wolf is a hook everyone bypasses, and |
| 17 | +# a habitually bypassed hook is worse than none. Every pattern below is either an |
| 18 | +# exact vendor token shape or a structure that has no innocent explanation. |
| 19 | +# Entropy heuristics are deliberately absent — they are the usual source of the |
| 20 | +# false positives that kill these things. |
| 21 | +# |
| 22 | +# Bypass once (and think about why): git commit --no-verify |
| 23 | +# ───────────────────────────────────────────────────────────────────────────── |
| 24 | +set -u |
| 25 | + |
| 26 | +RED='\033[0;31m'; YELLOW='\033[0;33m'; DIM='\033[2m'; OFF='\033[0m' |
| 27 | +findings=0 |
| 28 | + |
| 29 | +report() { |
| 30 | + findings=$((findings + 1)) |
| 31 | + printf "${RED}✋ %s${OFF}\n %s\n" "$1" "$2" >&2 |
| 32 | +} |
| 33 | + |
| 34 | +# Staged files, added/copied/modified only — renames and deletions carry no new content. |
| 35 | +staged=$(git diff --cached --name-only --diff-filter=ACM) |
| 36 | +[ -z "$staged" ] && exit 0 |
| 37 | + |
| 38 | +for file in $staged; do |
| 39 | + # The scanner and its documentation necessarily contain the patterns themselves. |
| 40 | + case "$file" in |
| 41 | + .husky/scan-secrets.sh|.gitignore|docs/security*|*.md) continue ;; |
| 42 | + esac |
| 43 | + |
| 44 | + # ── 1. Filenames that are vault dumps by convention ────────────────────── |
| 45 | + case "$(basename "$file")" in |
| 46 | + items.json|folders.json|report.json|merge-log.json|REVIEW.md) |
| 47 | + report "Vault dump staged: $file" \ |
| 48 | + "This is vault data, not source. Remove it: git restore --staged '$file'" |
| 49 | + continue |
| 50 | + ;; |
| 51 | + esac |
| 52 | + |
| 53 | + # Binary files have no text to scan. |
| 54 | + git diff --cached --numstat -- "$file" | grep -q '^-' && continue |
| 55 | + |
| 56 | + added=$(git diff --cached -U0 -- "$file" | grep '^+' | grep -v '^+++') |
| 57 | + [ -z "$added" ] && continue |
| 58 | + |
| 59 | + # ── 2. A Bitwarden EncString ───────────────────────────────────────────── |
| 60 | + # "<type>.<b64 iv>|<b64 ciphertext>|<b64 mac>" — Bitwarden's own ciphertext |
| 61 | + # format. Nothing else looks like this; its presence means vault data. |
| 62 | + if printf '%s' "$added" | grep -Eq '[0-9]\.[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}\|[A-Za-z0-9+/=]{20,}'; then |
| 63 | + report "Bitwarden EncString in $file" \ |
| 64 | + "That is encrypted vault content. It does not belong in source." |
| 65 | + fi |
| 66 | + |
| 67 | + # ── 3. The shape of a decrypted vault item ─────────────────────────────── |
| 68 | + # bw stamps every object it emits with "object":"item"/"folder". Combined with |
| 69 | + # a password or TOTP field, this is a decrypted dump rather than a fixture. |
| 70 | + if printf '%s' "$added" | grep -Eq '"object"[[:space:]]*:[[:space:]]*"(item|folder|cipherDetails)"' \ |
| 71 | + && printf '%s' "$added" | grep -Eq '"(password|totp|privateKey)"[[:space:]]*:[[:space:]]*"[^"]{4,}'; then |
| 72 | + report "Decrypted vault item in $file" \ |
| 73 | + "Looks like 'bw list items' output with real secrets. Use a redacted fixture." |
| 74 | + fi |
| 75 | + |
| 76 | + # ── 4. Private keys ────────────────────────────────────────────────────── |
| 77 | + if printf '%s' "$added" | grep -Eq -- '-----BEGIN [A-Z ]*PRIVATE KEY-----'; then |
| 78 | + report "Private key block in $file" "Never commit a private key." |
| 79 | + fi |
| 80 | + |
| 81 | + # ── 5. Exact vendor token shapes ───────────────────────────────────────── |
| 82 | + # Each of these is a documented, unambiguous prefix+length. No guessing. |
| 83 | + if printf '%s' "$added" | grep -Eq '(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{50,}'; then |
| 84 | + report "GitHub token in $file" "Revoke it, then remove it from the change." |
| 85 | + fi |
| 86 | + if printf '%s' "$added" | grep -Eq 'AKIA[0-9A-Z]{16}'; then |
| 87 | + report "AWS access key id in $file" "Revoke it immediately." |
| 88 | + fi |
| 89 | + if printf '%s' "$added" | grep -Eq 'xox[baprs]-[A-Za-z0-9-]{10,}'; then |
| 90 | + report "Slack token in $file" "Revoke it immediately." |
| 91 | + fi |
| 92 | + if printf '%s' "$added" | grep -Eq '(sk|rk)_(live|test)_[A-Za-z0-9]{20,}'; then |
| 93 | + report "Stripe key in $file" "Revoke it immediately." |
| 94 | + fi |
| 95 | + if printf '%s' "$added" | grep -Eq 'AIza[0-9A-Za-z_-]{35}'; then |
| 96 | + report "Google API key in $file" "Revoke it immediately." |
| 97 | + fi |
| 98 | + # NuGet keys matter here: the sibling repos publish packages. |
| 99 | + if printf '%s' "$added" | grep -Eq 'oy2[a-z0-9]{43}'; then |
| 100 | + report "NuGet API key in $file" "Revoke it on nuget.org." |
| 101 | + fi |
| 102 | + |
| 103 | + # ── 6. An assigned BW_SESSION ──────────────────────────────────────────── |
| 104 | + # The session key decrypts the entire vault. Only flagged when it is being |
| 105 | + # given a literal value — referring to the variable is normal and fine. |
| 106 | + if printf '%s' "$added" | grep -Eq 'BW_SESSION[[:space:]]*=[[:space:]]*["'"'"']?[A-Za-z0-9+/]{40,}={0,2}'; then |
| 107 | + report "Hard-coded BW_SESSION in $file" \ |
| 108 | + "That key decrypts the whole vault. Read it from the environment instead." |
| 109 | + fi |
| 110 | + |
| 111 | + # ── 7. A master password assigned in code ──────────────────────────────── |
| 112 | + if printf '%s' "$added" | grep -Eiq '(master_?password|masterpw)[[:space:]]*[=:][[:space:]]*["'"'"'][^"'"'"']{6,}'; then |
| 113 | + report "Hard-coded master password in $file" "Never. Prompt for it." |
| 114 | + fi |
| 115 | +done |
| 116 | + |
| 117 | +if [ "$findings" -gt 0 ]; then |
| 118 | + printf "\n${YELLOW}%s finding(s). Commit blocked.${OFF}\n" "$findings" >&2 |
| 119 | + printf "${DIM} Genuinely a false positive? Bypass once: git commit --no-verify${OFF}\n" >&2 |
| 120 | + printf "${DIM} If a real secret already reached a commit, rotate it — deleting it later${OFF}\n" >&2 |
| 121 | + printf "${DIM} does not remove it from git history.${OFF}\n" >&2 |
| 122 | + exit 1 |
| 123 | +fi |
| 124 | + |
| 125 | +exit 0 |
0 commit comments