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 token (Shannon entropy > 4.5 bits/char, length 20–64 bytes).
125    GenericHighEntropy,
126    // Policy-defined
127    /// A pattern defined in the policy document's `data.sensitive_patterns` field.
128    Custom,
129}
130
131impl CredentialKind {
132    /// Returns the string used in the `[REDACTED:<kind>]` label.
133    pub fn as_str(&self) -> &'static str {
134        match self {
135            Self::AnthropicKey => "AnthropicKey",
136            Self::AwsAccessKey => "AwsAccessKey",
137            Self::AzureConnectionString => "AzureConnectionString",
138            Self::CreditCardLuhn => "CreditCardLuhn",
139            Self::EcPrivateKey => "EcPrivateKey",
140            Self::EmailAddress => "EmailAddress",
141            Self::GcpServiceAccount => "GcpServiceAccount",
142            Self::GenericHighEntropy => "GenericHighEntropy",
143            Self::GitHubAppToken => "GitHubAppToken",
144            Self::GitHubPat => "GitHubPat",
145            Self::MongodbUrl => "MongodbUrl",
146            Self::MysqlUrl => "MysqlUrl",
147            Self::OpenAiKey => "OpenAiKey",
148            Self::OpensshPrivateKey => "OpensshPrivateKey",
149            Self::PgpPrivateKey => "PgpPrivateKey",
150            Self::PostgresUrl => "PostgresUrl",
151            Self::PrivateKey => "PrivateKey",
152            Self::RsaPrivateKey => "RsaPrivateKey",
153            Self::SlackBotToken => "SlackBotToken",
154            Self::SlackOAuthToken => "SlackOAuthToken",
155            Self::SlackUserToken => "SlackUserToken",
156            Self::SsnPattern => "SsnPattern",
157            Self::Custom => "Custom",
158        }
159    }
160
161    /// Relative confidence of this kind when two overlapping findings are
162    /// coalesced into one span.
163    ///
164    /// When several detectors match the same byte region (e.g. a GitHub PAT is
165    /// also flagged as a `GenericHighEntropy` token, or a database URL embeds an
166    /// `EmailAddress`), the merged span must carry the label of the most
167    /// specific, highest-confidence detector — never a generic backstop. A
168    /// higher number wins. Specific literal-prefix and PEM detectors and
169    /// policy-defined `Custom` patterns outrank the generic
170    /// `GenericHighEntropy` / `EmailAddress` heuristics.
171    fn priority(&self) -> u8 {
172        match self {
173            // Generic / heuristic backstops — lowest confidence.
174            Self::GenericHighEntropy => 0,
175            Self::EmailAddress => 1,
176            // Specific, high-signal detectors — they identify the exact
177            // credential kind and must win over the generic backstops above.
178            Self::AnthropicKey
179            | Self::AwsAccessKey
180            | Self::AzureConnectionString
181            | Self::CreditCardLuhn
182            | Self::EcPrivateKey
183            | Self::GcpServiceAccount
184            | Self::GitHubAppToken
185            | Self::GitHubPat
186            | Self::MongodbUrl
187            | Self::MysqlUrl
188            | Self::OpenAiKey
189            | Self::OpensshPrivateKey
190            | Self::PgpPrivateKey
191            | Self::PostgresUrl
192            | Self::PrivateKey
193            | Self::RsaPrivateKey
194            | Self::SlackBotToken
195            | Self::SlackOAuthToken
196            | Self::SlackUserToken
197            | Self::SsnPattern
198            | Self::Custom => 2,
199        }
200    }
201}
202
203/// A single detected credential finding.
204///
205/// `offset` is the byte offset in the original text where the pattern was found.
206/// `matched` is the redacted label, e.g. `[REDACTED:AwsAccessKey]`. The raw
207/// secret is never stored.
208///
209/// The `end` field is intentionally private; it is used by [`ScanResult::redact`]
210/// to splice the original match without exposing raw length arithmetic to callers.
211#[derive(Debug, Clone, PartialEq, Eq)]
212#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
213pub struct CredentialFinding {
214    /// Category of the detected credential.
215    pub kind: CredentialKind,
216    /// Byte offset in the original text where the pattern begins.
217    pub offset: usize,
218    /// Redacted label replacing the secret, e.g. `[REDACTED:AwsAccessKey]`.
219    pub matched: String,
220    #[cfg_attr(feature = "serde", serde(skip))]
221    end: usize,
222}
223
224impl CredentialFinding {
225    fn new(kind: CredentialKind, offset: usize, end: usize) -> Self {
226        let label = format!("[REDACTED:{}]", kind.as_str());
227        Self {
228            kind,
229            offset,
230            matched: label,
231            end,
232        }
233    }
234
235    /// Construct a finding for a match produced by a policy-defined regex pattern.
236    ///
237    /// Used by `aa-gateway` when custom `data.sensitive_patterns` regexes match.
238    /// The `offset` and `end` are byte positions returned by the regex engine.
239    pub fn from_regex_match(offset: usize, end: usize) -> Self {
240        Self::new(CredentialKind::Custom, offset, end)
241    }
242}
243
244/// The result of a [`CredentialScanner::scan`] call.
245#[derive(Debug, Clone, PartialEq, Eq)]
246#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))]
247pub struct ScanResult {
248    /// All credential findings detected in the scanned text, sorted by byte offset.
249    pub findings: Vec<CredentialFinding>,
250}
251
252impl ScanResult {
253    /// Returns `true` if no credential findings were detected.
254    pub fn is_clean(&self) -> bool {
255        self.findings.is_empty()
256    }
257
258    /// Returns a copy of `text` with every finding replaced by its redacted label.
259    ///
260    /// Overlapping findings are first coalesced into non-overlapping byte spans so
261    /// no region is ever partially redacted (which previously left raw secret
262    /// fragments and mangled labels in the output). The merged spans are then
263    /// spliced in reverse offset order so earlier byte positions remain valid
264    /// after each replacement. Spans whose boundaries do not fall on UTF-8
265    /// character boundaries are skipped rather than spliced, making the former
266    /// mid-codepoint panic structurally impossible.
267    pub fn redact(&self, text: &str) -> String {
268        let merged = coalesce_findings(&self.findings);
269        let mut result = text.to_string();
270        // Splice merged spans in reverse offset order so earlier positions stay valid.
271        for span in merged.iter().rev() {
272            if span.end <= result.len()
273                && span.offset <= span.end
274                && result.is_char_boundary(span.offset)
275                && result.is_char_boundary(span.end)
276            {
277                result.replace_range(span.offset..span.end, &span.label);
278            }
279        }
280        result
281    }
282}
283
284/// Configuration for the credential scanner.
285///
286/// Controls whether scanning is enabled and allows adding custom literal
287/// patterns beyond the built-in set.
288#[derive(Debug, Clone, Default)]
289pub struct ScannerConfig {
290    /// When `true`, scanning is disabled and [`CredentialScanner::scan`] always
291    /// returns an empty [`ScanResult`].
292    pub disabled: bool,
293    /// Additional literal prefixes to detect as [`CredentialKind::Custom`].
294    /// Each string is compiled into the Aho-Corasick automaton alongside the
295    /// built-in patterns.
296    pub custom_patterns: Vec<String>,
297}
298
299/// Pre-compiled multi-pattern credential scanner.
300///
301/// Construct once with [`CredentialScanner::new`] (or [`CredentialScanner::with_config`])
302/// and call [`CredentialScanner::scan`] repeatedly. Pattern compilation happens at
303/// construction time; each scan call is O(n) in the length of the input text.
304pub struct CredentialScanner {
305    patterns: AhoCorasick,
306    /// Maps each AC pattern index to its [`CredentialKind`]. Built-in patterns
307    /// use the static `AC_KINDS` entries; custom patterns are appended as
308    /// [`CredentialKind::Custom`].
309    kinds: Vec<CredentialKind>,
310    /// When `true`, [`scan`](Self::scan) short-circuits and returns an empty result.
311    disabled: bool,
312}
313
314impl Default for CredentialScanner {
315    fn default() -> Self {
316        Self::new()
317    }
318}
319
320impl CredentialScanner {
321    /// Build the scanner with all built-in patterns and scanning enabled.
322    ///
323    /// # Panics
324    ///
325    /// Panics only if the hard-coded AC patterns are somehow invalid — this
326    /// cannot happen in practice.
327    pub fn new() -> Self {
328        Self::with_config(ScannerConfig::default())
329    }
330
331    /// Build the scanner from explicit configuration.
332    ///
333    /// Custom patterns are appended after the built-in set and are tagged as
334    /// [`CredentialKind::Custom`]. If `config.disabled` is true the scanner
335    /// is inert — [`scan`](Self::scan) always returns an empty result.
336    pub fn with_config(config: ScannerConfig) -> Self {
337        let mut all_patterns: Vec<&str> = AC_PATTERNS.to_vec();
338        // Collect custom pattern references — lifetime tied to `config`.
339        let custom_refs: Vec<&str> = config.custom_patterns.iter().map(|s| s.as_str()).collect();
340        all_patterns.extend_from_slice(&custom_refs);
341
342        let mut kinds: Vec<CredentialKind> = AC_KINDS.to_vec();
343        kinds.extend(std::iter::repeat(CredentialKind::Custom).take(config.custom_patterns.len()));
344
345        let ac = AhoCorasick::builder()
346            .match_kind(aho_corasick::MatchKind::LeftmostFirst)
347            // AAASM-3727: scheme prefixes (postgres://), PEM headers, and the
348            // GCP JSON key are case-insensitive in the wild (RFC 3986 schemes,
349            // lower/mixed-case PEM). Match case-insensitively so case variants
350            // cannot bypass detection. Prefixes like AKIA / ghp_ stay high-signal.
351            .ascii_case_insensitive(true)
352            .build(&all_patterns)
353            .expect("AC patterns are always valid");
354
355        Self {
356            patterns: ac,
357            kinds,
358            disabled: config.disabled,
359        }
360    }
361
362    /// Scan `text` for credential patterns and return a [`ScanResult`].
363    ///
364    /// Four passes are performed:
365    /// 1. Aho-Corasick literal prefix scan — O(n), 18 patterns covering API keys,
366    ///    auth tokens, cloud credentials, database URLs, and PEM private key headers.
367    /// 2. Credit card and SSN digit-sequence scan.
368    /// 3. Email address scan.
369    /// 4. High-entropy token scan (Shannon entropy > 4.5 bits/char, length 20–64).
370    pub fn scan(&self, text: &str) -> ScanResult {
371        if self.disabled {
372            return ScanResult { findings: Vec::new() };
373        }
374
375        let mut findings = Vec::new();
376
377        // Phase 1: AC literal prefix scan (API keys, auth tokens, cloud creds,
378        //          database URLs, PEM private key headers — 18 patterns + custom)
379        for mat in self.patterns.find_iter(text) {
380            let kind = self.kinds[mat.pattern()].clone();
381            let offset = mat.start();
382            let end = token_end(text, mat.end());
383            findings.push(CredentialFinding::new(kind, offset, end));
384        }
385
386        // Phase 2: PII — credit card numbers and SSN patterns
387        scan_digit_sequences(text, &mut findings);
388
389        // Phase 3: Email addresses
390        scan_emails(text, &mut findings);
391
392        // Phase 4: High-entropy tokens (Shannon entropy > 4.5 bits/char, length 20–64)
393        scan_high_entropy(text, &mut findings);
394
395        findings.sort_by_key(|f| f.offset);
396        ScanResult { findings }
397    }
398}
399
400// ---------------------------------------------------------------------------
401// Internal helpers
402// ---------------------------------------------------------------------------
403
404/// A single non-overlapping byte span to be replaced by `redact`.
405struct MergedSpan {
406    offset: usize,
407    end: usize,
408    label: String,
409    /// Kind whose `label` the span currently carries — retained so a later
410    /// overlapping finding of higher [`CredentialKind::priority`] can claim the
411    /// merged span's label.
412    kind: CredentialKind,
413}
414
415/// Coalesce findings into non-overlapping, offset-ordered spans.
416///
417/// Findings are sorted by `(offset, end)` and any subsequent finding whose
418/// `offset` falls before the current span's `end` is merged into it (extending
419/// the span's `end` to the maximum, i.e. the union of overlapping spans so no
420/// raw secret fragment can survive). The merged span carries the label of the
421/// highest-[`CredentialKind::priority`] finding in the run, so a specific,
422/// high-confidence detector (e.g. `GitHubPat`, `PostgresUrl`) always wins over a
423/// generic backstop (`GenericHighEntropy`, `EmailAddress`) regardless of byte
424/// offset. This guarantees `redact` never leaves a region partially replaced and
425/// never downgrades a credential's label to a less specific kind.
426fn coalesce_findings(findings: &[CredentialFinding]) -> Vec<MergedSpan> {
427    let mut sorted: Vec<&CredentialFinding> = findings.iter().collect();
428    sorted.sort_by_key(|f| (f.offset, f.end));
429
430    let mut merged: Vec<MergedSpan> = Vec::with_capacity(sorted.len());
431    for f in sorted {
432        match merged.last_mut() {
433            // Overlapping (or touching) the current span — extend it to the
434            // union and adopt the higher-priority kind's label.
435            Some(last) if f.offset < last.end => {
436                last.end = last.end.max(f.end);
437                if f.kind.priority() > last.kind.priority() {
438                    last.label = f.matched.clone();
439                    last.kind = f.kind.clone();
440                }
441            }
442            _ => merged.push(MergedSpan {
443                offset: f.offset,
444                end: f.end,
445                label: f.matched.clone(),
446                kind: f.kind.clone(),
447            }),
448        }
449    }
450    merged
451}
452
453/// Returns the byte index of the first token-terminating character at or after
454/// `from`. Token terminators are whitespace and common delimiters.
455fn token_end(text: &str, from: usize) -> usize {
456    text[from..]
457        .find(|c: char| c.is_whitespace() || matches!(c, '"' | '\'' | ',' | ';' | ')' | ']' | '}'))
458        .map(|i| from + i)
459        .unwrap_or(text.len())
460}
461
462/// Returns `true` if `s` matches the SSN format `DDD-DD-DDDD` exactly.
463fn is_ssn(s: &str) -> bool {
464    let b = s.as_bytes();
465    b.len() == 11
466        && b[0..3].iter().all(u8::is_ascii_digit)
467        && b[3] == b'-'
468        && b[4..6].iter().all(u8::is_ascii_digit)
469        && b[6] == b'-'
470        && b[7..11].iter().all(u8::is_ascii_digit)
471}
472
473/// Returns `true` if `digits` (ASCII digit characters only, no separators) passes
474/// the Luhn checksum algorithm used by credit card numbers.
475fn luhn_valid(digits: &str) -> bool {
476    if digits.len() < 13 || digits.len() > 19 {
477        return false;
478    }
479    let mut sum = 0u32;
480    let mut double = false;
481    for ch in digits.chars().rev() {
482        let Some(d) = ch.to_digit(10) else {
483            return false;
484        };
485        let val = if double {
486            let v = d * 2;
487            if v > 9 {
488                v - 9
489            } else {
490                v
491            }
492        } else {
493            d
494        };
495        sum += val;
496        double = !double;
497    }
498    sum % 10 == 0
499}
500
501/// Scans `text` for credit card numbers (Luhn-validated) and SSN patterns (`DDD-DD-DDDD`).
502fn scan_digit_sequences(text: &str, findings: &mut Vec<CredentialFinding>) {
503    let bytes = text.as_bytes();
504    let mut i = 0;
505    while i < bytes.len() {
506        if !bytes[i].is_ascii_digit() {
507            i += 1;
508            continue;
509        }
510
511        let start = i;
512        let mut digits = String::new();
513        let mut j = i;
514        let limit = (start + 24).min(bytes.len());
515
516        while j < limit {
517            match bytes[j] {
518                b if b.is_ascii_digit() => {
519                    digits.push(b as char);
520                    j += 1;
521                }
522                b' ' | b'-' if !digits.is_empty() => {
523                    j += 1;
524                }
525                _ => break,
526            }
527        }
528
529        let end = j;
530        let segment = &text[start..end];
531
532        if is_ssn(segment) {
533            findings.push(CredentialFinding::new(CredentialKind::SsnPattern, start, end));
534        } else if digits.len() >= 13 && digits.len() <= 19 && luhn_valid(&digits) {
535            findings.push(CredentialFinding::new(CredentialKind::CreditCardLuhn, start, end));
536        }
537        i = end.max(i + 1);
538    }
539}
540
541/// Computes the Shannon entropy of `s` in bits per character.
542fn shannon_entropy(s: &str) -> f64 {
543    if s.is_empty() {
544        return 0.0;
545    }
546    let mut freq = [0u32; 256];
547    for &b in s.as_bytes() {
548        freq[b as usize] += 1;
549    }
550    let len = s.len() as f64;
551    freq.iter()
552        .filter(|&&c| c > 0)
553        .map(|&c| {
554            let p = c as f64 / len;
555            -p * p.log2()
556        })
557        .sum()
558}
559
560/// Scans `text` for high-entropy whitespace-delimited tokens (> 4.5 bits/char,
561/// length 20–64 bytes) and reports them as [`CredentialKind::GenericHighEntropy`].
562fn scan_high_entropy(text: &str, findings: &mut Vec<CredentialFinding>) {
563    let mut offset = 0usize;
564    for token in text.split_whitespace() {
565        let token_offset = text[offset..].find(token).map(|i| offset + i).unwrap_or(offset);
566        let whitespace_end = token_offset + token.len();
567        let len = token.len();
568        if (20..=64).contains(&len) && shannon_entropy(token) > 4.5 {
569            // The whitespace token can still carry trailing delimiters when a
570            // secret is embedded in structured text (e.g. `...key"}]}` in compact
571            // JSON). Clamp the finding's `end` at the first token-terminating
572            // character so the span covers only the credential — matching how the
573            // AC literal scan derives its `end`. Detection (offset, count, kind)
574            // is unchanged; only the span boundary is tightened so that once
575            // `redact` coalesces this with an overlapping specific finding, the
576            // merged span no longer swallows valid trailing bytes.
577            let end = token_end(text, token_offset);
578            findings.push(CredentialFinding::new(
579                CredentialKind::GenericHighEntropy,
580                token_offset,
581                end,
582            ));
583        }
584        offset = whitespace_end;
585    }
586}
587
588/// Scans `text` for email addresses by locating `@` signs and expanding outward.
589fn scan_emails(text: &str, findings: &mut Vec<CredentialFinding>) {
590    let mut search = text;
591    let mut base = 0usize;
592
593    while let Some(at) = search.find('@') {
594        let abs_at = base + at;
595
596        let local_start = text[..abs_at]
597            .rfind(|c: char| c.is_whitespace() || matches!(c, '<' | ',' | ';' | '"' | '\''))
598            .map(|i| i + 1)
599            .unwrap_or(0);
600
601        let domain_end = token_end(text, abs_at + 1);
602        let local = &text[local_start..abs_at];
603        let domain = &text[abs_at + 1..domain_end];
604
605        if !local.is_empty() && domain.contains('.') && domain.len() >= 3 {
606            findings.push(CredentialFinding::new(
607                CredentialKind::EmailAddress,
608                local_start,
609                domain_end,
610            ));
611        }
612
613        let next = abs_at + 1;
614        if next >= text.len() {
615            break;
616        }
617        search = &text[next..];
618        base = next;
619    }
620}
621
622// ---------------------------------------------------------------------------
623// Tests
624// ---------------------------------------------------------------------------
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629
630    // --- CredentialKind::as_str ---
631
632    #[test]
633    fn credential_kind_as_str_round_trips() {
634        assert_eq!(CredentialKind::AnthropicKey.as_str(), "AnthropicKey");
635        assert_eq!(CredentialKind::AwsAccessKey.as_str(), "AwsAccessKey");
636        assert_eq!(CredentialKind::GenericHighEntropy.as_str(), "GenericHighEntropy");
637    }
638
639    // --- API key patterns ---
640
641    #[test]
642    fn detects_anthropic_key() {
643        let scanner = CredentialScanner::new();
644        let result = scanner.scan("auth: sk-ant-api03-XXXXXXXXXXXXXXXXXXXX");
645        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
646    }
647
648    #[test]
649    fn detects_openai_key_not_misclassified_as_anthropic() {
650        let scanner = CredentialScanner::new();
651        let result = scanner.scan("key: sk-proj-XXXXXXXXXXXXXXXXXXXX");
652        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::OpenAiKey));
653        assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::AnthropicKey));
654    }
655
656    #[test]
657    fn detects_aws_access_key() {
658        let scanner = CredentialScanner::new();
659        let result = scanner.scan("AWS_ACCESS_KEY_ID=AKIAIOSFODNN7EXAMPLE");
660        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::AwsAccessKey));
661    }
662
663    #[test]
664    fn detects_gcp_service_account() {
665        let scanner = CredentialScanner::new();
666        let result = scanner.scan(r#"{"type": "service_account", "project_id": "my-project"}"#);
667        assert!(result
668            .findings
669            .iter()
670            .any(|f| f.kind == CredentialKind::GcpServiceAccount));
671    }
672
673    // --- AAASM-3727: case / whitespace bypass variants ---
674
675    #[test]
676    fn detects_gcp_service_account_compact_json() {
677        // Compact serializer output (no space after the colon) must be caught.
678        let scanner = CredentialScanner::new();
679        let result = scanner.scan(r#"{"type":"service_account","project_id":"p"}"#);
680        assert!(
681            result
682                .findings
683                .iter()
684                .any(|f| f.kind == CredentialKind::GcpServiceAccount),
685            "compact GCP service-account JSON must be detected"
686        );
687    }
688
689    #[test]
690    fn detects_gcp_service_account_spaces_around_colon() {
691        let scanner = CredentialScanner::new();
692        let result = scanner.scan(r#"{ "type" : "service_account" }"#);
693        assert!(
694            result
695                .findings
696                .iter()
697                .any(|f| f.kind == CredentialKind::GcpServiceAccount),
698            "spaced-colon GCP service-account JSON must be detected"
699        );
700    }
701
702    #[test]
703    fn detects_postgres_url_uppercase_scheme() {
704        // RFC 3986 schemes are case-insensitive; an upper-case scheme must not bypass.
705        let scanner = CredentialScanner::new();
706        let result = scanner.scan("DATABASE_URL=POSTGRES://user:password@host:5432/db");
707        assert!(
708            result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl),
709            "upper-case POSTGRES:// scheme must be detected"
710        );
711    }
712
713    #[test]
714    fn detects_lowercase_pem_private_key_header() {
715        let scanner = CredentialScanner::new();
716        let result =
717            scanner.scan("-----begin rsa private key-----\nMIIEpAIBAAKCAQEA...\n-----end rsa private key-----");
718        assert!(
719            result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey),
720            "lower-case PEM header must be detected"
721        );
722    }
723
724    // --- Auth token patterns ---
725
726    #[test]
727    fn detects_github_pat() {
728        let scanner = CredentialScanner::new();
729        let result = scanner.scan("token: ghp_1234567890abcdefghijklmnopqrstuvwxyz");
730        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat));
731    }
732
733    #[test]
734    fn detects_github_app_token() {
735        let scanner = CredentialScanner::new();
736        let result = scanner.scan("token: ghs_1234567890abcdefghijklmnopqrstuvwxyz");
737        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::GitHubAppToken));
738    }
739
740    #[test]
741    fn detects_slack_bot_token() {
742        let scanner = CredentialScanner::new();
743        let result = scanner.scan("SLACK_BOT_TOKEN=xoxb-123456789012-123456789012-XXXXXXXXXXXX");
744        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackBotToken));
745    }
746
747    #[test]
748    fn detects_slack_user_token() {
749        let scanner = CredentialScanner::new();
750        let result = scanner.scan("token=xoxp-123456789012-123456789012-XXXXXXXXXXXX");
751        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SlackUserToken));
752    }
753
754    #[test]
755    fn detects_slack_oauth_token() {
756        let scanner = CredentialScanner::new();
757        let result = scanner.scan("oauth=xoxa-123456789012-123456789012-XXXXXXXXXXXX");
758        assert!(result
759            .findings
760            .iter()
761            .any(|f| f.kind == CredentialKind::SlackOAuthToken));
762    }
763
764    // --- Cloud credential patterns ---
765
766    #[test]
767    fn detects_azure_connection_string() {
768        let scanner = CredentialScanner::new();
769        let result = scanner.scan("DefaultEndpointsProtocol=https;AccountName=myaccount;AccountKey=XXXX");
770        assert!(result
771            .findings
772            .iter()
773            .any(|f| f.kind == CredentialKind::AzureConnectionString));
774    }
775
776    // --- Database URL patterns ---
777
778    #[test]
779    fn detects_postgres_url() {
780        let scanner = CredentialScanner::new();
781        let result = scanner.scan("DATABASE_URL=postgres://user:password@host:5432/db");
782        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PostgresUrl));
783    }
784
785    #[test]
786    fn detects_mysql_url() {
787        let scanner = CredentialScanner::new();
788        let result = scanner.scan("db=mysql://user:secret@localhost/mydb");
789        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MysqlUrl));
790    }
791
792    #[test]
793    fn detects_mongodb_url() {
794        let scanner = CredentialScanner::new();
795        let result = scanner.scan("uri=mongodb://admin:pass@cluster0.mongodb.net/mydb");
796        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::MongodbUrl));
797    }
798
799    // --- Private key patterns ---
800
801    #[test]
802    fn detects_rsa_private_key() {
803        let scanner = CredentialScanner::new();
804        let result =
805            scanner.scan("-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA...\n-----END RSA PRIVATE KEY-----");
806        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::RsaPrivateKey));
807    }
808
809    #[test]
810    fn detects_ec_private_key() {
811        let scanner = CredentialScanner::new();
812        let result = scanner.scan("-----BEGIN EC PRIVATE KEY-----\nMHQCAQEEI...\n-----END EC PRIVATE KEY-----");
813        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EcPrivateKey));
814    }
815
816    #[test]
817    fn detects_openssh_private_key() {
818        let scanner = CredentialScanner::new();
819        let result = scanner
820            .scan("-----BEGIN OPENSSH PRIVATE KEY-----\nb3BlbnNzaC1rZXkAAAA=\n-----END OPENSSH PRIVATE KEY-----");
821        assert!(result
822            .findings
823            .iter()
824            .any(|f| f.kind == CredentialKind::OpensshPrivateKey));
825    }
826
827    #[test]
828    fn detects_generic_private_key() {
829        let scanner = CredentialScanner::new();
830        let result = scanner.scan("-----BEGIN PRIVATE KEY-----\nMIIEvAIBADANBgk=\n-----END PRIVATE KEY-----");
831        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PrivateKey));
832    }
833
834    #[test]
835    fn detects_pgp_private_key() {
836        let scanner = CredentialScanner::new();
837        let result =
838            scanner.scan("-----BEGIN PGP PRIVATE KEY BLOCK-----\nlQOYBF...\n-----END PGP PRIVATE KEY BLOCK-----");
839        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::PgpPrivateKey));
840    }
841
842    // --- PII patterns ---
843
844    #[test]
845    fn detects_credit_card_luhn() {
846        let scanner = CredentialScanner::new();
847        let result = scanner.scan("card: 4532015112830366");
848        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
849    }
850
851    #[test]
852    fn detects_credit_card_with_spaces() {
853        let scanner = CredentialScanner::new();
854        let result = scanner.scan("card: 4532 0151 1283 0366");
855        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
856    }
857
858    #[test]
859    fn does_not_flag_invalid_luhn() {
860        let scanner = CredentialScanner::new();
861        let result = scanner.scan("num: 4532015112830367");
862        assert!(!result.findings.iter().any(|f| f.kind == CredentialKind::CreditCardLuhn));
863    }
864
865    #[test]
866    fn detects_ssn() {
867        let scanner = CredentialScanner::new();
868        let result = scanner.scan("SSN: 123-45-6789");
869        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::SsnPattern));
870    }
871
872    #[test]
873    fn detects_email_address() {
874        let scanner = CredentialScanner::new();
875        let result = scanner.scan("contact: user@example.com for support");
876        assert!(result.findings.iter().any(|f| f.kind == CredentialKind::EmailAddress));
877    }
878
879    // --- High-entropy ---
880
881    #[test]
882    fn detects_high_entropy_token() {
883        let scanner = CredentialScanner::new();
884        let result = scanner.scan("secret: xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0eC5");
885        assert!(result
886            .findings
887            .iter()
888            .any(|f| f.kind == CredentialKind::GenericHighEntropy));
889    }
890
891    #[test]
892    fn does_not_flag_short_token_as_high_entropy() {
893        let scanner = CredentialScanner::new();
894        let result = scanner.scan("word: hello");
895        assert!(!result
896            .findings
897            .iter()
898            .any(|f| f.kind == CredentialKind::GenericHighEntropy));
899    }
900
901    // --- luhn_valid helper ---
902
903    #[test]
904    fn luhn_valid_visa_test_number() {
905        assert!(luhn_valid("4532015112830366"));
906    }
907
908    #[test]
909    fn luhn_valid_mastercard_test_number() {
910        assert!(luhn_valid("5425233430109903"));
911    }
912
913    #[test]
914    fn luhn_valid_amex_test_number() {
915        assert!(luhn_valid("371449635398431"));
916    }
917
918    #[test]
919    fn luhn_valid_discover_test_number() {
920        assert!(luhn_valid("6011111111111117"));
921    }
922
923    #[test]
924    fn luhn_invalid_altered_digit() {
925        assert!(!luhn_valid("4532015112830367"));
926    }
927
928    #[test]
929    fn luhn_rejects_too_short() {
930        assert!(!luhn_valid("123456789012"));
931    }
932
933    #[test]
934    fn luhn_rejects_too_long() {
935        assert!(!luhn_valid("45320151128303661234"));
936    }
937
938    // --- shannon_entropy helper ---
939
940    #[test]
941    fn entropy_zero_for_empty() {
942        assert_eq!(shannon_entropy(""), 0.0);
943    }
944
945    #[test]
946    fn entropy_low_for_repeated_char() {
947        assert!(shannon_entropy("aaaaaaaaaaaaaaaaaaaaaa") < 1.0);
948    }
949
950    #[test]
951    fn entropy_high_for_random_base64() {
952        assert!(shannon_entropy("xK9mP2nQvR7sT4wY1aB6dF3hJ8lN0") > 4.0);
953    }
954
955    #[test]
956    fn entropy_moderate_for_english_text() {
957        let e = shannon_entropy("Thequickbrownfoxjumpsoverthelazydog");
958        assert!(e > 3.0 && e < 5.0);
959    }
960
961    // --- ScanResult::redact() and is_clean() ---
962
963    #[test]
964    fn redact_replaces_github_pat() {
965        let scanner = CredentialScanner::new();
966        let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX end";
967        let result = scanner.scan(text);
968        let redacted = result.redact(text);
969        assert!(!redacted.contains("ghp_"));
970        assert!(redacted.contains("[REDACTED:GitHubPat]"));
971    }
972
973    #[test]
974    fn redact_is_deterministic() {
975        let scanner = CredentialScanner::new();
976        let text = "key: ghp_abc123XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX";
977        let result = scanner.scan(text);
978        assert_eq!(result.redact(text), result.redact(text));
979    }
980
981    #[test]
982    fn redact_clean_text_unchanged() {
983        let scanner = CredentialScanner::new();
984        let text = "This is a normal sentence with no secrets.";
985        let result = scanner.scan(text);
986        assert!(result.is_clean());
987        assert_eq!(result.redact(text), text);
988    }
989
990    #[test]
991    fn redact_multiple_findings_in_one_pass() {
992        let scanner = CredentialScanner::new();
993        let text = "a=ghp_XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX b=postgres://u:p@host/db";
994        let result = scanner.scan(text);
995        let redacted = result.redact(text);
996        assert!(!redacted.contains("ghp_"));
997        assert!(!redacted.contains("postgres://"));
998        assert!(redacted.contains("[REDACTED:GitHubPat]"));
999        assert!(redacted.contains("[REDACTED:PostgresUrl]"));
1000    }
1001
1002    #[test]
1003    fn is_clean_true_for_benign_text() {
1004        let scanner = CredentialScanner::new();
1005        assert!(scanner.scan("Hello, world! No secrets here.").is_clean());
1006    }
1007
1008    // --- AAASM-3689: overlapping-findings redaction must not leak fragments ---
1009
1010    #[test]
1011    fn redact_overlapping_findings_leaks_no_secret_fragment() {
1012        // A GitHub PAT embedded in an email-shaped string, adjacent to a
1013        // postgres URL — the AC-prefix, email, and high-entropy passes produce
1014        // overlapping byte ranges over the same region. Pre-fix this spliced
1015        // mangled labels and left raw secret bytes (e.g. "stgresUrl]]").
1016        let scanner = CredentialScanner::new();
1017        let text = "user@ghp_tokenAAAAAAAAAAAAAAAAAAAAAAAA.example.com_postgres://x:y@h/d";
1018        let result = scanner.scan(text);
1019        let redacted = result.redact(text);
1020
1021        // No raw secret fragment from a matched region survives.
1022        assert!(!redacted.contains("ghp_"), "raw GitHub PAT prefix leaked: {redacted}");
1023        assert!(!redacted.contains("postgres://"), "raw postgres URL leaked: {redacted}");
1024        assert!(!redacted.contains("tokenAAAA"), "raw token body leaked: {redacted}");
1025        assert!(
1026            !redacted.contains("stgresUrl"),
1027            "mangled-splice secret fragment leaked: {redacted}"
1028        );
1029        // Output contains only well-formed redaction labels — no mangled splices.
1030        assert!(redacted.contains("[REDACTED:"));
1031        assert!(!redacted.contains("]]"), "malformed nested label produced: {redacted}");
1032        // Every '[REDACTED:' opener has a matching ']' closer with a known kind —
1033        // a mangled splice would have left an opener without a clean close.
1034        for label in redacted.match_indices("[REDACTED:").map(|(i, _)| &redacted[i..]) {
1035            let close = label.find(']').expect("redaction label must be closed");
1036            let kind = &label["[REDACTED:".len()..close];
1037            assert!(!kind.is_empty(), "empty/mangled redaction kind in: {redacted}");
1038        }
1039    }
1040
1041    #[test]
1042    fn redact_overlap_at_multibyte_boundary_does_not_panic() {
1043        // Overlapping matches whose region spans multi-byte UTF-8 codepoints.
1044        // Pre-fix, an overlap boundary landing mid-codepoint panicked in
1045        // replace_range; the char-boundary guard now makes this impossible.
1046        let scanner = CredentialScanner::new();
1047        let text = "postgres://é:é@hosté.com sk-ant-éXXXXXXXXXXXXXXXXXXXX";
1048        let result = scanner.scan(text);
1049        // Must not panic, and must not leave the raw scheme behind.
1050        let redacted = result.redact(text);
1051        assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1052    }
1053
1054    #[test]
1055    fn redact_adjacent_overlapping_findings_merge_into_one_span() {
1056        // Two findings sharing an offset (prefix + high-entropy over the same
1057        // token) coalesce so the token is replaced exactly once, not double-spliced.
1058        let scanner = CredentialScanner::new();
1059        let text = "tok=ghp_abcdefABCDEF0123456789ABCDEF0123456789 done";
1060        let result = scanner.scan(text);
1061        let redacted = result.redact(text);
1062        assert!(!redacted.contains("ghp_"));
1063        assert!(!redacted.contains("abcdefABCDEF"), "raw token body leaked: {redacted}");
1064        assert!(
1065            redacted.contains(" done"),
1066            "trailing context must be preserved: {redacted}"
1067        );
1068    }
1069
1070    #[test]
1071    fn coalesce_keeps_specific_kind_label_over_generic() {
1072        // A GitHub PAT is also flagged as GenericHighEntropy over the same token.
1073        // The GenericHighEntropy finding starts at the earlier offset, but the
1074        // merged span must carry the specific GitHubPat label, not the generic
1075        // backstop — kind priority wins over offset order.
1076        let scanner = CredentialScanner::new();
1077        let text = "token=ghp_abcdefABCDEF0123456789ABCDEF0123456789";
1078        let result = scanner.scan(text);
1079        // Sanity: both detectors fired over the same region.
1080        assert!(
1081            result.findings.iter().any(|f| f.kind == CredentialKind::GitHubPat),
1082            "expected a GitHubPat finding: {:?}",
1083            result.findings
1084        );
1085        assert!(
1086            result
1087                .findings
1088                .iter()
1089                .any(|f| f.kind == CredentialKind::GenericHighEntropy),
1090            "expected a GenericHighEntropy finding: {:?}",
1091            result.findings
1092        );
1093        let redacted = result.redact(text);
1094        assert!(
1095            redacted.contains("[REDACTED:GitHubPat]"),
1096            "merged label must be the specific GitHubPat kind, not GenericHighEntropy: {redacted}"
1097        );
1098        assert!(
1099            !redacted.contains("GenericHighEntropy"),
1100            "generic backstop label must not win over a specific detector: {redacted}"
1101        );
1102        assert!(!redacted.contains("ghp_"), "raw token survived: {redacted}");
1103    }
1104
1105    #[test]
1106    fn coalesce_keeps_db_url_label_over_embedded_email() {
1107        // A database URL embeds an EmailAddress-shaped span (user@host). The
1108        // merged span must keep the specific PostgresUrl label, not collapse to
1109        // the generic EmailAddress backstop.
1110        let scanner = CredentialScanner::new();
1111        let text = "DATABASE_URL=postgres://user:password@db.internal:5432/mydb";
1112        let result = scanner.scan(text);
1113        let redacted = result.redact(text);
1114        assert_eq!(
1115            redacted, "[REDACTED:PostgresUrl]",
1116            "db-url region must redact to the specific PostgresUrl label: {redacted}"
1117        );
1118        assert!(!redacted.contains("postgres://"), "raw scheme survived: {redacted}");
1119    }
1120
1121    // --- CredentialKind::Custom and CredentialFinding::from_regex_match ---
1122
1123    #[test]
1124    fn custom_kind_as_str_returns_custom() {
1125        assert_eq!(CredentialKind::Custom.as_str(), "Custom");
1126    }
1127
1128    #[test]
1129    fn from_regex_match_creates_custom_finding() {
1130        let finding = CredentialFinding::from_regex_match(5, 20);
1131        assert_eq!(finding.kind, CredentialKind::Custom);
1132        assert_eq!(finding.offset, 5);
1133        assert_eq!(finding.matched, "[REDACTED:Custom]");
1134    }
1135
1136    // --- False-positive corpus ---
1137
1138    #[test]
1139    fn false_positive_corpus_has_no_hard_credential_hits() {
1140        let scanner = CredentialScanner::new();
1141        let corpus = [
1142            "The quick brown fox jumps over the lazy dog.",
1143            "fn main() { println!(\"Hello, world!\"); }",
1144            "SELECT * FROM users WHERE id = 42;",
1145            "cargo build --release --features std",
1146            "version = \"1.0.0\" edition = \"2021\"",
1147            "2026-04-27T15:34:15.377+0800",
1148            "error[E0382]: borrow of moved value: `x`",
1149        ];
1150        for text in &corpus {
1151            let result = scanner.scan(text);
1152            let hard: Vec<_> = result
1153                .findings
1154                .iter()
1155                .filter(|f| f.kind != CredentialKind::GenericHighEntropy)
1156                .collect();
1157            assert!(hard.is_empty(), "false positive in: {:?} → {:?}", text, hard);
1158        }
1159    }
1160
1161    // --- ScannerConfig ---
1162
1163    #[test]
1164    fn disabled_scanner_returns_empty_result() {
1165        let config = ScannerConfig {
1166            disabled: true,
1167            ..Default::default()
1168        };
1169        let scanner = CredentialScanner::with_config(config);
1170        let result = scanner.scan("sk-proj-XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX ghp_XXXXXXXXX");
1171        assert!(result.is_clean(), "disabled scanner must return no findings");
1172    }
1173
1174    #[test]
1175    fn custom_pattern_detected_as_custom_kind() {
1176        let config = ScannerConfig {
1177            custom_patterns: vec!["INTERNAL_SECRET_".into()],
1178            ..Default::default()
1179        };
1180        let scanner = CredentialScanner::with_config(config);
1181        let result = scanner.scan("token=INTERNAL_SECRET_hello");
1182        let custom: Vec<_> = result
1183            .findings
1184            .iter()
1185            .filter(|f| f.kind == CredentialKind::Custom)
1186            .collect();
1187        assert!(!custom.is_empty(), "custom pattern must produce a Custom finding");
1188        assert!(custom[0].matched.contains("[REDACTED:Custom]"));
1189    }
1190
1191    #[test]
1192    fn custom_pattern_coexists_with_builtin() {
1193        let config = ScannerConfig {
1194            custom_patterns: vec!["MY_TOKEN_".into()],
1195            ..Default::default()
1196        };
1197        let scanner = CredentialScanner::with_config(config);
1198        let text = "a=ghp_XXXXXXXXX b=MY_TOKEN_secret123";
1199        let result = scanner.scan(text);
1200        let kinds: Vec<_> = result.findings.iter().map(|f| &f.kind).collect();
1201        assert!(kinds.contains(&&CredentialKind::GitHubPat));
1202        assert!(kinds.contains(&&CredentialKind::Custom));
1203    }
1204
1205    #[test]
1206    fn default_config_matches_new() {
1207        let default_scanner = CredentialScanner::new();
1208        let config_scanner = CredentialScanner::with_config(ScannerConfig::default());
1209        let text = "key=ghp_XXXXXXXXX url=postgres://u:p@host/db";
1210        let r1 = default_scanner.scan(text);
1211        let r2 = config_scanner.scan(text);
1212        assert_eq!(r1.findings.len(), r2.findings.len());
1213        for (a, b) in r1.findings.iter().zip(r2.findings.iter()) {
1214            assert_eq!(a.kind, b.kind);
1215            assert_eq!(a.offset, b.offset);
1216        }
1217    }
1218}