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.
154 lines
7.1 KiB
Bash
Executable File
154 lines
7.1 KiB
Bash
Executable File
#!/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)"
|