feat(security): commit-time secret guard that FAILS the build on a committed credential
PR Pipeline — Authorize → Validate → Review → Merge / auth (pull_request) Successful in 5s
PR Pipeline — Authorize → Validate → Review → Merge / validate (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / lint (pull_request) Successful in 18s
PR Pipeline — Authorize → Validate → Review → Merge / ai-review (pull_request) Successful in 3s
PR Pipeline — Authorize → Validate → Review → Merge / gate (pull_request) Successful in 1s

The 2026-09-17 purge removed six live credentials that had sat in this repo
for weeks, several in .md prose. Nothing blocked that class of commit, so a
warning in a stream nobody reads was the only signal. This adds a guard that
fails the build instead of warning.

Guard
- scripts/secret-scan.sh: bash + coreutils + grep/sed/awk + git only (the Gitea
  Actions runner executes job steps inside the runner container — BusyBox grep,
  no node/python). Modes: --tree (git-tracked, default), --path DIR (no git),
  --staged (pre-commit), --diff REF. Exit 1 on a finding, 2 on config error.
- scripts/secret-patterns.tsv: checked-in pattern list — sk-, sk-or-v1-,
  sk_live_, literal Bearer tokens, PVEAPIToken=, raw Authorization values, PEM
  private-key blocks, prose credential lines, and password/api_key/secret/token
  assignments carrying a literal value. Prose is scanned exactly like code.
- scripts/secret-allowlist.tsv: one entry per deliberate synthetic example, each
  with a reason. A missing reason is a hard error (fail closed). The 2026-09-17
  purge's `«vault: ...»` placeholders are listed explicitly rather than filtered
  by a general "vault"/"synthetic" rule, so a new occurrence still needs a
  reviewed, reasoned entry.
- A small inert-value classifier drops env refs, paths, dotted code access,
  variable names and right-truncated redactions; it does not know the words
  "synthetic"/"example", so a fabrication is always an explicit exception.
- Findings are printed with the credential masked; a scan never echoes a full
  secret into the log.

Wiring
- .gitea/workflows/pr-pipeline.yaml lint job: explicit "Committed-credential
  scan" step plus the self-test. A finding fails the required
  `pr-pipeline / lint` context, which the merge gate depends on.
- scripts/prose-lint.sh (the local gate): a "Secret scan" section, so
  `bash scripts/prose-lint.sh` before pushing is equivalent to CI.

Tests
- tests/test_secret_scan.sh: 20 cases. Plants pattern-matching fixtures in temp
  trees (outside every allowlisted path) and asserts the guard FAILS, including
  the --staged commit-time path; asserts the tree is quiet; asserts allowlisted
  text at an unlisted path still fails (path-explicit, not word-based); asserts
  a reasonless allowlist entry exits 2.

Verified: guard run against 8245716^ (the pre-fix revision, before the purge)
fails on the real OpenRouter/LiteLLM/Zulip/Proxmox/Stirling credentials; guard
run over the current tree is clean.
This commit is contained in:
2026-09-22 15:04:37 +00:00
parent bb1b65340e
commit 8f1e5eebc4
7 changed files with 533 additions and 1 deletions
+153
View File
@@ -0,0 +1,153 @@
#!/usr/bin/env bash
# test_secret_scan.sh — self-test for the commit-time secret guard.
#
# Run: bash tests/test_secret_scan.sh
# Exit: 0 all cases passed, 1 a case failed.
#
# WHY THIS FILE EXISTS: a scanner that is never observed to fail is not a guard.
# Every fixture below is fabricated and pattern-shaped; the test writes it to a
# temp tree (a path no allowlist entry covers) and asserts the guard FAILS. The
# same fixtures are deliberately listed in scripts/secret-allowlist.tsv, so the
# repo-wide tree scan stays quiet while a planted copy still bites — that is the
# difference between an explicit, reasoned exception and a guard trained to
# ignore a word.
#
# Only bash + coreutils + grep. No python/node: the Gitea runner executes job
# steps inside the runner container, which has neither.
set -uo pipefail
HERE=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
ROOT=$(cd -- "$HERE/.." && pwd)
SCAN="$ROOT/scripts/secret-scan.sh"
PASS=0
FAIL=0
LAST_OUT=""
ok() { PASS=$((PASS + 1)); echo " ✅ $1"; }
bad() { FAIL=$((FAIL + 1)); echo " ❌ $1"; }
expect_exit() { # expect_exit <want-code> <label> <cmd...>
local want="$1" label="$2"; shift 2
local rc
LAST_OUT=$("$@" 2>&1); rc=$?
if [ "$rc" -eq "$want" ]; then ok "$label (exit $rc)"; else
bad "$label (wanted exit $want, got $rc)"
printf '%s\n' "$LAST_OUT" | sed 's/^/ /' | head -8
fi
}
expect_contains() { # expect_contains <label> <needle>
if printf '%s' "$LAST_OUT" | grep -qF -- "$2"; then ok "$1"; else
bad "$1 (output did not mention: $2)"
fi
}
TMPROOT=$(mktemp -d)
trap 'rm -rf "$TMPROOT"' EXIT
echo "── secret-scan self-test ──"
# ── 1. Guard syntax ───────────────────────────────────────────────────────
expect_exit 0 "scanner parses with bash -n" bash -n "$SCAN"
# ── 2. Guard FAILS on planted, pattern-matching fixtures ──────────────────
mkdir -p "$TMPROOT/planted"
cat > "$TMPROOT/planted/ops.env" <<'EOF'
OPENROUTER_API_KEY=sk-or-v1-00000000000000000000000000000000000000000000000000000000deadbeef
EOF
expect_exit 1 "planted sk-or-v1 key fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
expect_contains "planted sk-or-v1 key names the openrouter-key rule" "[openrouter-key]"
rm -f "$TMPROOT/planted/"*
cat > "$TMPROOT/planted/curl.sh" <<'EOF'
curl -s -H "Authorization: Bearer aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbbbbbb" http://example.invalid/
EOF
expect_exit 1 "planted literal Bearer token fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
expect_contains "planted Bearer token names the bearer-token rule" "[bearer-token]"
rm -f "$TMPROOT/planted/"*
cat > "$TMPROOT/planted/pve.sh" <<'EOF'
AUTH="Authorization: PVEAPIToken=root@pam!monitor=11111111-2222-3333-4444-555555555555"
EOF
expect_exit 1 "planted Proxmox token fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
expect_contains "planted Proxmox token names the proxmox-token rule" "[proxmox-token]"
rm -f "$TMPROOT/planted/"*
cat > "$TMPROOT/planted/deploy-key.pem" <<'EOF'
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
-----END OPENSSH PRIVATE KEY-----
EOF
expect_exit 1 "planted PEM private key fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
expect_contains "planted PEM key names the private-key rule" "[private-key]"
# Prose is scanned exactly like code — the original exposures were in .md files.
rm -f "$TMPROOT/planted/"*
cat > "$TMPROOT/planted/handover.md" <<'EOF'
- Admin credentials: `admin` / `correct-horse-battery-staple`
EOF
expect_exit 1 "planted prose credential line fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
expect_contains "planted prose line names the cred-prose rule" "[cred-prose]"
rm -f "$TMPROOT/planted/"*
cat > "$TMPROOT/planted/config.env" <<'EOF'
DB_PASSWORD=correct-horse-battery-staple
EOF
expect_exit 1 "planted password assignment fails the guard" bash "$SCAN" --path "$TMPROOT/planted" --quiet
expect_contains "planted password assignment names the secret-assign rule" "[secret-assign]"
# ── 3. Guard stays QUIET on inert values and on the real tree ─────────────
mkdir -p "$TMPROOT/inert"
cat > "$TMPROOT/inert/config.yaml" <<'EOF'
api_key: not-needed
bearer_token=monitor_key
api_key: $LITELLM_API_KEY
EOF
expect_exit 0 "env refs, sentinels and variable names are not credentials" bash "$SCAN" --path "$TMPROOT/inert" --quiet
expect_exit 0 "current repo tree passes the guard" bash "$SCAN" --tree
expect_contains "tree run reports the allowlisted exceptions it applied" "allowlisted exception(s)"
# ── 4. Allowlist entries are path-explicit, not word-based ────────────────
# This exact line is allowlisted in infrastructure-control.prose.md; the same
# text at an unlisted path must still fail, proving the exception is per-file
# and reviewed, not a blanket "ignore the word vault".
rm -f "$TMPROOT/planted/"*
cat > "$TMPROOT/planted/unlisted.md" <<'EOF'
- Admin credentials: `«vault: infrastructure/production STIRLING_ADMIN_PASSWORD»`
EOF
expect_exit 1 "allowlisted text at an unlisted path still fails" bash "$SCAN" --path "$TMPROOT/planted" --quiet
# ── 5. Commit-time mode: the guard blocks a STAGED credential ─────────────
# A throwaway git repo with its own copy of the scanner, so this exercises the
# real pre-commit path (--staged) without touching this repo's index.
mkdir -p "$TMPROOT/repo/scripts"
cp "$SCAN" "$TMPROOT/repo/scripts/secret-scan.sh"
cp "$ROOT/scripts/secret-patterns.tsv" "$TMPROOT/repo/scripts/secret-patterns.tsv"
cp "$ROOT/scripts/secret-allowlist.tsv" "$TMPROOT/repo/scripts/secret-allowlist.tsv"
git -C "$TMPROOT/repo" init -q
git -C "$TMPROOT/repo" -c user.email=t@example.invalid -c user.name=test commit -q --allow-empty -m base
cat > "$TMPROOT/repo/planted.env" <<'EOF'
OPENROUTER_API_KEY=sk-or-v1-00000000000000000000000000000000000000000000000000000000deadbeef
EOF
git -C "$TMPROOT/repo" add planted.env
expect_exit 1 "staged credential fails at commit time (--staged)" bash "$TMPROOT/repo/scripts/secret-scan.sh" --staged --quiet
expect_contains "staged credential names the openrouter-key rule" "[openrouter-key]"
# ── 6. Fail closed: an allowlist entry without a reason is a hard error ───
mkdir -p "$TMPROOT/scanner" "$TMPROOT/clean"
cp "$SCAN" "$TMPROOT/scanner/secret-scan.sh"
cp "$ROOT/scripts/secret-patterns.tsv" "$TMPROOT/scanner/secret-patterns.tsv"
printf '*\t*.md\twhatever\n' > "$TMPROOT/scanner/secret-allowlist.tsv"
echo "placeholder" > "$TMPROOT/clean/ok.md"
expect_exit 2 "allowlist entry with no reason fails closed" bash "$TMPROOT/scanner/secret-scan.sh" --path "$TMPROOT/clean" --quiet
# ── Verdict ───────────────────────────────────────────────────────────────
echo ""
if [ "$FAIL" -gt 0 ]; then
echo "❌ secret-scan self-test FAILED — $PASS passed, $FAIL failed"
exit 1
fi
echo "✅ secret-scan self-test passed ($PASS cases)"