Skip to main content

aa_security/
scanner.rs

1//! Credential leak detection using Aho-Corasick multi-pattern scanning.
2//!
3//! Only compiled when the `std` feature is enabled. The [`CredentialScanner`]
4//! is pre-compiled at construction time so each call to [`CredentialScanner::scan`]
5//! pays zero pattern-compilation cost.
6
7use aho_corasick::AhoCorasick;
8
9// ---------------------------------------------------------------------------
10// AC literal patterns — order matters: earlier index wins on same-position match.
11// sk-ant- must precede sk- so Anthropic keys are not misclassified as OpenAI keys.
12// ---------------------------------------------------------------------------
13
14const AC_PATTERNS: &[&str] = &[
15    "sk-ant-",                               // 0  AnthropicKey
16    "sk-",                                   // 1  OpenAiKey
17    "AKIA",                                  // 2  AwsAccessKey
18    "\"type\": \"service_account\"",         // 3  GcpServiceAccount
19    "DefaultEndpointsProtocol=",             // 4  AzureConnectionString
20    "ghp_",                                  // 5  GitHubPat
21    "ghs_",                                  // 6  GitHubAppToken
22    "xoxb-",                                 // 7  SlackBotToken
23    "xoxp-",                                 // 8  SlackUserToken
24    "xoxa-",                                 // 9  SlackOAuthToken
25    "postgres://",                           // 10 PostgresUrl
26    "mysql://",                              // 11 MysqlUrl
27    "mongodb://",                            // 12 MongodbUrl
28    "-----BEGIN RSA PRIVATE KEY-----",       // 13 RsaPrivateKey
29    "-----BEGIN EC PRIVATE KEY-----",        // 14 EcPrivateKey
30    "-----BEGIN OPENSSH PRIVATE KEY-----",   // 15 OpensshPrivateKey
31    "-----BEGIN PRIVATE KEY-----",           // 16 PrivateKey
32    "-----BEGIN PGP PRIVATE KEY BLOCK-----", // 17 PgpPrivateKey
33    // AAASM-3727: GCP service-account JSON whitespace variants. A compact
34    // serializer emits no space after the colon, and some emit a space before
35    // it; index 3's single-space literal misses both. These map to the same
36    // GcpServiceAccount kind so the realistic serialized forms are all caught.
37    "\"type\":\"service_account\"",   // 18 GcpServiceAccount (compact, no space)
38    "\"type\" :\"service_account\"",  // 19 GcpServiceAccount (space before colon)
39    "\"type\" : \"service_account\"", // 20 GcpServiceAccount (spaces around colon)
40];
41
42/// Maps AC pattern index → [`CredentialKind`].
43const AC_KINDS: &[CredentialKind] = &[
44    CredentialKind::AnthropicKey,          // 0
45    CredentialKind::OpenAiKey,             // 1
46    CredentialKind::AwsAccessKey,          // 2
47    CredentialKind::GcpServiceAccount,     // 3
48    CredentialKind::AzureConnectionString, // 4
49    CredentialKind::GitHubPat,             // 5
50    CredentialKind::GitHubAppToken,        // 6
51    CredentialKind::SlackBotToken,         // 7
52    CredentialKind::SlackUserToken,        // 8
53    CredentialKind::SlackOAuthToken,       // 9
54    CredentialKind::PostgresUrl,           // 10
55    CredentialKind::MysqlUrl,              // 11
56    CredentialKind::MongodbUrl,            // 12
57    CredentialKind::RsaPrivateKey,         // 13
58    CredentialKind::EcPrivateKey,          // 14
59    CredentialKind::OpensshPrivateKey,     // 15
60    CredentialKind::PrivateKey,            // 16
61    CredentialKind::PgpPrivateKey,         // 17
62    CredentialKind::GcpServiceAccount,     // 18 (compact JSON)
63    CredentialKind::GcpServiceAccount,     // 19 (space before colon)
64    CredentialKind::GcpServiceAccount,     // 20 (spaces around colon)
65];
66
67// ---------------------------------------------------------------------------
68// Public types
69// ---------------------------------------------------------------------------
70
71/// Category of a detected credential or sensitive value.
72#[derive(Debug, Clone, PartialEq, Eq)]
73#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
74pub enum CredentialKind {
75    // API keys
76    /// Anthropic API key (prefix `sk-ant-`).
77    AnthropicKey,
78    /// AWS access key ID (prefix `AKIA`).
79    AwsAccessKey,
80    /// GCP service account JSON credential (contains `"type": "service_account"`).
81    GcpServiceAccount,
82    /// OpenAI API key (prefix `sk-`).
83    OpenAiKey,
84    // Cloud credentials
85    /// Azure Storage connection string (prefix `DefaultEndpointsProtocol=`).
86    AzureConnectionString,
87    // Auth tokens
88    /// GitHub App installation token (prefix `ghs_`).
89    GitHubAppToken,
90    /// GitHub personal access token (prefix `ghp_`).
91    GitHubPat,
92    /// Slack bot token (prefix `xoxb-`).
93    SlackBotToken,
94    /// Slack OAuth token (prefix `xoxa-`).
95    SlackOAuthToken,
96    /// Slack user token (prefix `xoxp-`).
97    SlackUserToken,
98    // Database URLs
99    /// MongoDB connection URI (prefix `mongodb://`).
100    MongodbUrl,
101    /// MySQL connection URI (prefix `mysql://`).
102    MysqlUrl,
103    /// PostgreSQL connection URI (prefix `postgres://`).
104    PostgresUrl,
105    // Private keys
106    /// PEM-encoded EC private key (`-----BEGIN EC PRIVATE KEY-----`).
107    EcPrivateKey,
108    /// PEM-encoded OpenSSH private key (`-----BEGIN OPENSSH PRIVATE KEY-----`).
109    OpensshPrivateKey,
110    /// PEM-encoded PGP private key block (`-----BEGIN PGP PRIVATE KEY BLOCK-----`).
111    PgpPrivateKey,
112    /// PEM-encoded PKCS#8 private key (`-----BEGIN PRIVATE KEY-----`).
113    PrivateKey,
114    /// PEM-encoded RSA private key (`-----BEGIN RSA PRIVATE KEY-----`).
115    RsaPrivateKey,
116    // PII
117    /// Credit card number validated by the Luhn algorithm (13–19 digits).
118    CreditCardLuhn,
119    /// Email address containing `@` and a dot-separated domain.
120    EmailAddress,
121    /// US Social Security Number in `DDD-DD-DDDD` format.
122    SsnPattern,
123    // Generic
124    /// High-entropy or long encoded token: a whitespace token of length 20–64
125    /// with Shannon entropy > 4.5 bits/char, a contiguous hex run ≥ 64 chars, or
126    /// a contiguous base64 run > 64 chars above the entropy gate.
127    GenericHighEntropy,
128    // Policy-defined
129    /// A pattern defined in the policy document's `data.sensitive_patterns` field.
130    Custom,
131}
132
133impl CredentialKind {
134    /// Returns the string used in the `[REDACTED:<kind>]` label.
135    pub fn as_str(&self) -> &'static str {
136        match self {
137            Self::AnthropicKey => "AnthropicKey",
138            Self::AwsAccessKey => "AwsAccessKey",
139            Self::AzureConnectionString => "AzureConnectionString",
140            Self::CreditCardLuhn => "CreditCardLuhn",
141            Self::EcPrivateKey => "EcPrivateKey",
142            Self::EmailAddress => "EmailAddress",
143            Self::GcpServiceAccount => "GcpServiceAccount",
144            Self::GenericHighEntropy => "GenericHighEntropy",
145            Self::GitHubAppToken => "GitHubAppToken",
146            Self::GitHubPat => "GitHubPat",
147            Self::MongodbUrl => "MongodbUrl",
148            Self::MysqlUrl => "MysqlUrl",
149            Self::OpenAiKey => "OpenAiKey",
150            Self::OpensshPrivateKey => "OpensshPrivateKey",
151            Self::PgpPrivateKey => "PgpPrivateKey",
152            Self::PostgresUrl => "PostgresUrl",
153            Self::PrivateKey => "PrivateKey",
154            Self::RsaPrivateKey => "RsaPrivateKey",
155            Self::SlackBotToken => "SlackBotToken",
156            Self::SlackOAuthToken => "SlackOAuthToken",
157            Self::SlackUserToken => "SlackUserToken",
158            Self::SsnPattern => "SsnPattern",
159            Self::Custom => "Custom",
160        }
161    }
162
163    /// Relative confidence of this kind when two overlapping findings are
164    /// coalesced into one span.
165    ///
166    /// When several detectors match the same byte region (e.g. a GitHub PAT is
167    /// also flagged as a `GenericHighEntropy` token, or a database URL embeds an
168    /// `EmailAddress`), the merged span must carry the label of the most
169    /// specific, highest-confidence detector — never a generic backstop. A
170    /// higher number wins. Specific literal-prefix and PEM detectors and
171    /// policy-defined `Custom` patterns outrank the generic
172    /// `GenericHighEntropy` / `EmailAddress` heuristics.
173    fn priority(&self) -> u8 {
174        match self {
175            // Generic / heuristic backstops — lowest confidence.
176            Self::GenericHighEntropy => 0,
177            Self::EmailAddress => 1,
178            // Specific, high-signal detectors — they identify the exact
179            // credential kind and must win over the generic backstops above.
180            Self::AnthropicKey
181            | Self::AwsAccessKey
182            | Self::AzureConnectionString
183            | Self::CreditCardLuhn
184            | Self::EcPrivateKey
185            | Self::GcpServiceAccount
186            | Self::GitHubAppToken
187            | Self::GitHubPat
188            | Self::MongodbUrl
189            | Self::MysqlUrl
190            | Self::OpenAiKey
191            | Self::OpensshPrivateKey
192            | Self::PgpPrivateKey
193            | Self::PostgresUrl
194            | Self::PrivateKey
195            | Self::RsaPrivateKey
196            | Self::SlackBotToken
197            | Self::SlackOAuthToken
198            | Self::SlackUserToken
199            | Self::SsnPattern
200            | Self::Custom => 2,
201        }
202    }
203}
204
205/// A single detected credential finding.
206///
207/// `offset` is the byte offset in the original text where the pattern was found.
208/// `matched` is the redacted label, e.g. `[REDACTED:AwsAccessKey]`. The raw
209/// secret is never stored.
210///
211/// The `end` field is intentionally private; it is used by [`ScanResult::redact`]
212/// to splice the original match without exposing raw length arithmetic to callers.
213#[derive(Debug, Clone, PartialEq, Eq)]
214#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
215pub struct CredentialFinding {
216    /// Category of the detected credential.
217    pub kind: CredentialKind,
218    /// Byte offset in the original text where the pattern begins.
219    pub offset: usize,
220    /// Redacted label replacing the secret, e.g. `[REDACTED:AwsAccessKey]`.
221    pub matched: String,
222    #[cfg_attr(feature = "serde", serde(skip))]
223    end: usize,
224}
225
226impl CredentialFinding {
227    fn new(kind: CredentialKind, offset: usize, end: usize) -> Self {
228        let label = format!("[REDACTED:{}]", kind.as_str());
229        Self {
230            kind,
231            offset,
232            matched: label,
233            end,
234        }
235    }
236
237    /// Construct a finding for a match produced by a policy-defined regex pattern.
238    ///
239    /// Used by `aa-gateway` when custom `data.sensitive_patterns` regexes match.
240    /// The `offset` and `end` are byte positions returned by the regex engine.
241    pub fn from_regex_match(offset: usize, end: usize) -> Self {
242        Self::new(CredentialKind::Custom, offset, end)
243    }
244}
245
246/// The result of a [`CredentialScanner::scan`] call.
247#[derive(Debug, Clone, PartialEq, Eq)]
248#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
249pub struct ScanResult {
250    /// All credential findings detected in the scanned text, sorted by byte offset.
251    pub findings: Vec<CredentialFinding>,
252}
253
254impl ScanResult {
255    /// Returns `true` if no credential findings were detected.
256    pub fn is_clean(&self) -> bool {
257        self.findings.is_empty()
258    }
259
260    /// Returns a copy of `text` with every finding replaced by its redacted label.
261    ///
262    /// Overlapping findings are first coalesced into non-overlapping byte spans so
263    /// no region is ever partially redacted (which previously left raw secret
264    /// fragments and mangled labels in the output). The merged spans are then
265    /// spliced in reverse offset order so earlier byte positions remain valid
266    /// after each replacement. Spans whose boundaries do not fall on UTF-8
267    /// character boundaries are skipped rather than spliced, making the former
268    /// mid-codepoint panic structurally impossible.
269    pub fn redact(&self, text: &str) -> String {
270        let merged = coalesce_findings(&self.findings);
271        let mut result = text.to_string();
272        // Splice merged spans in reverse offset order so earlier positions stay valid.
273        for span in merged.iter().rev() {
274            if span.end <= result.len()
275                && span.offset <= span.end
276                && result.is_char_boundary(span.offset)
277                && result.is_char_boundary(span.end)
278            {
279                result.replace_range(span.offset..span.end, &span.label);
280            }
281        }
282        result
283    }
284}
285
286/// Configuration for the credential scanner.
287///
288/// Controls whether scanning is enabled and allows adding custom literal
289/// patterns beyond the built-in set.
290#[derive(Debug, Clone, Default)]
291pub struct ScannerConfig {
292    /// When `true`, scanning is disabled and [`CredentialScanner::scan`] always
293    /// returns an empty [`ScanResult`].
294    pub disabled: bool,
295    /// Additional literal prefixes to detect as [`CredentialKind::Custom`].
296    /// Each string is compiled into the Aho-Corasick automaton alongside the
297    /// built-in patterns.
298    pub custom_patterns: Vec<String>,
299}
300
301/// Pre-compiled multi-pattern credential scanner.
302///
303/// Construct once with [`CredentialScanner::new`] (or [`CredentialScanner::with_config`])
304/// and call [`CredentialScanner::scan`] repeatedly. Pattern compilation happens at
305/// construction time; each scan call is O(n) in the length of the input text.
306pub struct CredentialScanner {
307    patterns: AhoCorasick,
308    /// Maps each AC pattern index to its [`CredentialKind`]. Built-in patterns
309    /// use the static `AC_KINDS` entries; custom patterns are appended as
310    /// [`CredentialKind::Custom`].
311    kinds: Vec<CredentialKind>,
312    /// When `true`, [`scan`](Self::scan) short-circuits and returns an empty result.
313    disabled: bool,
314}
315
316impl Default for CredentialScanner {
317    fn default() -> Self {
318        Self::new()
319    }
320}
321
322impl CredentialScanner {
323    /// Build the scanner with all built-in patterns and scanning enabled.
324    ///
325    /// # Panics
326    ///
327    /// Panics only if the hard-coded AC patterns are somehow invalid — this
328    /// cannot happen in practice.
329    pub fn new() -> Self {
330        Self::with_config(ScannerConfig::default())
331    }
332
333    /// Build the scanner from explicit configuration.
334    ///
335    /// Custom patterns are appended after the built-in set and are tagged as
336    /// [`CredentialKind::Custom`]. If `config.disabled` is true the scanner
337    /// is inert — [`scan`](Self::scan) always returns an empty result.
338    pub fn with_config(config: ScannerConfig) -> Self {
339        let mut all_patterns: Vec<&str> = AC_PATTERNS.to_vec();
340        // Collect custom pattern references — lifetime tied to `config`.
341        let custom_refs: Vec<&str> = config.custom_patterns.iter().map(|s| s.as_str()).collect();
342        all_patterns.extend_from_slice(&custom_refs);
343
344        let mut kinds: Vec<CredentialKind> = AC_KINDS.to_vec();
345        kinds.extend(std::iter::repeat(CredentialKind::Custom).take(config.custom_patterns.len()));
346
347        let ac = AhoCorasick::builder()
348            .match_kind(aho_corasick::MatchKind::LeftmostFirst)
349            // AAASM-3727: scheme prefixes (postgres://), PEM headers, and the
350            // GCP JSON key are case-insensitive in the wild (RFC 3986 schemes,
351            // lower/mixed-case PEM). Match case-insensitively so case variants
352            // cannot bypass detection. Prefixes like AKIA / ghp_ stay high-signal.
353            .ascii_case_insensitive(true)
354            .build(&all_patterns)
355            .expect("AC patterns are always valid");
356
357        Self {
358            patterns: ac,
359            kinds,
360            disabled: config.disabled,
361        }
362    }
363
364    /// Scan `text` for credential patterns and return a [`ScanResult`].
365    ///
366    /// Four passes are performed:
367    /// 1. Aho-Corasick literal prefix scan — O(n), 18 patterns covering API keys,
368    ///    auth tokens, cloud credentials, database URLs, and PEM private key headers.
369    /// 2. Credit card and SSN digit-sequence scan.
370    /// 3. Email address scan.
371    /// 4. Generic high-entropy / long-encoded-blob scan: a 20–64 whitespace token
372    ///    above the entropy gate, a contiguous hex run ≥ 64 chars, or a base64
373    ///    run > 64 chars above the gate (see [`scan_high_entropy`]).
374    pub fn scan(&self, text: &str) -> ScanResult {
375        if self.disabled {
376            return ScanResult { findings: Vec::new() };
377        }
378
379        let mut findings = Vec::new();
380
381        // Phase 1: AC literal prefix scan (API keys, auth tokens, cloud creds,
382        //          database URLs, PEM private key headers — 18 patterns + custom)
383        for mat in self.patterns.find_iter(text) {
384            let kind = self.kinds[mat.pattern()].clone();
385            let offset = mat.start();
386            let end = token_end(text, mat.end());
387            findings.push(CredentialFinding::new(kind, offset, end));
388        }
389
390        // Phase 2: PII — credit card numbers and SSN patterns
391        scan_digit_sequences(text, &mut findings);
392
393        // Phase 3: Email addresses
394        scan_emails(text, &mut findings);
395
396        // Phase 4: High-entropy / long-hex tokens (encoding & length evasions, AAASM-3870)
397        scan_high_entropy(text, &mut findings);
398
399        // Phase 5: Azure `AccountKey=` values wherever they appear in a
400        //          connection string (AAASM-3997).
401        scan_azure_account_key(text, &mut findings);
402
403        findings.sort_by_key(|f| f.offset);
404        ScanResult { findings }
405    }
406}
407
408// ---------------------------------------------------------------------------
409// Internal helpers
410// ---------------------------------------------------------------------------
411
412/// A single non-overlapping byte span to be replaced by `redact`.
413struct MergedSpan {
414    offset: usize,
415    end: usize,
416    label: String,
417    /// Kind whose `label` the span currently carries — retained so a later
418    /// overlapping finding of higher [`CredentialKind::priority`] can claim the
419    /// merged span's label.
420    kind: CredentialKind,
421}
422
423/// Coalesce findings into non-overlapping, offset-ordered spans.
424///
425/// Findings are sorted by `(offset, end)` and any subsequent finding whose
426/// `offset` falls before the current span's `end` is merged into it (extending
427/// the span's `end` to the maximum, i.e. the union of overlapping spans so no
428/// raw secret fragment can survive). The merged span carries the label of the
429/// highest-[`CredentialKind::priority`] finding in the run, so a specific,
430/// high-confidence detector (e.g. `GitHubPat`, `PostgresUrl`) always wins over a
431/// generic backstop (`GenericHighEntropy`, `EmailAddress`) regardless of byte
432/// offset. This guarantees `redact` never leaves a region partially replaced and
433/// never downgrades a credential's label to a less specific kind.
434fn coalesce_findings(findings: &[CredentialFinding]) -> Vec<MergedSpan> {
435    let mut sorted: Vec<&CredentialFinding> = findings.iter().collect();
436    sorted.sort_by_key(|f| (f.offset, f.end));
437
438    let mut merged: Vec<MergedSpan> = Vec::with_capacity(sorted.len());
439    for f in sorted {
440        match merged.last_mut() {
441            // Overlapping (or touching) the current span — extend it to the
442            // union and adopt the higher-priority kind's label.
443            Some(last) if f.offset < last.end => {
444                last.end = last.end.max(f.end);
445                if f.kind.priority() > last.kind.priority() {
446                    last.label = f.matched.clone();
447                    last.kind = f.kind.clone();
448                }
449            }
450            _ => merged.push(MergedSpan {
451                offset: f.offset,
452                end: f.end,
453                label: f.matched.clone(),
454                kind: f.kind.clone(),
455            }),
456        }
457    }
458    merged
459}
460
461/// Redact the secret value of every Azure `AccountKey=<value>` in `text`,
462/// regardless of its position in a connection string (AAASM-3997).
463///
464/// The `DefaultEndpointsProtocol=` prefix detector coalesces its span only up to
465/// the first `;` (see [`token_end`]), so in a canonical
466/// `DefaultEndpointsProtocol=...;AccountName=...;AccountKey=<secret>` string the
467/// `AccountKey` — which sits after two `;` separators — was left in the clear.
468/// This pass targets the key's value directly: it spans from the `AccountKey=`
469/// marker to the next `;`, token terminator, or end of input, so the secret is
470/// redacted wherever it falls in the string.
471fn scan_azure_account_key(text: &str, findings: &mut Vec<CredentialFinding>) {
472    const MARKER: &str = "AccountKey=";
473    let mut search_from = 0;
474    while let Some(rel) = text[search_from..].find(MARKER) {
475        let offset = search_from + rel;
476        let value_start = offset + MARKER.len();
477        // The value ends at the next connection-string delimiter (`;`), a
478        // whitespace/quote/bracket token terminator, or the end of the input.
479        let end = text[value_start..]
480            .find(|c: char| c.is_whitespace() || matches!(c, ';' | '"' | '\'' | ',' | ')' | ']' | '}'))
481            .map(|i| value_start + i)
482            .unwrap_or(text.len());
483        findings.push(CredentialFinding::new(
484            CredentialKind::AzureConnectionString,
485            offset,
486            end,
487        ));
488        // Advance past this marker (at least) so overlapping/repeated keys still progress.
489        search_from = end.max(value_start);
490    }
491}
492
493/// Returns the byte index of the first token-terminating character at or after
494/// `from`. Token terminators are whitespace and common delimiters.
495fn token_end(text: &str, from: usize) -> usize {
496    text[from..]
497        .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}'))
498        .map(|i| from + i)
499        .unwrap_or(text.len())
500}
501
502/// Returns `true` if `s` matches the SSN format `DDD-DD-DDDD` exactly.
503fn is_ssn(s: &str) -> bool {
504    let b = s.as_bytes();
505    b.len() == 11
506        && b[0..3].iter().all(u8::is_ascii_digit)
507        && b[3] == b'-'
508        && b[4..6].iter().all(u8::is_ascii_digit)
509        && b[6] == b'-'
510        && b[7..11].iter().all(u8::is_ascii_digit)
511}
512
513/// Returns `true` if `digits` (ASCII digit characters only, no separators) passes
514/// the Luhn checksum algorithm used by credit card numbers.
515fn luhn_valid(digits: &str) -> bool {
516    if digits.len() < 13 || digits.len() > 19 {
517        return false;
518    }
519    let mut sum = 0u32;
520    let mut double = false;
521    for ch in digits.chars().rev() {
522        let Some(d) = ch.to_digit(10) else {
523            return false;
524        };
525        let val = if double {
526            let v = d * 2;
527            if v > 9 {
528                v - 9
529            } else {
530                v
531            }
532        } else {
533            d
534        };
535        sum += val;
536        double = !double;
537    }
538    sum % 10 == 0
539}
540
541/// Scans `text` for credit card numbers (Luhn-validated) and SSN patterns (`DDD-DD-DDDD`).
542fn scan_digit_sequences(text: &str, findings: &mut Vec<CredentialFinding>) {
543    let bytes = text.as_bytes();
544    let mut i = 0;
545    while i < bytes.len() {
546        if !bytes[i].is_ascii_digit() {
547            i += 1;
548            continue;
549        }
550
551        let start = i;
552        let mut digits = String::new();
553        let mut j = i;
554        let limit = (start + 24).min(bytes.len());
555
556        while j < limit {
557            match bytes[j] {
558                b if b.is_ascii_digit() => {
559                    digits.push(b as char);
560                    j += 1;
561                }
562                b' ' | b'-' if !digits.is_empty() => {
563                    j += 1;
564                }
565                _ => break,
566            }
567        }
568
569        let end = j;
570        let segment = &text[start..end];
571
572        if is_ssn(segment) {
573            findings.push(CredentialFinding::new(CredentialKind::SsnPattern, start, end));
574        } else if digits.len() >= 13 && digits.len() <= 19 && luhn_valid(&digits) {
575            findings.push(CredentialFinding::new(CredentialKind::CreditCardLuhn, start, end));
576        }
577        i = end.max(i + 1);
578    }
579}
580
581/// Computes the Shannon entropy of `s` in bits per character.
582fn shannon_entropy(s: &str) -> f64 {
583    if s.is_empty() {
584        return 0.0;
585    }
586    let mut freq = [0u32; 256];
587    for &b in s.as_bytes() {
588        freq[b as usize] += 1;
589    }
590    let len = s.len() as f64;
591    freq.iter()
592        .filter(|&&c| c > 0)
593        .map(|&c| {
594            let p = c as f64 / len;
595            -p * p.log2()
596        })
597        .sum()
598}
599
600/// Shannon-entropy gate, in bits per character.
601///
602/// Base64/base85 encodings of random bytes sit around 5-6 bits/char, while
603/// English prose and `snake_case` / `kebab-case` identifiers stay below this.
604/// Note hex tops out at `log2(16) = 4.0` bits/char, so hex-encoded secrets never
605/// trip this gate — they are caught by the dedicated hex rule (see
606/// [`HEX_RUN_MIN_LEN`]).
607const ENTROPY_BITS_GATE: f64 = 4.5;
608
609/// Minimum length of a contiguous hex run (`[0-9a-fA-F]`) flagged as a secret.
610///
611/// Set to 64 — the length of a hex-encoded 256-bit key (and of a SHA-256
612/// digest). The threshold is deliberately high to avoid redacting the shorter
613/// hex blobs that pervade normal payloads: 32-char MD5/UUID hex and 40-char git
614/// SHA-1 hashes stay below it and are **not** flagged. The accepted tradeoff is
615/// that hex blobs of 64+ chars — including SHA-256 digests — are redacted; this
616/// is harmless (redacting a public hash leaks nothing) and is the price of
617/// closing the hex-encoded-secret evasion, since a hex secret is byte-for-byte
618/// indistinguishable from a hash of the same length.
619const HEX_RUN_MIN_LEN: usize = 64;
620
621/// Minimum length of a contiguous base64/base64url run flagged as a secret.
622///
623/// The whitespace-token pass below already covers high-entropy tokens of length
624/// 20–64; this strictly-greater bound (> 64) is the additive AAASM-3870 rule that
625/// catches the long encoded blobs the 64-byte cap skipped, without re-flagging
626/// anything the token pass already handles. Combined with the entropy gate it
627/// stays clear of long-but-structured strings (e.g. connection strings) whose
628/// per-run entropy is below the gate.
629const BASE64_RUN_MIN_LEN: usize = 64;
630
631/// Returns `true` if `b` is in the base64 / base64url alphabet
632/// (alphanumerics plus `+ / - _`). `=` padding and all delimiters are excluded.
633fn is_base64_char(b: u8) -> bool {
634    b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'-' | b'_')
635}
636
637/// Scans `text` for generic secret-like tokens, reporting them as
638/// [`CredentialKind::GenericHighEntropy`]. Three additive passes run; each only
639/// *adds* findings, so the literal/URL/PEM detectors are never displaced and the
640/// conformance behaviour of the original whitespace pass is preserved exactly:
641///
642/// 1. **Whitespace-token entropy** (unchanged spec behaviour) — a whitespace
643///    token of length 20–64 with Shannon entropy > [`ENTROPY_BITS_GATE`].
644/// 2. **Long hex run** (AAASM-3870) — a contiguous hex run ≥ [`HEX_RUN_MIN_LEN`],
645///    closing the hex-encoding evasion (hex entropy is capped at 4.0 bits/char,
646///    below the gate, so pass 1 never catches it at any length).
647/// 3. **Long base64 run** (AAASM-3870) — a contiguous base64/base64url run
648///    longer than [`BASE64_RUN_MIN_LEN`] whose entropy exceeds the gate, closing
649///    the > 64-char length evasion that the pass-1 upper bound skipped.
650fn scan_high_entropy(text: &str, findings: &mut Vec<CredentialFinding>) {
651    // Pass 1: whitespace-delimited high-entropy tokens, length 20–64.
652    let mut offset = 0usize;
653    for token in text.split_whitespace() {
654        let token_offset = text[offset..].find(token).map(|i| offset + i).unwrap_or(offset);
655        let whitespace_end = token_offset + token.len();
656        let len = token.len();
657        if (20..=64).contains(&len) && shannon_entropy(token) > ENTROPY_BITS_GATE {
658            // The whitespace token can still carry trailing delimiters when a
659            // secret is embedded in structured text (e.g. `...key"}]}` in compact
660            // JSON). Clamp the finding's `end` at the first token-terminating
661            // character so the span covers only the credential — matching how the
662            // AC literal scan derives its `end`.
663            let end = token_end(text, token_offset);
664            findings.push(CredentialFinding::new(
665                CredentialKind::GenericHighEntropy,
666                token_offset,
667                end,
668            ));
669        }
670        offset = whitespace_end;
671    }
672
673    // Passes 2 & 3: contiguous encoded-blob runs that the token pass misses.
674    scan_long_hex_runs(text, findings);
675    scan_long_base64_runs(text, findings);
676}
677
678/// Pass 2 — flag every contiguous hex run of length ≥ [`HEX_RUN_MIN_LEN`].
679fn scan_long_hex_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
680    let bytes = text.as_bytes();
681    let mut i = 0;
682    while i < bytes.len() {
683        if !bytes[i].is_ascii_hexdigit() {
684            i += 1;
685            continue;
686        }
687        let start = i;
688        while i < bytes.len() && bytes[i].is_ascii_hexdigit() {
689            i += 1;
690        }
691        if i - start >= HEX_RUN_MIN_LEN {
692            findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
693        }
694    }
695}
696
697/// Pass 3 — flag every contiguous base64/base64url run longer than
698/// [`BASE64_RUN_MIN_LEN`] whose Shannon entropy exceeds [`ENTROPY_BITS_GATE`].
699fn scan_long_base64_runs(text: &str, findings: &mut Vec<CredentialFinding>) {
700    let bytes = text.as_bytes();
701    let mut i = 0;
702    while i < bytes.len() {
703        if !is_base64_char(bytes[i]) {
704            i += 1;
705            continue;
706        }
707        let start = i;
708        while i < bytes.len() && is_base64_char(bytes[i]) {
709            i += 1;
710        }
711        let run = &text[start..i];
712        if run.len() > BASE64_RUN_MIN_LEN && shannon_entropy(run) > ENTROPY_BITS_GATE {
713            findings.push(CredentialFinding::new(CredentialKind::GenericHighEntropy, start, i));
714        }
715    }
716}
717
718/// RFC 5321 caps the local-part of an address at 64 octets. A run longer than
719/// this cannot be a legitimate email, so it is skipped — this also bounds the
720/// per-`@` work on delimiter-free input (AAASM-3988).
721const MAX_EMAIL_LOCAL_LEN: usize = 64;
722
723/// RFC 5321 caps the domain of an address at 255 octets. Capping the forward
724/// domain scan at this length keeps [`scan_emails`] linear on pathological
725/// input (e.g. `a@a@a@…`) without affecting any real address (AAASM-3988).
726const MAX_EMAIL_DOMAIN_LEN: usize = 255;
727
728/// Like [`token_end`] but scans at most `max_len` bytes forward, returning a
729/// valid char boundary. Bounding the scan prevents a single `@` from costing
730/// O(n) on delimiter-free input, keeping [`scan_emails`] linear overall.
731fn bounded_token_end(text: &str, from: usize, max_len: usize) -> usize {
732    let mut end = from;
733    for (i, c) in text[from..].char_indices() {
734        if i >= max_len {
735            return from + i;
736        }
737        if c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}') {
738            return from + i;
739        }
740        end = from + i + c.len_utf8();
741    }
742    end
743}
744
745/// Scans `text` for email addresses in a single forward pass.
746///
747/// The local-part start is tracked as the byte offset just past the most recent
748/// token-delimiting character, so it is known in O(1) per `@` rather than an
749/// O(n) backward rescan. Combined with the local/domain length caps this keeps
750/// the scan linear even on adversarial input such as ~1 MB of consecutive `@`
751/// with no delimiters (AAASM-3988 — quadratic-time DoS).
752fn scan_emails(text: &str, findings: &mut Vec<CredentialFinding>) {
753    // Byte offset just past the most recent delimiter — i.e. the local-part
754    // start for the next `@` encountered. Equivalent to the old backward
755    // `rfind`, computed incrementally.
756    let mut local_start = 0usize;
757
758    for (idx, c) in text.char_indices() {
759        if c == '@' {
760            // Skip an empty or over-long local-part. The length cap also gates
761            // the domain scan below so delimiter-free runs stay linear.
762            if idx == local_start || idx - local_start > MAX_EMAIL_LOCAL_LEN {
763                continue;
764            }
765
766            let domain_start = idx + 1;
767            let domain_end = bounded_token_end(text, domain_start, MAX_EMAIL_DOMAIN_LEN);
768            let domain = &text[domain_start..domain_end];
769
770            if domain.contains('.') && domain.len() >= 3 {
771                findings.push(CredentialFinding::new(
772                    CredentialKind::EmailAddress,
773                    local_start,
774                    domain_end,
775                ));
776            }
777            continue;
778        }
779
780        if c.is_whitespace() || matches!(c, '<' | ',' | ';' | '"' | '\'') {
781            local_start = idx + c.len_utf8();
782        }
783    }
784}
785
786// ---------------------------------------------------------------------------
787// Tests
788// ---------------------------------------------------------------------------
789
790#[cfg(test)]
791mod tests {
792    use super::*;
793
794    // --- CredentialKind::as_str ---
795
796    #[test]
797    fn credential_kind_as_str_round_trips() {
798        assert_eq!(CredentialKind::AnthropicKey.as_str(), "AnthropicKey");
799        assert_eq!(CredentialKind::AwsAccessKey.as_str(), "AwsAccessKey");
800        assert_eq!(CredentialKind::GenericHighEntropy.as_str(), "GenericHighEntropy");
801    }
802
803    // --- API key patterns ---
804
805    #[test]
806    fn detects_anthropic_key() {
807        let scanner = CredentialScanner::new();
808        let result = scanner.scan("auth: sk-ant-api03-XXXXXXXXXXXXXXXXXXXX");
809        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
810    }
811
812    #[test]
813    fn detects_openai_key_not_misclassified_as_anthropic() {
814        let scanner = CredentialScanner::new();
815        let result = scanner.scan("key: sk-proj-XXXXXXXXXXXXXXXXXXXX");
816        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::OpenAiKey));
817        assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
818    }
819
820    #[test]
821    fn detects_aws_access_key() {
822        let scanner = CredentialScanner::new();
823        let result = scanner.scan("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
824        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
825    }
826
827    #[test]
828    fn detects_gcp_service_account() {
829        let scanner = CredentialScanner::new();
830        let result = scanner.scan(r#"{"type": "service_account", "project_id": "my-project"}"#);
831        assert!(result
832            .findings
833            .iter()
834            .any(|f| f.kind == CredentialKind::GcpServiceAccount));
835    }
836
837    // --- AAASM-3727: case / whitespace bypass variants ---
838
839    #[test]
840    fn detects_gcp_service_account_compact_json() {
841        // Compact serializer output (no space after the colon) must be caught.
842        let scanner = CredentialScanner::new();
843        let result = scanner.scan(r#"{"type":"service_account","project_id":"p"}"#);
844        assert!(
845            result
846                .findings
847                .iter()
848                .any(|f| f.kind == CredentialKind::GcpServiceAccount),
849            "compact GCP service-account JSON must be detected"
850        );
851    }
852
853    #[test]
854    fn detects_gcp_service_account_spaces_around_colon() {
855        let scanner = CredentialScanner::new();
856        let result = scanner.scan(r#"{ "type" : "service_account" }"#);
857        assert!(
858            result
859                .findings
860                .iter()
861                .any(|f| f.kind == CredentialKind::GcpServiceAccount),
862            "spaced-colon GCP service-account JSON must be detected"
863        );
864    }
865
866    #[test]
867    fn detects_postgres_url_uppercase_scheme() {
868        // RFC 3986 schemes are case-insensitive; an upper-case scheme must not bypass.
869        let scanner = CredentialScanner::new();
870        let result = scanner.scan("DATABASE_URL=POSTGRES://user:password@host:5432/db");
871        assert!(
872            result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl),
873            "upper-case POSTGRES:// scheme must be detected"
874        );
875    }
876
877    #[test]
878    fn detects_lowercase_pem_private_key_header() {
879        let scanner = CredentialScanner::new();
880        let result =
881            scanner.scan("-----begin rsa private key-----\nMIIEpAIBAAKCAQEA...\n-----end rsa private key-----");
882        assert!(
883            result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey),
884            "lower-case PEM header must be detected"
885        );
886    }
887
888    // --- Auth token patterns ---
889
890    #[test]
891    fn detects_github_pat() {
892        let scanner = CredentialScanner::new();
893        let result = scanner.scan("token: ghp_1234567890abcdefghijklmnopqrstuvwxyz");
894        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
895    }
896
897    #[test]
898    fn detects_github_app_token() {
899        let scanner = CredentialScanner::new();
900        let result = scanner.scan("token: ghs_1234567890abcdefghijklmnopqrstuvwxyz");
901        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubAppToken));
902    }
903
904    #[test]
905    fn detects_slack_bot_token() {
906        let scanner = CredentialScanner::new();
907        let result = scanner.scan("SLACK_BOT_TOKEN=xoxb-123456789012-123456789012-XXXXXXXXXXXX");
908        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackBotToken));
909    }
910
911    #[test]
912    fn detects_slack_user_token() {
913        let scanner = CredentialScanner::new();
914        let result = scanner.scan("token=xoxp-123456789012-123456789012-XXXXXXXXXXXX");
915        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackUserToken));
916    }
917
918    #[test]
919    fn detects_slack_oauth_token() {
920        let scanner = CredentialScanner::new();
921        let result = scanner.scan("oauth=xoxa-123456789012-123456789012-XXXXXXXXXXXX");
922        assert!(result
923            .findings
924            .iter()
925            .any(|f| f.kind == CredentialKind::SlackOAuthToken));
926    }
927
928    // --- Cloud credential patterns ---
929
930    #[test]
931    fn detects_azure_connection_string() {
932        let scanner = CredentialScanner::new();
933        let result = scanner.scan("DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=XXXX");
934        assert!(result
935            .findings
936            .iter()
937            .any(|f| f.kind == CredentialKind::AzureConnectionString));
938    }
939
940    #[test]
941    fn redacts_azure_account_key_value_after_semicolons() {
942        // AAASM-3997: the `DefaultEndpointsProtocol=` prefix detector stops at the
943        // first `;`, so the AccountKey — which appears two segments later — used to
944        // survive redaction in the clear. The dedicated AccountKey pass must redact
945        // the secret wherever it falls in the connection string.
946        let scanner = CredentialScanner::new();
947        let secret = "abc123DEF456ghi789JKL012mno345PQR678stu901VWX234yz==";
948        let input = format!(
949            "DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey={secret};EndpointSuffix=core.windows.net"
950        );
951        let redacted = scanner.scan(&input).redact(&input);
952        assert!(
953            !redacted.contains(secret),
954            "Azure AccountKey secret leaked past redaction: {redacted}"
955        );
956        assert!(
957            redacted.contains("[REDACTED:AzureConnectionString]"),
958            "expected an AzureConnectionString redaction label: {redacted}"
959        );
960        // The trailing segment after the key is preserved (only the value is redacted).
961        assert!(redacted.contains("EndpointSuffix=core.windows.net"));
962    }
963
964    // --- Database URL patterns ---
965
966    #[test]
967    fn detects_postgres_url() {
968        let scanner = CredentialScanner::new();
969        let result = scanner.scan("DATABASE_URL=postgres://user:password@host:5432/db");
970        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl));
971    }
972
973    #[test]
974    fn detects_mysql_url() {
975        let scanner = CredentialScanner::new();
976        let result = scanner.scan("db=mysql://user:secret@localhost/mydb");
977        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MysqlUrl));
978    }
979
980    #[test]
981    fn detects_mongodb_url() {
982        let scanner = CredentialScanner::new();
983        let result = scanner.scan("uri=mongodb://admin:pass@cluster0.mongodb.net/mydb");
984        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
985    }
986
987    // --- Private key patterns ---
988
989    #[test]
990    fn detects_rsa_private_key() {
991        let scanner = CredentialScanner::new();
992        let result =
993            scanner.scan("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----");
994        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey));
995    }
996
997    #[test]
998    fn detects_ec_private_key() {
999        let scanner = CredentialScanner::new();
1000        let result = scanner.scan("-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEI...\n-----END EC PRIVATE KEY-----");
1001        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EcPrivateKey));
1002    }
1003
1004    #[test]
1005    fn detects_openssh_private_key() {
1006        let scanner = CredentialScanner::new();
1007        let result = scanner
1008            .scan("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAA=\n-----END OPENSSH PRIVATE KEY-----");
1009        assert!(result
1010            .findings
1011            .iter()
1012            .any(|f| f.kind == CredentialKind::OpensshPrivateKey));
1013    }
1014
1015    #[test]
1016    fn detects_generic_private_key() {
1017        let scanner = CredentialScanner::new();
1018        let result = scanner.scan("-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgk=\n-----END PRIVATE KEY-----");
1019        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PrivateKey));
1020    }
1021
1022    #[test]
1023    fn detects_pgp_private_key() {
1024        let scanner = CredentialScanner::new();
1025        let result =
1026            scanner.scan("-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOYBF...\n-----END PGP PRIVATE KEY BLOCK-----");
1027        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PgpPrivateKey));
1028    }
1029
1030    // --- PII patterns ---
1031
1032    #[test]
1033    fn detects_credit_card_luhn() {
1034        let scanner = CredentialScanner::new();
1035        let result = scanner.scan("card: 4532015112830366");
1036        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1037    }
1038
1039    #[test]
1040    fn detects_credit_card_with_spaces() {
1041        let scanner = CredentialScanner::new();
1042        let result = scanner.scan("card: 4532 0151 1283 0366");
1043        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1044    }
1045
1046    #[test]
1047    fn does_not_flag_invalid_luhn() {
1048        let scanner = CredentialScanner::new();
1049        let result = scanner.scan("num: 4532015112830367");
1050        assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
1051    }
1052
1053    #[test]
1054    fn detects_ssn() {
1055        let scanner = CredentialScanner::new();
1056        let result = scanner.scan("SSN: 123-45-6789");
1057        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SsnPattern));
1058    }
1059
1060    #[test]
1061    fn detects_email_address() {
1062        let scanner = CredentialScanner::new();
1063        let result = scanner.scan("contact: user@example.com for support");
1064        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress));
1065    }
1066
1067    #[test]
1068    fn detects_email_after_delimiter() {
1069        // The forward-pass local-part tracking must start after the delimiter,
1070        // matching the previous backward-rfind behaviour.
1071        let input = "mail to: <alice@example.org>";
1072        let scanner = CredentialScanner::new();
1073        let result = scanner.scan(input);
1074        // The local-part must begin at 'alice' (just past '<'), not at '<'.
1075        assert!(
1076            result
1077                .findings
1078                .iter()
1079                .any(|f| f.kind == CredentialKind::EmailAddress
1080                    && input[f.offset..f.end].starts_with("alice@example.org"))
1081        );
1082    }
1083
1084    #[test]
1085    fn email_scan_is_linear_on_pathological_at_run() {
1086        // Regression for AAASM-3988: ~1 MB of consecutive '@' with no
1087        // delimiters previously drove scan_emails to O(n²) (~1e12 ops),
1088        // hanging the enforcement/redaction path. It must now complete
1089        // near-instantly and flag nothing.
1090        let scanner = CredentialScanner::new();
1091        let payload = "@".repeat(1_000_000);
1092
1093        let start = std::time::Instant::now();
1094        let result = scanner.scan(&payload);
1095        let elapsed = start.elapsed();
1096
1097        assert!(
1098            !result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress),
1099            "delimiter-free '@' run must not be flagged as an email",
1100        );
1101        assert!(
1102            elapsed < std::time::Duration::from_secs(1),
1103            "email scan took {elapsed:?}; expected well under a second",
1104        );
1105    }
1106
1107    #[test]
1108    fn email_scan_is_linear_on_alternating_at_run() {
1109        // A delimiter-free `a@a@a@…` run keeps the domain token scan bounded.
1110        let scanner = CredentialScanner::new();
1111        let payload = "a@".repeat(500_000);
1112
1113        let start = std::time::Instant::now();
1114        let _ = scanner.scan(&payload);
1115        assert!(
1116            start.elapsed() < std::time::Duration::from_secs(1),
1117            "alternating '@' run must scan in linear time",
1118        );
1119    }
1120
1121    // --- High-entropy ---
1122
1123    #[test]
1124    fn detects_high_entropy_token() {
1125        let scanner = CredentialScanner::new();
1126        let result = scanner.scan("secret: xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0eC5");
1127        assert!(result
1128            .findings
1129            .iter()
1130            .any(|f| f.kind == CredentialKind::GenericHighEntropy));
1131    }
1132
1133    #[test]
1134    fn does_not_flag_short_token_as_high_entropy() {
1135        let scanner = CredentialScanner::new();
1136        let result = scanner.scan("word: hello");
1137        assert!(!result
1138            .findings
1139            .iter()
1140            .any(|f| f.kind == CredentialKind::GenericHighEntropy));
1141    }
1142
1143    // --- AAASM-3870: encoding / length evasions ---
1144
1145    /// A 64-char lowercase-hex secret (hex-encoded 256-bit key) has entropy
1146    /// capped at 4.0 bits/char, so it slipped past the old 4.5-bit gate. The
1147    /// dedicated long-hex rule must now flag it.
1148    #[test]
1149    fn detects_64_char_lowercase_hex_secret() {
1150        let scanner = CredentialScanner::new();
1151        // 64 lowercase hex chars.
1152        let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1153        assert_eq!(secret.len(), 64, "fixture must be exactly 64 hex chars");
1154        let result = scanner.scan(&format!("token={secret}"));
1155        assert!(
1156            result
1157                .findings
1158                .iter()
1159                .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1160            "64-char hex secret must be flagged: {:?}",
1161            result.findings
1162        );
1163        assert!(!scanner.scan(secret).is_clean());
1164    }
1165
1166    /// A single base64 token longer than 64 chars was skipped entirely by the
1167    /// old length-bounded rule. Removing the upper bound must now flag it.
1168    #[test]
1169    fn detects_base64_token_beyond_64_chars() {
1170        let scanner = CredentialScanner::new();
1171        // 88-char base64 of random-looking bytes (entropy well above the gate).
1172        let secret = "aGVsbG9Xb3JsZFRoaXNJc0FWZXJ5TG9uZ0Jhc2U2NFNlY3JldFRva2VuQmV5b25kU2l4dHlGb3VyQ2hhcnM5OQ";
1173        assert!(secret.len() > 64, "fixture must exceed the old 64-char cap");
1174        let result = scanner.scan(&format!("authorization: {secret}"));
1175        assert!(
1176            result
1177                .findings
1178                .iter()
1179                .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1180            ">64-char base64 token must be flagged: {:?}",
1181            result.findings
1182        );
1183    }
1184
1185    /// Branded literal prefixes must remain detected after the rewrite — the
1186    /// long-token rules must not displace the high-signal AC matchers.
1187    #[test]
1188    fn branded_prefixes_still_flagged_after_rewrite() {
1189        let scanner = CredentialScanner::new();
1190        let result = scanner.scan("k=AKIAIOSFODNN7EXAMPLE p=ghp_0123456789abcdefghijklmnopqrstuvwxyz");
1191        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
1192        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
1193    }
1194
1195    /// Common shorter hex blobs (32-char MD5/UUID, 40-char git SHA-1) and a
1196    /// plain English sentence must NOT be flagged — the 64-char hex bar and the
1197    /// 20-char/4.5-bit entropy gate keep these benign payloads clean.
1198    #[test]
1199    fn does_not_flag_benign_hex_ids_or_prose() {
1200        let scanner = CredentialScanner::new();
1201        let benign = [
1202            // 40-char git SHA-1.
1203            "commit 0123456789abcdef0123456789abcdef01234567 fixed it",
1204            // 32-char MD5 / dashless UUID.
1205            "etag d41d8cd98f00b204e9800998ecf8427e cached",
1206            // 36-char UUID with dashes.
1207            "id 550e8400-e29b-41d4-a716-446655440000 ok",
1208            // Plain prose and a short id.
1209            "The quarterly report is ready for review by the team.",
1210            "user id 42 logged in",
1211        ];
1212        for text in &benign {
1213            let result = scanner.scan(text);
1214            assert!(
1215                !result
1216                    .findings
1217                    .iter()
1218                    .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1219                "benign text wrongly flagged: {:?} -> {:?}",
1220                text,
1221                result.findings
1222            );
1223        }
1224    }
1225
1226    /// End-to-end: a 64-char hex secret embedded in JSON is fully redacted with
1227    /// no raw fragment surviving.
1228    #[test]
1229    fn redact_removes_long_hex_secret() {
1230        let scanner = CredentialScanner::new();
1231        let secret = "deadbeefcafebabe0123456789abcdef0123456789abcdeffedcba9876543210";
1232        let text = format!(r#"{{"api_token":"{secret}"}}"#);
1233        let result = scanner.scan(&text);
1234        let redacted = result.redact(&text);
1235        assert!(!redacted.contains(secret), "raw hex secret survived: {redacted}");
1236        assert!(redacted.contains("[REDACTED:GenericHighEntropy]"));
1237    }
1238
1239    /// The additive passes must not disturb the original whitespace-token
1240    /// behaviour: a database URL still yields its specific URL finding plus the
1241    /// whole-blob GenericHighEntropy at offset 0 (3 findings), exactly as the
1242    /// conformance spec encodes it.
1243    #[test]
1244    fn additive_passes_preserve_url_and_whole_blob_entropy_findings() {
1245        let scanner = CredentialScanner::new();
1246        let result = scanner.scan("MONGO_URI=mongodb://admin:pass@cluster0.mongodb.net/mydb");
1247        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
1248        assert!(result
1249            .findings
1250            .iter()
1251            .any(|f| f.kind == CredentialKind::GenericHighEntropy && f.offset == 0));
1252    }
1253
1254    // --- luhn_valid helper ---
1255
1256    #[test]
1257    fn luhn_valid_visa_test_number() {
1258        assert!(luhn_valid("4532015112830366"));
1259    }
1260
1261    #[test]
1262    fn luhn_valid_mastercard_test_number() {
1263        assert!(luhn_valid("5425233430109903"));
1264    }
1265
1266    #[test]
1267    fn luhn_valid_amex_test_number() {
1268        assert!(luhn_valid("371449635398431"));
1269    }
1270
1271    #[test]
1272    fn luhn_valid_discover_test_number() {
1273        assert!(luhn_valid("6011111111111117"));
1274    }
1275
1276    #[test]
1277    fn luhn_invalid_altered_digit() {
1278        assert!(!luhn_valid("4532015112830367"));
1279    }
1280
1281    #[test]
1282    fn luhn_rejects_too_short() {
1283        assert!(!luhn_valid("123456789012"));
1284    }
1285
1286    #[test]
1287    fn luhn_rejects_too_long() {
1288        assert!(!luhn_valid("45320151128303661234"));
1289    }
1290
1291    // --- shannon_entropy helper ---
1292
1293    #[test]
1294    fn entropy_zero_for_empty() {
1295        assert_eq!(shannon_entropy(""), 0.0);
1296    }
1297
1298    #[test]
1299    fn entropy_low_for_repeated_char() {
1300        assert!(shannon_entropy("aaaaaaaaaaaaaaaaaaaaaa") < 1.0);
1301    }
1302
1303    #[test]
1304    fn entropy_high_for_random_base64() {
1305        assert!(shannon_entropy("xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0") > 4.0);
1306    }
1307
1308    #[test]
1309    fn entropy_moderate_for_english_text() {
1310        let e = shannon_entropy("Thequickbrownfoxjumpsoverthelazydog");
1311        assert!(e > 3.0 && e < 5.0);
1312    }
1313
1314    // --- ScanResult::redact() and is_clean() ---
1315
1316    #[test]
1317    fn redact_replaces_github_pat() {
1318        let scanner = CredentialScanner::new();
1319        let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX end";
1320        let result = scanner.scan(text);
1321        let redacted = result.redact(text);
1322        assert!(!redacted.contains("ghp_"));
1323        assert!(redacted.contains("[REDACTED:GitHubPat]"));
1324    }
1325
1326    #[test]
1327    fn redact_is_deterministic() {
1328        let scanner = CredentialScanner::new();
1329        let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
1330        let result = scanner.scan(text);
1331        assert_eq!(result.redact(text), result.redact(text));
1332    }
1333
1334    #[test]
1335    fn redact_clean_text_unchanged() {
1336        let scanner = CredentialScanner::new();
1337        let text = "This is a normal sentence with no secrets.";
1338        let result = scanner.scan(text);
1339        assert!(result.is_clean());
1340        assert_eq!(result.redact(text), text);
1341    }
1342
1343    #[test]
1344    fn redact_multiple_findings_in_one_pass() {
1345        let scanner = CredentialScanner::new();
1346        let text = "a=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX b=postgres://u:p@host/db";
1347        let result = scanner.scan(text);
1348        let redacted = result.redact(text);
1349        assert!(!redacted.contains("ghp_"));
1350        assert!(!redacted.contains("postgres://"));
1351        assert!(redacted.contains("[REDACTED:GitHubPat]"));
1352        assert!(redacted.contains("[REDACTED:PostgresUrl]"));
1353    }
1354
1355    #[test]
1356    fn is_clean_true_for_benign_text() {
1357        let scanner = CredentialScanner::new();
1358        assert!(scanner.scan("Hello, world! No secrets here.").is_clean());
1359    }
1360
1361    // --- AAASM-3689: overlapping-findings redaction must not leak fragments ---
1362
1363    #[test]
1364    fn redact_overlapping_findings_leaks_no_secret_fragment() {
1365        // A GitHub PAT embedded in an email-shaped string, adjacent to a
1366        // postgres URL — the AC-prefix, email, and high-entropy passes produce
1367        // overlapping byte ranges over the same region. Pre-fix this spliced
1368        // mangled labels and left raw secret bytes (e.g. "stgresUrl]]").
1369        let scanner = CredentialScanner::new();
1370        let text = "user@ghp_tokenAAAAAAAAAAAAAAAAAAAAAAAA.example.com_postgres://x:y@h/d";
1371        let result = scanner.scan(text);
1372        let redacted = result.redact(text);
1373
1374        // No raw secret fragment from a matched region survives.
1375        assert!(!redacted.contains("ghp_"), "raw GitHub PAT prefix leaked: {redacted}");
1376        assert!(!redacted.contains("postgres://"), "raw postgres URL leaked: {redacted}");
1377        assert!(!redacted.contains("tokenAAAA"), "raw token body leaked: {redacted}");
1378        assert!(
1379            !redacted.contains("stgresUrl"),
1380            "mangled-splice secret fragment leaked: {redacted}"
1381        );
1382        // Output contains only well-formed redaction labels — no mangled splices.
1383        assert!(redacted.contains("[REDACTED:"));
1384        assert!(!redacted.contains("]]"), "malformed nested label produced: {redacted}");
1385        // Every '[REDACTED:' opener has a matching ']' closer with a known kind —
1386        // a mangled splice would have left an opener without a clean close.
1387        for label in redacted.match_indices("[REDACTED:").map(|(i, _)| &redacted[i..]) {
1388            let close = label.find(']').expect("redaction label must be closed");
1389            let kind = &label["[REDACTED:".len()..close];
1390            assert!(!kind.is_empty(), "empty/mangled redaction kind in: {redacted}");
1391        }
1392    }
1393
1394    #[test]
1395    fn redact_overlap_at_multibyte_boundary_does_not_panic() {
1396        // Overlapping matches whose region spans multi-byte UTF-8 codepoints.
1397        // Pre-fix, an overlap boundary landing mid-codepoint panicked in
1398        // replace_range; the char-boundary guard now makes this impossible.
1399        let scanner = CredentialScanner::new();
1400        let text = "postgres://é:é@hosté.com sk-ant-éXXXXXXXXXXXXXXXXXXXX";
1401        let result = scanner.scan(text);
1402        // Must not panic, and must not leave the raw scheme behind.
1403        let redacted = result.redact(text);
1404        assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1405    }
1406
1407    #[test]
1408    fn redact_adjacent_overlapping_findings_merge_into_one_span() {
1409        // Two findings sharing an offset (prefix + high-entropy over the same
1410        // token) coalesce so the token is replaced exactly once, not double-spliced.
1411        let scanner = CredentialScanner::new();
1412        let text = "tok=ghp_abcdefABCDEF0123456789ABCDEF0123456789 done";
1413        let result = scanner.scan(text);
1414        let redacted = result.redact(text);
1415        assert!(!redacted.contains("ghp_"));
1416        assert!(!redacted.contains("abcdefABCDEF"), "raw token body leaked: {redacted}");
1417        assert!(
1418            redacted.contains(" done"),
1419            "trailing context must be preserved: {redacted}"
1420        );
1421    }
1422
1423    #[test]
1424    fn coalesce_keeps_specific_kind_label_over_generic() {
1425        // A GitHub PAT is also flagged as GenericHighEntropy over the same token.
1426        // The GenericHighEntropy finding starts at the earlier offset, but the
1427        // merged span must carry the specific GitHubPat label, not the generic
1428        // backstop — kind priority wins over offset order.
1429        let scanner = CredentialScanner::new();
1430        let text = "token=ghp_abcdefABCDEF0123456789ABCDEF0123456789";
1431        let result = scanner.scan(text);
1432        // Sanity: both detectors fired over the same region.
1433        assert!(
1434            result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat),
1435            "expected a GitHubPat finding: {:?}",
1436            result.findings
1437        );
1438        assert!(
1439            result
1440                .findings
1441                .iter()
1442                .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1443            "expected a GenericHighEntropy finding: {:?}",
1444            result.findings
1445        );
1446        let redacted = result.redact(text);
1447        assert!(
1448            redacted.contains("[REDACTED:GitHubPat]"),
1449            "merged label must be the specific GitHubPat kind, not GenericHighEntropy: {redacted}"
1450        );
1451        assert!(
1452            !redacted.contains("GenericHighEntropy"),
1453            "generic backstop label must not win over a specific detector: {redacted}"
1454        );
1455        assert!(!redacted.contains("ghp_"), "raw token survived: {redacted}");
1456    }
1457
1458    #[test]
1459    fn coalesce_keeps_db_url_label_over_embedded_email() {
1460        // A database URL embeds an EmailAddress-shaped span (user@host). The
1461        // merged span must keep the specific PostgresUrl label, not collapse to
1462        // the generic EmailAddress backstop.
1463        let scanner = CredentialScanner::new();
1464        let text = "DATABASE_URL=postgres://user:password@db.internal:5432/mydb";
1465        let result = scanner.scan(text);
1466        let redacted = result.redact(text);
1467        assert_eq!(
1468            redacted, "[REDACTED:PostgresUrl]",
1469            "db-url region must redact to the specific PostgresUrl label: {redacted}"
1470        );
1471        assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1472    }
1473
1474    // --- CredentialKind::Custom and CredentialFinding::from_regex_match ---
1475
1476    #[test]
1477    fn custom_kind_as_str_returns_custom() {
1478        assert_eq!(CredentialKind::Custom.as_str(), "Custom");
1479    }
1480
1481    #[test]
1482    fn from_regex_match_creates_custom_finding() {
1483        let finding = CredentialFinding::from_regex_match(5, 20);
1484        assert_eq!(finding.kind, CredentialKind::Custom);
1485        assert_eq!(finding.offset, 5);
1486        assert_eq!(finding.matched, "[REDACTED:Custom]");
1487    }
1488
1489    // --- False-positive corpus ---
1490
1491    #[test]
1492    fn false_positive_corpus_has_no_hard_credential_hits() {
1493        let scanner = CredentialScanner::new();
1494        let corpus = [
1495            "The quick brown fox jumps over the lazy dog.",
1496            "fn main() { println!(\"Hello, world!\"); }",
1497            "SELECT * FROM users WHERE id = 42;",
1498            "cargo build --release --features std",
1499            "version = \"1.0.0\" edition = \"2021\"",
1500            "2026-04-27T15:34:15.377+0800",
1501            "error[E0382]: borrow of moved value: `x`",
1502        ];
1503        for text in &corpus {
1504            let result = scanner.scan(text);
1505            let hard: Vec<_> = result
1506                .findings
1507                .iter()
1508                .filter(|f| f.kind != CredentialKind::GenericHighEntropy)
1509                .collect();
1510            assert!(hard.is_empty(), "false positive in: {:?} → {:?}", text, hard);
1511        }
1512    }
1513
1514    // --- ScannerConfig ---
1515
1516    #[test]
1517    fn disabled_scanner_returns_empty_result() {
1518        let config = ScannerConfig {
1519            disabled: true,
1520            ..Default::default()
1521        };
1522        let scanner = CredentialScanner::with_config(config);
1523        let result = scanner.scan("sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ghp_XXXXXXXXX");
1524        assert!(result.is_clean(), "disabled scanner must return no findings");
1525    }
1526
1527    #[test]
1528    fn custom_pattern_detected_as_custom_kind() {
1529        let config = ScannerConfig {
1530            custom_patterns: vec!["INTERNAL_SECRET_".into()],
1531            ..Default::default()
1532        };
1533        let scanner = CredentialScanner::with_config(config);
1534        let result = scanner.scan("token=INTERNAL_SECRET_hello");
1535        let custom: Vec<_> = result
1536            .findings
1537            .iter()
1538            .filter(|f| f.kind == CredentialKind::Custom)
1539            .collect();
1540        assert!(!custom.is_empty(), "custom pattern must produce a Custom finding");
1541        assert!(custom[0].matched.contains("[REDACTED:Custom]"));
1542    }
1543
1544    #[test]
1545    fn custom_pattern_coexists_with_builtin() {
1546        let config = ScannerConfig {
1547            custom_patterns: vec!["MY_TOKEN_".into()],
1548            ..Default::default()
1549        };
1550        let scanner = CredentialScanner::with_config(config);
1551        let text = "a=ghp_XXXXXXXXX b=MY_TOKEN_secret123";
1552        let result = scanner.scan(text);
1553        let kinds: Vec<_> = result.findings.iter().map(|f| &f.kind).collect();
1554        assert!(kinds.contains(&&CredentialKind::GitHubPat));
1555        assert!(kinds.contains(&&CredentialKind::Custom));
1556    }
1557
1558    #[test]
1559    fn default_config_matches_new() {
1560        let default_scanner = CredentialScanner::new();
1561        let config_scanner = CredentialScanner::with_config(ScannerConfig::default());
1562        let text = "key=ghp_XXXXXXXXX url=postgres://u:p@host/db";
1563        let r1 = default_scanner.scan(text);
1564        let r2 = config_scanner.scan(text);
1565        assert_eq!(r1.findings.len(), r2.findings.len());
1566        for (a, b) in r1.findings.iter().zip(r2.findings.iter()) {
1567            assert_eq!(a.kind, b.kind);
1568            assert_eq!(a.offset, b.offset);
1569        }
1570    }
1571}