#!/usr/bin/env bash # Secret gate: block any commit whose staged diff contains live-secret shapes or # credential assignments. This repo IS the deployment and every workstation # pulls it, so a committed secret is instantly in every agent's context window # on the fleet. Deliberate override: ALLOW_SECRETS=1 git commit ... # # Two rules, deliberately different in scope: # # 1. Token SHAPES are scanned across the ENTIRE staged diff, whatever the file # type. The first version of this gate whitelisted extensions # (*.md *.yml *.yaml *.json *.toml *.sh *.ts *.js), so a `postgres.env`, a # `.container` quadlet, an `.ini`, or an extension-less dotfile — exactly # the shapes the dotfiles tier is made of — was never read at all. # 2. Credential ASSIGNMENTS (PASSWORD=…, secret_key: …) are scanned only in # non-prose files. The auto-learned memory tier is prose ABOUT credentials, # and a gate that fires there gets overridden until it means nothing; the # 2026-10-20 audit measured 7 such prose hits on the tracked tree. Markdown # keeps shape detection only, which is what it had before. # # Bcrypt hashes and Basic-auth headers joined the shape list on 2026-10-20 # (issue #40) after a live Caddy basicauth hash plus its plaintext password — # and the Pi-hole internal password — were found in a tracked memory file. set -uo pipefail shapes='sk-[A-Za-z0-9]{18,}|ghp_[A-Za-z0-9]{36}|github_pat_[A-Za-z0-9_]{22,}|glpat-[A-Za-z0-9_-]{20}|xox[baprs]-[A-Za-z0-9-]{10,}|AKIA[A-Z0-9]{16}|ASIA[A-Z0-9]{16}|BEGIN [A-Z ]*PRIVATE KEY|aes_[A-Za-z0-9/+]{40}|eyJ[A-Za-z0-9_-]{20,}\.ey|\$2[aby]\$[0-9]{2}\$[./A-Za-z0-9]{20,}|Authorization:[[:space:]]*[Bb]asic[[:space:]]+[A-Za-z0-9+/=]{24,}' # `newpass1234`, `secret_key: "rotated-value"` — an assignment with a literal # value. `$VAR`, `${VAULT}`, and empty values cannot match. assign='[A-Za-z0-9_]*(PASSW|SECRET|TOKEN|API_?KEY|ACCESS_KEY|PRIVATE_KEY|CREDENTIAL)[A-Za-z0-9_]*[[:space:]]*[:=][[:space:]]*["'"'"']?[A-Za-z0-9/+._@-]{8,}' placeholders='changeme|change[-_]|placeholder|redacted|example|sample|dummy|nokey|no[-_]key|not[-_]needed|your[-_]|localhost|xxxx|yyyy|\$\{|<[a-z]|\*\*\*|%s|null|false|true' # Exemptions: a variable naming a location (`…_FILE=`, `…_PATH=`) or a value # that is a filesystem path is a pointer, not a credential. Flagging those is # how a gate gets permanently overridden. assign_exempt='(_FILE|_PATH|_DIR|_URL|_ENDPOINT|_SOCKET)[[:space:]]*[:=]|[:=][[:space:]]*["'"'"']?(/|\.\./)' # Fail closed: a gate that cannot read the staged diff must not report success. if ! files=$(git diff --cached --diff-filter=ACM --name-only 2>&1); then { echo "SECRET GATE: cannot read the staged diff — refusing to commit." printf ' %s\n' "$files" echo "Override deliberately: ALLOW_SECRETS=1 git commit ..." } >&2 [ -n "${ALLOW_SECRETS:-}" ] && exit 0 exit 1 fi # stderr is captured so the failure branch can quote it; on success it must not # leak a `warning:` line into the file list and have it mistaken for a path. files=$(printf '%s\n' "$files" | grep -vE '^(warning|fatal|error):' || true) [ -z "${files//[[:space:]]/}" ] && exit 0 report="" exempt="" count=0 while IFS= read -r f; do [ -z "$f" ] && continue # Self-exemption, deliberately an exact filename list and not `tests/`: the # gate's fixtures must contain credential-shaped strings or there is nothing to # test against, and this hook documents the shapes it looks for. A secret parked # in any other test file is still caught. case "$f" in .githooks/pre-commit|tests/secret-gate.bats) exempt="$exempt $f"; continue ;; esac diff=$(git diff --cached -U0 -- "$f") [ -z "$diff" ] && continue # Only ADDED lines can leak. Matching removals too blocks the very commit that # deletes a credential — which is exactly what happened on 2026-10-20, when the # gate refused the redaction of the password it exists to catch. added=$(printf '%s\n' "$diff" | grep -E '^\+' | grep -vE '^\+\+\+' | sed 's/^+//' || true) [ -z "$added" ] && continue hits=$(printf '%s\n' "$added" | grep -Ei "$shapes" || true) if [ "${f##*.}" != "md" ]; then assigns=$(printf '%s\n' "$added" | grep -Ei "$assign" | grep -Ev "$assign_exempt" | grep -Eiv "$placeholders" || true) hits=$(printf '%s\n%s\n' "$hits" "$assigns" | sed '/^[[:space:]]*$/d') fi if [ -n "$hits" ]; then n=$(printf '%s\n' "$hits" | wc -l) count=$((count + n)) report+=" $f"$'\n'"$(printf '%s\n' "$hits" | cut -c1-100 | sed 's/^/ /')" fi done <<< "$files" # Say so out loud: an exemption that prints nothing is how a carve-out becomes # invisible, and invisible is the property a secret wants. if [ -n "$exempt" ]; then printf 'secret-gate: self-test fixtures exempt:%s\n' "$exempt" >&2 fi if [ -n "${ALLOW_SECRETS:-}" ]; then [ -n "$report" ] && printf 'secret-gate: ALLOW_SECRETS set, allowing %s secret-shaped string(s) through.\n' "$count" exit 0 fi if [ -n "$report" ]; then { echo "SECRET GATE: staged diff contains $count secret-shaped string(s):" printf '%s\n' "$report" echo "Fix it, or override deliberately: ALLOW_SECRETS=1 git commit ..." echo "A credential that reaches a tracked file is already in history on every" echo "machine that pulls this repo — deleting it later does not un-leak it." } >&2 exit 1 fi exit 0