Summary
mask() in src/scanners.rs can reveal up to 67% of a secret's characters in plaintext for secrets 9-19 characters long — far more than the tool's own documented safety contract.
docs/design-spec.md §3 ("Safety requirements (unique to this tool)") states:
Matched strings are always handled masked (sk-ab12****...****ef34, exposing only the first 4 and last 4 characters).
Root cause
fn mask(raw: &str) -> String {
let n = raw.chars().count();
if n <= 8 {
return "*".repeat(n.max(1));
}
let keep = (n / 5).clamp(3, 6);
...
}
For n just above the n <= 8 full-mask cutoff, n / 5 rounds down to 0 or 1, so the lower clamp bound of 3 dominates — keep is forced to 3 regardless of how small n actually is. That means 2*3=6 characters are always shown in plaintext (3 head + 3 tail) even for a 9-character secret, where that's most of the value.
Proof (measured, not guessed)
Added a temporary test iterating mask() over lengths 9..=20 and printing the actual reveal ratio:
n=9 keep=3 revealed=6/9 (67%) masked=abc****...****ghi (len=9)
n=10 keep=3 revealed=6/10 (60%) masked=abc****...****hij (len=10)
n=11 keep=3 revealed=6/11 (55%)
n=12 keep=3 revealed=6/12 (50%)
n=13 keep=3 revealed=6/13 (46%)
n=14 keep=3 revealed=6/14 (43%)
n=15 keep=3 revealed=6/15 (40%)
...
For all lengths 9-19, more than 40% of the secret is shown; at n=9 it's 67% — only the 3 middle characters are actually hidden.
Why this matters
Built-in rules (aws_access_key_id, github_token, etc.) all require raw-secret lengths of 15+ characters, so builtin-sourced candidates mostly avoid the worst of this range (though 15-19 still reveal 32-40%). But gitleaks/trufflehog (external tools, when installed) can report arbitrary-length secrets from their own rulesets — short passwords, legacy tokens, etc. — and masked_preview/context_line for those go straight into report.md and the LLM prompt context (promptctx::shared_context) unmodified. A 9-13 character secret masked by this function leaks the majority of its characters into a report that may itself get committed, shared, or pasted into chat — defeating the stated purpose of masking (design-spec.md §3: "to prevent the tool itself from becoming a leak vector").
Fix
Change the lower clamp bound from 3 to 1: let keep = (n / 5).clamp(1, 6);. This keeps the existing ~40% ceiling that already applies at n>=15 (unchanged), while capping the worst case at n=9 down to 22% (2 of 9 characters). Minimal, single-token diff.
Summary
mask()insrc/scanners.rscan reveal up to 67% of a secret's characters in plaintext for secrets 9-19 characters long — far more than the tool's own documented safety contract.docs/design-spec.md§3 ("Safety requirements (unique to this tool)") states:Root cause
For
njust above then <= 8full-mask cutoff,n / 5rounds down to 0 or 1, so the lower clamp bound of3dominates —keepis forced to 3 regardless of how smallnactually is. That means 2*3=6 characters are always shown in plaintext (3 head + 3 tail) even for a 9-character secret, where that's most of the value.Proof (measured, not guessed)
Added a temporary test iterating
mask()over lengths 9..=20 and printing the actual reveal ratio:For all lengths 9-19, more than 40% of the secret is shown; at n=9 it's 67% — only the 3 middle characters are actually hidden.
Why this matters
Built-in rules (
aws_access_key_id,github_token, etc.) all require raw-secret lengths of 15+ characters, so builtin-sourced candidates mostly avoid the worst of this range (though 15-19 still reveal 32-40%). Butgitleaks/trufflehog(external tools, when installed) can report arbitrary-length secrets from their own rulesets — short passwords, legacy tokens, etc. — andmasked_preview/context_linefor those go straight intoreport.mdand the LLM prompt context (promptctx::shared_context) unmodified. A 9-13 character secret masked by this function leaks the majority of its characters into a report that may itself get committed, shared, or pasted into chat — defeating the stated purpose of masking (design-spec.md §3: "to prevent the tool itself from becoming a leak vector").Fix
Change the lower clamp bound from
3to1:let keep = (n / 5).clamp(1, 6);. This keeps the existing ~40% ceiling that already applies at n>=15 (unchanged), while capping the worst case at n=9 down to 22% (2 of 9 characters). Minimal, single-token diff.