feat(secrets): --built, because the repository is the wrong place to stop

--staged and --tracked scan what is in git. Neither sees the bundle, which is
the only artifact a user receives -- and a key reaches it without ever being
committed, inlined from an environment variable at build time. An auditor of
applications of this kind reported hardcoded credentials in the frontend bundle
of seven of eight in a single week.

Two tiers, because one would have been useless:

  findings (exit 1)  eyJ, service_role, apikey=, plus every pattern the other
                     modes already use
  noted (exit 0)     NEXT_PUBLIC_, VITE_, REACT_APP_, anon

The second tier is printed and fails 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 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.

eyJ is confined to --built on purpose: it is the base64 of the `{"` every JWT
header starts with, and against source it matches ordinary base64 constantly.

Verified: a planted JWT and service_role in a scratch dist/ are found and exit
1; removing them exits 0 with the public references still listed; a directory
that does not exist exits 2, because nothing scanned is not a pass. Findings are
reported relative to the build directory -- an absolute path consumed the whole
truncation budget and left findings that named a file and showed nothing.

One correction shipped with it: the comment above the report claimed the match
is never echoed in full. It is not redacted at all, only truncated at 120
characters, so a short credential is printed whole. The comment now says what
the code does. Masking the matched span is the real fix and is filed separately.

closes #8

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-17 22:59:39 -05:00
parent 2dca89e635
commit 29864ccc0a
1 changed files with 107 additions and 4 deletions

View File

@ -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"