Remove a live credential from a test fixture, and scan for the next one #33

Merged
jknapp merged 2 commits from fix/test-fixture-secret into main 2026-08-25 18:54:56 +00:00
5 changed files with 186 additions and 1 deletions
Showing only changes of commit 68b73a9102 - Show all commits
+32
View File
@@ -0,0 +1,32 @@
name: Secret Scan
# **No `paths:` filter, deliberately.** The credential this exists for lived in
# `app/src-tauri/src/docker/container.rs`, which `build.yml` would have skipped —
# that workflow only runs for `container/**`. A scan that can be avoided by
# touching the wrong directory is not a scan.
#
# This is the half of the check that nobody can bypass. The pre-commit hook in
# `.githooks/` is faster and friendlier, but it is opt-in per clone and
# `--no-verify` skips it; both are true of every git hook and neither is fixable
# from inside a repository.
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
jobs:
scan:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
# The whole tracked tree, not just the diff. Scanning a range is cheaper
# but depends on getting the range right across pushes, force-pushes,
# merges and PR events — and a wrong range fails *open*. The full scan
# takes under half a second on this repository and cannot be evaded by
# arranging for the interesting commit to sit outside the window.
- name: Scan tracked files for credentials
run: sh scripts/scan-secrets.sh --tracked
+14
View File
@@ -0,0 +1,14 @@
#!/bin/sh
# Refuse a commit that adds something shaped like a live credential.
#
# Installed by pointing git at this directory:
#
# git config core.hooksPath .githooks
#
# which `npm run hooks` in app/ does for you. It is per-clone — git will not let
# a repository configure its own hooks path, for the obvious reason that cloning
# a repo would then be enough to run its code. So this is opt-in on every
# machine, `--no-verify` skips it, and neither of those is a flaw to fix here:
# the CI job in `.gitea/workflows/build.yml` is the half nobody can bypass. The
# hook exists to tell you in one second rather than in five minutes.
exec "$(git rev-parse --show-toplevel)/scripts/scan-secrets.sh" --staged
+27
View File
@@ -525,6 +525,33 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
`models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
## Secrets
**`scripts/scan-secrets.sh` refuses a commit that adds something shaped like a live
credential.** Enable the hook once per clone with `npm run hooks` (from `app/`), which sets
`core.hooksPath` to `.githooks`. A repository cannot configure its own hooks path — cloning it
would then be enough to run its code — so this is opt-in everywhere, and `--no-verify` skips it.
The `Secret Scan` workflow is the half nobody can bypass; it carries **no `paths:` filter**, on
purpose, because the incident that prompted all this lived in `app/**` and `build.yml` only runs
for `container/**`.
Three rules, and the second half of the third is what keeps it usable: vendor-prefixed tokens
(`ghp_`, `sk-`, `AKIA`, `xox`, …), `BEGIN … PRIVATE KEY` blocks, and an opaque literal assigned to
a secret-shaped name. That last one needs **both** halves — the identifier must read as a
credential *and* the whole literal must be hex or base64 with no word structure. Name-proximity
alone flags `secure::get_project_secret(&id, "aws-secret-access-key")`, which is a keychain key
name; the literal test is what excludes it. Measured against the tree: 0 false positives, and it
catches the real incident (`9b2f4fe`) when replayed.
A line ending `pragma: allowlist secret` is skipped. Make a fixture obviously fake before reaching
for it.
**Why this exists:** `the_custom_env_fingerprint_never_carries_the_value` used the maintainer's
real Gitea **site-admin** token as its fixture — a test about secrets not escaping, leaking one. It
survived 92 commits and fourteen days in the public GitHub mirror, past five audit rounds and two
independent reviews, because every one of them read the code under change and this sat in a test
nobody had reason to open. Fixtures are never live values; there is no case where they need to be.
## Testing
Frontend tests use Vitest with jsdom environment and React Testing Library. Setup file at `src/test/setup.ts`. Run a single test file:
+2 -1
View File
@@ -9,7 +9,8 @@
"preview": "vite preview",
"tauri": "tauri",
"test": "vitest run",
"test:watch": "vitest"
"test:watch": "vitest",
"hooks": "git -C .. config core.hooksPath .githooks && echo \"pre-commit secret scan enabled\""
},
"dependencies": {
"@tauri-apps/api": "^2",
+111
View File
@@ -0,0 +1,111 @@
#!/bin/sh
# Refuse to let a live credential into the repository.
#
# Written after one got in: `the_custom_env_fingerprint_never_carries_the_value`
# used the maintainer's real Gitea site-admin token as its fixture. It survived
# 92 commits and fourteen days in a public mirror, past five audit rounds and two
# independent reviews — because every one of those looked at the code under
# change, and this sat in a test nobody had reason to open. A grep would have
# caught it on the first day. This is that grep.
#
# Usage:
# scan-secrets.sh --staged what `git commit` is about to record (the hook)
# scan-secrets.sh --range A..B every line added between two commits (CI)
# scan-secrets.sh --tracked every tracked file, as it stands now
#
# Exit 0 clean, 1 on a finding, 2 on misuse.
#
# ## Why it scans *added lines* and not the whole file
#
# The repository already contains long opaque strings — 317 literals of 32+
# characters, almost all of them legitimate. A scanner that failed on those would
# be turned off within a day, which is the normal way this kind of check dies.
# Judging only what a commit *adds* keeps the signal where a person can act on it.
#
# ## Escape hatch
#
# A line carrying `pragma: allowlist secret` is skipped. Deliberately wordy: it
# should be uncomfortable enough to type that it is read as a claim, and it
# leaves something greppable behind.
set -eu
MODE="${1:---staged}"
RANGE="${2:-}"
case "$MODE" in
--staged) ADDED=$(git diff --cached --unified=0 --no-color -- . 2>/dev/null || true) ;;
--range) [ -n "$RANGE" ] || { echo "scan-secrets: --range needs A..B" >&2; exit 2; }
ADDED=$(git diff --unified=0 --no-color "$RANGE" -- . 2>/dev/null || true) ;;
--tracked)
# `grep -Iq .` first: without it a binary blob's bytes reach the
# rules below, and GNU grep answers "binary file matches" instead
# of the line — so a real finding inside one would be reported as
# a sentence nobody can act on, and a stray NUL can end the scan
# early. Text files only; binaries are not where source secrets
# live, and `--staged` never sees them either (git emits
# "Binary files differ", not content).
ADDED=$(git ls-files -z \
| xargs -0 -I{} sh -c 'grep -Iq . "{}" 2>/dev/null && sed "s/^/+/" "{}" 2>/dev/null' \
|| true) ;;
*) echo "scan-secrets: unknown mode $MODE" >&2; exit 2 ;;
esac
# Only added lines; drop diff headers (+++ b/path) so a filename never matches.
CANDIDATES=$(printf '%s\n' "$ADDED" \
| grep '^+' \
| grep -v '^+++' \
| grep -v 'pragma: allowlist secret' \
|| true)
[ -n "$CANDIDATES" ] || exit 0
FOUND=0
report() {
FOUND=1
printf '\n %s\n' "$1"
printf '%s\n' "$2" | sed 's/^/ /' | cut -c1-160
}
# --- Rule 1: vendor-issued credentials. Shape alone identifies these, so there
# --- is no false-positive story to tell and no identifier context needed.
VENDOR=$(printf '%s\n' "$CANDIDATES" | grep -nE \
'gh[pousr]_[A-Za-z0-9]{20,}|github_pat_[A-Za-z0-9_]{20,}|glpat-[A-Za-z0-9_-]{20,}|xox[baprs]-[A-Za-z0-9-]{10,}|sk-[A-Za-z0-9]{32,}|AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|ya29\.[A-Za-z0-9_-]{20,}|AIza[0-9A-Za-z_-]{35}|npm_[A-Za-z0-9]{36}|dckr_pat_[A-Za-z0-9_-]{20,}' \
|| true)
[ -n "$VENDOR" ] && report "A vendor-issued credential (its prefix identifies the provider):" "$VENDOR"
# --- Rule 2: private key material.
KEYS=$(printf '%s\n' "$CANDIDATES" | grep -nE -- '-----BEGIN [A-Z ]*PRIVATE KEY-----' || true)
[ -n "$KEYS" ] && report "Private key material:" "$KEYS"
# --- Rule 3: an opaque literal assigned to a secret-shaped name.
#
# This is the rule that would have caught the Gitea token — `let secret =
# "<40 hex>"`. Both halves are required, and that is what keeps it usable:
# the *name* must read as a credential, and the *whole literal* must be hex or
# base64 with no word structure. `"aws-secret-access-key"` is a keychain key
# name sitting right next to the word `secret`, and its hyphens are what keep it
# out; measured against the tree, name-proximity alone flagged four lines of
# which three were that shape.
OPAQUE=$(printf '%s\n' "$CANDIDATES" | grep -niE \
'(secret|token|api[_-]?key|apikey|passwo?rd|passwd|credential|auth[_-]?(key|token))[^A-Za-z0-9]{0,12}[:=][^"'"'"']{0,12}["'"'"']([0-9a-fA-F]{32,}|[A-Za-z0-9+/]{40,}={0,2})["'"'"']' \
|| true)
[ -n "$OPAQUE" ] && report "An opaque literal assigned to a secret-shaped name:" "$OPAQUE"
if [ "$FOUND" -eq 1 ]; then
cat >&2 <<'MSG'
────────────────────────────────────────────────────────────────────────
Refusing the commit: it adds something shaped like a live credential.
If it IS live: do not amend and move on. Rotate it first — anything that
reaches a branch is on the mirror, and the mirror is public.
If it is genuinely not a secret — a fixture, a public key id, test data —
make the literal obviously fake ("not-a-real-token-0000…"), or append
`pragma: allowlist secret` to the line to say so on the record.
────────────────────────────────────────────────────────────────────────
MSG
exit 1
fi
exit 0