Skip to main content

khive_runtime/
secret_gate.rs

1//! Write-time secret detection gate (issue #76).
2//!
3//! Scans caller-supplied content strings before any storage write.  A match
4//! causes a hard `RuntimeError::SecretDetected` that names the detector and
5//! carries a masked excerpt — it never echoes the full candidate back.
6//!
7//! Scope: **credentials only** — API keys, tokens, private keys, passwords,
8//! and connection strings with embedded credentials.  General PII such as
9//! email addresses, phone numbers, and company names is intentionally NOT
10//! blocked; those are normal knowledge-graph content.
11//!
12//! Detection is layered, cheap-first:
13//!
14//! 1. **Known-prefix / known-shape patterns** — AWS AKIA/ASIA, GitHub tokens,
15//!    OpenAI `sk-proj-`, Anthropic `sk-ant-`, Stripe live keys, Fly.io tokens,
16//!    Vercel secrets, Slack `xox*`, JWT triples, PEM private-key headers,
17//!    Age secret keys, URL userinfo (`scheme://user:pass@`).
18//!    Bare `sk-` is also checked but only when NOT followed by a known safe
19//!    word boundary (e.g. `sk-learn`, `sk-image`).
20//! 2. **High-entropy token heuristic** — base64/hex/base64url runs ≥ 24 chars
21//!    near a trigger word (key, secret, password, credential, bearer, auth,
22//!    apikey, api_key, access_key, private_key).  The word `token` alone is NOT
23//!    a trigger to avoid blocking `tokenizer_*`, `token_count`, etc.
24//!
25//! Allowlist (false-positive suppression):
26//! - Pure hex strings (sha256, git SHA) — passed unconditionally.
27//! - UUID canonical form (`xxxxxxxx-xxxx-…`) — passed.
28//! - Base64/base64url content hashes with an explicit `sha<N>-` prefix (SRI
29//!   hashes, npm lockfile integrity) — passed when not preceded by a known-vendor
30//!   prefix.  Bare base64 tokens without the `sha<N>-` prefix are NOT passed.
31//! - Strings that are entirely ASCII punctuation/whitespace (e.g. code) — not
32//!   subject to the entropy heuristic, only the literal-prefix checks apply.
33//! - Non-ASCII characters (CJK prose, accented text, emoji) act as token
34//!   delimiters for the entropy heuristic: only maximal ASCII runs are
35//!   entropy-checked.  Real base64/hex/base64url credentials are ASCII, and
36//!   `shannon_entropy` runs over UTF-8 bytes — multibyte codepoints inflate the
37//!   byte-wise entropy and false-positive on natural-language non-Latin content.
38//!   Treating non-ASCII as a delimiter (rather than skipping any whitespace
39//!   token that merely contains it) keeps CJK prose unflagged while still
40//!   catching an ASCII credential glued to CJK text/punctuation/fullwidth
41//!   whitespace.  The literal-prefix checks (Layer 1) treat any
42//!   non-ASCII-alphanumeric char (CJK, accented text, emoji) as a token
43//!   boundary, so a known-prefix secret is caught whether the adjacent
44//!   non-ASCII sits before the prefix (`数据AKIA…`) or after it (`AKIA…数据`).
45
46use crate::error::{RuntimeError, RuntimeResult};
47
48// ─── Public API ──────────────────────────────────────────────────────────────
49
50/// Returned when a write would store credential-looking content.
51///
52/// Carries the detector name and a masked excerpt (`first6...Nchars`).  The
53/// full candidate is never stored in the error.
54#[derive(Debug, Clone, PartialEq, Eq)]
55pub struct SecretMatch {
56    /// Human-readable name of the detector that fired.
57    pub detector: &'static str,
58    /// `first6...N` — the first 6 chars of the match followed by the total length.
59    pub masked: String,
60}
61
62impl std::fmt::Display for SecretMatch {
63    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
64        write!(
65            f,
66            "content matches secret pattern {} at masked excerpt {}",
67            self.detector, self.masked
68        )
69    }
70}
71
72/// Hard-block content from being written.
73///
74/// Returns `Err(RuntimeError::SecretDetected)` on the first match found, or
75/// `Ok(())` if no secret pattern fires.
76pub fn check(content: &str) -> RuntimeResult<()> {
77    if let Some(m) = scan(content) {
78        return Err(RuntimeError::SecretDetected(m));
79    }
80    Ok(())
81}
82
83/// Recursively scan a JSON value for credential-shaped strings.
84///
85/// Walks every string leaf (object values, array elements, nested objects).
86/// Returns `Err(RuntimeError::SecretDetected)` on the first match found.
87/// `None` / null / numeric / boolean JSON values are skipped.
88pub fn check_json(value: &serde_json::Value) -> RuntimeResult<()> {
89    scan_json_value(value)
90}
91
92/// Scan a string-tagged slice (entity/note tags).
93///
94/// Each tag string is scanned individually.
95pub fn check_tags(tags: &[String]) -> RuntimeResult<()> {
96    for tag in tags {
97        check(tag)?;
98    }
99    Ok(())
100}
101
102fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
103    match value {
104        serde_json::Value::String(s) => check(s),
105        serde_json::Value::Array(arr) => {
106            for v in arr {
107                scan_json_value(v)?;
108            }
109            Ok(())
110        }
111        serde_json::Value::Object(map) => {
112            for (k, v) in map {
113                // Scan both the key (a credential can appear as a JSON key name)
114                // and the value recursively.
115                check(k)?;
116                scan_json_value(v)?;
117            }
118            Ok(())
119        }
120        _ => Ok(()),
121    }
122}
123
124// ─── Scanner ─────────────────────────────────────────────────────────────────
125
126/// Marker substituted for a detected secret span by [`mask_secrets`].
127const REDACTION_MARKER: &str = "***MASKED***";
128
129/// Return the LEFTMOST secret in `text` as `(matched_slice, detector)`.
130///
131/// The matched slice borrows from `text`, so the caller can recover its byte
132/// span via pointer arithmetic — this is what lets [`mask_secrets`] redact in
133/// place while [`scan`] only needs the masked excerpt.
134///
135/// "Leftmost" (smallest start offset), NOT first-by-detector-priority, is the
136/// load-bearing contract: [`mask_secrets`] copies the text *before* each match
137/// verbatim, so a non-leftmost match would leak an earlier secret detected by a
138/// lower-priority detector (e.g. an `sk-ant-` key sitting to the left of a
139/// `ghp_` token). Both detector layers are folded through [`keep_leftmost`].
140fn scan_match(text: &str) -> Option<(&str, &'static str)> {
141    scan_from(text, 0)
142}
143
144/// Like [`scan_match`], but only returns secrets whose span starts at or after
145/// `from`, while still evaluating Layer-2 trigger context against the FULL
146/// `text`. [`mask_secrets`] calls this with an advancing `from` so that an
147/// entropy token is detected even when its only trigger word sits to the left of
148/// an already-redacted earlier secret. Layer-1 known patterns are context-free,
149/// so scanning the `&text[from..]` suffix is equivalent; offsets recovered via
150/// pointer arithmetic against the original `text` base stay absolute.
151fn scan_from(text: &str, from: usize) -> Option<(&str, &'static str)> {
152    let base = text.as_ptr() as usize;
153    // Layer 1: known prefix / shape patterns. Context-free → suffix scan; the
154    // returned slice still borrows from the same allocation, so its absolute
155    // offset is `slice.as_ptr() - base`.
156    let mut best = check_known_patterns(&text[from..]);
157    // Layer 2: entropy heuristic on long tokens near trigger words. Evaluated
158    // over the full text (so left-of-`from` trigger words count) but only tokens
159    // at offset >= from are returned; kept only if left of the best known match.
160    keep_leftmost(&mut best, check_entropy_heuristic(text, from), base);
161    best
162}
163
164/// Replace `best` with `cand` when `cand` starts earlier in the original text
165/// (`base` is the start address of that text). On a tie the incumbent wins, so
166/// callers offer more-specific detectors first. This is what makes
167/// [`check_known_patterns`] and [`scan_match`] return the leftmost secret span
168/// rather than the first detector that happens to match anywhere.
169fn keep_leftmost<'a>(
170    best: &mut Option<(&'a str, &'static str)>,
171    cand: Option<(&'a str, &'static str)>,
172    base: usize,
173) {
174    if let Some((slice, name)) = cand {
175        let start = slice.as_ptr() as usize - base;
176        let replace = match *best {
177            Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
178            None => true,
179        };
180        if replace {
181            *best = Some((slice, name));
182        }
183    }
184}
185
186/// Return the first `SecretMatch` found in `text`, or `None`.
187fn scan(text: &str) -> Option<SecretMatch> {
188    scan_match(text).map(|(slice, detector)| build_match(detector, slice))
189}
190
191/// Redact every detected secret span in `text`, replacing each with
192/// `***MASKED***`.
193///
194/// This is the masking counterpart to [`check`]: where `check` hard-blocks a
195/// write on the first match, `mask_secrets` is for content that must be STORED
196/// with credentials stripped (the session mirror). A transcript line cannot be
197/// rejected wholesale, so each credential span is replaced in place while the
198/// surrounding prose is preserved. It reuses the SAME canonical detector set as
199/// `check`/`scan`, so callers must never maintain a second, weaker masker.
200///
201/// Returns `Cow::Borrowed` when no secret is present (the common case), avoiding
202/// an allocation. Spans are discovered left to right against the ORIGINAL text
203/// via `scan_from`: each scan advances a `from` cursor past the previous span
204/// but always evaluates trigger context over the full input. This closes the
205/// entropy-context gap — a high-entropy value whose only trigger word sits to
206/// the left of an earlier-redacted secret is still detected, because the trigger
207/// window is never sliced away. The known-prefix detectors (real API keys:
208/// `sk-ant-`, `sk-proj-`, `AKIA`/`ASIA`, GitHub, Stripe, …) are context-free and
209/// matched the same way.
210pub fn mask_secrets(text: &str) -> std::borrow::Cow<'_, str> {
211    let base = text.as_ptr() as usize;
212    // Collect every secret span (absolute byte offsets into `text`) before
213    // writing any output, so trigger-context detection always sees the original
214    // string rather than the suffix after the previous redaction.
215    let mut spans: Vec<(usize, usize)> = Vec::new();
216    let mut from = 0;
217    while from < text.len() {
218        match scan_from(text, from) {
219            Some((sub, _detector)) => {
220                let start = sub.as_ptr() as usize - base;
221                // The prefix detectors return whitespace-delimited tokens, so a
222                // credential glued to structural punctuation (JSON quotes/braces,
223                // sentence commas) carries that trailing punctuation into the
224                // match. Trim a conservative trailing set that can never be part
225                // of a credential, so redacting does not consume surrounding JSON
226                // or prose structure. `=` `/` `+` `.` `-` `_` are intentionally
227                // NOT trimmed — they are valid base64/JWT/key characters.
228                let core_len = sub
229                    .trim_end_matches(['"', '\'', '`', '}', ']', ')', ',', ';'])
230                    .len();
231                let end = start + core_len.max(1);
232                spans.push((start, end));
233                // `scan_from` only returns matches with start >= from, and `end`
234                // is strictly greater than `start`, so `from` strictly advances.
235                from = end;
236            }
237            None => break,
238        }
239    }
240    if spans.is_empty() {
241        return std::borrow::Cow::Borrowed(text);
242    }
243    let mut out = String::with_capacity(text.len());
244    let mut cursor = 0;
245    for (start, end) in spans {
246        // Spans are non-overlapping and ascending (each starts at/after the prior
247        // `end`); `max(cursor)` is a defensive guard, never load-bearing.
248        let start = start.max(cursor);
249        out.push_str(&text[cursor..start]);
250        out.push_str(REDACTION_MARKER);
251        cursor = end.max(cursor);
252    }
253    out.push_str(&text[cursor..]);
254    std::borrow::Cow::Owned(out)
255}
256
257// ─── Layer 1: known patterns ─────────────────────────────────────────────────
258
259/// Each entry: (detector_name, needle, min_total_token_len).
260///
261/// The needle must appear as a word-boundary-adjacent prefix in the token.
262/// `min_total_token_len` is the minimum length the token (needle + remainder)
263/// must have — prevents the prefix alone triggering without a payload.
264const PREFIX_DETECTORS: &[(&str, &str, usize)] = &[
265    // AWS
266    ("aws-access-key-id", "AKIA", 20),
267    ("aws-access-key-id", "ASIA", 20),
268    // GitHub tokens: personal-access (ghp_), OAuth (gho_), GitHub App
269    // user-to-server (ghu_), server-to-server (ghs_), refresh (ghr_), and the
270    // fine-grained PAT (github_pat_). All but github_pat_ share the gh*_ + 36+
271    // base62 shape.
272    ("github-token", "ghp_", 36),
273    ("github-token", "gho_", 36),
274    ("github-token", "ghu_", 36),
275    ("github-token", "ghs_", 36),
276    ("github-token", "ghr_", 36),
277    ("github-token", "github_pat_", 20),
278    // OpenAI
279    ("openai-api-key", "sk-proj-", 40),
280    // NOTE: bare "sk-" also matches Anthropic/Stripe below; put it last so
281    // the more-specific detectors fire first when both would match.
282    // Anthropic
283    ("anthropic-api-key", "sk-ant-", 20),
284    // Stripe live keys
285    ("stripe-secret-key", "sk_live_", 30),
286    ("stripe-restricted-key", "rk_live_", 30),
287    // Fly.io (fm2_ prefix only — FlyV1 handled separately because it embeds a space)
288    ("fly-token", "fm2_", 20),
289    // Vercel
290    ("vercel-token", "vercel_", 20),
291    // Slack
292    ("slack-token", "xoxb-", 40),
293    ("slack-token", "xoxa-", 40),
294    ("slack-token", "xoxp-", 40),
295    ("slack-token", "xoxr-", 40),
296    ("slack-token", "xoxs-", 40),
297    // Age secret key
298    ("age-secret-key", "AGE-SECRET-KEY-", 60),
299];
300
301/// Known safe compound words that start with `sk-` but are not credentials.
302/// E.g. scikit-learn slugs such as `sk-learn`, `sk-image`, `sk-lego`.
303const SK_SAFE_PREFIXES: &[&str] = &["sk-learn", "sk-image", "sk-lego", "sk-base", "sk-misc"];
304
305/// Shape-based patterns checked with custom logic.
306///
307/// Returns the LEFTMOST match across every detector (see [`keep_leftmost`]). The
308/// detectors are still offered in priority order, so two detectors that match at
309/// the SAME offset (e.g. bare `sk-` and the more-specific `sk-ant-`) resolve to
310/// the first-offered one.
311fn check_known_patterns(text: &str) -> Option<(&str, &'static str)> {
312    let base = text.as_ptr() as usize;
313    let mut best: Option<(&str, &'static str)> = None;
314
315    // --- Prefix patterns ---
316    for &(name, needle, min_len) in PREFIX_DETECTORS {
317        keep_leftmost(
318            &mut best,
319            find_prefix_token(text, needle, min_len).map(|m| (m, name)),
320            base,
321        );
322    }
323
324    // --- Bare `sk-` (after all more-specific sk- detectors above) ---
325    // Require length ≥ 30 AND exclude known safe scikit/library compound words.
326    if let Some(token) = find_prefix_token(text, "sk-", 30) {
327        if !SK_SAFE_PREFIXES.iter().any(|safe| token.starts_with(safe)) {
328            keep_leftmost(&mut best, Some((token, "openai-api-key")), base);
329        }
330    }
331
332    // --- Fly.io FlyV1 token: "FlyV1 <base64-payload>" ---
333    // The format embeds a space, so the generic prefix extractor (which stops at
334    // whitespace) cannot measure the combined length.  Check for `FlyV1 ` followed
335    // by ≥ 4 non-whitespace characters as the payload.
336    if let Some(pos) = text.find("FlyV1 ") {
337        let at_boundary = pos == 0 || {
338            text[..pos]
339                .chars()
340                .next_back()
341                .is_none_or(|c| !c.is_ascii_alphanumeric())
342        };
343        if at_boundary {
344            let payload_start = pos + 6; // skip "FlyV1 "
345            let payload = extract_token(&text[payload_start..]);
346            if payload.len() >= 4 {
347                let candidate = &text[pos..payload_start + payload.len()];
348                keep_leftmost(&mut best, Some((candidate, "fly-token")), base);
349            }
350        }
351    }
352
353    // --- PEM private key block ---
354    // "-----BEGIN <TYPE> PRIVATE KEY-----"
355    if text.contains("-----BEGIN") && text.contains("PRIVATE KEY-----") {
356        if let Some(pos) = text.find("-----BEGIN") {
357            // Measure only the key block itself (up to END marker or end-of-string),
358            // not the rest of the surrounding text, so build_match reports the
359            // block length rather than the remaining string length.
360            let block_end = text[pos..]
361                .find("-----END")
362                .map(|rel| {
363                    text[pos + rel..]
364                        .find('\n')
365                        .map(|l| pos + rel + l + 1)
366                        .unwrap_or(text.len())
367                })
368                .unwrap_or(text.len());
369            let excerpt = &text[pos..block_end];
370            keep_leftmost(&mut best, Some((excerpt, "pem-private-key")), base);
371        }
372    }
373
374    // --- JWT triple: eyJ...eyJ...eyJ (header.payload.signature) ---
375    // A JWT starts with "eyJ" (base64url of `{"`) and has exactly two dots.
376    keep_leftmost(&mut best, find_jwt(text).map(|m| (m, "jwt")), base);
377
378    // --- URL userinfo: scheme://user:pass@host ---
379    keep_leftmost(
380        &mut best,
381        find_url_userinfo(text).map(|m| (m, "url-userinfo")),
382        base,
383    );
384
385    best
386}
387
388/// Locate the first token in `text` that starts with `needle` and has a
389/// total length >= `min_len`.  Returns a slice of the full token on match.
390fn find_prefix_token<'a>(text: &'a str, needle: &str, min_len: usize) -> Option<&'a str> {
391    let mut start = 0;
392    while let Some(rel) = text[start..].find(needle) {
393        let abs = start + rel;
394        // Require that the needle starts at a token boundary (start-of-string
395        // or preceded by a non-ASCII-alphanumeric char).  The needles are ASCII,
396        // so only an ASCII alphanumeric can be a real continuation of the same
397        // token; CJK/accented text (which Rust counts as `is_alphanumeric`) must
398        // act as a delimiter, else a secret glued to non-Latin prose (`数据AKIA…`)
399        // is missed.
400        let at_boundary = abs == 0 || {
401            let prev = text[..abs].chars().next_back().unwrap_or(' ');
402            !prev.is_ascii_alphanumeric()
403        };
404        if at_boundary {
405            let token = extract_token(&text[abs..]);
406            if token.len() >= min_len {
407                return Some(token);
408            }
409        }
410        start = abs + needle.len().max(1);
411    }
412    None
413}
414
415/// Scan for a JWT pattern: at least two "eyJ" segments separated by a `.`
416/// character, with each segment at least 10 chars.
417fn find_jwt(text: &str) -> Option<&str> {
418    let bytes = text.as_bytes();
419    let mut i = 0;
420    while i + 4 < bytes.len() {
421        if bytes[i..].starts_with(b"eyJ") {
422            // Find the end of this JWT (whitespace or string end).
423            let end = bytes[i..]
424                .iter()
425                .position(|&b| b == b' ' || b == b'\n' || b == b'\r' || b == b'\t')
426                .map(|p| i + p)
427                .unwrap_or(bytes.len());
428            let candidate = &text[i..end];
429            // Must have at least 2 dots and 3 eyJ-prefixed segments.
430            let dots = candidate.as_bytes().iter().filter(|&&b| b == b'.').count();
431            if dots >= 2 {
432                let parts: Vec<&str> = candidate.splitn(3, '.').collect();
433                if parts.len() == 3
434                    && parts[0].starts_with("eyJ")
435                    && parts[1].starts_with("eyJ")
436                    && parts[0].len() >= 10
437                    && parts[1].len() >= 10
438                {
439                    return Some(candidate);
440                }
441            }
442            i = end + 1;
443        } else {
444            i += 1;
445        }
446    }
447    None
448}
449
450/// Detect `scheme://user:pass@host` patterns where the `user:pass` portion
451/// contains actual credentials (both user and pass non-empty).
452fn find_url_userinfo(text: &str) -> Option<&str> {
453    let mut search = text;
454    let mut base = 0usize;
455    while let Some(at_rel) = search.find("://") {
456        let at_abs = base + at_rel;
457        // After `://`, look for `@` before the next `/`, `?`, ` `, or newline.
458        let rest_start = at_abs + 3;
459        let rest = &text[rest_start..];
460        if let Some(at_pos) = rest.find('@') {
461            let userinfo = &rest[..at_pos];
462            // Must contain a colon and both sides non-empty.
463            if let Some(colon) = userinfo.find(':') {
464                let user = &userinfo[..colon];
465                let pass = &userinfo[colon + 1..];
466                if !user.is_empty() && !pass.is_empty() && pass.len() >= 4 {
467                    // Return a slice starting from the scheme.  Walk back from
468                    // `at_abs` to the first non-scheme char and resume just past
469                    // it.  Use `char_indices` and skip by the separator's full
470                    // UTF-8 width: a multibyte separator (e.g. CJK prose before a
471                    // credential URL) would otherwise leave `scheme_start` inside
472                    // the codepoint and panic the slice below.
473                    let scheme_start = text[..at_abs]
474                        .char_indices()
475                        .rev()
476                        .find(|(_, c)| {
477                            !c.is_ascii_alphanumeric() && *c != '+' && *c != '-' && *c != '.'
478                        })
479                        .map(|(idx, c)| idx + c.len_utf8())
480                        .unwrap_or(0);
481                    // Ensure there are no spaces in userinfo (not a code snippet).
482                    if !userinfo.contains(' ') && !userinfo.contains('\n') {
483                        let end = rest_start
484                            + at_pos
485                            + 1
486                            + rest[at_pos + 1..]
487                                .find([' ', '\n', '\r'])
488                                .unwrap_or(rest[at_pos + 1..].len());
489                        return Some(&text[scheme_start..end.min(text.len())]);
490                    }
491                }
492            }
493        }
494        base = at_abs + 3;
495        search = &text[base..];
496    }
497    None
498}
499
500// ─── Layer 2: entropy heuristic ─────────────────────────────────────────────
501
502/// Trigger words near which high-entropy tokens are suspicious.
503///
504/// The bare substring `token` is NOT in this list because it fires on benign
505/// terms like `tokenizer`, `token_count`, and `next_token`.  Instead we use
506/// the dedicated boundary-aware helpers `has_standalone_token` (standalone word)
507/// and `has_token_assignment` (`token=` / `token:` with word boundary before).
508const TRIGGER_WORDS: &[&str] = &[
509    "key",
510    "secret",
511    "password",
512    "passwd",
513    "credential",
514    "bearer",
515    "auth",
516    "apikey",
517    "api_key",
518    "access_key",
519    "private_key",
520];
521
522/// Minimum token length to apply the entropy check.
523const MIN_ENTROPY_LEN: usize = 24;
524
525/// Shannon entropy threshold (bits per character) above which a token is
526/// considered high-entropy.  7.0 corresponds to ~99% utilisation of a
527/// 128-symbol alphabet — typical for random base64/hex.
528const ENTROPY_THRESHOLD: f64 = 4.5;
529
530/// Window around a trigger word in which a high-entropy token must appear.
531const TRIGGER_WINDOW: usize = 120;
532
533/// Largest index `<= i` that lies on a UTF-8 char boundary of `s`. Stable
534/// replacement for the unstable `str::floor_char_boundary`; used to snap
535/// byte-offset windows that may land inside a multibyte char before slicing.
536fn floor_char_boundary(s: &str, i: usize) -> usize {
537    let mut i = i.min(s.len());
538    while i > 0 && !s.is_char_boundary(i) {
539        i -= 1;
540    }
541    i
542}
543
544/// `from` restricts which tokens may be RETURNED (only those starting at or
545/// after `from`), but the trigger-context window is still computed over the full
546/// `text`. This lets [`mask_secrets`] advance past an earlier redaction without
547/// losing a trigger word that sat to the left of it.
548fn check_entropy_heuristic(text: &str, from: usize) -> Option<(&str, &'static str)> {
549    // Tokenize into maximal ASCII non-whitespace runs, recording each run's byte
550    // offset.  Non-ASCII characters are delimiters (alongside ASCII whitespace):
551    // real base64/hex/base64url credentials are ASCII, so splitting on non-ASCII
552    // isolates an ASCII credential glued to CJK text/punctuation/fullwidth
553    // whitespace, while a run of natural-language CJK yields no ASCII run long
554    // enough to trip the length floor below.  On pure-ASCII input this is
555    // identical to `split_ascii_whitespace`.
556    let tokens: Vec<(usize, &str)> = text
557        .split(|c: char| c.is_ascii_whitespace() || !c.is_ascii())
558        .filter(|t| !t.is_empty())
559        .map(|t| {
560            let offset = t.as_ptr() as usize - text.as_ptr() as usize;
561            (offset, t)
562        })
563        .collect();
564
565    for &(tok_offset, raw_token) in &tokens {
566        // Strip common delimiters that wrap the actual value.
567        let token = strip_delimiters(raw_token);
568        // Only RETURN tokens at or after `from` (already-redacted spans lie
569        // before it); the trigger window below still spans the full text.
570        let token_offset = token.as_ptr() as usize - text.as_ptr() as usize;
571        if token_offset < from {
572            continue;
573        }
574        if token.len() < MIN_ENTROPY_LEN {
575            continue;
576        }
577
578        // `token` is ASCII here (non-ASCII was split out at tokenization), so
579        // `shannon_entropy` over its bytes is a true per-character entropy.
580
581        // UUID and sha-prefixed base64 content hashes (SRI / npm lockfile) are
582        // unconditionally allowlisted: their forms are unambiguous regardless of
583        // surrounding context.
584        if is_uuid_canonical(token) || is_base64_content_hash(token) {
585            continue;
586        }
587
588        // Compute the trigger window before deciding whether to allowlist hex
589        // tokens.  A pure-hex token near a credential trigger word cannot be
590        // safely assumed to be a non-secret hash and must be entropy-checked.
591        let window_start = floor_char_boundary(text, tok_offset.saturating_sub(TRIGGER_WINDOW));
592        let window_end = floor_char_boundary(text, tok_offset + raw_token.len() + TRIGGER_WINDOW);
593        let window = &text[window_start..window_end];
594        let low_window = window.to_ascii_lowercase();
595
596        let near_trigger = TRIGGER_WORDS.iter().any(|tw| low_window.contains(tw))
597            || has_standalone_token(&low_window)
598            || has_token_assignment(&low_window);
599
600        // Pure hex tokens (git SHA, checksum digests) are allowlisted only when
601        // they are NOT near a credential trigger.
602        if !near_trigger && is_pure_hex(token) {
603            continue;
604        }
605
606        // Hex API keys (AWS secret access key, Stripe test keys, random hex
607        // tokens) are pure hex yet are real credentials.  The entropy heuristic
608        // cannot catch them — hex alphabet maxes at log2(16) = 4.0 bits/char,
609        // which is always below ENTROPY_THRESHOLD (4.5).  A credential-shaped
610        // hex token (32 / 40 / 64 / 128 chars) near a trigger word is always
611        // flagged.  Credential triggers dominate: adding "sha" or "hash" to
612        // the window does not rescue the token — a caller controlling the prose
613        // could trivially bypass the gate with one extra word.  Safe git SHAs
614        // and content-hash digests do not appear near credential trigger words
615        // and are already allowed via the `!near_trigger && is_pure_hex` path.
616        const HEX_CREDENTIAL_LENGTHS: &[usize] = &[32, 40, 64, 128];
617        if near_trigger && is_pure_hex(token) && HEX_CREDENTIAL_LENGTHS.contains(&token.len()) {
618            return Some((token, "hex-credential-token"));
619        }
620
621        let entropy = shannon_entropy(token.as_bytes());
622        if entropy < ENTROPY_THRESHOLD {
623            continue;
624        }
625
626        // High-entropy token in trigger context — flag it.
627        if near_trigger {
628            return Some((token, "high-entropy-token"));
629        }
630    }
631    None
632}
633
634/// Returns `true` when `low_window` contains the word `token` as a standalone
635/// word — i.e. surrounded by non-ASCII-alphanumeric boundaries (CJK/accented
636/// prose counts as a boundary) — but NOT as part of compound identifiers such
637/// as `tokenizer`, `token_count`, or `next_token`.
638fn has_standalone_token(low_window: &str) -> bool {
639    let needle = "token";
640    let mut start = 0;
641    while let Some(rel) = low_window[start..].find(needle) {
642        let abs = start + rel;
643        let before_ok = abs == 0
644            || low_window[..abs]
645                .chars()
646                .next_back()
647                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
648        let after_end = abs + needle.len();
649        let after_ok = after_end >= low_window.len()
650            || low_window[after_end..]
651                .chars()
652                .next()
653                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
654        if before_ok && after_ok {
655            return true;
656        }
657        start = abs + needle.len().max(1);
658    }
659    false
660}
661
662/// Returns `true` when `low_window` contains the assignment form `token=` or
663/// `token:` where the `token` identifier has a word boundary BEFORE it.
664///
665/// This is boundary-aware so that compound identifiers like `next_token:` or
666/// `pagination_token=` do NOT trigger — only a standalone `token=`/`token:`
667/// at the start of a field name does.
668///
669/// Examples that return `true`:  `token=<value>`, `token: <value>`,
670///   `"token": "<value>"` (JSON key-value pairs).
671/// Examples that return `false`: `next_token: <value>`,
672///   `pagination_token=<value>`, `token_count: <value>`.
673fn has_token_assignment(low_window: &str) -> bool {
674    let needle = "token";
675    let mut start = 0;
676    while let Some(rel) = low_window[start..].find(needle) {
677        let abs = start + rel;
678        // Require a word boundary BEFORE `token`.
679        let before_ok = abs == 0
680            || low_window[..abs]
681                .chars()
682                .next_back()
683                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
684        let after_end = abs + needle.len();
685        // Require `=` or `:` immediately after `token` (possibly with surrounding
686        // whitespace or quotes stripped by the time we see the lowercased window).
687        let after_char = low_window[after_end..].chars().next();
688        let after_is_assign = matches!(after_char, Some('=') | Some(':'));
689        if before_ok && after_is_assign {
690            return true;
691        }
692        start = abs + needle.len().max(1);
693    }
694    false
695}
696
697// ─── Allowlist helpers ───────────────────────────────────────────────────────
698
699/// Returns `true` for pure-hex tokens (case-insensitive, optional `0x`/`0X` prefix,
700/// 8–128 chars) — git SHAs, checksum digests, uuid-hex without hyphens.
701///
702/// This helper is used with context: pure-hex tokens near credential trigger words
703/// are NOT allowlisted (see `check_entropy_heuristic`).  Only call this function
704/// when you have already confirmed no trigger context is nearby.
705fn is_pure_hex(token: &str) -> bool {
706    let hex_part = token
707        .strip_prefix("0x")
708        .or(token.strip_prefix("0X"))
709        .unwrap_or(token);
710    hex_part.len() >= 8 && hex_part.len() <= 128 && hex_part.bytes().all(|b| b.is_ascii_hexdigit())
711}
712
713/// Returns `true` for tokens that are unambiguous base64/base64url content
714/// hashes with an explicit `sha<N>-` prefix (SRI hash, npm lockfile integrity).
715///
716/// Criteria:
717/// - Token starts with `sha<digits>-` (e.g. `sha256-`, `sha384-`, `sha512-`).
718/// - The body after the prefix matches a SHA-family length (43, 64, or 86–88
719///   unpadded chars).
720/// - Every byte in the body is a standard-base64 or URL-safe-base64 character.
721/// - Does NOT start with a known vendor-token prefix (those are credentials
722///   regardless of alphabet).
723///
724/// Bare base64 tokens of those lengths WITHOUT the `sha<N>-` prefix are NOT
725/// allowlisted here — a 43-char base64url API token near the word "key" is
726/// indistinguishable from a sha256 hash body without the prefix, so we require
727/// the explicit prefix to avoid false-negative credential escapes.
728fn is_base64_content_hash(token: &str) -> bool {
729    // Known vendor prefixes — never allowlist even if they look like base64.
730    // Includes bare `sk-` to prevent OpenAI-shaped tokens from being allowlisted.
731    const VENDOR_PREFIXES: &[&str] = &[
732        "sk-",
733        "rk_live_",
734        "fm2_",
735        "vercel_",
736        "xoxb-",
737        "xoxa-",
738        "xoxp-",
739        "xoxr-",
740        "xoxs-",
741        "ghp_",
742        "gho_",
743        "ghu_",
744        "ghs_",
745        "ghr_",
746        "github_pat_",
747        "AKIA",
748        "ASIA",
749        "AGE-SECRET-KEY-",
750        "FlyV1",
751    ];
752    if VENDOR_PREFIXES.iter().any(|p| token.starts_with(p)) {
753        return false;
754    }
755    // Require an explicit SRI `sha[0-9]+-` prefix.  Bare base64 at sha-length
756    // is NOT allowlisted — it is indistinguishable from a real API token.
757    let body = if let Some(rest) = token.strip_prefix("sha") {
758        // rest starts with digits followed by '-'
759        let dash = rest.find('-').unwrap_or(rest.len());
760        let digits = &rest[..dash];
761        if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) && dash < rest.len() {
762            &rest[dash + 1..] // everything after "sha<digits>-"
763        } else {
764            return false; // no valid sha<N>- prefix → not a known content hash
765        }
766    } else {
767        return false; // no sha prefix → not allowlisted
768    };
769    // Strip optional padding (at most 2 `=`).
770    let stripped = body.trim_end_matches('=');
771    let pad_removed = body.len() - stripped.len();
772    if pad_removed > 2 {
773        return false;
774    }
775    // Accept only SHA-family content-hash lengths (43, 64, 86–88 chars unpadded).
776    let n = stripped.len();
777    if n != 43 && n != 64 && !(86..=88).contains(&n) {
778        return false;
779    }
780    // Accept both standard-base64 and URL-safe-base64 alphabets.
781    stripped
782        .bytes()
783        .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'-' || b == b'_')
784}
785
786/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
787fn is_uuid_canonical(s: &str) -> bool {
788    let b = s.as_bytes();
789    if b.len() != 36 {
790        return false;
791    }
792    b[8] == b'-'
793        && b[13] == b'-'
794        && b[18] == b'-'
795        && b[23] == b'-'
796        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
797        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
798        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
799        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
800        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
801}
802
803/// Strip common wrapping characters (`"`, `'`, `` ` ``, `:`, `=`) from both ends.
804fn strip_delimiters(s: &str) -> &str {
805    s.trim_matches(|c| matches!(c, '"' | '\'' | '`' | ':' | '=' | ',' | ';'))
806}
807
808// ─── Utilities ───────────────────────────────────────────────────────────────
809
810/// Extract a contiguous token (non-whitespace chars) starting at the beginning of `s`.
811fn extract_token(s: &str) -> &str {
812    let end = s
813        .find(|c: char| c.is_whitespace() || c == '\n' || c == '\r')
814        .unwrap_or(s.len());
815    &s[..end]
816}
817
818/// Shannon entropy in bits per character.
819///
820/// H = -∑ p_i log2(p_i)
821fn shannon_entropy(bytes: &[u8]) -> f64 {
822    if bytes.is_empty() {
823        return 0.0;
824    }
825    let mut counts = [0u32; 256];
826    for &b in bytes {
827        counts[b as usize] += 1;
828    }
829    let len = bytes.len() as f64;
830    counts
831        .iter()
832        .filter(|&&c| c > 0)
833        .map(|&c| {
834            let p = c as f64 / len;
835            -p * p.log2()
836        })
837        .sum()
838}
839
840/// Build a `SecretMatch` from a detector name and the candidate string.
841///
842/// The masked excerpt is: first 6 chars + "..." + total length.
843/// Never includes more than 6 chars of the actual value.
844fn build_match(detector: &'static str, candidate: &str) -> SecretMatch {
845    let chars: Vec<char> = candidate.chars().collect();
846    let preview: String = chars.iter().take(6).collect();
847    let masked = format!("{}...{}chars", preview, chars.len());
848    SecretMatch { detector, masked }
849}
850
851// ─── Tests ───────────────────────────────────────────────────────────────────
852
853#[cfg(test)]
854mod tests {
855    use super::*;
856
857    // ── Catch suite ──────────────────────────────────────────────────────────
858
859    #[test]
860    fn blocks_aws_akia() {
861        // FAKE key: prefix is real shape, 16-char suffix invented.
862        let fake = "AKIAFAKEKEY1234567890";
863        assert!(scan(fake).is_some(), "AKIA must be caught");
864        let m = scan(fake).unwrap();
865        assert_eq!(m.detector, "aws-access-key-id");
866        // Masked excerpt must not echo the full key.
867        assert!(
868            !m.masked.contains("FAKEKEY1234567890"),
869            "must not echo the secret: {}",
870            m.masked
871        );
872    }
873
874    #[test]
875    fn blocks_aws_asia() {
876        let fake = "ASIAFAKEKEY00000000000";
877        let m = scan(fake);
878        assert!(m.is_some(), "ASIA must be caught");
879        assert_eq!(m.unwrap().detector, "aws-access-key-id");
880    }
881
882    #[test]
883    fn blocks_github_ghp() {
884        // 36 chars total to pass min_len.
885        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
886        assert!(scan(fake).is_some(), "ghp_ must be caught");
887    }
888
889    #[test]
890    fn blocks_github_gho() {
891        let fake = "gho_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
892        assert!(scan(fake).is_some(), "gho_ must be caught");
893    }
894
895    #[test]
896    fn blocks_github_pat() {
897        let fake = "github_pat_AAAAAABBBBBBCCCCCC";
898        assert!(scan(fake).is_some(), "github_pat_ must be caught");
899    }
900
901    #[test]
902    fn blocks_openai_sk() {
903        let fake = "sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg";
904        assert!(scan(fake).is_some(), "sk- must be caught");
905    }
906
907    #[test]
908    fn blocks_anthropic_sk_ant() {
909        let fake = "sk-ant-api03-AAAAAAAAAAAAAAA";
910        assert!(scan(fake).is_some(), "sk-ant- must be caught");
911        assert_eq!(scan(fake).unwrap().detector, "anthropic-api-key");
912    }
913
914    #[test]
915    fn blocks_stripe_live() {
916        let fake = "sk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
917        assert!(scan(fake).is_some(), "sk_live_ must be caught");
918        assert_eq!(scan(fake).unwrap().detector, "stripe-secret-key");
919    }
920
921    #[test]
922    fn blocks_stripe_restricted() {
923        let fake = "rk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
924        assert!(scan(fake).is_some(), "rk_live_ must be caught");
925        assert_eq!(scan(fake).unwrap().detector, "stripe-restricted-key");
926    }
927
928    #[test]
929    fn blocks_fly_flyv1() {
930        let fake = "FlyV1 FAKEFLYTOKEN000000000000000000";
931        assert!(scan(fake).is_some(), "FlyV1 must be caught");
932        assert_eq!(scan(fake).unwrap().detector, "fly-token");
933    }
934
935    #[test]
936    fn blocks_fly_fm2() {
937        let fake = "fm2_FAKEFLYTOKEN00000000000000000";
938        assert!(scan(fake).is_some(), "fm2_ must be caught");
939        assert_eq!(scan(fake).unwrap().detector, "fly-token");
940    }
941
942    #[test]
943    fn blocks_vercel_token() {
944        let fake = "vercel_FAKETOKEN00000000000000000";
945        assert!(scan(fake).is_some(), "vercel_ must be caught");
946        assert_eq!(scan(fake).unwrap().detector, "vercel-token");
947    }
948
949    #[test]
950    fn blocks_slack_xoxb() {
951        let fake = "xoxb-FAKE-SLACKTOKEN-000000000000000000000000";
952        assert!(scan(fake).is_some(), "xoxb- must be caught");
953        assert_eq!(scan(fake).unwrap().detector, "slack-token");
954    }
955
956    #[test]
957    fn blocks_pem_private_key() {
958        // Split the header so the literal detector-trigger string is not present
959        // verbatim in source — pre-commit's detect-private-key hook would fire.
960        // The gate detects it at runtime because scan() sees the assembled string.
961        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
962        let fake = format!("{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----", header);
963        assert!(scan(&fake).is_some(), "PEM private key must be caught");
964        assert_eq!(scan(&fake).unwrap().detector, "pem-private-key");
965    }
966
967    #[test]
968    fn blocks_pem_ec_private_key() {
969        let header = ["-----BEGIN EC", " PRIVATE KEY-----"].concat(); // gitleaks:allow
970        let fake = format!("{}\nMHQCAQEE\u{2026}\n-----END EC PRIVATE KEY-----", header);
971        assert!(scan(&fake).is_some(), "EC PEM must be caught");
972    }
973
974    #[test]
975    fn blocks_age_secret_key() {
976        // AGE-SECRET-KEY- followed by 59 base32 chars (Bech32m body).
977        let fake = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
978        assert!(scan(fake).is_some(), "AGE-SECRET-KEY- must be caught");
979        assert_eq!(scan(fake).unwrap().detector, "age-secret-key");
980    }
981
982    #[test]
983    fn blocks_jwt_triple() {
984        // Synthetic JWT structure: header.payload.signature (no real key).
985        let fake = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
986        assert!(scan(fake).is_some(), "JWT triple must be caught");
987        assert_eq!(scan(fake).unwrap().detector, "jwt");
988    }
989
990    #[test]
991    fn blocks_url_userinfo() {
992        let fake = "postgresql://dbuser:S3cr3tP4ss@db.example.com:5432/mydb";
993        assert!(scan(fake).is_some(), "URL userinfo must be caught");
994        assert_eq!(scan(fake).unwrap().detector, "url-userinfo");
995    }
996
997    #[test]
998    fn blocks_high_entropy_near_bearer_word() {
999        // 32 random-looking base64 chars adjacent to the word "bearer".
1000        let fake = "Bearer token: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1001        assert!(
1002            scan(fake).is_some(),
1003            "high-entropy value near 'bearer' must be caught"
1004        );
1005        assert_eq!(scan(fake).unwrap().detector, "high-entropy-token");
1006    }
1007
1008    #[test]
1009    fn blocks_high_entropy_near_secret_word() {
1010        let fake = "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1011        assert!(
1012            scan(fake).is_some(),
1013            "high-entropy value near 'secret' must be caught"
1014        );
1015    }
1016
1017    #[test]
1018    fn error_message_masks_secret() {
1019        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1020        let m = scan(fake).unwrap();
1021        // Masked form: first 6 chars + "...N chars".
1022        // Must NOT contain the full suffix.
1023        let masked = &m.masked;
1024        assert!(
1025            !masked.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
1026            "mask must not echo the full secret value; got: {masked}"
1027        );
1028        // Must start with "ghp_AA" (first 6 chars of the token).
1029        assert!(
1030            masked.starts_with("ghp_AA"),
1031            "mask must show first 6 chars; got: {masked}"
1032        );
1033    }
1034
1035    // ── False-positive suite ─────────────────────────────────────────────────
1036
1037    #[test]
1038    fn allows_sha256_hex() {
1039        // 64-char lowercase hex — typical sha256 digest.
1040        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1041        assert!(
1042            scan(sha).is_none(),
1043            "sha256 hex must pass (allowlisted); fired: {:?}",
1044            scan(sha)
1045        );
1046    }
1047
1048    #[test]
1049    fn allows_uuid() {
1050        let uuid = "550e8400-e29b-41d4-a716-446655440000";
1051        assert!(
1052            scan(uuid).is_none(),
1053            "UUID must pass; fired: {:?}",
1054            scan(uuid)
1055        );
1056    }
1057
1058    #[test]
1059    fn allows_git_sha() {
1060        // 40-char lowercase git SHA.
1061        let sha = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
1062        assert!(
1063            scan(sha).is_none(),
1064            "git SHA must pass; fired: {:?}",
1065            scan(sha)
1066        );
1067    }
1068
1069    #[test]
1070    fn allows_normal_prose() {
1071        let prose =
1072            "The FlashAttention paper introduces IO-aware tiling for transformer self-attention.";
1073        assert!(scan(prose).is_none(), "normal prose must pass");
1074    }
1075
1076    #[test]
1077    fn allows_code_snippet() {
1078        let code = r#"fn create_entity(name: &str, kind: &str) -> RuntimeResult<Entity> {
1079    self.validate_entity_kind(kind)?;
1080    Ok(Entity::new("local", kind, name))
1081}"#;
1082        assert!(
1083            scan(code).is_none(),
1084            "code snippet must pass; fired: {:?}",
1085            scan(code)
1086        );
1087    }
1088
1089    #[test]
1090    fn allows_long_url_without_credentials() {
1091        let url = "https://docs.example.com/api/v2/entities?kind=concept&limit=100";
1092        assert!(scan(url).is_none(), "URL without userinfo must pass");
1093    }
1094
1095    #[test]
1096    fn allows_base64_image_stub() {
1097        // Realistic short base64 data URI stub — no trigger words, below threshold length.
1098        let b64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQ";
1099        assert!(
1100            scan(b64).is_none(),
1101            "base64 image stub without trigger word must pass; fired: {:?}",
1102            scan(b64)
1103        );
1104    }
1105
1106    #[test]
1107    fn allows_long_plain_url() {
1108        let url = "https://api.github.com/repos/ohdearquant/khive/pulls/76/comments?per_page=100";
1109        assert!(
1110            scan(url).is_none(),
1111            "plain URL must pass; fired: {:?}",
1112            scan(url)
1113        );
1114    }
1115
1116    #[test]
1117    fn allows_manifest_content_hash() {
1118        // A string like what appears in Cargo.lock or npm lockfiles.
1119        let line =
1120            "checksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"";
1121        assert!(
1122            scan(line).is_none(),
1123            "manifest content hash line must pass; fired: {:?}",
1124            scan(line)
1125        );
1126    }
1127
1128    #[test]
1129    fn masked_excerpt_format() {
1130        let fake = "AKIAFAKEKEY1234567890";
1131        let m = scan(fake).unwrap();
1132        // Format: first6...Nchars
1133        assert!(m.masked.contains("..."), "masked must contain '...'");
1134        assert!(m.masked.ends_with("chars"), "masked must end with 'chars'");
1135    }
1136
1137    // ── Gate function ────────────────────────────────────────────────────────
1138
1139    #[test]
1140    fn check_returns_ok_for_safe_content() {
1141        assert!(check("A normal memory note about LoRA.").is_ok());
1142    }
1143
1144    #[test]
1145    fn check_returns_err_for_secret() {
1146        let fake = "AKIAFAKEKEY1234567890";
1147        let result = check(fake);
1148        assert!(result.is_err(), "check must fail for AKIA key");
1149        let err = result.unwrap_err();
1150        assert!(
1151            matches!(err, RuntimeError::SecretDetected(_)),
1152            "error variant must be SecretDetected"
1153        );
1154    }
1155
1156    // ── Entropy helpers ──────────────────────────────────────────────────────
1157
1158    #[test]
1159    fn entropy_of_uniform_string_is_zero() {
1160        let s = "aaaaaaaaaaaaaaaa";
1161        assert!(shannon_entropy(s.as_bytes()) < 0.01);
1162    }
1163
1164    #[test]
1165    fn entropy_of_random_bytes_is_high() {
1166        // A truly random-looking string should exceed 4.5 bits/char.
1167        let s = b"X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // 32 mixed base64 chars
1168        assert!(shannon_entropy(s) > 4.5, "entropy={}", shannon_entropy(s));
1169    }
1170
1171    #[test]
1172    fn cjk_prose_near_trigger_is_not_flagged() {
1173        // Regression: a multibyte CJK run (~19 chars = 57 bytes) clears the
1174        // byte-length floor, and `shannon_entropy` over UTF-8 bytes reads it as
1175        // high-entropy — so a Chinese title near the `auth` trigger word used to
1176        // false-positive as `high-entropy-token`.  Non-ASCII tokens are now
1177        // skipped by the entropy heuristic: real base64/hex credentials are
1178        // ASCII, so this cannot hide a secret.
1179        let content = "更新 auth 配置数据库连接管理系统核心模块设计文档";
1180        assert!(
1181            check(content).is_ok(),
1182            "CJK prose near a trigger word must not be flagged as a secret"
1183        );
1184    }
1185
1186    #[test]
1187    fn ascii_secret_near_trigger_still_flagged() {
1188        // The non-ASCII skip must NOT weaken detection of genuine ASCII
1189        // high-entropy credentials near a trigger word.
1190        let content = "api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1";
1191        assert!(
1192            check(content).is_err(),
1193            "ASCII high-entropy token near a trigger word must still be blocked"
1194        );
1195    }
1196
1197    #[test]
1198    fn ascii_secret_in_cjk_context_does_not_panic_and_is_flagged() {
1199        // The ±120-byte trigger window around an ASCII token can land in the
1200        // middle of a multibyte CJK character when the token is embedded in
1201        // non-Latin prose.  Slicing on a non-char-boundary would panic — the
1202        // window bounds are snapped via `floor_char_boundary`.  Detection of
1203        // the genuine ASCII secret must still fire.
1204        let cjk = "数据库连接管理系统核心模块设计文档".repeat(6); // 17 chars × 6 = 306 bytes
1205                                                                  // The leading single-byte `x` breaks 3-byte CJK alignment so the window
1206                                                                  // start (token_offset - 120) lands mid-character without the snap.
1207        let content = format!("{cjk}x api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1 {cjk}");
1208        assert!(
1209            check(&content).is_err(),
1210            "ASCII secret in CJK context must still be blocked (and must not panic)"
1211        );
1212    }
1213
1214    #[test]
1215    fn ascii_secret_glued_to_cjk_is_still_flagged() {
1216        // Regression: a prefixless high-entropy credential glued (no ASCII
1217        // whitespace) to CJK text, CJK brackets/quotes, a fullwidth space, or a
1218        // fullwidth colon used to slip through, because the whole whitespace token
1219        // contained a non-ASCII byte and was skipped wholesale.  Non-ASCII is now
1220        // a token delimiter, so the ASCII credential run is isolated and
1221        // entropy-checked while the surrounding ±120-byte window still sees the
1222        // trigger word.
1223        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
1224        let cases = [
1225            format!("api_key {secret}数据"),     // CJK suffix glued to the token
1226            format!("api_key 「{secret}」"),     // CJK brackets wrap the token
1227            format!("api_key {secret}"),        // U+3000 ideographic space separator
1228            format!("api_key:{secret}"),        // U+FF1A fullwidth colon separator
1229            format!("数据{secret}更新 api_key"), // CJK-glued prefix, trigger after
1230        ];
1231        for content in &cases {
1232            assert!(
1233                check(content).is_err(),
1234                "ASCII secret glued to CJK must be blocked: {content:?}"
1235            );
1236        }
1237    }
1238
1239    #[test]
1240    fn high_entropy_ascii_run_without_trigger_is_not_flagged() {
1241        // The non-ASCII-as-delimiter change must not weaken the trigger-context
1242        // discipline: a high-entropy ASCII run isolated from CJK prose but NOT
1243        // near a credential trigger word is still allowed (only the tokenizer
1244        // changed, not the `near_trigger` gate).
1245        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
1246        let content = format!("数据库连接{secret}核心模块设计文档");
1247        assert!(
1248            check(&content).is_ok(),
1249            "high-entropy ASCII run with no trigger word must not be flagged"
1250        );
1251    }
1252
1253    #[test]
1254    fn known_prefix_secret_glued_after_cjk_is_still_flagged() {
1255        // Round-2 regression: a Layer-1 known-prefix secret glued directly after
1256        // CJK prose (no ASCII whitespace) was missed, because the prefix boundary
1257        // check used `is_alphanumeric` — which Rust counts true for CJK — so the
1258        // preceding ideograph was not treated as a delimiter.  These credentials
1259        // must be caught with no nearby ASCII trigger word, on the left side too.
1260        let cases = [
1261            "数据AKIAIOSFODNN7EXAMPLE",             // gitleaks:allow
1262            "令牌github_pat_11ABCDEFG0HIJKLMNOPQR", // gitleaks:allow
1263            "密钥sk-ant-api03-AAAAAAAAAAAAAAAAAA",  // gitleaks:allow
1264            "配置FlyV1 fm2_AAAABBBBCCCCDDDD",       // gitleaks:allow
1265        ];
1266        for content in cases {
1267            assert!(
1268                check(content).is_err(),
1269                "known-prefix secret glued after CJK must be blocked: {content:?}"
1270            );
1271        }
1272    }
1273
1274    #[test]
1275    fn url_userinfo_after_cjk_does_not_panic_and_is_flagged() {
1276        // Round-3 regression: a credential URL glued after CJK prose panicked,
1277        // because scheme_start was (separator byte index + 1) — one byte into a
1278        // multibyte CJK separator — and the slice fell on a non-char boundary.
1279        // The public check() API must return a controlled error, never panic.
1280        let cases = [
1281            "数据postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
1282            "配置mysql://root:hunter2pw@10.0.0.1:3306/app",         // gitleaks:allow
1283            "连接redis://svc:V3ryS3cretPw@cache.internal:6379",     // gitleaks:allow
1284        ];
1285        for content in cases {
1286            assert!(
1287                check(content).is_err(),
1288                "credential URL after CJK must be blocked, not panic: {content:?}"
1289            );
1290        }
1291    }
1292
1293    #[test]
1294    fn non_ascii_glued_token_trigger_is_still_flagged() {
1295        // Round-4 regression: `token=`/`token:`/standalone `token` glued directly
1296        // after non-ASCII prose was missed because has_standalone_token /
1297        // has_token_assignment used is_alphanumeric for the word boundary — CJK,
1298        // accented letters, and fullwidth digits all count as alphanumeric in
1299        // Rust, so the preceding char was not seen as a boundary and the `token`
1300        // trigger was suppressed, leaving the high-entropy value unflagged.
1301        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1302        let blocked = [
1303            format!("数据token={opaque}"),    // CJK + assignment form, ASCII '='
1304            format!("配置token: {opaque}"),   // CJK + assignment form, ASCII ':'
1305            format!("密钥token {opaque}"),    // CJK + standalone-word form
1306            format!("résumétoken: {opaque}"), // accented letter before `token`
1307            format!("1token: {opaque}"),     // fullwidth digit before `token`
1308        ];
1309        for content in &blocked {
1310            assert!(
1311                check(content).is_err(),
1312                "non-ASCII-glued token trigger must flag the value: {content:?}"
1313            );
1314        }
1315        // Compound identifiers stay excluded — the `_` boundary rule is unchanged
1316        // and an ASCII letter before `token` is still a continuation, so these
1317        // (including the pure-ASCII `servicetoken:`) must still pass.
1318        let allowed = [
1319            format!("数据next_token: {opaque}"),
1320            format!("数据token_count: {opaque}"),
1321            format!("servicetoken: {opaque}"),
1322        ];
1323        for content in &allowed {
1324            assert!(
1325                check(content).is_ok(),
1326                "compound token identifier must not be flagged: {content:?}"
1327            );
1328        }
1329    }
1330
1331    #[test]
1332    fn allowlist_passes_sha256() {
1333        // A plain sha256 hex digest passes via `is_pure_hex` (not `is_allowlisted`
1334        // because hex is now context-dependent; this tests the primitive directly).
1335        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1336        assert!(is_pure_hex(sha));
1337    }
1338
1339    #[test]
1340    fn allowlist_passes_uuid_canonical() {
1341        assert!(is_uuid_canonical("550e8400-e29b-41d4-a716-446655440000"));
1342    }
1343
1344    #[test]
1345    fn allowlist_does_not_pass_mixed_token() {
1346        // A token that starts with letters but mixes in non-hex chars.
1347        assert!(!is_pure_hex("sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg"));
1348    }
1349
1350    // ── Structured-field gate helpers ────────────────────────────────────────
1351
1352    #[test]
1353    fn check_json_blocks_secret_in_object_value() {
1354        let props = serde_json::json!({ "api_key": "AKIAFAKEKEY1234567890" });
1355        assert!(
1356            check_json(&props).is_err(),
1357            "secret in properties object value must be blocked"
1358        );
1359    }
1360
1361    #[test]
1362    fn check_json_blocks_secret_in_nested_object() {
1363        let props = serde_json::json!({ "credentials": { "token": "sk-proj-FAKEKEY00000000000000000000000000000000" } }); // gitleaks:allow
1364        assert!(
1365            check_json(&props).is_err(),
1366            "secret in nested properties object must be blocked"
1367        );
1368    }
1369
1370    #[test]
1371    fn check_json_blocks_secret_in_array() {
1372        let props = serde_json::json!(["normal", "AKIAFAKEKEY1234567890"]);
1373        assert!(
1374            check_json(&props).is_err(),
1375            "secret in JSON array must be blocked"
1376        );
1377    }
1378
1379    #[test]
1380    fn check_json_passes_safe_properties() {
1381        let props = serde_json::json!({
1382            "domain": "attention",
1383            "status": "researched",
1384            "year": 2024
1385        });
1386        assert!(
1387            check_json(&props).is_ok(),
1388            "normal properties must pass; fired: {:?}",
1389            check_json(&props).err()
1390        );
1391    }
1392
1393    #[test]
1394    fn check_tags_blocks_credential_tag() {
1395        let tags = vec![
1396            "type:concept".to_string(),
1397            "AKIAFAKEKEY1234567890".to_string(),
1398        ];
1399        assert!(
1400            check_tags(&tags).is_err(),
1401            "credential-shaped tag must be blocked"
1402        );
1403    }
1404
1405    #[test]
1406    fn check_tags_passes_normal_tags() {
1407        let tags = vec!["type:concept".to_string(), "domain:attention".to_string()];
1408        assert!(
1409            check_tags(&tags).is_ok(),
1410            "normal tags must pass; fired: {:?}",
1411            check_tags(&tags).err()
1412        );
1413    }
1414
1415    // ── False-positive: sk-learn and scikit-learn slugs ──────────────────────
1416
1417    #[test]
1418    fn allows_sk_learn_prose() {
1419        // scikit-learn slug used as an entity name or knowledge atom.
1420        let texts = &[
1421            "sk-learn is a Python machine learning library",
1422            "sk-learn-compatible transformer pipeline reference",
1423            "sk-learn scikit-learn estimator interface",
1424        ];
1425        for t in texts {
1426            assert!(
1427                scan(t).is_none(),
1428                "sk-learn prose must pass; fired: {:?} on {:?}",
1429                scan(t),
1430                t
1431            );
1432        }
1433    }
1434
1435    #[test]
1436    fn blocks_openai_sk_proj_not_confused_with_sk_learn() {
1437        // Real OpenAI key shape must still be caught.
1438        let fake = "sk-proj-FAKEKEY00000000000000000000000000000000"; // gitleaks:allow
1439        assert!(
1440            scan(fake).is_some(),
1441            "sk-proj- key must still be caught after sk-learn exemption"
1442        );
1443    }
1444
1445    // ── False-positive: SRI / tokenizer hash metadata ────────────────────────
1446
1447    #[test]
1448    fn allows_sri_hash() {
1449        // SRI hash as used in HTML integrity attributes (sha384, base64-encoded).
1450        // Placed near the word "key" to test the entropy heuristic allowlist.
1451        let line = "integrity key: sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC";
1452        assert!(
1453            scan(line).is_none(),
1454            "SRI hash must pass; fired: {:?}",
1455            scan(line)
1456        );
1457    }
1458
1459    #[test]
1460    fn allows_base64_tokenizer_hash_metadata() {
1461        // Tokenizer metadata containing a base64 hash near technical keywords.
1462        let line = "tokenizer_vocab_hash: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1463        assert!(
1464            scan(line).is_none(),
1465            "tokenizer hash metadata must pass; fired: {:?}",
1466            scan(line)
1467        );
1468    }
1469
1470    #[test]
1471    fn allows_npm_lockfile_integrity() {
1472        // npm lockfile integrity line with sha512 base64url hash (86 base64 chars + ==).
1473        // sha512 digest = 64 bytes → base64 = 88 chars (86 unpadded + ==).
1474        let body_86 = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1234567890abcdefghijklmnopqrstuvwxABCDEFGHIJKLMNOPQRST";
1475        assert_eq!(body_86.len(), 86, "test body must be exactly 86 chars");
1476        let line = format!(
1477            "resolved: https://registry.npmjs.org/foo/-/foo-1.0.0.tgz\nintegrity: sha512-{body_86}=="
1478        );
1479        assert!(
1480            scan(&line).is_none(),
1481            "npm lockfile integrity must pass; fired: {:?}",
1482            scan(&line)
1483        );
1484    }
1485
1486    // ── False-positive: tokenizer vs token trigger word ─────────────────────
1487
1488    #[test]
1489    fn allows_tokenizer_vocab_hash_no_block() {
1490        // `tokenizer_vocab_hash` contains the substring "token" but NOT as a
1491        // standalone word (followed by 'i' which is alphanumeric), so the
1492        // standalone-token boundary check must not fire here.
1493        let line = "tokenizer_vocab_hash = Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1494        assert!(
1495            scan(line).is_none(),
1496            "tokenizer_vocab_hash must pass; 'token' is only standalone-word matched; fired: {:?}",
1497            scan(line)
1498        );
1499    }
1500
1501    // ── True-positives: bare base64 at sha-lengths near trigger words ────────
1502
1503    #[test]
1504    fn blocks_bare_base64url_43chars_near_key() {
1505        // A 43-char base64url token (= sha256 body length) near the word "key".
1506        // Without a sha<N>- prefix this MUST be caught, not allowlisted.
1507        let token_43 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"; // gitleaks:allow
1508        assert_eq!(token_43.len(), 43, "test token must be exactly 43 chars");
1509        let line = format!("api key {token_43}");
1510        assert!(
1511            scan(&line).is_some(),
1512            "43-char base64url token near 'key' must be caught (no sha-prefix = not a hash); fired: {:?}",
1513            scan(&line)
1514        );
1515    }
1516
1517    #[test]
1518    fn blocks_bare_base64url_64chars_near_secret() {
1519        // A 64-char base64url token (= sha384 body length) near "secret".
1520        // Must be caught without sha<N>- prefix.
1521        let token_64 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENa"; // gitleaks:allow
1522        assert_eq!(token_64.len(), 64, "test token must be exactly 64 chars");
1523        let line = format!("secret: {token_64}");
1524        assert!(
1525            scan(&line).is_some(),
1526            "64-char base64url token near 'secret' must be caught; got: {:?}",
1527            scan(&line)
1528        );
1529    }
1530
1531    #[test]
1532    fn blocks_bare_base64url_86chars_near_auth() {
1533        // An 86-char base64url token (= sha512 body length) near "auth".
1534        // Must be caught without sha<N>- prefix.
1535        let token_86 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENwJalrXUtnFEMI-K7MDENabc"; // gitleaks:allow
1536        assert_eq!(token_86.len(), 86, "test token must be exactly 86 chars");
1537        let line = format!("auth header {token_86}");
1538        assert!(
1539            scan(&line).is_some(),
1540            "86-char base64url token near 'auth' must be caught; got: {:?}",
1541            scan(&line)
1542        );
1543    }
1544
1545    // ── True-positives: standalone `token` trigger ───────────────────────────
1546
1547    #[test]
1548    fn blocks_service_token_opaque_value() {
1549        // "service token <opaque-high-entropy>" — `token` as a standalone word
1550        // with a high-entropy value must be caught.
1551        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1552        assert!(
1553            opaque.len() >= 24,
1554            "opaque must be long enough for entropy check"
1555        );
1556        let line = format!("service token {opaque}");
1557        assert!(
1558            scan(&line).is_some(),
1559            "service token <opaque> must be caught by standalone 'token' check; got: {:?}",
1560            scan(&line)
1561        );
1562    }
1563
1564    #[test]
1565    fn blocks_token_equals_credential() {
1566        // `token=<high-entropy>` (assignment form) must be caught via has_token_assignment.
1567        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1568        let line = format!("token={opaque}");
1569        assert!(
1570            scan(&line).is_some(),
1571            "token=<value> must be caught via token= trigger; got: {:?}",
1572            scan(&line)
1573        );
1574    }
1575
1576    #[test]
1577    fn blocks_token_colon_credential() {
1578        // `token: <high-entropy>` (key-value form) must be caught via has_token_assignment.
1579        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1580        let line = format!("token: {opaque}");
1581        assert!(
1582            scan(&line).is_some(),
1583            "token: <value> must be caught via token: trigger; got: {:?}",
1584            scan(&line)
1585        );
1586    }
1587
1588    #[test]
1589    fn allows_next_token_technical_context() {
1590        // `next_token` is a technical term; the high-entropy value here has low
1591        // entropy anyway, so it must pass.
1592        let line = "next_token: cursor-page-2-abcdef12345678";
1593        assert!(
1594            scan(line).is_none(),
1595            "next_token technical context must not be blocked; fired: {:?}",
1596            scan(line)
1597        );
1598    }
1599
1600    // ── Finding 6: boundary-aware token= / token: — compound identifiers must pass ──
1601
1602    #[test]
1603    fn allows_next_token_high_entropy_cursor() {
1604        // `next_token:` with a realistic high-entropy pagination cursor must NOT be
1605        // blocked.  `next_token` has `_token` suffix — not a standalone assignment form.
1606        let cursor = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1607        let line = format!("next_token: {cursor}");
1608        assert!(
1609            scan(&line).is_none(),
1610            "next_token with high-entropy cursor must pass (compound identifier); fired: {:?}",
1611            scan(&line)
1612        );
1613    }
1614
1615    #[test]
1616    fn allows_token_count_high_entropy() {
1617        // `token_count:` with a high-entropy value must NOT be blocked.
1618        // `token_count` has `token_` prefix — the word boundary after `token` is `_`,
1619        // which is excluded by has_token_assignment.
1620        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1621        let line = format!("token_count: {opaque}");
1622        assert!(
1623            scan(&line).is_none(),
1624            "token_count with high-entropy value must pass; fired: {:?}",
1625            scan(&line)
1626        );
1627    }
1628
1629    // ── Finding 5: hex allowlist is not applied when trigger context is present ─
1630    //
1631    // Pure hex strings have a theoretical maximum entropy of log2(16) = 4.0 bits/char,
1632    // which is below the ENTROPY_THRESHOLD of 4.5.  That means pure hex tokens cannot
1633    // reach the entropy threshold and will never be flagged by the heuristic alone.
1634    //
1635    // However, the hex allowlist was previously applied BEFORE the trigger window was
1636    // computed, meaning a future threshold reduction or edge case could silently
1637    // skip credential-context hex.  The fix: compute trigger context first; only
1638    // apply the hex allowlist when NOT near a trigger.  The tests below verify the
1639    // structural change is in place by confirming that non-pure-hex high-entropy
1640    // tokens near triggers are caught (showing the trigger path is live), and that
1641    // purely hex tokens near triggers still correctly pass (entropy too low to flag).
1642
1643    #[test]
1644    fn hex_near_key_blocked_in_credential_context() {
1645        // A pure-hex 32-char token near "api key" is a credential-shaped hex
1646        // token in trigger context.  Entropy alone cannot flag it (hex max =
1647        // 4.0 < 4.5 threshold), but the explicit hex-credential-token path
1648        // must catch it.
1649        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
1650        assert_eq!(hex32.len(), 32);
1651        let line = format!("api key {hex32}");
1652        assert!(
1653            scan(&line).is_some(),
1654            "32-char pure hex near 'api key' must be blocked; got None"
1655        );
1656    }
1657
1658    #[test]
1659    fn hex_credential_lengths_blocked_near_trigger() {
1660        // Verify all four credential-shaped lengths are caught near a trigger.
1661        let hex40 = "a3f5c2e9d1b8047e63a1f4c2d5b6e8f1a9c3d2e4";
1662        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
1663        let hex128 = format!("{hex64}{hex64}");
1664        assert_eq!(hex40.len(), 40);
1665        assert_eq!(hex64.len(), 64);
1666        assert_eq!(hex128.len(), 128);
1667
1668        for (label, hex) in &[
1669            ("hex40", hex40),
1670            ("hex64", hex64),
1671            ("hex128", hex128.as_str()),
1672        ] {
1673            let line = format!("secret key: {hex}");
1674            assert!(
1675                scan(&line).is_some(),
1676                "{label} near 'secret key' must be blocked; got None"
1677            );
1678        }
1679    }
1680
1681    #[test]
1682    fn hex_blocked_when_trigger_and_hash_word_coexist() {
1683        // Credential trigger dominates: adding "hash" or "sha" to the window does
1684        // not rescue a pure-hex token when a credential trigger is also present.
1685        // An attacker controlling the prose could otherwise bypass the gate with
1686        // one extra word, so the hash-word exception must NOT apply in trigger context.
1687        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
1688        let key_hash_line = format!("api key hash {hex32}");
1689        let secret_sha_line = format!("secret sha {hex32}");
1690        assert!(
1691            scan(&key_hash_line).is_some(),
1692            "'api key hash <hex32>' must be blocked; got None"
1693        );
1694        assert!(
1695            scan(&secret_sha_line).is_some(),
1696            "'secret sha <hex32>' must be blocked; got None"
1697        );
1698    }
1699
1700    #[test]
1701    fn hex_near_sha_context_word_allowed() {
1702        // A 40-char hex with "sha" or "commit" in the window — but no credential
1703        // trigger — must be allowed (git SHA or content hash in normal prose).
1704        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
1705        let sha_line = format!("sha1: {hex40}");
1706        let commit_line = format!("commit sha {hex40}");
1707        assert!(
1708            scan(&sha_line).is_none(),
1709            "hex40 near 'sha1' context must be allowed; fired: {:?}",
1710            scan(&sha_line)
1711        );
1712        assert!(
1713            scan(&commit_line).is_none(),
1714            "hex40 near 'commit sha' context must be allowed; fired: {:?}",
1715            scan(&commit_line)
1716        );
1717    }
1718
1719    #[test]
1720    fn hex64_near_hash_context_allowed() {
1721        // A 64-char hex near "sha256" or "hash" — with no credential trigger —
1722        // must be allowed (content digest in normal prose).
1723        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
1724        let sha_line = format!("sha256: {hex64}");
1725        let hash_line = format!("hash value {hex64}");
1726        assert!(
1727            scan(&sha_line).is_none(),
1728            "hex64 near 'sha256' must be allowed; fired: {:?}",
1729            scan(&sha_line)
1730        );
1731        assert!(
1732            scan(&hash_line).is_none(),
1733            "hex64 near 'hash' must be allowed; fired: {:?}",
1734            scan(&hash_line)
1735        );
1736    }
1737
1738    #[test]
1739    fn blocks_high_entropy_hex_like_token_near_key() {
1740        // A token whose character set exceeds pure hex (contains mixed-case, digits,
1741        // and non-hex chars) that ALSO passes `is_pure_hex = false` AND has high
1742        // entropy AND appears near "key" MUST be caught.  This is the realistic
1743        // real-world case: hex-looking API tokens often mix case and non-hex chars.
1744        // Example: a 32-char mixed-charset token near "api key".
1745        let mixed = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow — not pure hex
1746        assert!(!is_pure_hex(mixed), "test token must not be pure hex");
1747        let line = format!("api key {mixed}");
1748        assert!(
1749            scan(&line).is_some(),
1750            "mixed-charset high-entropy token near 'api key' must be caught; got: {:?}",
1751            scan(&line)
1752        );
1753    }
1754
1755    #[test]
1756    fn allows_hex40_without_trigger() {
1757        // 40-char hex string in a neutral context (no trigger word) must still pass —
1758        // it's likely a git commit SHA or content hash.
1759        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
1760        let line = format!("commit: {hex40}");
1761        assert!(
1762            scan(&line).is_none(),
1763            "40-char hex without trigger word must pass; fired: {:?}",
1764            scan(&line)
1765        );
1766    }
1767
1768    // ── Finding 4: check_json scans object keys ───────────────────────────────
1769
1770    #[test]
1771    fn check_json_blocks_secret_in_object_key() {
1772        // A credential used as a JSON object key (not a value) must be caught.
1773        let props = serde_json::json!({ "ghp_FakeGitHubToken0000000000000000000": "redacted" }); // gitleaks:allow
1774        assert!(
1775            check_json(&props).is_err(),
1776            "credential as JSON object key must be blocked"
1777        );
1778    }
1779
1780    #[test]
1781    fn check_json_blocks_nested_secret_key() {
1782        // Nested credential key must be caught.
1783        let props = serde_json::json!({
1784            "metadata": {
1785                "AKIAFAKEKEY000000000": "value" // gitleaks:allow
1786            }
1787        });
1788        assert!(
1789            check_json(&props).is_err(),
1790            "nested credential as JSON object key must be blocked"
1791        );
1792    }
1793
1794    // ── PEM masking format ───────────────────────────────────────────────────
1795
1796    #[test]
1797    fn pem_masked_excerpt_reflects_block_length_not_rest_of_string() {
1798        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
1799        let fake = format!(
1800            "{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\nsome trailing text that is very long",
1801            header
1802        );
1803        let m = scan(&fake).unwrap();
1804        assert_eq!(m.detector, "pem-private-key");
1805        // The masked length should reflect only the key block, not the whole string.
1806        // "some trailing text that is very long" is ~37 chars; total string is much longer.
1807        // The block ends after "-----END RSA PRIVATE KEY-----\n".
1808        // We just verify it is shorter than the full string length.
1809        let full_len = fake.chars().count();
1810        let reported_len: usize = m
1811            .masked
1812            .trim_end_matches("chars")
1813            .rsplit("...")
1814            .next()
1815            .and_then(|s| s.parse().ok())
1816            .unwrap_or(full_len + 1);
1817        assert!(
1818            reported_len < full_len,
1819            "masked length ({reported_len}) should be less than full string length ({full_len})"
1820        );
1821    }
1822
1823    // ── UTF-8 char-boundary reproduction tests ───────────────────────────────
1824    //
1825    // These tests verify that no code path in secret_gate panics when multibyte
1826    // UTF-8 characters (emoji, CJK, accented Latin) appear at positions where
1827    // byte-level slicing could land mid-codepoint.  Each test targets a specific
1828    // code path.  A panic means the bug is live; a pass means the path is safe.
1829
1830    /// `build_match` masked preview: if the detected candidate starts with
1831    /// multibyte chars the "first 6 chars" preview must not slice on a byte
1832    /// boundary that falls mid-codepoint.  build_match already uses
1833    /// `chars().take(6)`, but we exercise it with emoji-prefixed candidates.
1834    #[test]
1835    fn utf8_build_match_preview_multibyte_prefix_no_panic() {
1836        // "🔑" = 4 bytes; repeat 3 times = 12 bytes for only 3 chars.
1837        // A ghp_-prefixed token with an emoji: let's construct a scenario where
1838        // a known-prefix secret is immediately adjacent to multibyte content so
1839        // that build_match receives a slice starting at a multibyte char.
1840        // PEM block with multibyte chars in the body exercises build_match on a
1841        // candidate that may contain non-ASCII.
1842        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
1843        let fake = format!("{}\n🔑密钥\n-----END RSA PRIVATE KEY-----", header);
1844        // Must not panic; mask must not echo full body.
1845        let m = scan(&fake);
1846        assert!(m.is_some(), "PEM with emoji body must still be caught");
1847        let m = m.unwrap();
1848        assert!(
1849            !m.masked.contains("🔑密钥"),
1850            "mask must not echo the emoji body"
1851        );
1852    }
1853
1854    /// `extract_token` called with a string starting with multibyte chars:
1855    /// the FlyV1 handler calls `extract_token(&text[payload_start..])` where
1856    /// `payload_start` is just past "FlyV1 " (ASCII).  If the payload is ASCII
1857    /// this is trivially safe, but we verify it cannot panic when the rest of
1858    /// the text after the payload contains multibyte chars.
1859    #[test]
1860    fn utf8_extract_token_multibyte_suffix_no_panic() {
1861        // "FlyV1 ABCDEFGHIJ密钥" — the payload is "ABCDEFGHIJ密钥"; extract_token
1862        // must stop at the ideographic chars (which are NOT ASCII whitespace) and
1863        // return the whole glued run without panicking.
1864        let text = "FlyV1 ABCDEFGHIJ密钥";
1865        // scan() must not panic.
1866        let _ = scan(text);
1867    }
1868
1869    /// `find_prefix_token` with multibyte chars immediately before and after
1870    /// the known prefix: checks text[..abs] boundary slices and
1871    /// extract_token(&text[abs..]) do not panic.
1872    #[test]
1873    fn utf8_prefix_detector_multibyte_adjacent_no_panic() {
1874        // 🔑 (4 bytes) immediately before AKIA: boundary at abs = 4, which is a
1875        // valid char boundary (end of the emoji).  extract_token sees ASCII from abs.
1876        let text = "🔑AKIAFAKEKEY00000000000000";
1877        let _ = scan(text); // must not panic
1878
1879        // é (U+00E9 = 2 bytes) immediately before ghp_:
1880        let text2 = "éghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1881        let _ = scan(text2); // must not panic
1882
1883        // Emoji immediately after the token — extract_token ends at the emoji
1884        // (non-whitespace, but non-ASCII acts as delimiter in entropy heuristic).
1885        // For prefix tokens extract_token stops at ASCII whitespace only, so the
1886        // emoji would be included in the token length measurement.
1887        let text3 = "AKIAFAKEKEY00000000000000🔑";
1888        let _ = scan(text3); // must not panic
1889    }
1890
1891    /// `find_jwt` with multibyte chars as "whitespace" adjacent to a JWT-like
1892    /// candidate: `i = end + 1` could skip into a multibyte char if `end`
1893    /// pointed at a non-ASCII byte.  The position() search only looks for ASCII
1894    /// whitespace bytes, so a multibyte space (U+3000) is NOT found — `end`
1895    /// equals bytes.len() and `i = bytes.len() + 1` exits the loop.  Still
1896    /// verify no panic on CJK-surrounded JWT-like content.
1897    #[test]
1898    fn utf8_jwt_multibyte_adjacent_no_panic() {
1899        // A (fake) JWT-like triple surrounded by CJK text.
1900        let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
1901        let text = format!("数据{jwt}密钥");
1902        let _ = scan(&text); // must not panic
1903
1904        // JWT followed by ideographic space (U+3000 = 3 bytes 0xE3 0x80 0x80) —
1905        // not matched by the ASCII-whitespace position() search.
1906        let text2 = format!("{jwt}\u{3000}morecontent");
1907        let _ = scan(&text2); // must not panic
1908
1909        // JWT followed by emoji
1910        let text3 = format!("{jwt}🔑");
1911        let _ = scan(&text3); // must not panic
1912    }
1913
1914    /// `find_url_userinfo` with multibyte chars between "://" and "@":
1915    /// `at_pos` from `rest.find('@')` and `colon` from `userinfo.find(':')` are
1916    /// ASCII markers (char boundaries), but `scheme_start` calculation uses
1917    /// char_indices().rev() which must handle multibyte chars in the scheme
1918    /// prefix correctly.
1919    #[test]
1920    fn utf8_url_userinfo_multibyte_scheme_no_panic() {
1921        // CJK glued to a credential URL — the scheme_start walker must not place
1922        // the start inside a multibyte codepoint.
1923        let cases = [
1924            "🔑postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
1925            "密钥mysql://root:hunter2pw@10.0.0.1:3306/app",       // gitleaks:allow
1926            "éredis://svc:V3ryS3cretPw@cache.internal:6379",      // gitleaks:allow
1927        ];
1928        for text in &cases {
1929            // Must not panic and must detect the credential.
1930            let result = scan(text);
1931            assert!(
1932                result.is_some(),
1933                "URL credential after multibyte must be caught: {text:?}"
1934            );
1935        }
1936    }
1937
1938    /// `check_entropy_heuristic` window slicing with multibyte content at the
1939    /// ±TRIGGER_WINDOW boundary: `floor_char_boundary` must prevent slicing
1940    /// on a non-char boundary.
1941    #[test]
1942    fn utf8_entropy_window_multibyte_boundary_no_panic() {
1943        // Construct content where the TRIGGER_WINDOW (120 bytes) boundary falls
1944        // inside a 3-byte CJK character.  Repeat "数" (U+6570 = 3 bytes) to fill
1945        // exactly 119 bytes, then add an ASCII trigger word + high-entropy token.
1946        // Window start: token_offset - 120 = lands inside one of the CJK chars.
1947        let cjk_fill = "数".repeat(39); // 39 × 3 = 117 bytes
1948        assert_eq!(cjk_fill.len(), 117);
1949        // Pad with 2 more ASCII chars ("xy") so that the 120-byte window lands at
1950        // byte 119 which is the second byte of the 40th "数" — mid-multibyte.
1951        let secret = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
1952        let content = format!("{cjk_fill}xy key {secret}");
1953        let _ = scan(&content); // must not panic
1954
1955        // Also test the right edge: token ends at byte offset, window_end =
1956        // token_offset + raw_token.len() + 120 may land mid-multibyte.
1957        let content2 = format!("key {secret}{cjk_fill}xy");
1958        let _ = scan(&content2); // must not panic
1959    }
1960
1961    /// `check()` top-level fuzz: a large batch of inputs with multibyte
1962    /// characters at various offsets to catch any remaining panic sites.
1963    /// All results must be either Ok or Err (not a panic).
1964    #[test]
1965    fn utf8_no_panic_property_test() {
1966        let multibyte_items = [
1967            "🔑",       // 4-byte emoji
1968            "密",       // 3-byte CJK
1969            "é",        // 2-byte accented Latin
1970            "\u{3000}", // 3-byte ideographic space
1971            "🇺🇸",       // 8-byte emoji flag (two surrogate-like scalars)
1972        ];
1973        let secrets = [
1974            "AKIAFAKEKEY00000000000000",
1975            "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
1976            "sk-ant-api03-AAAAAAAAAAAAAAA",
1977            "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1",
1978            "FlyV1 fm2_AAAABBBBCCCCDDDDEEEEFFFF",
1979        ];
1980        for mb in &multibyte_items {
1981            for secret in &secrets {
1982                for sep in &["", " ", "\n"] {
1983                    // multibyte before secret
1984                    let s = format!("{mb}{sep}{secret}");
1985                    let _ = check(&s);
1986                    // multibyte after secret
1987                    let s = format!("{secret}{sep}{mb}");
1988                    let _ = check(&s);
1989                    // multibyte both sides
1990                    let s = format!("{mb}{sep}{secret}{sep}{mb}");
1991                    let _ = check(&s);
1992                    // repeated multibyte filling TRIGGER_WINDOW boundary
1993                    let fill = mb.repeat(50);
1994                    let s = format!("{fill} api_key {secret} {fill}");
1995                    let _ = check(&s);
1996                }
1997            }
1998        }
1999    }
2000
2001    // ── mask_secrets: in-place redaction reusing the canonical detector ───────
2002
2003    #[test]
2004    fn mask_secrets_borrows_clean_text() {
2005        let clean = "The FlashAttention paper introduces IO-aware tiling.";
2006        let masked = mask_secrets(clean);
2007        assert!(
2008            matches!(masked, std::borrow::Cow::Borrowed(_)),
2009            "clean text must not allocate"
2010        );
2011        assert_eq!(masked, clean);
2012    }
2013
2014    #[test]
2015    fn mask_secrets_redacts_shapes_the_old_mirror_regex_missed() {
2016        // These are exactly the detectors the session mirror's local regex did
2017        // NOT cover — the Critical finding driving the move to this shared masker.
2018        let cases = [
2019            "key: sk-proj-FAKEKEY00000000000000000000000000000000", // gitleaks:allow
2020            "cred ASIAFAKEKEY00000000000",                          // gitleaks:allow
2021            "stripe sk_live_FAKESTRIPE0000000000000",               // gitleaks:allow
2022            "db postgresql://dbuser:S3cr3tP4ss@db.example.com/db",  // gitleaks:allow
2023        ];
2024        for c in &cases {
2025            let masked = mask_secrets(c);
2026            assert!(
2027                masked.contains(REDACTION_MARKER),
2028                "must redact: {c:?} -> {masked:?}"
2029            );
2030        }
2031    }
2032
2033    #[test]
2034    fn mask_secrets_redacts_every_span_and_keeps_prose() {
2035        let line =
2036            "first sk-ant-api03-AAAAAAAAAAAAAAA then ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA end";
2037        let masked = mask_secrets(line);
2038        assert!(
2039            !masked.contains("sk-ant-api03") && !masked.contains("ghp_AAAA"),
2040            "no secret may survive: {masked}"
2041        );
2042        assert_eq!(
2043            masked.matches(REDACTION_MARKER).count(),
2044            2,
2045            "both secrets must be redacted: {masked}"
2046        );
2047        assert!(masked.starts_with("first "), "prose preserved: {masked}");
2048        assert!(masked.ends_with(" end"), "prose preserved: {masked}");
2049    }
2050
2051    #[test]
2052    fn mask_secrets_output_passes_check() {
2053        // The masked output must itself be clean — no credential left for the
2054        // write-time gate to catch.
2055        let line = "token=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and AKIAFAKEKEY1234567890";
2056        let masked = mask_secrets(line).into_owned();
2057        assert!(
2058            check(&masked).is_ok(),
2059            "masked output must pass the gate: {masked}"
2060        );
2061    }
2062
2063    #[test]
2064    fn mask_secrets_redacts_entropy_secret_left_of_known_secret() {
2065        // Cross-layer leftmost regression: a Layer-2 entropy secret sits to the
2066        // LEFT of a Layer-1 known-prefix secret. A scan that short-circuits on
2067        // the first known match (or returns first-by-detector-priority) would
2068        // redact `ghp_…` and copy the entropy token before it verbatim — leaking
2069        // it. `scan_match` must fold both layers through leftmost selection.
2070        let line =
2071            "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM and ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // gitleaks:allow
2072        let masked = mask_secrets(line).into_owned();
2073        assert!(
2074            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM") && !masked.contains("ghp_AAAA"),
2075            "neither the entropy secret nor the known secret may survive: {masked}"
2076        );
2077        assert_eq!(
2078            masked.matches(REDACTION_MARKER).count(),
2079            2,
2080            "both secrets must be redacted exactly once: {masked}"
2081        );
2082        assert!(
2083            check(&masked).is_ok(),
2084            "masked output must pass the gate: {masked}"
2085        );
2086    }
2087
2088    #[test]
2089    fn github_app_token_families_are_masked() {
2090        // codex #368 round-2 [Critical]: ghu_ (user-to-server), ghs_
2091        // (server-to-server), and ghr_ (refresh) GitHub App tokens are real
2092        // credential families that previously bypassed the prefix detector and
2093        // leaked through the mirror. They are context-free — no trigger word
2094        // needed.
2095        let cases = [
2096            "ghu_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // gitleaks:allow
2097            "ghs_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",  // gitleaks:allow
2098            "ghr_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",  // gitleaks:allow
2099        ];
2100        for token in &cases {
2101            assert!(
2102                check(token).is_err(),
2103                "gate must hard-block GitHub App token {token}"
2104            );
2105            let line = format!("auth: {token} trailing");
2106            let masked = mask_secrets(&line).into_owned();
2107            assert!(
2108                !masked.contains(token),
2109                "GitHub App token must not survive masking: {masked}"
2110            );
2111            assert!(
2112                check(&masked).is_ok(),
2113                "masked output must pass the gate: {masked}"
2114            );
2115        }
2116    }
2117
2118    #[test]
2119    fn mask_secrets_redacts_entropy_token_whose_trigger_is_left_of_earlier_secret() {
2120        // codex #368 round-2 [Critical]: the entropy detector only fires near a
2121        // trigger word. When the trigger (`api_key`) sits to the LEFT of an
2122        // earlier known-prefix secret (`ghp_…`), a masker that rescans only the
2123        // suffix after each redaction loses that context and leaks the later
2124        // high-entropy token. Spans must be discovered against the ORIGINAL text.
2125        let line =
2126            "api_key ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
2127        let masked = mask_secrets(line).into_owned();
2128        assert!(
2129            !masked.contains("ghp_AAAA"),
2130            "the known secret must be redacted: {masked}"
2131        );
2132        assert!(
2133            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"),
2134            "the later entropy token must be redacted even though its trigger \
2135             word sits left of the earlier redaction: {masked}"
2136        );
2137        assert_eq!(
2138            masked.matches(REDACTION_MARKER).count(),
2139            2,
2140            "both secrets must be redacted exactly once: {masked}"
2141        );
2142        assert!(
2143            check(&masked).is_ok(),
2144            "masked output must pass the gate: {masked}"
2145        );
2146    }
2147}