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