The gate scanned only `*.md *.yml *.yaml *.json *.toml *.sh *.ts *.js`. 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. Discovered while deciding what else belongs in `dotfiles/`: `~/.config` carries `containers/systemd/postgres.env` with a real `POSTGRES_PASSWORD`, which the ignore list did not cover either (`git check-ignore` says so: `.env` does not match `postgres.env`). Token SHAPES now scan the entire staged diff, every file type; bcrypt hashes and `Authorization: Basic` headers joined the shape list. Credential ASSIGNMENTS are scanned in non-prose files only — the auto-learned memory tier is prose *about* credentials (7 such hits measured on the tracked tree), and a gate that fires there gets overridden until it means nothing. Markdown keeps shape detection, which is what it had before. Three invariants, each pinned by a test in tests/secret-gate.bats (13 tests, black-box: the hook is copied into a scratch repo and git itself runs it): - Fail closed. The first version of this rewrite passed `-I` to `git diff`, which is not valid there; the empty file list made every secret commit sail through, and a blind gate looks exactly like a clean tree. If the hook cannot read the staged diff it now refuses the commit. - Only ADDED lines can leak. The gate refused the redaction commit in this branch because the diff still carried the removed password — a gate that blocks the removal of a secret holds the leak hostage. - Path-valued variables (`…_FILE=`, `…_PATH=`) and placeholder/indirected values are not secrets, so the gate stays worth listening to. `.gitignore` quarantines `*.env` at any name, and gains the theme-media rule whose untracking lands in the next commit. `AGENTS.md` states the new contract. Refs #40
160 lines
5.6 KiB
Bash
Executable File
160 lines
5.6 KiB
Bash
Executable File
#!/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 <file> <content> -> 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 = <operator_token>'
|
|
[ "$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"* ]]
|
|
}
|