Summary
For the generic_high_entropy_assignment rule, builtin_scan passes the whole regex match (keyword + operator + quotes + secret) to mask() instead of the actual secret capture group. Because mask() keeps a few characters at the tail of whatever string it's given, the tail of the whole match — which is "...end-of-secret + closing quote" — ends up exposed in plaintext in masked_preview and context_line.
This directly violates the tool's core safety guarantee documented in README.md:
Nothing in this tool ever prints, logs, or serializes a raw secret value.
Since masked_preview/context_line are fed straight into the LLM prompt (persona review) and into report.md, this means real trailing characters of scanned secrets can end up in an LLM prompt and in a checked-in/shared report file.
Where
src/scanners.rs:210-213 — the rule regex has two capture groups: group 1 = keyword/operator (api_key, token, etc.), group 2 = the actual quoted secret value:
re: r#"(?i)(api[_-]?key|secret|token|password|passwd|access[_-]?key)\s*[:=]\s*['"]([A-Za-z0-9_\-/+=]{20,})['"]"#,
src/scanners.rs builtin_scan (~line 276-280):
for (re, rule) in &compiled {
for m in re.find_iter(line) {
line_matches.push((rule, m.as_str())); // whole match, not the capture group
}
}
m.as_str() returns the whole match, not m.get(2) (the real secret). This whole-match string is later passed to mask() for masked_preview and to mask_line_all() for context_line.
Impact
- Trailing characters of the actual secret value leak in plaintext into
masked_preview / context_line.
masked_preview's len=N reports the whole match's length (keyword + operator + quotes + secret), not the secret's own length — inconsistent with the scheme documented in README.md ("longer values keep clamp(n/5, 3, 6) characters at each end ... e.g. AKIA****...****3F2Q (len=20)").
- Every other rule in
rules() has no capture groups (the whole match is the secret), so this only affects generic_high_entropy_assignment — but that's the fallback rule most likely to fire on arbitrary KEY = "..."-shaped code, so it's not a narrow edge case.
Suggested fix
Use the rule's designated secret capture group (group 2 for generic_high_entropy_assignment, whole match for every other rule) as the value passed to mask(), mask_line_all(), and fingerprint() — not the whole regex match.
Summary
For the
generic_high_entropy_assignmentrule,builtin_scanpasses the whole regex match (keyword + operator + quotes + secret) tomask()instead of the actual secret capture group. Becausemask()keeps a few characters at the tail of whatever string it's given, the tail of the whole match — which is "...end-of-secret + closing quote" — ends up exposed in plaintext inmasked_previewandcontext_line.This directly violates the tool's core safety guarantee documented in
README.md:Since
masked_preview/context_lineare fed straight into the LLM prompt (persona review) and intoreport.md, this means real trailing characters of scanned secrets can end up in an LLM prompt and in a checked-in/shared report file.Where
src/scanners.rs:210-213— the rule regex has two capture groups: group 1 = keyword/operator (api_key,token, etc.), group 2 = the actual quoted secret value:src/scanners.rsbuiltin_scan(~line 276-280):m.as_str()returns the whole match, notm.get(2)(the real secret). This whole-match string is later passed tomask()formasked_previewand tomask_line_all()forcontext_line.Impact
masked_preview/context_line.masked_preview'slen=Nreports the whole match's length (keyword + operator + quotes + secret), not the secret's own length — inconsistent with the scheme documented inREADME.md("longer values keepclamp(n/5, 3, 6)characters at each end ... e.g.AKIA****...****3F2Q (len=20)").rules()has no capture groups (the whole match is the secret), so this only affectsgeneric_high_entropy_assignment— but that's the fallback rule most likely to fire on arbitraryKEY = "..."-shaped code, so it's not a narrow edge case.Suggested fix
Use the rule's designated secret capture group (group 2 for
generic_high_entropy_assignment, whole match for every other rule) as the value passed tomask(),mask_line_all(), andfingerprint()— not the whole regex match.