fix(secrets): the scanner printed the credential it found

The report truncated each line to 120 characters and redacted nothing, so a
credential shorter than the budget was printed whole -- into the terminal
scrollback, the CI log, and wherever that log is shipped. It applied to every
mode, which meant a real leak caught by the pre-commit hook was also a real leak
printed to a terminal. The comment above it claimed the match was never echoed
in full; it was corrected to describe the behaviour in the previous commit, and
this changes the behaviour instead.

The match is now masked before truncation. \001 is the substitution delimiter,
as a real control byte rather than the literal backslash-zero-zero-one a
double-quoted "\001" produces -- that first attempt made sed take `\` as its
delimiter and silently substitute nothing, which looked exactly like working
code. These patterns contain both / and |, so either would end the expression
early.

Widening the JWT pattern was part of the same fix, not a separate improvement.
Masking removes exactly what the pattern matched, so `eyJ[A-Za-z0-9_-]{10,}`
redacted the header and printed the payload and signature next to it -- and
those are the token. It now matches all three segments. A pattern that
under-matches is a pattern that half-prints the secret.

Verified in --built and --staged: a planted JWT and a user:pass@host URL are
each reported with file and line, and neither planted value appears anywhere in
the output.

closes #11

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
null 2026-08-17 23:08:57 -05:00
parent e000f53883
commit 0e9b5c482a
1 changed files with 21 additions and 8 deletions

View File

@ -68,6 +68,11 @@ cd "$(git rev-parse --show-toplevel 2>/dev/null)" || {
say() { printf 'secrets: %s\n' "$*" >&2; } say() { printf 'secrets: %s\n' "$*" >&2; }
# The delimiter for the masking substitution below. A real control byte, because
# these patterns contain both `/` and `|` and either would end the expression
# early. It cannot occur in a pattern and it cannot occur in source text.
MASK_D=$'\001'
MODE="staged" MODE="staged"
BUILT_DIR="" BUILT_DIR=""
ALLOW=() ALLOW=()
@ -92,7 +97,10 @@ done
# and it is far too eager to run against source, where it matches ordinary # and it is far too eager to run against source, where it matches ordinary
# base64. In a bundle it is worth the noise. # base64. In a bundle it is worth the noise.
BUILT_PATTERNS=( BUILT_PATTERNS=(
'eyJ[A-Za-z0-9_-]{10,}' # All three segments, not just the header. Masking removes exactly what the
# pattern matched, so a pattern that stops at the first dot redacts `eyJ...`
# and prints the payload and signature beside it -- which is the token.
'eyJ[A-Za-z0-9_-]{10,}(\.[A-Za-z0-9_-]+){0,2}'
'\bservice_role\b' '\bservice_role\b'
'\bapikey["'"'"'[:space:]]*[:=]' '\bapikey["'"'"'[:space:]]*[:=]'
) )
@ -225,13 +233,18 @@ for pattern in "${PATTERNS[@]}"; do
done done
[ -n "$skip" ] && continue [ -n "$skip" ] && continue
# The line is truncated to 120 characters, which bounds how much of a long # The match is masked, then the line is truncated. Truncation alone was not
# value reaches the terminal. It does NOT redact the match: a credential # enough and used to be all there was: it bounds how much of a LONG value
# shorter than the budget is printed whole, and a scanner that prints the # reaches the terminal and prints a short one whole, so the scanner
# secret it found has published it to the scrollback, the CI log, and # published the very thing it was built to find — to the scrollback, the CI
# wherever that log is shipped. Masking the matched span is the real fix and # log, and wherever that log is shipped.
# is tracked separately -- until then, treat this output as sensitive. #
printf ' %.120s…\n' "$hit" # \001 as the delimiter, because these patterns contain both `/` and `|`
# and either would end the expression early. It cannot occur in a pattern
# and it cannot occur in the text of a source file.
masked="$(printf '%s' "$hit" | sed -E "s${MASK_D}${pattern}${MASK_D}[redacted]${MASK_D}g" 2>/dev/null)"
[ -n "$masked" ] || masked="[a line matching a credential pattern, unprintable]"
printf ' %.120s…\n' "$masked"
found=$((found + 1)) found=$((found + 1))
done < <(printf '%s\n' "$CONTENT" | grep -nEI "$pattern" 2>/dev/null | head -20) done < <(printf '%s\n' "$CONTENT" | grep -nEI "$pattern" 2>/dev/null | head -20)
done done