Remove a live credential from a test fixture, and scan for the next one #33
@@ -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
|
||||
Executable
+14
@@ -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
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -4177,7 +4177,12 @@ mod tests {
|
||||
// It goes into `triple-c.custom-env-fingerprint`, which `docker inspect`
|
||||
// hands to anything on the host, `docker commit` copies onto the
|
||||
// project's snapshot image, and the recreation check logs on a mismatch.
|
||||
let secret = "33da01c1b320644920c20d6b5e0a1c6b3c3451c2";
|
||||
// **Never a real credential.** This literal was the maintainer's actual
|
||||
// Gitea token for fourteen days and ninety-two commits, on a public
|
||||
// mirror — in a test whose whole subject is that secrets do not escape.
|
||||
// A fixture only has to be *a value*; it never has to be a live one, so
|
||||
// there is no version of this that justifies pasting something real.
|
||||
let secret = "not-a-real-token-0000000000000000000000";
|
||||
let fp = compute_env_fingerprint(&[EnvVar {
|
||||
key: "TEA_TOKEN".to_string(),
|
||||
value: secret.to_string(),
|
||||
|
||||
Executable
+111
@@ -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
|
||||
Reference in New Issue
Block a user