#!/usr/bin/env bats # tests/secret-gate.bats — black-box tests for .githooks/pre-commit # # Contract: a commit whose staged content contains a credential is blocked # (non-zero exit, stderr names the file); a commit whose content is benign # configuration or prose about credentials is allowed. The gate is copied into a # scratch repo so git itself invokes it — no sourcing, no internal state. setup() { HOOK="$BATS_TEST_DIRNAME/../.githooks/pre-commit" REPO="$(mktemp -d)/g" mkdir -p "$REPO/.git/hooks" git init -q "$REPO" git -C "$REPO" config user.email gate@test git -C "$REPO" config user.name gate cp "$HOOK" "$REPO/.git/hooks/pre-commit" chmod +x "$REPO/.git/hooks/pre-commit" } teardown() { rm -rf "$(dirname "$REPO")"; } # try_commit -> sets status/output from the real commit attempt try_commit() { local file="$1" content="$2" mkdir -p "$REPO/$(dirname "$file")" printf '%s\n' "$content" > "$REPO/$file" run git -C "$REPO" add -A run git -C "$REPO" commit -q -m "attempt: $file" } @test "blocks a low-entropy password in a .env file" { try_commit "postgres.env" 'POSTGRES_PASSWORD=hunter2corp2026' [ "$status" -ne 0 ] [[ "$output" == *"SECRET GATE"* ]] [[ "$output" == *"postgres.env"* ]] } @test "blocks a credential in a file type the extension whitelist used to skip" { try_commit "app.conf" '[app] secret_key = "rotatedvalue99"' [ "$status" -ne 0 ] [[ "$output" == *"app.conf"* ]] } @test "blocks a token shape in a plain text file" { try_commit "notes.txt" "backup remote: ghp_$(printf 'a%.0s' {1..36})" [ "$status" -ne 0 ] [[ "$output" == *"notes.txt"* ]] } @test "blocks a bcrypt hash" { try_commit "dump.txt" 'basicauth: $2b$14$'"$(printf 'a%.0s' {1..53})" [ "$status" -ne 0 ] [[ "$output" == *"dump.txt"* ]] } @test "blocks a credential in a quadlet unit" { try_commit "db.container" '[Container] Environment=POSTGRES_PASSWORD=devpostgres2026' [ "$status" -ne 0 ] [[ "$output" == *"db.container"* ]] } @test "prose about credentials in markdown is still allowed" { try_commit "docs/runbook.md" 'The GITEA_TOKEN moved out of .zshrc into secrets.env. Set password: user.set_password(value) and api_key = "sk-no-key-required".' [ "$status" -eq 0 ] } @test "placeholder and indirected values in config files are allowed" { try_commit "app.conf" '[db] PASSWORD = CHANGEME SECRET_KEY = ${VAULT_SECRET} api_key = "sk-no-key-required" token_file = ' [ "$status" -eq 0 ] } @test "environment variable indirection is not a secret" { try_commit "start.sh" '#!/bin/sh export GITEA_TOKEN="$GITEA_TOKEN" export API_KEY_FILE=/run/secrets/api_key' [ "$status" -eq 0 ] } @test "binary content never trips the gate" { head -c 4096 /dev/urandom > "$REPO/wall.jpg" run git -C "$REPO" add -A run git -C "$REPO" commit -q -m "binary" [ "$status" -eq 0 ] } @test "ALLOW_SECRETS still overrides, and says so" { try_commit "postgres.env" 'POSTGRES_PASSWORD=hunter2corp2026' [ "$status" -ne 0 ] mkdir -p "$REPO/.git/hooks" run env ALLOW_SECRETS=1 git -C "$REPO" commit -q -m "override" [ "$status" -eq 0 ] [[ "$output" == *"ALLOW_SECRETS set"* ]] } @test "this repo's ignore policy quarantines env files at any name" { repo="$BATS_TEST_DIRNAME/.." run git -C "$repo" check-ignore -q --no-index dotfiles/db/postgres.env [ "$status" -eq 0 ] run git -C "$repo" check-ignore -q --no-index dotfiles/app/frank-job-hunt.env [ "$status" -eq 0 ] run git -C "$repo" check-ignore -q --no-index dotfiles/bat/config [ "$status" -ne 0 ] } @test "the gate refuses to pass when it cannot read the staged diff" { # A gate that swallows a git error reports success on every commit. This is not # hypothetical: the 2026-10-20 rewrite passed `-I` to git diff, which is not a # valid flag there, and the empty file list made every secret commit sail through. nodir="$(mktemp -d)" run bash -c "cd '$nodir' && '$HOOK'" rm -rf "$nodir" [ "$status" -ne 0 ] [[ "$output" == *"cannot read the staged diff"* ]] } @test "deleting a credential is never blocked" { # The commit that removes a secret must always be possible, or the gate holds a # leak hostage. It did exactly that on 2026-10-20: matching removed lines made # the redaction commit look like the leak. Seed with the documented override, # then remove with an ordinary commit. printf 'POSTGRES_PASSWORD=hunter2corp2026\n' > "$REPO/db.conf" run git -C "$REPO" add -A run env ALLOW_SECRETS=1 git -C "$REPO" commit -q -m "seed a leak" [ "$status" -eq 0 ] printf '[db]\nPASSWORD = ${VAULT_SECRET}\n' > "$REPO/db.conf" run git -C "$REPO" add -A run git -C "$REPO" commit -q -m "redact the leaked password" [ "$status" -eq 0 ] } @test "the gate can commit itself" { # Fixtures have to look like credentials, so a gate that scans its own test file # without exemption can never be committed — it holds its own maintenance # hostage. The exemption is two exact paths, and it must announce itself. mkdir -p "$REPO/.githooks" "$REPO/tests" cp "$HOOK" "$REPO/.githooks/pre-commit" printf 'POSTGRES_PASSWORD=hunter2corp2026\n' > "$REPO/tests/secret-gate.bats" run git -C "$REPO" add -A run git -C "$REPO" commit -q -m "gate and its fixtures" [ "$status" -eq 0 ] [[ "$output" == *"self-test fixtures exempt"* ]] } @test "a credential hidden in some other test file is still caught" { # The exemption is a filename list, not the tests/ directory. mkdir -p "$REPO/tests" printf 'POSTGRES_PASSWORD=hunter2corp2026\n' > "$REPO/tests/other.bats" run git -C "$REPO" add -A run git -C "$REPO" commit -q -m "smuggle" [ "$status" -ne 0 ] [[ "$output" == *"tests/other.bats"* ]] } @test "a credential under a path git quotes is still read" { # core.quotePath is on by default, so `git diff --name-only` prints "caf\303\251/…" for a # non-ASCII directory. Feed that quoted string back to `git diff -- ` and the diff is # empty — and a gate that skips an unreadable file reports success. This is the fail-closed # promise breaking on its own terms, not a cosmetic quoting complaint. try_commit "café/postgres.env" 'POSTGRES_PASSWORD=hunter2corp2026' [ "$status" -ne 0 ] [[ "$output" == *"postgres.env"* ]] } @test "a placeholder word elsewhere on the line does not exempt a live value" { # The rule is about the VALUE. `# example` in a comment says nothing about the token the # line assigns, but a whole-line placeholder filter lets the comment vouch for the secret. try_commit "app.env" 'API_TOKEN=hunter2corp2026 # example, placeholder, changeme' [ "$status" -ne 0 ] [[ "$output" == *"app.env"* ]] } @test "each hit is attributed to the file it came from" { # The report is how the next agent finds the leak. If one file's last hit line runs into the # next file's name, the audit starts at a clean file and the leaker keeps its name. mkdir -p "$REPO/a" "$REPO/b" printf 'A_PASSWORD=hunter2corp2026\n' > "$REPO/a/one.env" printf 'B_PASSWORD=hunter2corp2026\n' > "$REPO/b/two.env" run git -C "$REPO" add -A run git -C "$REPO" commit -q -m "two leakers" [ "$status" -ne 0 ] printf '%s\n' "$output" | grep -qx ' a/one.env' printf '%s\n' "$output" | grep -qx ' b/two.env' ! printf '%s\n' "$output" | grep -qE 'hunter2corp2026 +b/two\.env' } @test "a documented fake id in prose is committable; a live-shaped id in the same file is not" { # Measured on blankpad 2026-10-21: the live-sync heartbeat failed three ticks in a row # because a managed-skill page documents how to write a secret gate and quotes AWS's own # placeholder id. A gate that blocks the machine tier over an inert example gets worked # around, and a workaround is the hole. The exemption reads the matched token, so a real # id in the same file still blocks. try_commit "docs.md" 'Inert fixtures look like AKIAIOSFODNN7EXAMPLE or AKIA0000000000000EXAMPLE.' [ "$status" -eq 0 ] try_commit "docs.md" 'Inert fixture AKIAIOSFODNN7EXAMPLE and a live one AKIAQZR4W7DBPCFG7V3Q.' [ "$status" -ne 0 ] [[ "$output" == *"docs.md"* ]] }