diff --git a/docs/architecture/scripts/secrets.sh b/docs/architecture/scripts/secrets.sh index e733c8f..b3bd38c 100755 --- a/docs/architecture/scripts/secrets.sh +++ b/docs/architecture/scripts/secrets.sh @@ -25,9 +25,31 @@ # # bash scripts/secrets.sh # staged changes (use in pre-commit) # bash scripts/secrets.sh --tracked # everything tracked, for an audit +# bash scripts/secrets.sh --built dist/ # the artifact users receive # SECRETS_PATTERN_FILE=src/lib/log.ts bash scripts/secrets.sh # bash scripts/secrets.sh --allow docs/examples/ # +# ## --built, and why the repository is the wrong place to stop +# +# The two modes above scan what is in git. Neither sees the bundle, which is the +# only artifact a user actually receives — and a key can reach it without ever +# being committed, from an environment variable inlined at build time. Somebody +# auditing applications of this kind reported finding hardcoded credentials in +# the frontend bundle of seven of eight in a single week. +# +# So --built walks a build directory instead, with two tiers of result: +# +# findings, which fail eyJ (a JWT header), service_role, apikey=, Bearer, +# plus every pattern the other modes use +# noted, which do not anon, VITE_, REACT_APP_, NEXT_PUBLIC_ +# +# The second tier is printed and changes nothing. Those prefixes mean +# "deliberately shipped to the browser", so failing on them would be a +# permanently red gate, and a gate that is always red is one everybody has +# learned to ignore. But they are worth *seeing* enumerated: a Supabase anon key +# is safe exactly as far as row-level security makes it safe, and knowing it is +# out there is the input to that judgement rather than a substitute for it. +# # ## What it cannot do # # It reads the working tree and the index. **A secret already committed is still @@ -47,17 +69,44 @@ cd "$(git rev-parse --show-toplevel 2>/dev/null)" || { say() { printf 'secrets: %s\n' "$*" >&2; } MODE="staged" +BUILT_DIR="" ALLOW=() while [ $# -gt 0 ]; do case "$1" in --tracked) MODE="tracked"; shift ;; --staged) MODE="staged"; shift ;; + --built) + MODE="built" + BUILT_DIR="${2:-}" + [ -n "$BUILT_DIR" ] || { say "--built needs a directory (dist/, build/, .next/…)"; exit 2; } + case "$BUILT_DIR" in -*) say "--built needs a directory, got '$BUILT_DIR'"; exit 2 ;; esac + shift 2 ;; --allow) ALLOW+=("${2:-}"); shift 2 ;; *) say "unknown argument: $1"; exit 2 ;; esac done +# Shapes that reach a bundle and should not. Added to PATTERNS below only in +# --built mode: `eyJ` is the base64 of `{"` that every JWT header starts with, +# and it is far too eager to run against source, where it matches ordinary +# base64. In a bundle it is worth the noise. +BUILT_PATTERNS=( + 'eyJ[A-Za-z0-9_-]{10,}' + '\bservice_role\b' + '\bapikey["'"'"'[:space:]]*[:=]' +) + +# Shapes that are *meant* to be public. Reported, never failed on — see the +# header. A finding you cannot act on is a finding that teaches people to skip +# the report. +NOTED_PATTERNS=( + '\bNEXT_PUBLIC_[A-Z0-9_]+' + '\bVITE_[A-Z0-9_]+' + '\bREACT_APP_[A-Z0-9_]+' + '\banon["'"'"'[:space:]]*[:=]' +) + # The built-in set. Deliberately shapes that are *structurally* credential-like # rather than words that merely appear near credentials — `password` in a # sentence is not a leak, and a scanner that says it is gets muted. @@ -105,7 +154,35 @@ if [ -n "${SECRETS_PATTERN_FILE:-}" ] && [ -f "$SECRETS_PATTERN_FILE" ]; then fi fi -if [ "$MODE" = "staged" ]; then +if [ "$MODE" = "built" ]; then + [ -d "$BUILT_DIR" ] || { say "no such directory: $BUILT_DIR"; say "Nothing was scanned, which is not a pass."; exit 2; } + + CONTENT="" + WHAT="the built output in $BUILT_DIR" + PATTERNS+=("${BUILT_PATTERNS[@]}") + + while IFS= read -r file; do + skip="" + for allowed in ${ALLOW[@]+"${ALLOW[@]}"}; do + case "$file" in *"$allowed"*) skip="yes" ;; esac + done + [ -n "$skip" ] && continue + + # Source maps are the build's own copy of the source and would double every + # finding; they are worth scanning on purpose, not by accident. + case "$file" in *.map) continue ;; esac + + file "$file" 2>/dev/null | grep -q "text" || continue + + # Relative to the build directory, not the absolute path find produced. + # The report truncates each line to keep a secret off the terminal, and an + # absolute path in a temp directory can consume that budget entirely -- + # leaving a finding that names a file and shows nothing about the match. + rel="${file#"$BUILT_DIR"/}" + CONTENT+="$(sed "s|^|${rel}: |" "$file")"$'\n' + done < <(find "$BUILT_DIR" -type f -size -20M 2>/dev/null) + +elif [ "$MODE" = "staged" ]; then # Added lines only. A removed line containing a token is somebody deleting # one, which is the opposite of a leak. CONTENT="$(git diff --cached --unified=0 --no-color | grep '^+' | grep -v '^+++' || true)" @@ -148,14 +225,40 @@ for pattern in "${PATTERNS[@]}"; do done [ -n "$skip" ] && continue - # The line is printed truncated and the match is never echoed in full — a - # scanner that prints the secret it found has published it to the terminal - # scrollback, the CI log, and wherever that log is shipped. + # The line is truncated to 120 characters, which bounds how much of a long + # value reaches the terminal. It does NOT redact the match: a credential + # shorter than the budget is printed whole, and a scanner that prints the + # secret it found has published it to the scrollback, the CI log, and + # wherever that log is shipped. Masking the matched span is the real fix and + # is tracked separately -- until then, treat this output as sensitive. printf ' %.120s…\n' "$hit" found=$((found + 1)) done < <(printf '%s\n' "$CONTENT" | grep -nEI "$pattern" 2>/dev/null | head -20) done +# The public-by-design tier. Printed, counted, and deliberately not fatal. +if [ "$MODE" = "built" ]; then + noted=0 + + for pattern in "${NOTED_PATTERNS[@]}"; do + while IFS= read -r hit; do + [ -n "$hit" ] || continue + if [ "$noted" -eq 0 ]; then + say "shipped to the browser on purpose — check each is meant to be public:" + fi + printf ' %.120s…\n' "$hit" + noted=$((noted + 1)) + done < <(printf '%s\n' "$CONTENT" | grep -oEI "$pattern" 2>/dev/null | sort -u | head -20) + done + + if [ "$noted" -gt 0 ]; then + say "$noted public reference(s) above. Not a failure: those prefixes mean" + say "the value was compiled in deliberately. A Supabase anon key is safe" + say "exactly as far as row-level security makes it safe — this is the input" + say "to that judgement, not a substitute for it." + fi +fi + if [ "$found" -gt 0 ]; then say "$found candidate credential(s) in $WHAT." say "If one is real: rotate it first. Deleting the line does not remove it"