Skip to main content

khive_runtime/
secret_gate.rs

1//! Write-time secret detection gate.
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) — **all of the following are
26//! prose-context exemptions, not unconditional passes: a credential trigger
27//! word in the surrounding window always dominates.** A UUID or a
28//! sha-prefixed content hash sitting directly beside "api_key"/"secret"/"auth"
29//! is exactly as ambiguous as any other high-entropy candidate and falls
30//! through to explicit detection instead of being silently allowed.
31//! - Pure hex strings (sha256, git SHA) — passed when not near a trigger.
32//! - UUID canonical form (`xxxxxxxx-xxxx-…`) — passed when not near a trigger.
33//! - Base64/base64url content hashes with an explicit `sha<N>-` prefix (SRI
34//!   hashes, npm lockfile integrity) — passed when not near a trigger and not
35//!   preceded by a known-vendor prefix.  Bare base64 tokens without the
36//!   `sha<N>-` prefix are NOT passed.
37//! - Strings that are entirely ASCII punctuation/whitespace (e.g. code) — not
38//!   subject to the entropy heuristic, only the literal-prefix checks apply.
39//! - Non-ASCII characters (CJK prose, accented text, emoji) act as token
40//!   delimiters for the entropy heuristic: only maximal ASCII runs are
41//!   entropy-checked.  Real base64/hex/base64url credentials are ASCII, and
42//!   `shannon_entropy` runs over UTF-8 bytes — multibyte codepoints inflate the
43//!   byte-wise entropy and false-positive on natural-language non-Latin content.
44//!   Treating non-ASCII as a delimiter (rather than skipping any whitespace
45//!   token that merely contains it) keeps CJK prose unflagged while still
46//!   catching an ASCII credential glued to CJK text/punctuation/fullwidth
47//!   whitespace.  The literal-prefix checks (Layer 1) treat any
48//!   non-ASCII-alphanumeric char (CJK, accented text, emoji) as a token
49//!   boundary, so a known-prefix secret is caught whether the adjacent
50//!   non-ASCII sits before the prefix (`数据AKIA…`) or after it (`AKIA…数据`).
51//! - Structured identifiers: a token is only considered for this exemption
52//!   when it contains at least one of `/`, `-`, `_`, or `.` (the gate); it is
53//!   then decomposed into maximal alphanumeric runs by splitting on *every*
54//!   non-alphanumeric character (not just the four gating separators — any
55//!   other ASCII punctuation glued into the same whitespace token, e.g. a
56//!   stray `:` or `,`, also acts as a run boundary).  A token exempts when it
57//!   decomposes into two or more such runs and every run is letters-then-digits
58//!   or pure digits, at most 24 chars long, with a low case-transition density.
59//!   This covers content like `fable-ops/ADR-DRAFT-adr079.md` or
60//!   `local workspace artifact`, which is otherwise
61//!   indistinguishable from a high-entropy secret once glued into one
62//!   whitespace token.  Random base64/base62 secrets do not decompose this
63//!   way: their case and digit placement is effectively uniform rather than
64//!   word-shaped, so a hyphenated or underscored secret still fails this
65//!   check and remains subject to the entropy heuristic below.
66//!
67//!   **This exemption applies ONLY outside an explicit credential trigger
68//!   context.** Signals that measure Shannon entropy over an attacker-chosen
69//!   run boundary (e.g. requiring a trailing file extension, or an average
70//!   per-run letter entropy below a threshold) are not sound near a trigger
71//!   word: an attacker who controls where a credential's separators fall can
72//!   always choose run lengths whose entropy reads no higher than an ordinary
73//!   short English path segment, since the measure only sees a
74//!   character-frequency histogram, never word semantics. So near a trigger
75//!   word, a structured-identifier-shaped token gets no exemption at all and
76//!   falls through to the entropy heuristic like any other token. This is an
77//!   accepted false-positive tradeoff on a small number of genuine
78//!   paths/doc-slugs that happen to sit near a trigger word AND read above
79//!   the entropy threshold on their own — see
80//!   `accepted_false_positive_adr_draft_path_near_trigger` and its siblings
81//!   for the specific repro cases this blocks, and the call site in
82//!   `check_entropy_heuristic`.
83//!
84//! Trigger-word matching only fires on genuine mentions, not substring
85//! collisions: bare trigger words (`key`, `secret`, `password`, `passwd`,
86//! `credential`, `bearer`, `auth`, `apikey`) are matched at a word boundary
87//! (`contains_bounded_word`), so `auth` does not fire inside `authorized` or
88//! `authentication`, nor `key` inside `monkey`/`keyword`. The three compound
89//! entries that already embed an underscore (`api_key`, `access_key`,
90//! `private_key`) are matched as a plain substring instead, since the
91//! underscore already disambiguates them and word-boundary matching would
92//! only weaken them (e.g. `secret_access_key` no longer needs the bare
93//! `secret`/`key` matches once `access_key` fires on its own).
94//!
95//! A structured-identifier-shaped token sitting near a **genuinely standalone**
96//! trigger word (e.g. `auth work saved at .../repo-audit.md`, where `auth` is
97//! an actual topical mention rather than a substring collision) is an accepted
98//! false positive: no window-narrowing or exemption-widening scheme survives
99//! the adversarial regression corpus without also reopening a real bypass,
100//! because the caller (or an attacker) fully controls the prose between a
101//! trigger word and a payload: narrowing `TRIGGER_WINDOW` or reinstating the
102//! structured-identifier exemption near "bare" trigger mentions both fail the
103//! same known bypass strings that motivated closing them.
104//!
105//! The caller-visible block message (`SecretMatch`'s `Display` impl) also
106//! carries actionable guidance (`block_guidance`) to split or reword the
107//! flagged token.
108//!
109//! The word-boundary rule above treats underscore as a BOUNDARY for bare
110//! `TRIGGER_WORDS` (`contains_bounded_word`): deliberately different from
111//! `has_standalone_token`'s rule for the word `token`, which treats
112//! underscore as a continuation so `tokenizer`/`next_token`/`token_count`
113//! stay exempt. Treating underscore as a boundary for the bare set is what
114//! lets common underscore-joined credential-config compounds keep firing:
115//! `SECRET_KEY=...` (Django/Flask-style config), `auth_token=...`,
116//! `session_secret_...`, `signing_key=...` all match on the `secret`/`key`/
117//! `auth` half even though none of them is in `COMPOUND_TRIGGER_WORDS`. This
118//! is implemented by parameterizing the boundary rule (`contains_word`'s
119//! `underscore_is_word_char` argument) rather than sharing one rule between
120//! the two callers.
121//!
122//! A production-corpus replay harness (`corpus_replay`, `#[ignore]`d, run via
123//! `KHIVE_REPLAY_DB=<path> cargo test ... -- --ignored --nocapture`) measures
124//! the detector's block rate against real note/entity content; see the
125//! harness's own output for current numbers rather than a point-in-time count
126//! here, which would drift as the corpus changes.
127
128use crate::error::{RuntimeError, RuntimeResult};
129
130// ─── Public API ──────────────────────────────────────────────────────────────
131
132/// Returned when a write would store credential-looking content.
133///
134/// Carries the detector name and a masked excerpt (`first6...Nchars`).  The
135/// full candidate is never stored in the error.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct SecretMatch {
138    /// Human-readable name of the detector that fired.
139    pub detector: &'static str,
140    /// `first6...N` — the first 6 chars of the match followed by the total length.
141    pub masked: String,
142}
143
144impl std::fmt::Display for SecretMatch {
145    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
146        write!(
147            f,
148            "content matches secret pattern {} at masked excerpt {}. {}",
149            self.detector,
150            self.masked,
151            block_guidance(self.detector)
152        )
153    }
154}
155
156/// Actionable, caller-visible guidance for a hard block, keyed by detector
157/// name. If the content genuinely is a credential, remove it. If it
158/// is not — the common case for the detectors below, which key off SHAPE
159/// near a trigger word rather than a known credential prefix — the fix is to
160/// break up the flagged token so it no longer reads as one contiguous
161/// high-entropy value: put it on its own line away from words like
162/// key/secret/auth/token, or insert a space/punctuation inside a long path or
163/// identifier (e.g. `workspaces / 20260705 / topic` instead of one glued
164/// token).
165fn block_guidance(detector: &'static str) -> &'static str {
166    match detector {
167        "high-entropy-token"
168        | "uuid-near-trigger"
169        | "content-hash-near-trigger"
170        | "hex-credential-token" => {
171            "If this is a real credential, remove it before writing. If it is not \
172             (e.g. a file path, UUID, or hash that happens to sit near a word like \
173             key/secret/auth/token), reword so the token is not glued directly next \
174             to that word — e.g. move it to its own line, or insert a space or word \
175             between them."
176        }
177        _ => {
178            "If this is a real credential, remove it before writing; store secrets \
179              in an env var or secrets manager instead."
180        }
181    }
182}
183
184/// Hard-block content from being written.
185///
186/// Returns `Err(RuntimeError::SecretDetected)` on the first match found, or
187/// `Ok(())` if no secret pattern fires.
188pub fn check(content: &str) -> RuntimeResult<()> {
189    if let Some(m) = scan(content) {
190        return Err(RuntimeError::SecretDetected(m));
191    }
192    Ok(())
193}
194
195/// Recursively scan a JSON value for credential-shaped strings.
196///
197/// Walks every string leaf (object values, array elements, nested objects).
198/// Returns `Err(RuntimeError::SecretDetected)` on the first match found.
199/// `None` / null / numeric / boolean JSON values are skipped.
200pub fn check_json(value: &serde_json::Value) -> RuntimeResult<()> {
201    scan_json_value(value)
202}
203
204/// Scan a string-tagged slice (entity/note tags).
205///
206/// Each tag string is scanned individually.
207pub fn check_tags(tags: &[String]) -> RuntimeResult<()> {
208    for tag in tags {
209        check(tag)?;
210    }
211    Ok(())
212}
213
214fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
215    match value {
216        serde_json::Value::String(s) => check(s),
217        serde_json::Value::Array(arr) => {
218            for v in arr {
219                scan_json_value(v)?;
220            }
221            Ok(())
222        }
223        serde_json::Value::Object(map) => {
224            for (k, v) in map {
225                // Scan both the key (a credential can appear as a JSON key name)
226                // and the value recursively.
227                check(k)?;
228                scan_json_value(v)?;
229            }
230            Ok(())
231        }
232        _ => Ok(()),
233    }
234}
235
236// ─── Scanner ─────────────────────────────────────────────────────────────────
237
238/// Marker substituted for a detected secret span by [`mask_secrets`].
239const REDACTION_MARKER: &str = "***MASKED***";
240
241/// Return the LEFTMOST secret in `text` as `(matched_slice, detector)`.
242///
243/// The matched slice borrows from `text`, so the caller can recover its byte
244/// span via pointer arithmetic — this is what lets [`mask_secrets`] redact in
245/// place while [`scan`] only needs the masked excerpt.
246///
247/// "Leftmost" (smallest start offset), NOT first-by-detector-priority, is the
248/// load-bearing contract: [`mask_secrets`] copies the text *before* each match
249/// verbatim, so a non-leftmost match would leak an earlier secret detected by a
250/// lower-priority detector (e.g. an `sk-ant-` key sitting to the left of a
251/// `ghp_` token). Both detector layers are folded through [`keep_leftmost`].
252fn scan_match(text: &str) -> Option<(&str, &'static str)> {
253    scan_from(text, 0)
254}
255
256/// Like [`scan_match`], but only returns secrets whose span starts at or after
257/// `from`, while still evaluating Layer-2 trigger context against the FULL
258/// `text`. [`mask_secrets`] calls this with an advancing `from` so that an
259/// entropy token is detected even when its only trigger word sits to the left of
260/// an already-redacted earlier secret. Layer-1 known patterns are context-free,
261/// so scanning the `&text[from..]` suffix is equivalent; offsets recovered via
262/// pointer arithmetic against the original `text` base stay absolute.
263fn scan_from(text: &str, from: usize) -> Option<(&str, &'static str)> {
264    let base = text.as_ptr() as usize;
265    // Layer 1: known prefix / shape patterns. Context-free → suffix scan; the
266    // returned slice still borrows from the same allocation, so its absolute
267    // offset is `slice.as_ptr() - base`.
268    let mut best = check_known_patterns(&text[from..]);
269    // Layer 2: entropy heuristic on long tokens near trigger words. Evaluated
270    // over the full text (so left-of-`from` trigger words count) but only tokens
271    // at offset >= from are returned; kept only if left of the best known match.
272    keep_leftmost(&mut best, check_entropy_heuristic(text, from), base);
273    best
274}
275
276/// Replace `best` with `cand` when `cand` starts earlier in the original text
277/// (`base` is the start address of that text). On a tie the incumbent wins, so
278/// callers offer more-specific detectors first. This is what makes
279/// [`check_known_patterns`] and [`scan_match`] return the leftmost secret span
280/// rather than the first detector that happens to match anywhere.
281fn keep_leftmost<'a>(
282    best: &mut Option<(&'a str, &'static str)>,
283    cand: Option<(&'a str, &'static str)>,
284    base: usize,
285) {
286    if let Some((slice, name)) = cand {
287        let start = slice.as_ptr() as usize - base;
288        let replace = match *best {
289            Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
290            None => true,
291        };
292        if replace {
293            *best = Some((slice, name));
294        }
295    }
296}
297
298/// Return the first `SecretMatch` found in `text`, or `None`.
299fn scan(text: &str) -> Option<SecretMatch> {
300    scan_match(text).map(|(slice, detector)| build_match(detector, slice))
301}
302
303/// Redact every detected secret span in `text`, replacing each with
304/// `***MASKED***`.
305///
306/// This is the masking counterpart to [`check`]: where `check` hard-blocks a
307/// write on the first match, `mask_secrets` is for content that must be STORED
308/// with credentials stripped (the session mirror). A transcript line cannot be
309/// rejected wholesale, so each credential span is replaced in place while the
310/// surrounding prose is preserved. It reuses the SAME canonical detector set as
311/// `check`/`scan`, so callers must never maintain a second, weaker masker.
312///
313/// Returns `Cow::Borrowed` when no secret is present (the common case), avoiding
314/// an allocation. Spans are discovered left to right against the ORIGINAL text
315/// via `scan_from`: each scan advances a `from` cursor past the previous span
316/// but always evaluates trigger context over the full input. This closes the
317/// entropy-context gap — a high-entropy value whose only trigger word sits to
318/// the left of an earlier-redacted secret is still detected, because the trigger
319/// window is never sliced away. The known-prefix detectors (real API keys:
320/// `sk-ant-`, `sk-proj-`, `AKIA`/`ASIA`, GitHub, Stripe, …) are context-free and
321/// matched the same way.
322pub fn mask_secrets(text: &str) -> std::borrow::Cow<'_, str> {
323    let base = text.as_ptr() as usize;
324    // Collect every secret span (absolute byte offsets into `text`) before
325    // writing any output, so trigger-context detection always sees the original
326    // string rather than the suffix after the previous redaction.
327    let mut spans: Vec<(usize, usize)> = Vec::new();
328    let mut from = 0;
329    while from < text.len() {
330        match scan_from(text, from) {
331            Some((sub, _detector)) => {
332                let start = sub.as_ptr() as usize - base;
333                // The prefix detectors return whitespace-delimited tokens, so a
334                // credential glued to structural punctuation (JSON quotes/braces,
335                // sentence commas) carries that trailing punctuation into the
336                // match. Trim a conservative trailing set that can never be part
337                // of a credential, so redacting does not consume surrounding JSON
338                // or prose structure. `=` `/` `+` `.` `-` `_` are intentionally
339                // NOT trimmed — they are valid base64/JWT/key characters.
340                let core_len = sub
341                    .trim_end_matches(['"', '\'', '`', '}', ']', ')', ',', ';'])
342                    .len();
343                let end = start + core_len.max(1);
344                spans.push((start, end));
345                // `scan_from` only returns matches with start >= from, and `end`
346                // is strictly greater than `start`, so `from` strictly advances.
347                from = end;
348            }
349            None => break,
350        }
351    }
352    if spans.is_empty() {
353        return std::borrow::Cow::Borrowed(text);
354    }
355    let mut out = String::with_capacity(text.len());
356    let mut cursor = 0;
357    for (start, end) in spans {
358        // Spans are non-overlapping and ascending (each starts at/after the prior
359        // `end`); `max(cursor)` is a defensive guard, never load-bearing.
360        let start = start.max(cursor);
361        out.push_str(&text[cursor..start]);
362        out.push_str(REDACTION_MARKER);
363        cursor = end.max(cursor);
364    }
365    out.push_str(&text[cursor..]);
366    std::borrow::Cow::Owned(out)
367}
368
369// ─── Layer 1: known patterns ─────────────────────────────────────────────────
370
371/// Each entry: (detector_name, needle, min_total_token_len).
372///
373/// The needle must appear as a word-boundary-adjacent prefix in the token.
374/// `min_total_token_len` is the minimum length the token (needle + remainder)
375/// must have — prevents the prefix alone triggering without a payload.
376const PREFIX_DETECTORS: &[(&str, &str, usize)] = &[
377    // AWS
378    ("aws-access-key-id", "AKIA", 20),
379    ("aws-access-key-id", "ASIA", 20),
380    // GitHub tokens: personal-access (ghp_), OAuth (gho_), GitHub App
381    // user-to-server (ghu_), server-to-server (ghs_), refresh (ghr_), and the
382    // fine-grained PAT (github_pat_). All but github_pat_ share the gh*_ + 36+
383    // base62 shape.
384    ("github-token", "ghp_", 36),
385    ("github-token", "gho_", 36),
386    ("github-token", "ghu_", 36),
387    ("github-token", "ghs_", 36),
388    ("github-token", "ghr_", 36),
389    ("github-token", "github_pat_", 20),
390    // OpenAI
391    ("openai-api-key", "sk-proj-", 40),
392    // NOTE: bare "sk-" also matches Anthropic/Stripe below; put it last so
393    // the more-specific detectors fire first when both would match.
394    // Anthropic
395    ("anthropic-api-key", "sk-ant-", 20),
396    // Stripe live keys
397    ("stripe-secret-key", "sk_live_", 30),
398    ("stripe-restricted-key", "rk_live_", 30),
399    // Fly.io (fm2_ prefix only — FlyV1 handled separately because it embeds a space)
400    ("fly-token", "fm2_", 20),
401    // Vercel
402    ("vercel-token", "vercel_", 20),
403    // Slack
404    ("slack-token", "xoxb-", 40),
405    ("slack-token", "xoxa-", 40),
406    ("slack-token", "xoxp-", 40),
407    ("slack-token", "xoxr-", 40),
408    ("slack-token", "xoxs-", 40),
409    // Age secret key
410    ("age-secret-key", "AGE-SECRET-KEY-", 60),
411];
412
413/// Known safe compound words that start with `sk-` but are not credentials.
414/// E.g. scikit-learn slugs such as `sk-learn`, `sk-image`, `sk-lego`.
415const SK_SAFE_PREFIXES: &[&str] = &["sk-learn", "sk-image", "sk-lego", "sk-base", "sk-misc"];
416
417/// Shape-based patterns checked with custom logic.
418///
419/// Returns the LEFTMOST match across every detector (see [`keep_leftmost`]). The
420/// detectors are still offered in priority order, so two detectors that match at
421/// the SAME offset (e.g. bare `sk-` and the more-specific `sk-ant-`) resolve to
422/// the first-offered one.
423fn check_known_patterns(text: &str) -> Option<(&str, &'static str)> {
424    let base = text.as_ptr() as usize;
425    let mut best: Option<(&str, &'static str)> = None;
426
427    // --- Prefix patterns ---
428    for &(name, needle, min_len) in PREFIX_DETECTORS {
429        keep_leftmost(
430            &mut best,
431            find_prefix_token(text, needle, min_len).map(|m| (m, name)),
432            base,
433        );
434    }
435
436    // --- Bare `sk-` (after all more-specific sk- detectors above) ---
437    // Require length ≥ 30 AND exclude known safe scikit/library compound words.
438    if let Some(token) = find_prefix_token(text, "sk-", 30) {
439        if !SK_SAFE_PREFIXES.iter().any(|safe| token.starts_with(safe)) {
440            keep_leftmost(&mut best, Some((token, "openai-api-key")), base);
441        }
442    }
443
444    // --- Fly.io FlyV1 token: "FlyV1 <base64-payload>" ---
445    // The format embeds a space, so the generic prefix extractor (which stops at
446    // whitespace) cannot measure the combined length.  Check for `FlyV1 ` followed
447    // by ≥ 4 non-whitespace characters as the payload.
448    if let Some(pos) = text.find("FlyV1 ") {
449        let at_boundary = pos == 0 || {
450            text[..pos]
451                .chars()
452                .next_back()
453                .is_none_or(|c| !c.is_ascii_alphanumeric())
454        };
455        if at_boundary {
456            let payload_start = pos + 6; // skip "FlyV1 "
457            let payload = extract_token(&text[payload_start..]);
458            if payload.len() >= 4 {
459                let candidate = &text[pos..payload_start + payload.len()];
460                keep_leftmost(&mut best, Some((candidate, "fly-token")), base);
461            }
462        }
463    }
464
465    // --- PEM private key block ---
466    // "-----BEGIN <TYPE> PRIVATE KEY-----"
467    if text.contains("-----BEGIN") && text.contains("PRIVATE KEY-----") {
468        if let Some(pos) = text.find("-----BEGIN") {
469            // Measure only the key block itself (up to END marker or end-of-string),
470            // not the rest of the surrounding text, so build_match reports the
471            // block length rather than the remaining string length.
472            let block_end = text[pos..]
473                .find("-----END")
474                .map(|rel| {
475                    text[pos + rel..]
476                        .find('\n')
477                        .map(|l| pos + rel + l + 1)
478                        .unwrap_or(text.len())
479                })
480                .unwrap_or(text.len());
481            let excerpt = &text[pos..block_end];
482            keep_leftmost(&mut best, Some((excerpt, "pem-private-key")), base);
483        }
484    }
485
486    // --- JWT triple: eyJ...eyJ...eyJ (header.payload.signature) ---
487    // A JWT starts with "eyJ" (base64url of `{"`) and has exactly two dots.
488    keep_leftmost(&mut best, find_jwt(text).map(|m| (m, "jwt")), base);
489
490    // --- URL userinfo: scheme://user:pass@host ---
491    keep_leftmost(
492        &mut best,
493        find_url_userinfo(text).map(|m| (m, "url-userinfo")),
494        base,
495    );
496
497    best
498}
499
500/// Locate the first token in `text` that starts with `needle` and has a
501/// total length >= `min_len`.  Returns a slice of the full token on match.
502fn find_prefix_token<'a>(text: &'a str, needle: &str, min_len: usize) -> Option<&'a str> {
503    let mut start = 0;
504    while let Some(rel) = text[start..].find(needle) {
505        let abs = start + rel;
506        // Require that the needle starts at a token boundary (start-of-string
507        // or preceded by a non-ASCII-alphanumeric char).  The needles are ASCII,
508        // so only an ASCII alphanumeric can be a real continuation of the same
509        // token; CJK/accented text (which Rust counts as `is_alphanumeric`) must
510        // act as a delimiter, else a secret glued to non-Latin prose (`数据AKIA…`)
511        // is missed.
512        let at_boundary = abs == 0 || {
513            let prev = text[..abs].chars().next_back().unwrap_or(' ');
514            !prev.is_ascii_alphanumeric()
515        };
516        if at_boundary {
517            let token = extract_token(&text[abs..]);
518            if token.len() >= min_len {
519                return Some(token);
520            }
521        }
522        start = abs + needle.len().max(1);
523    }
524    None
525}
526
527/// Scan for a JWT pattern: at least two "eyJ" segments separated by a `.`
528/// character, with each segment at least 10 chars.
529fn find_jwt(text: &str) -> Option<&str> {
530    let bytes = text.as_bytes();
531    let mut i = 0;
532    while i + 4 < bytes.len() {
533        if bytes[i..].starts_with(b"eyJ") {
534            // Find the end of this JWT (whitespace or string end).
535            let end = bytes[i..]
536                .iter()
537                .position(|&b| b == b' ' || b == b'\n' || b == b'\r' || b == b'\t')
538                .map(|p| i + p)
539                .unwrap_or(bytes.len());
540            let candidate = &text[i..end];
541            // Must have at least 2 dots and 3 eyJ-prefixed segments.
542            let dots = candidate.as_bytes().iter().filter(|&&b| b == b'.').count();
543            if dots >= 2 {
544                let parts: Vec<&str> = candidate.splitn(3, '.').collect();
545                if parts.len() == 3
546                    && parts[0].starts_with("eyJ")
547                    && parts[1].starts_with("eyJ")
548                    && parts[0].len() >= 10
549                    && parts[1].len() >= 10
550                {
551                    return Some(candidate);
552                }
553            }
554            i = end + 1;
555        } else {
556            i += 1;
557        }
558    }
559    None
560}
561
562/// Detect `scheme://user:pass@host` patterns where the `user:pass` portion
563/// contains actual credentials (both user and pass non-empty).
564fn find_url_userinfo(text: &str) -> Option<&str> {
565    let mut search = text;
566    let mut base = 0usize;
567    while let Some(at_rel) = search.find("://") {
568        let at_abs = base + at_rel;
569        // After `://`, look for `@` before the next `/`, `?`, ` `, or newline.
570        let rest_start = at_abs + 3;
571        let rest = &text[rest_start..];
572        if let Some(at_pos) = rest.find('@') {
573            let userinfo = &rest[..at_pos];
574            // Must contain a colon and both sides non-empty.
575            if let Some(colon) = userinfo.find(':') {
576                let user = &userinfo[..colon];
577                let pass = &userinfo[colon + 1..];
578                if !user.is_empty() && !pass.is_empty() && pass.len() >= 4 {
579                    // Return a slice starting from the scheme.  Walk back from
580                    // `at_abs` to the first non-scheme char and resume just past
581                    // it.  Use `char_indices` and skip by the separator's full
582                    // UTF-8 width: a multibyte separator (e.g. CJK prose before a
583                    // credential URL) would otherwise leave `scheme_start` inside
584                    // the codepoint and panic the slice below.
585                    let scheme_start = text[..at_abs]
586                        .char_indices()
587                        .rev()
588                        .find(|(_, c)| {
589                            !c.is_ascii_alphanumeric() && *c != '+' && *c != '-' && *c != '.'
590                        })
591                        .map(|(idx, c)| idx + c.len_utf8())
592                        .unwrap_or(0);
593                    // Ensure there are no spaces in userinfo (not a code snippet).
594                    if !userinfo.contains(' ') && !userinfo.contains('\n') {
595                        let end = rest_start
596                            + at_pos
597                            + 1
598                            + rest[at_pos + 1..]
599                                .find([' ', '\n', '\r'])
600                                .unwrap_or(rest[at_pos + 1..].len());
601                        return Some(&text[scheme_start..end.min(text.len())]);
602                    }
603                }
604            }
605        }
606        base = at_abs + 3;
607        search = &text[base..];
608    }
609    None
610}
611
612// ─── Layer 2: entropy heuristic ─────────────────────────────────────────────
613
614/// Trigger words checked as a bounded standalone word (see
615/// [`contains_bounded_word`]) — bare English words that can otherwise appear
616/// as a pure substring collision inside unrelated identifiers or prose:
617/// `auth` inside `authorized`/`authentication`, `key` inside
618/// `monkey`/`turkey`/`keyword`, `secret` inside `secretary`.  Round-5 decision
619/// (see the module doc): a substring collision like this poisons the trigger
620/// window on prose that never mentions credentials at all, which is a
621/// distinct failure mode from a genuine (if topical) mention of the word —
622/// see issues #577 / #632.  Matching these words at a word boundary removes
623/// the substring-collision false positives while changing nothing about
624/// detection of a genuine standalone mention: `auth` as its own word (`auth
625/// header`, `auth:`) still triggers exactly as before.
626///
627/// The bare substring `token` is NOT in this list because it fires on benign
628/// terms like `tokenizer`, `token_count`, and `next_token`.  Instead we use
629/// the dedicated boundary-aware helpers `has_standalone_token` (standalone word)
630/// and `has_token_assignment` (`token=` / `token:` with word boundary before).
631const TRIGGER_WORDS: &[&str] = &[
632    "key",
633    "secret",
634    "password",
635    "passwd",
636    "credential",
637    "bearer",
638    "auth",
639    "apikey",
640];
641
642/// Trigger words checked as a plain substring (unchanged behavior) rather than
643/// a bounded word — these already embed the underscore separator that
644/// disambiguates them from ordinary prose (`secret_access_key`, `api_key`),
645/// so word-boundary matching would only weaken detection: an attacker who
646/// glues the compound onto a longer identifier (`secret_access_key_v2`) or
647/// composes it inside another compound (`aws_secret_access_key`) must still
648/// trip the gate. Do not move an entry here unless it structurally requires
649/// an underscore to make sense as a trigger.
650const COMPOUND_TRIGGER_WORDS: &[&str] = &["api_key", "access_key", "private_key"];
651
652/// Minimum token length to apply the entropy check.
653const MIN_ENTROPY_LEN: usize = 24;
654
655/// Shannon entropy threshold (bits per character) above which a token is
656/// considered high-entropy.  7.0 corresponds to ~99% utilisation of a
657/// 128-symbol alphabet — typical for random base64/hex.
658const ENTROPY_THRESHOLD: f64 = 4.5;
659
660/// Window around a trigger word in which a high-entropy token must appear.
661const TRIGGER_WINDOW: usize = 120;
662
663/// Largest index `<= i` that lies on a UTF-8 char boundary of `s`. Stable
664/// replacement for the unstable `str::floor_char_boundary`; used to snap
665/// byte-offset windows that may land inside a multibyte char before slicing.
666fn floor_char_boundary(s: &str, i: usize) -> usize {
667    let mut i = i.min(s.len());
668    while i > 0 && !s.is_char_boundary(i) {
669        i -= 1;
670    }
671    i
672}
673
674/// `from` restricts which tokens may be RETURNED (only those starting at or
675/// after `from`), but the trigger-context window is still computed over the full
676/// `text`. This lets [`mask_secrets`] advance past an earlier redaction without
677/// losing a trigger word that sat to the left of it.
678fn check_entropy_heuristic(text: &str, from: usize) -> Option<(&str, &'static str)> {
679    // Tokenize into maximal ASCII non-whitespace runs, recording each run's byte
680    // offset.  Non-ASCII characters are delimiters (alongside ASCII whitespace):
681    // real base64/hex/base64url credentials are ASCII, so splitting on non-ASCII
682    // isolates an ASCII credential glued to CJK text/punctuation/fullwidth
683    // whitespace, while a run of natural-language CJK yields no ASCII run long
684    // enough to trip the length floor below.  On pure-ASCII input this is
685    // identical to `split_ascii_whitespace`.
686    let tokens: Vec<(usize, &str)> = text
687        .split(|c: char| c.is_ascii_whitespace() || !c.is_ascii())
688        .filter(|t| !t.is_empty())
689        .map(|t| {
690            let offset = t.as_ptr() as usize - text.as_ptr() as usize;
691            (offset, t)
692        })
693        .collect();
694
695    for &(tok_offset, raw_token) in &tokens {
696        // Strip common delimiters that wrap the actual value.
697        let token = strip_delimiters(raw_token);
698        // Only RETURN tokens at or after `from` (already-redacted spans lie
699        // before it); the trigger window below still spans the full text.
700        let token_offset = token.as_ptr() as usize - text.as_ptr() as usize;
701        if token_offset < from {
702            continue;
703        }
704        if token.len() < MIN_ENTROPY_LEN {
705            continue;
706        }
707
708        // `token` is ASCII here (non-ASCII was split out at tokenization), so
709        // `shannon_entropy` over its bytes is a true per-character entropy.
710
711        // Compute the trigger window BEFORE any shape-based allowlist decision.
712        // Every allowlist below (UUID, base64 content-hash, pure-hex) is a
713        // prose-context exemption, not an unconditional one: a credential
714        // trigger word dominates shape allowlists, because attacker-suppliable
715        // shapes (a UUID, a sha-prefixed hash) are exactly as ambiguous near a
716        // trigger word as any other high-entropy candidate.
717        let window_start = floor_char_boundary(text, tok_offset.saturating_sub(TRIGGER_WINDOW));
718        let window_end = floor_char_boundary(text, tok_offset + raw_token.len() + TRIGGER_WINDOW);
719        let window = &text[window_start..window_end];
720        let low_window = window.to_ascii_lowercase();
721
722        let near_trigger = COMPOUND_TRIGGER_WORDS
723            .iter()
724            .any(|tw| low_window.contains(tw))
725            || TRIGGER_WORDS
726                .iter()
727                .any(|tw| contains_bounded_word(&low_window, tw))
728            || has_standalone_token(&low_window)
729            || has_token_assignment(&low_window);
730
731        // UUID canonical form and sha-prefixed base64 content hashes (SRI /
732        // npm lockfile integrity) are allowlisted only outside trigger
733        // context. Near a trigger, both shapes fall through to detection
734        // below instead of being silently passed.
735        //
736        // A UUID's own character entropy cannot be relied on to catch it once
737        // it falls through: hex digits cap at log2(16) = 4.0 bits/char, which
738        // never reaches ENTROPY_THRESHOLD (4.5) regardless of token length.
739        // The explicit checks immediately below are what actually block a
740        // UUID-shaped or hash-shaped token in trigger context; letting it run
741        // into the generic entropy computation at the bottom of this loop
742        // would silently readmit it. A corpus replay of ~19k real notes/docs
743        // measured exactly one benign token (an internal task `area_id` UUID
744        // co-occurring with the word "auth" inside `authorized_write`) newly
745        // blocked by this rule — an accepted false positive, not a systemic
746        // regression.
747        //
748        // Both exact-shape checkers require the WHOLE token to match, so a
749        // credential glued to ordinary storage syntax (`api_key=<uuid>`,
750        // `(<uuid>)`, `{"api_key":"<uuid>"}`, a trailing sentence period,
751        // a doubled assignment, or a label itself containing `:`/`=`)
752        // would otherwise never reach them: `strip_delimiters` above only
753        // trims `"'`:=,;` at the token's OUTER ends, not braces/parens, and
754        // not an internal `=`/`:` from an assignment form. `value_candidates`
755        // enumerates every plausible value extraction from those glued forms
756        // specifically for this pair of checks — it does not replace `token`
757        // for any other check in this loop (entropy, hex, structured-
758        // identifier), none of which require an exact shape match. This is a
759        // small bounded iteration over separator positions in one token, not
760        // an allocation-heavy scan.
761        if near_trigger && value_candidates(token).any(is_uuid_canonical) {
762            return Some((token, "uuid-near-trigger"));
763        }
764        if near_trigger && value_candidates(token).any(is_base64_content_hash) {
765            return Some((token, "content-hash-near-trigger"));
766        }
767        if !near_trigger && (is_uuid_canonical(token) || is_base64_content_hash(token)) {
768            continue;
769        }
770
771        // Pure hex tokens (git SHA, checksum digests) are allowlisted only when
772        // they are NOT near a credential trigger.
773        if !near_trigger && is_pure_hex(token) {
774            continue;
775        }
776
777        // Hex API keys (AWS secret access key, Stripe test keys, random hex
778        // tokens) are pure hex yet are real credentials.  The entropy heuristic
779        // cannot catch them — hex alphabet maxes at log2(16) = 4.0 bits/char,
780        // which is always below ENTROPY_THRESHOLD (4.5).  A credential-shaped
781        // hex token (32 / 40 / 64 / 128 chars) near a trigger word is always
782        // flagged.  Credential triggers dominate: adding "sha" or "hash" to
783        // the window does not rescue the token — a caller controlling the prose
784        // could trivially bypass the gate with one extra word.  Safe git SHAs
785        // and content-hash digests do not appear near credential trigger words
786        // and are already allowed via the `!near_trigger && is_pure_hex` path.
787        const HEX_CREDENTIAL_LENGTHS: &[usize] = &[32, 40, 64, 128];
788        if near_trigger && is_pure_hex(token) && HEX_CREDENTIAL_LENGTHS.contains(&token.len()) {
789            return Some((token, "hex-credential-token"));
790        }
791
792        // Structured identifiers (file paths, branch names, ADR/doc slugs,
793        // snake_case identifiers) are exempted from the entropy check — see
794        // the module doc and `is_structured_identifier`. Must come after the
795        // UUID/content-hash and hex-credential-token checks above (neither of
796        // which it weakens) and before the entropy computation, since a
797        // legitimate path can exceed ENTROPY_THRESHOLD on Shannon entropy
798        // alone. The exemption applies ONLY outside trigger context: see the
799        // module doc for why no shape-based signal can be made sound near a
800        // trigger word; in trigger context this falls through to the entropy
801        // heuristic below unconditionally, an accepted false-positive
802        // tradeoff for genuine paths that happen to score high entropy.
803        if !near_trigger && is_structured_identifier(token) {
804            continue;
805        }
806
807        let entropy = shannon_entropy(token.as_bytes());
808        if entropy < ENTROPY_THRESHOLD {
809            continue;
810        }
811
812        // High-entropy token in trigger context — flag it.
813        if near_trigger {
814            return Some((token, "high-entropy-token"));
815        }
816    }
817    None
818}
819
820/// Returns `true` when `low_window` contains `needle` as a standalone word —
821/// bounded on both sides by a character outside the caller-supplied word-char
822/// set (or start/end of string) — rather than merely as a substring.
823///
824/// `underscore_is_word_char` selects which of two, deliberately different,
825/// boundary rules the caller needs:
826/// - `true` (used by [`has_standalone_token`] / [`has_token_assignment`] for
827///   `token`): underscore is a continuation of the same identifier, so
828///   `next_token`, `tokenizer`, and `token_count` do NOT match — a prior,
829///   deliberate decision that must not change.
830/// - `false` (used by [`contains_bounded_word`] for the bare `TRIGGER_WORDS`):
831///   underscore IS a boundary, so `secret_key=`/`auth_token=`/`signing_key=`
832///   still match on the `secret`/`auth`/`key` half of the compound — these
833///   underscore-joined credential-config compounds (Django/Flask `SECRET_KEY`,
834///   OAuth `auth_token`, JWT `signing_key`) are exactly the shape a credential
835///   trigger must not lose. Only *letter*-joined collisions (`authorized`,
836///   `authentication`, `monkey`, `keyword`) are meant to stop matching.
837///
838/// CJK/accented prose always counts as a boundary in both modes (only ASCII
839/// alphanumerics — plus underscore when `underscore_is_word_char` is `true`
840/// — are treated as word characters).
841fn contains_word(low_window: &str, needle: &str, underscore_is_word_char: bool) -> bool {
842    let is_word_char = |c: char| c.is_ascii_alphanumeric() || (underscore_is_word_char && c == '_');
843    let mut start = 0;
844    while let Some(rel) = low_window[start..].find(needle) {
845        let abs = start + rel;
846        let before_ok = abs == 0
847            || low_window[..abs]
848                .chars()
849                .next_back()
850                .is_none_or(|c| !is_word_char(c));
851        let after_end = abs + needle.len();
852        let after_ok = after_end >= low_window.len()
853            || low_window[after_end..]
854                .chars()
855                .next()
856                .is_none_or(|c| !is_word_char(c));
857        if before_ok && after_ok {
858            return true;
859        }
860        start = abs + needle.len().max(1);
861    }
862    false
863}
864
865/// Returns `true` when `low_window` contains the bare trigger word `needle`
866/// as a standalone word, with underscore treated as a BOUNDARY (see
867/// [`contains_word`]) — so `secret_key=…`/`auth_token=…`/`signing_key=…`
868/// still match (on the `secret`/`auth`/`key` half), while pure letter-joined
869/// collisions like `authorized`/`authentication`/`monkey`/`keyword` do not.
870fn contains_bounded_word(low_window: &str, needle: &str) -> bool {
871    contains_word(low_window, needle, false)
872}
873
874/// Returns `true` when `low_window` contains the word `token` as a standalone
875/// word, with underscore treated as a WORD CHARACTER / continuation (see
876/// [`contains_word`]) — but NOT as part of compound identifiers such as
877/// `tokenizer`, `token_count`, or `next_token`. This underscore-as-
878/// continuation rule is deliberately different from
879/// [`contains_bounded_word`]: `token` alone is not a
880/// credential trigger (it fires on too many benign technical terms), so it
881/// needs the narrower, underscore-inclusive standalone-word definition,
882/// whereas the bare `TRIGGER_WORDS` need underscore-joined compounds like
883/// `secret_key` to still register.
884fn has_standalone_token(low_window: &str) -> bool {
885    contains_word(low_window, "token", true)
886}
887
888/// Returns `true` when `low_window` contains the assignment form `token=` or
889/// `token:` where the `token` identifier has a word boundary BEFORE it.
890///
891/// This is boundary-aware so that compound identifiers like `next_token:` or
892/// `pagination_token=` do NOT trigger — only a standalone `token=`/`token:`
893/// at the start of a field name does.
894///
895/// Examples that return `true`:  `token=<value>`, `token: <value>`,
896///   `"token": "<value>"` (JSON key-value pairs).
897/// Examples that return `false`: `next_token: <value>`,
898///   `pagination_token=<value>`, `token_count: <value>`.
899fn has_token_assignment(low_window: &str) -> bool {
900    let needle = "token";
901    let mut start = 0;
902    while let Some(rel) = low_window[start..].find(needle) {
903        let abs = start + rel;
904        // Require a word boundary BEFORE `token`.
905        let before_ok = abs == 0
906            || low_window[..abs]
907                .chars()
908                .next_back()
909                .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
910        let after_end = abs + needle.len();
911        // Require `=` or `:` immediately after `token` (possibly with surrounding
912        // whitespace or quotes stripped by the time we see the lowercased window).
913        let after_char = low_window[after_end..].chars().next();
914        let after_is_assign = matches!(after_char, Some('=') | Some(':'));
915        if before_ok && after_is_assign {
916            return true;
917        }
918        start = abs + needle.len().max(1);
919    }
920    false
921}
922
923// ─── Allowlist helpers ───────────────────────────────────────────────────────
924
925/// Returns `true` for pure-hex tokens (case-insensitive, optional `0x`/`0X` prefix,
926/// 8–128 chars) — git SHAs, checksum digests, uuid-hex without hyphens.
927///
928/// This helper is used with context: pure-hex tokens near credential trigger words
929/// are NOT allowlisted (see `check_entropy_heuristic`).  Only call this function
930/// when you have already confirmed no trigger context is nearby.
931fn is_pure_hex(token: &str) -> bool {
932    let hex_part = token
933        .strip_prefix("0x")
934        .or(token.strip_prefix("0X"))
935        .unwrap_or(token);
936    hex_part.len() >= 8 && hex_part.len() <= 128 && hex_part.bytes().all(|b| b.is_ascii_hexdigit())
937}
938
939/// Returns `true` for tokens that are unambiguous base64/base64url content
940/// hashes with an explicit `sha<N>-` prefix (SRI hash, npm lockfile integrity).
941///
942/// Criteria:
943/// - Token starts with `sha<digits>-` (e.g. `sha256-`, `sha384-`, `sha512-`).
944/// - The body after the prefix matches a SHA-family length (43, 64, or 86–88
945///   unpadded chars).
946/// - Every byte in the body is a standard-base64 or URL-safe-base64 character.
947/// - Does NOT start with a known vendor-token prefix (those are credentials
948///   regardless of alphabet).
949///
950/// Bare base64 tokens of those lengths WITHOUT the `sha<N>-` prefix are NOT
951/// allowlisted here — a 43-char base64url API token near the word "key" is
952/// indistinguishable from a sha256 hash body without the prefix, so we require
953/// the explicit prefix to avoid false-negative credential escapes.
954fn is_base64_content_hash(token: &str) -> bool {
955    // Known vendor prefixes — never allowlist even if they look like base64.
956    // Includes bare `sk-` to prevent OpenAI-shaped tokens from being allowlisted.
957    const VENDOR_PREFIXES: &[&str] = &[
958        "sk-",
959        "rk_live_",
960        "fm2_",
961        "vercel_",
962        "xoxb-",
963        "xoxa-",
964        "xoxp-",
965        "xoxr-",
966        "xoxs-",
967        "ghp_",
968        "gho_",
969        "ghu_",
970        "ghs_",
971        "ghr_",
972        "github_pat_",
973        "AKIA",
974        "ASIA",
975        "AGE-SECRET-KEY-",
976        "FlyV1",
977    ];
978    if VENDOR_PREFIXES.iter().any(|p| token.starts_with(p)) {
979        return false;
980    }
981    // Require an explicit SRI `sha[0-9]+-` prefix.  Bare base64 at sha-length
982    // is NOT allowlisted — it is indistinguishable from a real API token.
983    let body = if let Some(rest) = token.strip_prefix("sha") {
984        // rest starts with digits followed by '-'
985        let dash = rest.find('-').unwrap_or(rest.len());
986        let digits = &rest[..dash];
987        if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) && dash < rest.len() {
988            &rest[dash + 1..] // everything after "sha<digits>-"
989        } else {
990            return false; // no valid sha<N>- prefix → not a known content hash
991        }
992    } else {
993        return false; // no sha prefix → not allowlisted
994    };
995    // Strip optional padding (at most 2 `=`).
996    let stripped = body.trim_end_matches('=');
997    let pad_removed = body.len() - stripped.len();
998    if pad_removed > 2 {
999        return false;
1000    }
1001    // Accept only SHA-family content-hash lengths (43, 64, 86–88 chars unpadded).
1002    let n = stripped.len();
1003    if n != 43 && n != 64 && !(86..=88).contains(&n) {
1004        return false;
1005    }
1006    // Accept both standard-base64 and URL-safe-base64 alphabets.
1007    stripped
1008        .bytes()
1009        .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'-' || b == b'_')
1010}
1011
1012/// Structural separators that gate entry into [`is_structured_identifier`]
1013/// (rule 1: the token must contain at least one of these). The actual run
1014/// decomposition (rule 2) splits on every non-alphanumeric character, not
1015/// just these four — see the doc comment on `is_structured_identifier`.
1016const STRUCTURAL_SEPARATORS: [char; 4] = ['/', '-', '_', '.'];
1017
1018/// Largest length a single path/branch/identifier segment (a "run" between
1019/// separators) may have and still be considered word-shaped.
1020const MAX_RUN_LEN: usize = 24;
1021
1022/// Runs whose letter portion is at or below this length skip the
1023/// case-transition-density check: density is not a meaningful signal on very
1024/// short runs (e.g. `R1`, `v2`, `ADR`).
1025const DENSITY_EXEMPT_LETTER_LEN: usize = 4;
1026
1027/// Maximum case-transition density (transitions divided by letter_count - 1)
1028/// a run's letter portion may have and still be considered word-shaped.
1029const MAX_CASE_TRANSITION_DENSITY: f64 = 0.3;
1030
1031/// Returns `true` when `token` is shaped like a file path, branch name, or
1032/// other structured identifier rather than a high-entropy secret.
1033///
1034/// A structured identifier decomposes into two or more maximal
1035/// ASCII-alphanumeric "runs" separated by `/`, `-`, `_`, or `.`, where every
1036/// run is word-shaped: letters-then-digits (`adr079`, `slices234`, `R1`) or
1037/// pure digits (`20260701`), at most [`MAX_RUN_LEN`] chars, with a low
1038/// case-transition density in the letter portion. Random base64/base62
1039/// secrets glued between separators reliably fail this shape check: their
1040/// case and digit placement is essentially uniform rather than word-like, so
1041/// a run either exceeds the length cap or mixes case too densely to pass.
1042///
1043/// Outside credential-trigger context this shape check alone is sufficient to
1044/// exempt a token from the entropy heuristic. In trigger context the caller
1045/// grants NO exemption at all: see the module doc and the call site in
1046/// [`check_entropy_heuristic`].
1047fn is_structured_identifier(token: &str) -> bool {
1048    if !token.contains(|c: char| STRUCTURAL_SEPARATORS.contains(&c)) {
1049        return false;
1050    }
1051    let runs: Vec<&str> = token
1052        .split(|c: char| !c.is_ascii_alphanumeric())
1053        .filter(|r| !r.is_empty())
1054        .collect();
1055    runs.len() >= 2 && runs.iter().all(|run| is_word_shaped_run(run))
1056}
1057
1058/// A single run (segment between structural separators) is word-shaped when
1059/// it matches `[A-Za-z]+[0-9]*` or `[0-9]+`, is at most [`MAX_RUN_LEN`] chars,
1060/// and (for the letters-then-digits form) its letter portion has a low
1061/// case-transition density.
1062fn is_word_shaped_run(run: &str) -> bool {
1063    if run.is_empty() || run.len() > MAX_RUN_LEN {
1064        return false;
1065    }
1066    let bytes = run.as_bytes();
1067    if bytes.iter().all(|b| b.is_ascii_digit()) {
1068        return true;
1069    }
1070    let letter_end = bytes
1071        .iter()
1072        .position(|b| !b.is_ascii_alphabetic())
1073        .unwrap_or(bytes.len());
1074    // A run that does not start with a letter, and is not pure digits (ruled
1075    // out above), mixes digits and letters in a shape other than
1076    // letters-then-digits — not word-shaped.
1077    if letter_end == 0 {
1078        return false;
1079    }
1080    // Everything after the leading letters must be digits only (no further
1081    // letters), else the run is not the `[A-Za-z]+[0-9]*` shape.
1082    if !bytes[letter_end..].iter().all(|b| b.is_ascii_digit()) {
1083        return false;
1084    }
1085    case_transition_density_ok(&run[..letter_end])
1086}
1087
1088/// `true` when the case-transition density of `letters` (an all-ASCII-letter
1089/// string) is at or below [`MAX_CASE_TRANSITION_DENSITY`]. A transition is an
1090/// adjacent letter pair where one side is uppercase and the other is not.
1091/// Runs with few enough letters pass automatically (see
1092/// [`DENSITY_EXEMPT_LETTER_LEN`]) since density is noisy on short strings.
1093fn case_transition_density_ok(letters: &str) -> bool {
1094    let chars: Vec<char> = letters.chars().collect();
1095    if chars.len() <= DENSITY_EXEMPT_LETTER_LEN {
1096        return true;
1097    }
1098    let transitions = chars
1099        .windows(2)
1100        .filter(|w| w[0].is_ascii_uppercase() != w[1].is_ascii_uppercase())
1101        .count();
1102    let density = transitions as f64 / (chars.len() - 1) as f64;
1103    density <= MAX_CASE_TRANSITION_DENSITY
1104}
1105
1106/// `xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx`
1107fn is_uuid_canonical(s: &str) -> bool {
1108    let b = s.as_bytes();
1109    if b.len() != 36 {
1110        return false;
1111    }
1112    b[8] == b'-'
1113        && b[13] == b'-'
1114        && b[18] == b'-'
1115        && b[23] == b'-'
1116        && b[..8].iter().all(|c| c.is_ascii_hexdigit())
1117        && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
1118        && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
1119        && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
1120        && b[24..].iter().all(|c| c.is_ascii_hexdigit())
1121}
1122
1123/// Strip common wrapping characters (`"`, `'`, `` ` ``, `:`, `=`) from both ends.
1124fn strip_delimiters(s: &str) -> &str {
1125    s.trim_matches(|c| matches!(c, '"' | '\'' | '`' | ':' | '=' | ',' | ';'))
1126}
1127
1128/// Strip `{}()[]"'.,;` from both ends of `s`, repeatedly (JSON nests one
1129/// wrapper inside another).
1130fn strip_wrappers(s: &str) -> &str {
1131    s.trim_matches(|c: char| {
1132        matches!(
1133            c,
1134            '{' | '}' | '(' | ')' | '[' | ']' | '"' | '\'' | '`' | '.' | ',' | ';'
1135        )
1136    })
1137}
1138
1139fn wrapper_strip_repeated(token: &str) -> &str {
1140    let mut cur = token;
1141    loop {
1142        let next = strip_wrappers(cur);
1143        if next == cur {
1144            return cur;
1145        }
1146        cur = next;
1147    }
1148}
1149
1150/// Yield every candidate value that an assignment/wrapper-glued whitespace
1151/// token could contain, so shape allowlists that require an EXACT match
1152/// (`is_uuid_canonical`, `is_base64_content_hash`) still recognize the
1153/// credential once it is glued to normal storage syntax: `key=value`,
1154/// `(value)`, `{"key":"value"}`, `key1=key2=value`, a trailing sentence
1155/// period, or a label itself containing `:`/`=` (`{"api:key":"value"}`).
1156/// Used only to derive candidates for the near-trigger UUID/content-hash
1157/// checks in `check_entropy_heuristic` — it does NOT replace `token` for
1158/// the entropy, hex, or structured-identifier paths, none of which require
1159/// an exact shape match and so are unaffected by this extraction.
1160///
1161/// Strips wrapper punctuation from both ends first, then yields the
1162/// wrapper-stripped whole token, plus the wrapper-stripped suffix after
1163/// EVERY internal `=`/`:` occurrence (skipping empty suffixes). No single
1164/// separator position can be assumed correct: the true key/value or
1165/// JSON-label boundary might be the first separator (`secret=sha256-...`),
1166/// but a base64/base64url value can itself end in `=` padding — for a
1167/// padded content hash that padding IS the last `=` in the token, so a
1168/// last-separator split would land on the padding boundary instead. A
1169/// label can also itself contain `:`/`=` (`{"api:key":"<uuid>"}`) or the
1170/// assignment can be doubled (`key=label=<uuid>`), so neither "first" nor
1171/// "last" is a sound single choice. Emitting every suffix and letting the
1172/// caller test each one is the only choice that is sound in all these
1173/// shapes: the true value always appears as *some* suffix, and a `=`/`:`
1174/// that lands inside padding or a label simply yields a non-matching
1175/// suffix that the caller's shape check harmlessly rejects.
1176///
1177/// Byte-scan via `char_indices` over an already-short token (whitespace-
1178/// delimited, so bounded by realistic line length) — no allocation, since
1179/// this runs in the hot scan path.
1180fn value_candidates(token: &str) -> impl Iterator<Item = &str> {
1181    let cur = wrapper_strip_repeated(token);
1182    std::iter::once(cur).chain(cur.char_indices().filter_map(move |(i, c)| {
1183        if c == '=' || c == ':' {
1184            let after = strip_wrappers(&cur[i + c.len_utf8()..]);
1185            if !after.is_empty() {
1186                return Some(after);
1187            }
1188        }
1189        None
1190    }))
1191}
1192
1193// ─── Utilities ───────────────────────────────────────────────────────────────
1194
1195/// Extract a contiguous token (non-whitespace chars) starting at the beginning of `s`.
1196fn extract_token(s: &str) -> &str {
1197    let end = s
1198        .find(|c: char| c.is_whitespace() || c == '\n' || c == '\r')
1199        .unwrap_or(s.len());
1200    &s[..end]
1201}
1202
1203/// Shannon entropy in bits per character.
1204///
1205/// H = -∑ p_i log2(p_i)
1206fn shannon_entropy(bytes: &[u8]) -> f64 {
1207    if bytes.is_empty() {
1208        return 0.0;
1209    }
1210    let mut counts = [0u32; 256];
1211    for &b in bytes {
1212        counts[b as usize] += 1;
1213    }
1214    let len = bytes.len() as f64;
1215    counts
1216        .iter()
1217        .filter(|&&c| c > 0)
1218        .map(|&c| {
1219            let p = c as f64 / len;
1220            -p * p.log2()
1221        })
1222        .sum()
1223}
1224
1225/// Build a `SecretMatch` from a detector name and the candidate string.
1226///
1227/// The masked excerpt is: first 6 chars + "..." + total length.
1228/// Never includes more than 6 chars of the actual value.
1229fn build_match(detector: &'static str, candidate: &str) -> SecretMatch {
1230    let chars: Vec<char> = candidate.chars().collect();
1231    let preview: String = chars.iter().take(6).collect();
1232    let masked = format!("{}...{}chars", preview, chars.len());
1233    SecretMatch { detector, masked }
1234}
1235
1236// ─── Tests ───────────────────────────────────────────────────────────────────
1237
1238#[cfg(test)]
1239mod tests {
1240    use super::*;
1241
1242    #[test]
1243    fn blocks_aws_akia() {
1244        // FAKE key: prefix is real shape, 16-char suffix invented.
1245        let fake = "AKIAFAKEKEY1234567890";
1246        assert!(scan(fake).is_some(), "AKIA must be caught");
1247        let m = scan(fake).unwrap();
1248        assert_eq!(m.detector, "aws-access-key-id");
1249        // Masked excerpt must not echo the full key.
1250        assert!(
1251            !m.masked.contains("FAKEKEY1234567890"),
1252            "must not echo the secret: {}",
1253            m.masked
1254        );
1255    }
1256
1257    #[test]
1258    fn blocks_aws_asia() {
1259        let fake = "ASIAFAKEKEY00000000000";
1260        let m = scan(fake);
1261        assert!(m.is_some(), "ASIA must be caught");
1262        assert_eq!(m.unwrap().detector, "aws-access-key-id");
1263    }
1264
1265    #[test]
1266    fn blocks_github_ghp() {
1267        // 36 chars total to pass min_len.
1268        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1269        assert!(scan(fake).is_some(), "ghp_ must be caught");
1270    }
1271
1272    #[test]
1273    fn blocks_github_gho() {
1274        let fake = "gho_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
1275        assert!(scan(fake).is_some(), "gho_ must be caught");
1276    }
1277
1278    #[test]
1279    fn blocks_github_pat() {
1280        let fake = "github_pat_AAAAAABBBBBBCCCCCC";
1281        assert!(scan(fake).is_some(), "github_pat_ must be caught");
1282    }
1283
1284    #[test]
1285    fn blocks_openai_sk() {
1286        let fake = "sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg";
1287        assert!(scan(fake).is_some(), "sk- must be caught");
1288    }
1289
1290    #[test]
1291    fn blocks_anthropic_sk_ant() {
1292        let fake = "sk-ant-api03-AAAAAAAAAAAAAAA";
1293        assert!(scan(fake).is_some(), "sk-ant- must be caught");
1294        assert_eq!(scan(fake).unwrap().detector, "anthropic-api-key");
1295    }
1296
1297    #[test]
1298    fn blocks_stripe_live() {
1299        let fake = "sk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
1300        assert!(scan(fake).is_some(), "sk_live_ must be caught");
1301        assert_eq!(scan(fake).unwrap().detector, "stripe-secret-key");
1302    }
1303
1304    #[test]
1305    fn blocks_stripe_restricted() {
1306        let fake = "rk_live_FAKESTRIPE0000000000000"; // gitleaks:allow
1307        assert!(scan(fake).is_some(), "rk_live_ must be caught");
1308        assert_eq!(scan(fake).unwrap().detector, "stripe-restricted-key");
1309    }
1310
1311    #[test]
1312    fn blocks_fly_flyv1() {
1313        let fake = "FlyV1 FAKEFLYTOKEN000000000000000000";
1314        assert!(scan(fake).is_some(), "FlyV1 must be caught");
1315        assert_eq!(scan(fake).unwrap().detector, "fly-token");
1316    }
1317
1318    #[test]
1319    fn blocks_fly_fm2() {
1320        let fake = "fm2_FAKEFLYTOKEN00000000000000000";
1321        assert!(scan(fake).is_some(), "fm2_ must be caught");
1322        assert_eq!(scan(fake).unwrap().detector, "fly-token");
1323    }
1324
1325    #[test]
1326    fn blocks_vercel_token() {
1327        let fake = "vercel_FAKETOKEN00000000000000000";
1328        assert!(scan(fake).is_some(), "vercel_ must be caught");
1329        assert_eq!(scan(fake).unwrap().detector, "vercel-token");
1330    }
1331
1332    #[test]
1333    fn blocks_slack_xoxb() {
1334        let fake = "xoxb-FAKE-SLACKTOKEN-000000000000000000000000";
1335        assert!(scan(fake).is_some(), "xoxb- must be caught");
1336        assert_eq!(scan(fake).unwrap().detector, "slack-token");
1337    }
1338
1339    #[test]
1340    fn blocks_pem_private_key() {
1341        // Split the header so the literal detector-trigger string is not present
1342        // verbatim in source — pre-commit's detect-private-key hook would fire.
1343        // The gate detects it at runtime because scan() sees the assembled string.
1344        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
1345        let fake = format!("{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----", header);
1346        assert!(scan(&fake).is_some(), "PEM private key must be caught");
1347        assert_eq!(scan(&fake).unwrap().detector, "pem-private-key");
1348    }
1349
1350    #[test]
1351    fn blocks_pem_ec_private_key() {
1352        let header = ["-----BEGIN EC", " PRIVATE KEY-----"].concat(); // gitleaks:allow
1353        let fake = format!("{}\nMHQCAQEE\u{2026}\n-----END EC PRIVATE KEY-----", header);
1354        assert!(scan(&fake).is_some(), "EC PEM must be caught");
1355    }
1356
1357    #[test]
1358    fn blocks_age_secret_key() {
1359        // AGE-SECRET-KEY- followed by 59 base32 chars (Bech32m body).
1360        let fake = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
1361        assert!(scan(fake).is_some(), "AGE-SECRET-KEY- must be caught");
1362        assert_eq!(scan(fake).unwrap().detector, "age-secret-key");
1363    }
1364
1365    #[test]
1366    fn blocks_jwt_triple() {
1367        // Synthetic JWT structure: header.payload.signature (no real key).
1368        let fake = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
1369        assert!(scan(fake).is_some(), "JWT triple must be caught");
1370        assert_eq!(scan(fake).unwrap().detector, "jwt");
1371    }
1372
1373    #[test]
1374    fn blocks_url_userinfo() {
1375        let fake = "postgresql://dbuser:S3cr3tP4ss@db.example.com:5432/mydb";
1376        assert!(scan(fake).is_some(), "URL userinfo must be caught");
1377        assert_eq!(scan(fake).unwrap().detector, "url-userinfo");
1378    }
1379
1380    #[test]
1381    fn blocks_high_entropy_near_bearer_word() {
1382        // 32 random-looking base64 chars adjacent to the word "bearer".
1383        let fake = "Bearer token: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1384        assert!(
1385            scan(fake).is_some(),
1386            "high-entropy value near 'bearer' must be caught"
1387        );
1388        assert_eq!(scan(fake).unwrap().detector, "high-entropy-token");
1389    }
1390
1391    #[test]
1392    fn blocks_high_entropy_near_secret_word() {
1393        let fake = "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1394        assert!(
1395            scan(fake).is_some(),
1396            "high-entropy value near 'secret' must be caught"
1397        );
1398    }
1399
1400    #[test]
1401    fn error_message_masks_secret() {
1402        let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
1403        let m = scan(fake).unwrap();
1404        // Masked form: first 6 chars + "...N chars".
1405        // Must NOT contain the full suffix.
1406        let masked = &m.masked;
1407        assert!(
1408            !masked.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
1409            "mask must not echo the full secret value; got: {masked}"
1410        );
1411        // Must start with "ghp_AA" (first 6 chars of the token).
1412        assert!(
1413            masked.starts_with("ghp_AA"),
1414            "mask must show first 6 chars; got: {masked}"
1415        );
1416    }
1417
1418    // ── False-positive suite ─────────────────────────────────────────────────
1419
1420    #[test]
1421    fn allows_sha256_hex() {
1422        // 64-char lowercase hex — typical sha256 digest.
1423        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1424        assert!(
1425            scan(sha).is_none(),
1426            "sha256 hex must pass (allowlisted); fired: {:?}",
1427            scan(sha)
1428        );
1429    }
1430
1431    #[test]
1432    fn allows_uuid() {
1433        let uuid = "550e8400-e29b-41d4-a716-446655440000";
1434        assert!(
1435            scan(uuid).is_none(),
1436            "UUID must pass; fired: {:?}",
1437            scan(uuid)
1438        );
1439    }
1440
1441    #[test]
1442    fn allows_git_sha() {
1443        // 40-char lowercase git SHA.
1444        let sha = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
1445        assert!(
1446            scan(sha).is_none(),
1447            "git SHA must pass; fired: {:?}",
1448            scan(sha)
1449        );
1450    }
1451
1452    #[test]
1453    fn allows_normal_prose() {
1454        let prose =
1455            "The FlashAttention paper introduces IO-aware tiling for transformer self-attention.";
1456        assert!(scan(prose).is_none(), "normal prose must pass");
1457    }
1458
1459    #[test]
1460    fn allows_code_snippet() {
1461        let code = r#"fn create_entity(name: &str, kind: &str) -> RuntimeResult<Entity> {
1462    self.validate_entity_kind(kind)?;
1463    Ok(Entity::new("local", kind, name))
1464}"#;
1465        assert!(
1466            scan(code).is_none(),
1467            "code snippet must pass; fired: {:?}",
1468            scan(code)
1469        );
1470    }
1471
1472    #[test]
1473    fn allows_long_url_without_credentials() {
1474        let url = "https://docs.example.com/api/v2/entities?kind=concept&limit=100";
1475        assert!(scan(url).is_none(), "URL without userinfo must pass");
1476    }
1477
1478    #[test]
1479    fn allows_base64_image_stub() {
1480        // Realistic short base64 data URI stub — no trigger words, below threshold length.
1481        let b64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQ";
1482        assert!(
1483            scan(b64).is_none(),
1484            "base64 image stub without trigger word must pass; fired: {:?}",
1485            scan(b64)
1486        );
1487    }
1488
1489    #[test]
1490    fn allows_long_plain_url() {
1491        let url = "https://api.github.com/repos/ohdearquant/khive/pulls/76/comments?per_page=100";
1492        assert!(
1493            scan(url).is_none(),
1494            "plain URL must pass; fired: {:?}",
1495            scan(url)
1496        );
1497    }
1498
1499    #[test]
1500    fn allows_manifest_content_hash() {
1501        // A string like what appears in Cargo.lock or npm lockfiles.
1502        let line =
1503            "checksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"";
1504        assert!(
1505            scan(line).is_none(),
1506            "manifest content hash line must pass; fired: {:?}",
1507            scan(line)
1508        );
1509    }
1510
1511    #[test]
1512    fn masked_excerpt_format() {
1513        let fake = "AKIAFAKEKEY1234567890";
1514        let m = scan(fake).unwrap();
1515        // Format: first6...Nchars
1516        assert!(m.masked.contains("..."), "masked must contain '...'");
1517        assert!(m.masked.ends_with("chars"), "masked must end with 'chars'");
1518    }
1519
1520    // ── Gate function ────────────────────────────────────────────────────────
1521
1522    #[test]
1523    fn check_returns_ok_for_safe_content() {
1524        assert!(check("A normal memory note about LoRA.").is_ok());
1525    }
1526
1527    #[test]
1528    fn check_returns_err_for_secret() {
1529        let fake = "AKIAFAKEKEY1234567890";
1530        let result = check(fake);
1531        assert!(result.is_err(), "check must fail for AKIA key");
1532        let err = result.unwrap_err();
1533        assert!(
1534            matches!(err, RuntimeError::SecretDetected(_)),
1535            "error variant must be SecretDetected"
1536        );
1537    }
1538
1539    // ── Entropy helpers ──────────────────────────────────────────────────────
1540
1541    #[test]
1542    fn entropy_of_uniform_string_is_zero() {
1543        let s = "aaaaaaaaaaaaaaaa";
1544        assert!(shannon_entropy(s.as_bytes()) < 0.01);
1545    }
1546
1547    #[test]
1548    fn entropy_of_random_bytes_is_high() {
1549        // A truly random-looking string should exceed 4.5 bits/char.
1550        let s = b"X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // 32 mixed base64 chars
1551        assert!(shannon_entropy(s) > 4.5, "entropy={}", shannon_entropy(s));
1552    }
1553
1554    #[test]
1555    fn cjk_prose_near_trigger_is_not_flagged() {
1556        // Regression: a multibyte CJK run (~19 chars = 57 bytes) clears the
1557        // byte-length floor, and `shannon_entropy` over UTF-8 bytes reads it as
1558        // high-entropy — so a Chinese title near the `auth` trigger word used to
1559        // false-positive as `high-entropy-token`.  Non-ASCII tokens are now
1560        // skipped by the entropy heuristic: real base64/hex credentials are
1561        // ASCII, so this cannot hide a secret.
1562        let content = "更新 auth 配置数据库连接管理系统核心模块设计文档";
1563        assert!(
1564            check(content).is_ok(),
1565            "CJK prose near a trigger word must not be flagged as a secret"
1566        );
1567    }
1568
1569    #[test]
1570    fn ascii_secret_near_trigger_still_flagged() {
1571        // The non-ASCII skip must NOT weaken detection of genuine ASCII
1572        // high-entropy credentials near a trigger word.
1573        let content = "api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1";
1574        assert!(
1575            check(content).is_err(),
1576            "ASCII high-entropy token near a trigger word must still be blocked"
1577        );
1578    }
1579
1580    #[test]
1581    fn ascii_secret_in_cjk_context_does_not_panic_and_is_flagged() {
1582        // The ±120-byte trigger window around an ASCII token can land in the
1583        // middle of a multibyte CJK character when the token is embedded in
1584        // non-Latin prose.  Slicing on a non-char-boundary would panic — the
1585        // window bounds are snapped via `floor_char_boundary`.  Detection of
1586        // the genuine ASCII secret must still fire.
1587        let cjk = "数据库连接管理系统核心模块设计文档".repeat(6); // 17 chars × 6 = 306 bytes
1588                                                                  // The leading single-byte `x` breaks 3-byte CJK alignment so the window
1589                                                                  // start (token_offset - 120) lands mid-character without the snap.
1590        let content = format!("{cjk}x api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1 {cjk}");
1591        assert!(
1592            check(&content).is_err(),
1593            "ASCII secret in CJK context must still be blocked (and must not panic)"
1594        );
1595    }
1596
1597    #[test]
1598    fn ascii_secret_glued_to_cjk_is_still_flagged() {
1599        // Regression: a prefixless high-entropy credential glued (no ASCII
1600        // whitespace) to CJK text, CJK brackets/quotes, a fullwidth space, or a
1601        // fullwidth colon used to slip through, because the whole whitespace token
1602        // contained a non-ASCII byte and was skipped wholesale.  Non-ASCII is now
1603        // a token delimiter, so the ASCII credential run is isolated and
1604        // entropy-checked while the surrounding ±120-byte window still sees the
1605        // trigger word.
1606        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
1607        let cases = [
1608            format!("api_key {secret}数据"),     // CJK suffix glued to the token
1609            format!("api_key 「{secret}」"),     // CJK brackets wrap the token
1610            format!("api_key {secret}"),        // U+3000 ideographic space separator
1611            format!("api_key:{secret}"),        // U+FF1A fullwidth colon separator
1612            format!("数据{secret}更新 api_key"), // CJK-glued prefix, trigger after
1613        ];
1614        for content in &cases {
1615            assert!(
1616                check(content).is_err(),
1617                "ASCII secret glued to CJK must be blocked: {content:?}"
1618            );
1619        }
1620    }
1621
1622    #[test]
1623    fn high_entropy_ascii_run_without_trigger_is_not_flagged() {
1624        // The non-ASCII-as-delimiter change must not weaken the trigger-context
1625        // discipline: a high-entropy ASCII run isolated from CJK prose but NOT
1626        // near a credential trigger word is still allowed (only the tokenizer
1627        // changed, not the `near_trigger` gate).
1628        let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
1629        let content = format!("数据库连接{secret}核心模块设计文档");
1630        assert!(
1631            check(&content).is_ok(),
1632            "high-entropy ASCII run with no trigger word must not be flagged"
1633        );
1634    }
1635
1636    #[test]
1637    fn known_prefix_secret_glued_after_cjk_is_still_flagged() {
1638        // A Layer-1 known-prefix secret glued directly after
1639        // CJK prose (no ASCII whitespace) was missed, because the prefix boundary
1640        // check used `is_alphanumeric` — which Rust counts true for CJK — so the
1641        // preceding ideograph was not treated as a delimiter.  These credentials
1642        // must be caught with no nearby ASCII trigger word, on the left side too.
1643        let cases = [
1644            "数据AKIAIOSFODNN7EXAMPLE",             // gitleaks:allow
1645            "令牌github_pat_11ABCDEFG0HIJKLMNOPQR", // gitleaks:allow
1646            "密钥sk-ant-api03-AAAAAAAAAAAAAAAAAA",  // gitleaks:allow
1647            "配置FlyV1 fm2_AAAABBBBCCCCDDDD",       // gitleaks:allow
1648        ];
1649        for content in cases {
1650            assert!(
1651                check(content).is_err(),
1652                "known-prefix secret glued after CJK must be blocked: {content:?}"
1653            );
1654        }
1655    }
1656
1657    #[test]
1658    fn url_userinfo_after_cjk_does_not_panic_and_is_flagged() {
1659        // A credential URL glued after CJK prose panicked,
1660        // because scheme_start was (separator byte index + 1) — one byte into a
1661        // multibyte CJK separator — and the slice fell on a non-char boundary.
1662        // The public check() API must return a controlled error, never panic.
1663        let cases = [
1664            "数据postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
1665            "配置mysql://root:hunter2pw@10.0.0.1:3306/app",         // gitleaks:allow
1666            "连接redis://svc:V3ryS3cretPw@cache.internal:6379",     // gitleaks:allow
1667        ];
1668        for content in cases {
1669            assert!(
1670                check(content).is_err(),
1671                "credential URL after CJK must be blocked, not panic: {content:?}"
1672            );
1673        }
1674    }
1675
1676    #[test]
1677    fn non_ascii_glued_token_trigger_is_still_flagged() {
1678        // `token=`/`token:`/standalone `token` glued directly
1679        // after non-ASCII prose was missed because has_standalone_token /
1680        // has_token_assignment used is_alphanumeric for the word boundary — CJK,
1681        // accented letters, and fullwidth digits all count as alphanumeric in
1682        // Rust, so the preceding char was not seen as a boundary and the `token`
1683        // trigger was suppressed, leaving the high-entropy value unflagged.
1684        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1685        let blocked = [
1686            format!("数据token={opaque}"),    // CJK + assignment form, ASCII '='
1687            format!("配置token: {opaque}"),   // CJK + assignment form, ASCII ':'
1688            format!("密钥token {opaque}"),    // CJK + standalone-word form
1689            format!("résumétoken: {opaque}"), // accented letter before `token`
1690            format!("1token: {opaque}"),     // fullwidth digit before `token`
1691        ];
1692        for content in &blocked {
1693            assert!(
1694                check(content).is_err(),
1695                "non-ASCII-glued token trigger must flag the value: {content:?}"
1696            );
1697        }
1698        // Compound identifiers stay excluded — the `_` boundary rule is unchanged
1699        // and an ASCII letter before `token` is still a continuation, so these
1700        // (including the pure-ASCII `servicetoken:`) must still pass.
1701        let allowed = [
1702            format!("数据next_token: {opaque}"),
1703            format!("数据token_count: {opaque}"),
1704            format!("servicetoken: {opaque}"),
1705        ];
1706        for content in &allowed {
1707            assert!(
1708                check(content).is_ok(),
1709                "compound token identifier must not be flagged: {content:?}"
1710            );
1711        }
1712    }
1713
1714    #[test]
1715    fn allowlist_passes_sha256() {
1716        // A plain sha256 hex digest passes via `is_pure_hex` (not `is_allowlisted`
1717        // because hex is now context-dependent; this tests the primitive directly).
1718        let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1719        assert!(is_pure_hex(sha));
1720    }
1721
1722    #[test]
1723    fn allowlist_passes_uuid_canonical() {
1724        assert!(is_uuid_canonical("550e8400-e29b-41d4-a716-446655440000"));
1725    }
1726
1727    #[test]
1728    fn allowlist_does_not_pass_mixed_token() {
1729        // A token that starts with letters but mixes in non-hex chars.
1730        assert!(!is_pure_hex("sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg"));
1731    }
1732
1733    // ── Structured-field gate helpers ────────────────────────────────────────
1734
1735    #[test]
1736    fn check_json_blocks_secret_in_object_value() {
1737        let props = serde_json::json!({ "api_key": "AKIAFAKEKEY1234567890" });
1738        assert!(
1739            check_json(&props).is_err(),
1740            "secret in properties object value must be blocked"
1741        );
1742    }
1743
1744    #[test]
1745    fn check_json_blocks_secret_in_nested_object() {
1746        let props = serde_json::json!({ "credentials": { "token": "sk-proj-FAKEKEY00000000000000000000000000000000" } }); // gitleaks:allow
1747        assert!(
1748            check_json(&props).is_err(),
1749            "secret in nested properties object must be blocked"
1750        );
1751    }
1752
1753    #[test]
1754    fn check_json_blocks_secret_in_array() {
1755        let props = serde_json::json!(["normal", "AKIAFAKEKEY1234567890"]);
1756        assert!(
1757            check_json(&props).is_err(),
1758            "secret in JSON array must be blocked"
1759        );
1760    }
1761
1762    #[test]
1763    fn check_json_passes_safe_properties() {
1764        let props = serde_json::json!({
1765            "domain": "attention",
1766            "status": "researched",
1767            "year": 2024
1768        });
1769        assert!(
1770            check_json(&props).is_ok(),
1771            "normal properties must pass; fired: {:?}",
1772            check_json(&props).err()
1773        );
1774    }
1775
1776    #[test]
1777    fn check_tags_blocks_credential_tag() {
1778        let tags = vec![
1779            "type:concept".to_string(),
1780            "AKIAFAKEKEY1234567890".to_string(),
1781        ];
1782        assert!(
1783            check_tags(&tags).is_err(),
1784            "credential-shaped tag must be blocked"
1785        );
1786    }
1787
1788    #[test]
1789    fn check_tags_passes_normal_tags() {
1790        let tags = vec!["type:concept".to_string(), "domain:attention".to_string()];
1791        assert!(
1792            check_tags(&tags).is_ok(),
1793            "normal tags must pass; fired: {:?}",
1794            check_tags(&tags).err()
1795        );
1796    }
1797
1798    // ── False-positive: sk-learn and scikit-learn slugs ──────────────────────
1799
1800    #[test]
1801    fn allows_sk_learn_prose() {
1802        // scikit-learn slug used as an entity name or knowledge atom.
1803        let texts = &[
1804            "sk-learn is a Python machine learning library",
1805            "sk-learn-compatible transformer pipeline reference",
1806            "sk-learn scikit-learn estimator interface",
1807        ];
1808        for t in texts {
1809            assert!(
1810                scan(t).is_none(),
1811                "sk-learn prose must pass; fired: {:?} on {:?}",
1812                scan(t),
1813                t
1814            );
1815        }
1816    }
1817
1818    #[test]
1819    fn blocks_openai_sk_proj_not_confused_with_sk_learn() {
1820        // Real OpenAI key shape must still be caught.
1821        let fake = "sk-proj-FAKEKEY00000000000000000000000000000000"; // gitleaks:allow
1822        assert!(
1823            scan(fake).is_some(),
1824            "sk-proj- key must still be caught after sk-learn exemption"
1825        );
1826    }
1827
1828    // ── False-positive: SRI / tokenizer hash metadata ────────────────────────
1829
1830    #[test]
1831    fn blocks_sri_hash_near_key_word_accepted_fp() {
1832        // SRI hash as used in HTML integrity attributes (sha384, base64-encoded),
1833        // placed directly beside the trigger word "key". The content-hash
1834        // allowlist is a prose-context exemption, not unconditional: near a
1835        // credential trigger, a sha-prefixed hash falls through to the explicit
1836        // near-trigger content-hash detector like any other high-entropy
1837        // candidate. This is an accepted false positive on a real but rare
1838        // shape (an integrity hash literally next to the word "key").
1839        let line = "integrity key: sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC";
1840        assert!(
1841            scan(line).is_some(),
1842            "SRI hash near trigger word 'key' must now be blocked (accepted FP); passed unexpectedly"
1843        );
1844    }
1845
1846    #[test]
1847    fn allows_base64_tokenizer_hash_metadata() {
1848        // Tokenizer metadata containing a base64 hash near technical keywords.
1849        let line = "tokenizer_vocab_hash: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1850        assert!(
1851            scan(line).is_none(),
1852            "tokenizer hash metadata must pass; fired: {:?}",
1853            scan(line)
1854        );
1855    }
1856
1857    #[test]
1858    fn allows_npm_lockfile_integrity() {
1859        // npm lockfile integrity line with sha512 base64url hash (86 base64 chars + ==).
1860        // sha512 digest = 64 bytes → base64 = 88 chars (86 unpadded + ==).
1861        let body_86 = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1234567890abcdefghijklmnopqrstuvwxABCDEFGHIJKLMNOPQRST";
1862        assert_eq!(body_86.len(), 86, "test body must be exactly 86 chars");
1863        let line = format!(
1864            "resolved: https://registry.npmjs.org/foo/-/foo-1.0.0.tgz\nintegrity: sha512-{body_86}=="
1865        );
1866        assert!(
1867            scan(&line).is_none(),
1868            "npm lockfile integrity must pass; fired: {:?}",
1869            scan(&line)
1870        );
1871    }
1872
1873    // ── False-positive: tokenizer vs token trigger word ─────────────────────
1874
1875    #[test]
1876    fn allows_tokenizer_vocab_hash_no_block() {
1877        // `tokenizer_vocab_hash` contains the substring "token" but NOT as a
1878        // standalone word (followed by 'i' which is alphanumeric), so the
1879        // standalone-token boundary check must not fire here.
1880        let line = "tokenizer_vocab_hash = Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow
1881        assert!(
1882            scan(line).is_none(),
1883            "tokenizer_vocab_hash must pass; 'token' is only standalone-word matched; fired: {:?}",
1884            scan(line)
1885        );
1886    }
1887
1888    // ── True-positives: bare base64 at sha-lengths near trigger words ────────
1889
1890    #[test]
1891    fn blocks_bare_base64url_43chars_near_key() {
1892        // A 43-char base64url token (= sha256 body length) near the word "key".
1893        // Without a sha<N>- prefix this MUST be caught, not allowlisted.
1894        let token_43 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"; // gitleaks:allow
1895        assert_eq!(token_43.len(), 43, "test token must be exactly 43 chars");
1896        let line = format!("api key {token_43}");
1897        assert!(
1898            scan(&line).is_some(),
1899            "43-char base64url token near 'key' must be caught (no sha-prefix = not a hash); fired: {:?}",
1900            scan(&line)
1901        );
1902    }
1903
1904    #[test]
1905    fn blocks_bare_base64url_64chars_near_secret() {
1906        // A 64-char base64url token (= sha384 body length) near "secret".
1907        // Must be caught without sha<N>- prefix.
1908        let token_64 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENa"; // gitleaks:allow
1909        assert_eq!(token_64.len(), 64, "test token must be exactly 64 chars");
1910        let line = format!("secret: {token_64}");
1911        assert!(
1912            scan(&line).is_some(),
1913            "64-char base64url token near 'secret' must be caught; got: {:?}",
1914            scan(&line)
1915        );
1916    }
1917
1918    #[test]
1919    fn blocks_bare_base64url_86chars_near_auth() {
1920        // An 86-char base64url token (= sha512 body length) near "auth".
1921        // Must be caught without sha<N>- prefix.
1922        let token_86 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENwJalrXUtnFEMI-K7MDENabc"; // gitleaks:allow
1923        assert_eq!(token_86.len(), 86, "test token must be exactly 86 chars");
1924        let line = format!("auth header {token_86}");
1925        assert!(
1926            scan(&line).is_some(),
1927            "86-char base64url token near 'auth' must be caught; got: {:?}",
1928            scan(&line)
1929        );
1930    }
1931
1932    // ── True-positives: standalone `token` trigger ───────────────────────────
1933
1934    #[test]
1935    fn blocks_service_token_opaque_value() {
1936        // "service token <opaque-high-entropy>" — `token` as a standalone word
1937        // with a high-entropy value must be caught.
1938        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1939        assert!(
1940            opaque.len() >= 24,
1941            "opaque must be long enough for entropy check"
1942        );
1943        let line = format!("service token {opaque}");
1944        assert!(
1945            scan(&line).is_some(),
1946            "service token <opaque> must be caught by standalone 'token' check; got: {:?}",
1947            scan(&line)
1948        );
1949    }
1950
1951    #[test]
1952    fn blocks_token_equals_credential() {
1953        // `token=<high-entropy>` (assignment form) must be caught via has_token_assignment.
1954        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1955        let line = format!("token={opaque}");
1956        assert!(
1957            scan(&line).is_some(),
1958            "token=<value> must be caught via token= trigger; got: {:?}",
1959            scan(&line)
1960        );
1961    }
1962
1963    #[test]
1964    fn blocks_token_colon_credential() {
1965        // `token: <high-entropy>` (key-value form) must be caught via has_token_assignment.
1966        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1967        let line = format!("token: {opaque}");
1968        assert!(
1969            scan(&line).is_some(),
1970            "token: <value> must be caught via token: trigger; got: {:?}",
1971            scan(&line)
1972        );
1973    }
1974
1975    #[test]
1976    fn allows_next_token_technical_context() {
1977        // `next_token` is a technical term; the high-entropy value here has low
1978        // entropy anyway, so it must pass.
1979        let line = "next_token: cursor-page-2-abcdef12345678";
1980        assert!(
1981            scan(line).is_none(),
1982            "next_token technical context must not be blocked; fired: {:?}",
1983            scan(line)
1984        );
1985    }
1986
1987    // ── Boundary-aware token= / token: (compound identifiers must pass) ─────
1988
1989    #[test]
1990    fn allows_next_token_high_entropy_cursor() {
1991        // `next_token:` with a realistic high-entropy pagination cursor must NOT be
1992        // blocked.  `next_token` has `_token` suffix — not a standalone assignment form.
1993        let cursor = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
1994        let line = format!("next_token: {cursor}");
1995        assert!(
1996            scan(&line).is_none(),
1997            "next_token with high-entropy cursor must pass (compound identifier); fired: {:?}",
1998            scan(&line)
1999        );
2000    }
2001
2002    #[test]
2003    fn allows_token_count_high_entropy() {
2004        // `token_count:` with a high-entropy value must NOT be blocked.
2005        // `token_count` has `token_` prefix — the word boundary after `token` is `_`,
2006        // which is excluded by has_token_assignment.
2007        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
2008        let line = format!("token_count: {opaque}");
2009        assert!(
2010            scan(&line).is_none(),
2011            "token_count with high-entropy value must pass; fired: {:?}",
2012            scan(&line)
2013        );
2014    }
2015
2016    // ── Hex allowlist is not applied when trigger context is present ────────
2017    //
2018    // Pure hex strings have a theoretical maximum entropy of log2(16) = 4.0 bits/char,
2019    // which is below the ENTROPY_THRESHOLD of 4.5.  That means pure hex tokens cannot
2020    // reach the entropy threshold and will never be flagged by the heuristic alone.
2021    //
2022    // However, the hex allowlist was previously applied BEFORE the trigger window was
2023    // computed, meaning a future threshold reduction or edge case could silently
2024    // skip credential-context hex.  The fix: compute trigger context first; only
2025    // apply the hex allowlist when NOT near a trigger.  The tests below verify the
2026    // structural change is in place by confirming that non-pure-hex high-entropy
2027    // tokens near triggers are caught (showing the trigger path is live), and that
2028    // purely hex tokens near triggers still correctly pass (entropy too low to flag).
2029
2030    #[test]
2031    fn hex_near_key_blocked_in_credential_context() {
2032        // A pure-hex 32-char token near "api key" is a credential-shaped hex
2033        // token in trigger context.  Entropy alone cannot flag it (hex max =
2034        // 4.0 < 4.5 threshold), but the explicit hex-credential-token path
2035        // must catch it.
2036        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
2037        assert_eq!(hex32.len(), 32);
2038        let line = format!("api key {hex32}");
2039        assert!(
2040            scan(&line).is_some(),
2041            "32-char pure hex near 'api key' must be blocked; got None"
2042        );
2043    }
2044
2045    #[test]
2046    fn hex_credential_lengths_blocked_near_trigger() {
2047        // Verify all four credential-shaped lengths are caught near a trigger.
2048        let hex40 = "a3f5c2e9d1b8047e63a1f4c2d5b6e8f1a9c3d2e4";
2049        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
2050        let hex128 = format!("{hex64}{hex64}");
2051        assert_eq!(hex40.len(), 40);
2052        assert_eq!(hex64.len(), 64);
2053        assert_eq!(hex128.len(), 128);
2054
2055        for (label, hex) in &[
2056            ("hex40", hex40),
2057            ("hex64", hex64),
2058            ("hex128", hex128.as_str()),
2059        ] {
2060            let line = format!("secret key: {hex}");
2061            assert!(
2062                scan(&line).is_some(),
2063                "{label} near 'secret key' must be blocked; got None"
2064            );
2065        }
2066    }
2067
2068    #[test]
2069    fn hex_blocked_when_trigger_and_hash_word_coexist() {
2070        // Credential trigger dominates: adding "hash" or "sha" to the window does
2071        // not rescue a pure-hex token when a credential trigger is also present.
2072        // An attacker controlling the prose could otherwise bypass the gate with
2073        // one extra word, so the hash-word exception must NOT apply in trigger context.
2074        let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
2075        let key_hash_line = format!("api key hash {hex32}");
2076        let secret_sha_line = format!("secret sha {hex32}");
2077        assert!(
2078            scan(&key_hash_line).is_some(),
2079            "'api key hash <hex32>' must be blocked; got None"
2080        );
2081        assert!(
2082            scan(&secret_sha_line).is_some(),
2083            "'secret sha <hex32>' must be blocked; got None"
2084        );
2085    }
2086
2087    #[test]
2088    fn hex_near_sha_context_word_allowed() {
2089        // A 40-char hex with "sha" or "commit" in the window — but no credential
2090        // trigger — must be allowed (git SHA or content hash in normal prose).
2091        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
2092        let sha_line = format!("sha1: {hex40}");
2093        let commit_line = format!("commit sha {hex40}");
2094        assert!(
2095            scan(&sha_line).is_none(),
2096            "hex40 near 'sha1' context must be allowed; fired: {:?}",
2097            scan(&sha_line)
2098        );
2099        assert!(
2100            scan(&commit_line).is_none(),
2101            "hex40 near 'commit sha' context must be allowed; fired: {:?}",
2102            scan(&commit_line)
2103        );
2104    }
2105
2106    #[test]
2107    fn hex64_near_hash_context_allowed() {
2108        // A 64-char hex near "sha256" or "hash" — with no credential trigger —
2109        // must be allowed (content digest in normal prose).
2110        let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
2111        let sha_line = format!("sha256: {hex64}");
2112        let hash_line = format!("hash value {hex64}");
2113        assert!(
2114            scan(&sha_line).is_none(),
2115            "hex64 near 'sha256' must be allowed; fired: {:?}",
2116            scan(&sha_line)
2117        );
2118        assert!(
2119            scan(&hash_line).is_none(),
2120            "hex64 near 'hash' must be allowed; fired: {:?}",
2121            scan(&hash_line)
2122        );
2123    }
2124
2125    #[test]
2126    fn blocks_high_entropy_hex_like_token_near_key() {
2127        // A token whose character set exceeds pure hex (contains mixed-case, digits,
2128        // and non-hex chars) that ALSO passes `is_pure_hex = false` AND has high
2129        // entropy AND appears near "key" MUST be caught.  This is the realistic
2130        // real-world case: hex-looking API tokens often mix case and non-hex chars.
2131        // Example: a 32-char mixed-charset token near "api key".
2132        let mixed = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; // gitleaks:allow — not pure hex
2133        assert!(!is_pure_hex(mixed), "test token must not be pure hex");
2134        let line = format!("api key {mixed}");
2135        assert!(
2136            scan(&line).is_some(),
2137            "mixed-charset high-entropy token near 'api key' must be caught; got: {:?}",
2138            scan(&line)
2139        );
2140    }
2141
2142    #[test]
2143    fn allows_hex40_without_trigger() {
2144        // 40-char hex string in a neutral context (no trigger word) must still pass —
2145        // it's likely a git commit SHA or content hash.
2146        let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
2147        let line = format!("commit: {hex40}");
2148        assert!(
2149            scan(&line).is_none(),
2150            "40-char hex without trigger word must pass; fired: {:?}",
2151            scan(&line)
2152        );
2153    }
2154
2155    // ── check_json scans object keys ─────────────────────────────────────────
2156
2157    #[test]
2158    fn check_json_blocks_secret_in_object_key() {
2159        // A credential used as a JSON object key (not a value) must be caught.
2160        let props = serde_json::json!({ "ghp_FakeGitHubToken0000000000000000000": "redacted" }); // gitleaks:allow
2161        assert!(
2162            check_json(&props).is_err(),
2163            "credential as JSON object key must be blocked"
2164        );
2165    }
2166
2167    #[test]
2168    fn check_json_blocks_nested_secret_key() {
2169        // Nested credential key must be caught.
2170        let props = serde_json::json!({
2171            "metadata": {
2172                "AKIAFAKEKEY000000000": "value" // gitleaks:allow
2173            }
2174        });
2175        assert!(
2176            check_json(&props).is_err(),
2177            "nested credential as JSON object key must be blocked"
2178        );
2179    }
2180
2181    // ── PEM masking format ───────────────────────────────────────────────────
2182
2183    #[test]
2184    fn pem_masked_excerpt_reflects_block_length_not_rest_of_string() {
2185        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
2186        let fake = format!(
2187            "{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\nsome trailing text that is very long",
2188            header
2189        );
2190        let m = scan(&fake).unwrap();
2191        assert_eq!(m.detector, "pem-private-key");
2192        // The masked length should reflect only the key block, not the whole string.
2193        // "some trailing text that is very long" is ~37 chars; total string is much longer.
2194        // The block ends after "-----END RSA PRIVATE KEY-----\n".
2195        // We just verify it is shorter than the full string length.
2196        let full_len = fake.chars().count();
2197        let reported_len: usize = m
2198            .masked
2199            .trim_end_matches("chars")
2200            .rsplit("...")
2201            .next()
2202            .and_then(|s| s.parse().ok())
2203            .unwrap_or(full_len + 1);
2204        assert!(
2205            reported_len < full_len,
2206            "masked length ({reported_len}) should be less than full string length ({full_len})"
2207        );
2208    }
2209
2210    // ── UTF-8 char-boundary reproduction tests ───────────────────────────────
2211    //
2212    // These tests verify that no code path in secret_gate panics when multibyte
2213    // UTF-8 characters (emoji, CJK, accented Latin) appear at positions where
2214    // byte-level slicing could land mid-codepoint.  Each test targets a specific
2215    // code path.  A panic means the bug is live; a pass means the path is safe.
2216
2217    /// `build_match` masked preview: if the detected candidate starts with
2218    /// multibyte chars the "first 6 chars" preview must not slice on a byte
2219    /// boundary that falls mid-codepoint.  build_match already uses
2220    /// `chars().take(6)`, but we exercise it with emoji-prefixed candidates.
2221    #[test]
2222    fn utf8_build_match_preview_multibyte_prefix_no_panic() {
2223        // "🔑" = 4 bytes; repeat 3 times = 12 bytes for only 3 chars.
2224        // A ghp_-prefixed token with an emoji: let's construct a scenario where
2225        // a known-prefix secret is immediately adjacent to multibyte content so
2226        // that build_match receives a slice starting at a multibyte char.
2227        // PEM block with multibyte chars in the body exercises build_match on a
2228        // candidate that may contain non-ASCII.
2229        let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); // gitleaks:allow
2230        let fake = format!("{}\n🔑密钥\n-----END RSA PRIVATE KEY-----", header);
2231        // Must not panic; mask must not echo full body.
2232        let m = scan(&fake);
2233        assert!(m.is_some(), "PEM with emoji body must still be caught");
2234        let m = m.unwrap();
2235        assert!(
2236            !m.masked.contains("🔑密钥"),
2237            "mask must not echo the emoji body"
2238        );
2239    }
2240
2241    /// `extract_token` called with a string starting with multibyte chars:
2242    /// the FlyV1 handler calls `extract_token(&text[payload_start..])` where
2243    /// `payload_start` is just past "FlyV1 " (ASCII).  If the payload is ASCII
2244    /// this is trivially safe, but we verify it cannot panic when the rest of
2245    /// the text after the payload contains multibyte chars.
2246    #[test]
2247    fn utf8_extract_token_multibyte_suffix_no_panic() {
2248        // "FlyV1 ABCDEFGHIJ密钥" — the payload is "ABCDEFGHIJ密钥"; extract_token
2249        // must stop at the ideographic chars (which are NOT ASCII whitespace) and
2250        // return the whole glued run without panicking.
2251        let text = "FlyV1 ABCDEFGHIJ密钥";
2252        // scan() must not panic.
2253        let _ = scan(text);
2254    }
2255
2256    /// `find_prefix_token` with multibyte chars immediately before and after
2257    /// the known prefix: checks text[..abs] boundary slices and
2258    /// extract_token(&text[abs..]) do not panic.
2259    #[test]
2260    fn utf8_prefix_detector_multibyte_adjacent_no_panic() {
2261        // 🔑 (4 bytes) immediately before AKIA: boundary at abs = 4, which is a
2262        // valid char boundary (end of the emoji).  extract_token sees ASCII from abs.
2263        let text = "🔑AKIAFAKEKEY00000000000000";
2264        let _ = scan(text); // must not panic
2265
2266        // é (U+00E9 = 2 bytes) immediately before ghp_:
2267        let text2 = "éghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
2268        let _ = scan(text2); // must not panic
2269
2270        // Emoji immediately after the token — extract_token ends at the emoji
2271        // (non-whitespace, but non-ASCII acts as delimiter in entropy heuristic).
2272        // For prefix tokens extract_token stops at ASCII whitespace only, so the
2273        // emoji would be included in the token length measurement.
2274        let text3 = "AKIAFAKEKEY00000000000000🔑";
2275        let _ = scan(text3); // must not panic
2276    }
2277
2278    /// `find_jwt` with multibyte chars as "whitespace" adjacent to a JWT-like
2279    /// candidate: `i = end + 1` could skip into a multibyte char if `end`
2280    /// pointed at a non-ASCII byte.  The position() search only looks for ASCII
2281    /// whitespace bytes, so a multibyte space (U+3000) is NOT found — `end`
2282    /// equals bytes.len() and `i = bytes.len() + 1` exits the loop.  Still
2283    /// verify no panic on CJK-surrounded JWT-like content.
2284    #[test]
2285    fn utf8_jwt_multibyte_adjacent_no_panic() {
2286        // A (fake) JWT-like triple surrounded by CJK text.
2287        let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; // gitleaks:allow
2288        let text = format!("数据{jwt}密钥");
2289        let _ = scan(&text); // must not panic
2290
2291        // JWT followed by ideographic space (U+3000 = 3 bytes 0xE3 0x80 0x80) —
2292        // not matched by the ASCII-whitespace position() search.
2293        let text2 = format!("{jwt}\u{3000}morecontent");
2294        let _ = scan(&text2); // must not panic
2295
2296        // JWT followed by emoji
2297        let text3 = format!("{jwt}🔑");
2298        let _ = scan(&text3); // must not panic
2299    }
2300
2301    /// `find_url_userinfo` with multibyte chars between "://" and "@":
2302    /// `at_pos` from `rest.find('@')` and `colon` from `userinfo.find(':')` are
2303    /// ASCII markers (char boundaries), but `scheme_start` calculation uses
2304    /// char_indices().rev() which must handle multibyte chars in the scheme
2305    /// prefix correctly.
2306    #[test]
2307    fn utf8_url_userinfo_multibyte_scheme_no_panic() {
2308        // CJK glued to a credential URL — the scheme_start walker must not place
2309        // the start inside a multibyte codepoint.
2310        let cases = [
2311            "🔑postgresql://dbuser:S3cr3tP4ss@db.example.com/db", // gitleaks:allow
2312            "密钥mysql://root:hunter2pw@10.0.0.1:3306/app",       // gitleaks:allow
2313            "éredis://svc:V3ryS3cretPw@cache.internal:6379",      // gitleaks:allow
2314        ];
2315        for text in &cases {
2316            // Must not panic and must detect the credential.
2317            let result = scan(text);
2318            assert!(
2319                result.is_some(),
2320                "URL credential after multibyte must be caught: {text:?}"
2321            );
2322        }
2323    }
2324
2325    /// `check_entropy_heuristic` window slicing with multibyte content at the
2326    /// ±TRIGGER_WINDOW boundary: `floor_char_boundary` must prevent slicing
2327    /// on a non-char boundary.
2328    #[test]
2329    fn utf8_entropy_window_multibyte_boundary_no_panic() {
2330        // Construct content where the TRIGGER_WINDOW (120 bytes) boundary falls
2331        // inside a 3-byte CJK character.  Repeat "数" (U+6570 = 3 bytes) to fill
2332        // exactly 119 bytes, then add an ASCII trigger word + high-entropy token.
2333        // Window start: token_offset - 120 = lands inside one of the CJK chars.
2334        let cjk_fill = "数".repeat(39); // 39 × 3 = 117 bytes
2335        assert_eq!(cjk_fill.len(), 117);
2336        // Pad with 2 more ASCII chars ("xy") so that the 120-byte window lands at
2337        // byte 119 which is the second byte of the 40th "数" — mid-multibyte.
2338        let secret = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
2339        let content = format!("{cjk_fill}xy key {secret}");
2340        let _ = scan(&content); // must not panic
2341
2342        // Also test the right edge: token ends at byte offset, window_end =
2343        // token_offset + raw_token.len() + 120 may land mid-multibyte.
2344        let content2 = format!("key {secret}{cjk_fill}xy");
2345        let _ = scan(&content2); // must not panic
2346    }
2347
2348    /// `check()` top-level fuzz: a large batch of inputs with multibyte
2349    /// characters at various offsets to catch any remaining panic sites.
2350    /// All results must be either Ok or Err (not a panic).
2351    #[test]
2352    fn utf8_no_panic_property_test() {
2353        let multibyte_items = [
2354            "🔑",       // 4-byte emoji
2355            "密",       // 3-byte CJK
2356            "é",        // 2-byte accented Latin
2357            "\u{3000}", // 3-byte ideographic space
2358            "🇺🇸",       // 8-byte emoji flag (two surrogate-like scalars)
2359        ];
2360        let secrets = [
2361            "AKIAFAKEKEY00000000000000",
2362            "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA",
2363            "sk-ant-api03-AAAAAAAAAAAAAAA",
2364            "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1",
2365            "FlyV1 fm2_AAAABBBBCCCCDDDDEEEEFFFF",
2366        ];
2367        for mb in &multibyte_items {
2368            for secret in &secrets {
2369                for sep in &["", " ", "\n"] {
2370                    // multibyte before secret
2371                    let s = format!("{mb}{sep}{secret}");
2372                    let _ = check(&s);
2373                    // multibyte after secret
2374                    let s = format!("{secret}{sep}{mb}");
2375                    let _ = check(&s);
2376                    // multibyte both sides
2377                    let s = format!("{mb}{sep}{secret}{sep}{mb}");
2378                    let _ = check(&s);
2379                    // repeated multibyte filling TRIGGER_WINDOW boundary
2380                    let fill = mb.repeat(50);
2381                    let s = format!("{fill} api_key {secret} {fill}");
2382                    let _ = check(&s);
2383                }
2384            }
2385        }
2386    }
2387
2388    // ── mask_secrets: in-place redaction reusing the canonical detector ───────
2389
2390    #[test]
2391    fn mask_secrets_borrows_clean_text() {
2392        let clean = "The FlashAttention paper introduces IO-aware tiling.";
2393        let masked = mask_secrets(clean);
2394        assert!(
2395            matches!(masked, std::borrow::Cow::Borrowed(_)),
2396            "clean text must not allocate"
2397        );
2398        assert_eq!(masked, clean);
2399    }
2400
2401    #[test]
2402    fn mask_secrets_redacts_shapes_the_old_mirror_regex_missed() {
2403        // These are exactly the detectors the session mirror's previous local
2404        // regex did NOT cover, which is why it now shares this masker.
2405        let cases = [
2406            "key: sk-proj-FAKEKEY00000000000000000000000000000000", // gitleaks:allow
2407            "cred ASIAFAKEKEY00000000000",                          // gitleaks:allow
2408            "stripe sk_live_FAKESTRIPE0000000000000",               // gitleaks:allow
2409            "db postgresql://dbuser:S3cr3tP4ss@db.example.com/db",  // gitleaks:allow
2410        ];
2411        for c in &cases {
2412            let masked = mask_secrets(c);
2413            assert!(
2414                masked.contains(REDACTION_MARKER),
2415                "must redact: {c:?} -> {masked:?}"
2416            );
2417        }
2418    }
2419
2420    #[test]
2421    fn mask_secrets_redacts_every_span_and_keeps_prose() {
2422        let line =
2423            "first sk-ant-api03-AAAAAAAAAAAAAAA then ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA end";
2424        let masked = mask_secrets(line);
2425        assert!(
2426            !masked.contains("sk-ant-api03") && !masked.contains("ghp_AAAA"),
2427            "no secret may survive: {masked}"
2428        );
2429        assert_eq!(
2430            masked.matches(REDACTION_MARKER).count(),
2431            2,
2432            "both secrets must be redacted: {masked}"
2433        );
2434        assert!(masked.starts_with("first "), "prose preserved: {masked}");
2435        assert!(masked.ends_with(" end"), "prose preserved: {masked}");
2436    }
2437
2438    #[test]
2439    fn mask_secrets_output_passes_check() {
2440        // The masked output must itself be clean — no credential left for the
2441        // write-time gate to catch.
2442        let line = "token=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and AKIAFAKEKEY1234567890";
2443        let masked = mask_secrets(line).into_owned();
2444        assert!(
2445            check(&masked).is_ok(),
2446            "masked output must pass the gate: {masked}"
2447        );
2448    }
2449
2450    #[test]
2451    fn mask_secrets_redacts_entropy_secret_left_of_known_secret() {
2452        // Cross-layer leftmost regression: a Layer-2 entropy secret sits to the
2453        // LEFT of a Layer-1 known-prefix secret. A scan that short-circuits on
2454        // the first known match (or returns first-by-detector-priority) would
2455        // redact `ghp_…` and copy the entropy token before it verbatim — leaking
2456        // it. `scan_match` must fold both layers through leftmost selection.
2457        let line =
2458            "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM and ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; // gitleaks:allow
2459        let masked = mask_secrets(line).into_owned();
2460        assert!(
2461            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM") && !masked.contains("ghp_AAAA"),
2462            "neither the entropy secret nor the known secret may survive: {masked}"
2463        );
2464        assert_eq!(
2465            masked.matches(REDACTION_MARKER).count(),
2466            2,
2467            "both secrets must be redacted exactly once: {masked}"
2468        );
2469        assert!(
2470            check(&masked).is_ok(),
2471            "masked output must pass the gate: {masked}"
2472        );
2473    }
2474
2475    #[test]
2476    fn github_app_token_families_are_masked() {
2477        // ghu_ (user-to-server), ghs_ (server-to-server), and ghr_ (refresh)
2478        // GitHub App tokens are real credential families. They are
2479        // context-free: no trigger word needed.
2480        let cases = [
2481            "ghu_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", // gitleaks:allow
2482            "ghs_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB",  // gitleaks:allow
2483            "ghr_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC",  // gitleaks:allow
2484        ];
2485        for token in &cases {
2486            assert!(
2487                check(token).is_err(),
2488                "gate must hard-block GitHub App token {token}"
2489            );
2490            let line = format!("auth: {token} trailing");
2491            let masked = mask_secrets(&line).into_owned();
2492            assert!(
2493                !masked.contains(token),
2494                "GitHub App token must not survive masking: {masked}"
2495            );
2496            assert!(
2497                check(&masked).is_ok(),
2498                "masked output must pass the gate: {masked}"
2499            );
2500        }
2501    }
2502
2503    #[test]
2504    fn mask_secrets_redacts_entropy_token_whose_trigger_is_left_of_earlier_secret() {
2505        // The entropy detector only fires near a
2506        // trigger word. When the trigger (`api_key`) sits to the LEFT of an
2507        // earlier known-prefix secret (`ghp_…`), a masker that rescans only the
2508        // suffix after each redaction loses that context and leaks the later
2509        // high-entropy token. Spans must be discovered against the ORIGINAL text.
2510        let line =
2511            "api_key ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; // gitleaks:allow
2512        let masked = mask_secrets(line).into_owned();
2513        assert!(
2514            !masked.contains("ghp_AAAA"),
2515            "the known secret must be redacted: {masked}"
2516        );
2517        assert!(
2518            !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"),
2519            "the later entropy token must be redacted even though its trigger \
2520             word sits left of the earlier redaction: {masked}"
2521        );
2522        assert_eq!(
2523            masked.matches(REDACTION_MARKER).count(),
2524            2,
2525            "both secrets must be redacted exactly once: {masked}"
2526        );
2527        assert!(
2528            check(&masked).is_ok(),
2529            "masked output must pass the gate: {masked}"
2530        );
2531    }
2532
2533    // ── Structured-identifier exemption: file paths / branch names ──────────
2534    //
2535    // The entropy heuristic tokenizes on whitespace, so a full file path is
2536    // one long token, and mixed-case+digit+punctuation paths can legitimately
2537    // exceed the Shannon-entropy threshold. The structured-identifier
2538    // exemption does not apply in trigger context (see the module doc): no
2539    // sound signal separates a real path from an attacker-chopped/padded
2540    // credential once Shannon entropy is the only measure and the attacker
2541    // controls run boundaries. The three cases below are accepted false
2542    // positives: their own full-token entropy exceeds ENTROPY_THRESHOLD, so
2543    // they block near a trigger word.
2544
2545    #[test]
2546    fn blocks_file_path_near_secret_word_accepted_fp_round4() {
2547        // Full-token entropy 4.5994 > ENTROPY_THRESHOLD (4.5).
2548        let content =
2549            "workspace path fable-ops/ADR-DRAFT-adr079-slices234.md for the secret gate bug";
2550        assert!(
2551            check(content).is_err(),
2552            "accepted FP post round-4: structured file path near 'secret' is now \
2553             blocked; got {:?}",
2554            scan(content)
2555        );
2556    }
2557
2558    #[test]
2559    fn blocks_workspace_path_near_key_word_accepted_fp_round4() {
2560        // Full-token entropy 4.7938 > 4.5.
2561        let content = "key: see internal/workspaces/20260701/adr079-slices234/PACKET.md";
2562        assert!(
2563            check(content).is_err(),
2564            "accepted FP post round-4: workspace path near 'key' is now blocked; \
2565             got {:?}",
2566            scan(content)
2567        );
2568    }
2569
2570    #[test]
2571    fn blocks_short_run_path_near_auth_word_accepted_fp_round4() {
2572        // Full-token entropy 4.5955 > 4.5.
2573        let content =
2574            "auth work saved at internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
2575        assert!(
2576            check(content).is_err(),
2577            "accepted FP post round-4: path with a short 'R1' run near 'auth' is \
2578             now blocked; got {:?}",
2579            scan(content)
2580        );
2581    }
2582
2583    #[test]
2584    fn allows_branch_and_review_filename_near_key_word() {
2585        let content =
2586            "branch feat-session-mirror pushed, see review_pr335_round2.md for the key findings";
2587        assert!(
2588            check(content).is_ok(),
2589            "branch name and review filename near 'key' must not be blocked; fired: {:?}",
2590            scan(content)
2591        );
2592    }
2593
2594    #[test]
2595    fn allows_adr_doc_path_near_password_word() {
2596        let content = "password reset doc: docs/adr/ADR-055-epistemic-edge-relations.md";
2597        assert!(
2598            check(content).is_ok(),
2599            "ADR doc path near 'password' must not be blocked; fired: {:?}",
2600            scan(content)
2601        );
2602    }
2603
2604    #[test]
2605    fn allows_source_file_path_near_credential_word() {
2606        let content = "credential handling code crates/khive-pack-session/src/mirror/ingest.rs";
2607        assert!(
2608            check(content).is_ok(),
2609            "source file path near 'credential' must not be blocked; fired: {:?}",
2610            scan(content)
2611        );
2612    }
2613
2614    #[test]
2615    fn allows_long_snake_case_identifier_near_key_word() {
2616        let content = "api key handling lives in check_entropy_heuristic_impl";
2617        assert!(
2618            check(content).is_ok(),
2619            "snake_case identifier near 'key' must not be blocked; fired: {:?}",
2620            scan(content)
2621        );
2622    }
2623
2624    // ── Structured-identifier exemption: catch-suite regression ─────────────
2625
2626    #[test]
2627    fn hyphenated_random_secret_is_not_a_structured_identifier() {
2628        // Same token as `blocks_bare_base64url_43chars_near_key`: hyphenated
2629        // but not word-shaped. The second run exceeds the 24-char run cap,
2630        // and the first run's case-transition density (~0.42) exceeds the
2631        // 0.3 threshold on its own, so this must not be exempted and the
2632        // existing catch-suite test must keep blocking it.
2633        assert!(!is_structured_identifier(
2634            "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"
2635        ));
2636        let line = "api key wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123";
2637        assert!(
2638            scan(line).is_some(),
2639            "hyphenated random secret must still be blocked; got: {:?}",
2640            scan(line)
2641        );
2642    }
2643
2644    // ── Structured-identifier exemption: direct unit tests ───────────────────
2645
2646    #[test]
2647    fn structured_identifier_true_for_repro_paths() {
2648        let paths = [
2649            "fable-ops/ADR-DRAFT-adr079-slices234.md",
2650            "internal/workspaces/20260701/adr079-slices234/PACKET.md",
2651            "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
2652            "review_pr335_round2.md",
2653            "docs/adr/ADR-055-epistemic-edge-relations.md",
2654            "crates/khive-pack-session/src/mirror/ingest.rs",
2655            "check_entropy_heuristic_impl",
2656        ];
2657        for p in paths {
2658            assert!(
2659                is_structured_identifier(p),
2660                "expected structured identifier: {p}"
2661            );
2662        }
2663    }
2664
2665    #[test]
2666    fn structured_identifier_false_without_separator() {
2667        // No `/`, `-`, `_`, or `.` present — fails rule 1 outright.
2668        assert!(!is_structured_identifier(
2669            "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"
2670        ));
2671    }
2672
2673    #[test]
2674    fn structured_identifier_false_for_leetspeak_digit_interleaving() {
2675        // Digits interleaved with letters within a run (not a trailing digit
2676        // suffix) fail the `[A-Za-z]+[0-9]*` / `[0-9]+` shape check.
2677        assert!(!is_structured_identifier("S3cr3t-P4ssw0rd-t0ken-here!"));
2678    }
2679
2680    #[test]
2681    fn structured_identifier_false_for_run_over_length_cap() {
2682        // A 26-char single alphabetic run between separators fails the
2683        // 24-char per-run length cap even though it is otherwise trivially
2684        // word-shaped (uniform lowercase, zero case transitions).
2685        let long_run = "a".repeat(26);
2686        let token = format!("prefix-{long_run}-suffix");
2687        assert!(!is_structured_identifier(&token));
2688    }
2689
2690    // ── Structured-identifier exemption drops entirely in trigger context ───
2691    //
2692    // Narrower fixes that keep some exemption alive in trigger context (e.g.
2693    // requiring a trailing file-extension run, or requiring >= 2 path-shaped
2694    // runs with a low average per-run letters-only entropy) are all
2695    // attacker-defeatable: a random credential can be extension-suffixed, or
2696    // split/padded into short runs that drive each run's own entropy toward
2697    // its length ceiling (log2(run_len)), which real short path words already
2698    // sit at. Shannon entropy over an attacker-chosen run boundary cannot
2699    // distinguish "distinct letters that spell an English word" from
2700    // "distinct letters chosen adversarially" — both hit the same
2701    // log2(length) ceiling, so no aggregation is sound. The exemption is
2702    // therefore dropped unconditionally in trigger context: a
2703    // structured-identifier-shaped token near a trigger word is
2704    // entropy-checked like any other token, with 3 known false positives
2705    // accepted (see `accepted_false_positive_*` below).
2706
2707    #[test]
2708    fn blocks_separator_secret_access_key_bypass() {
2709        // Adversarial bypass shape: an AWS-secret-key-like value split into
2710        // separator-delimited word-shaped runs to dodge the entropy check.
2711        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk";
2712        assert!(
2713            check(content).is_err(),
2714            "AWS Secret Access Key shaped bypass must be blocked: {:?}",
2715            scan(content)
2716        );
2717    }
2718
2719    #[test]
2720    fn blocks_adversarial_lowercase_only_separator_token_near_access_key() {
2721        let content = "access_key qrstuvwxyz/abcdefghij/klmnopqrst/uvwxyzab";
2722        assert!(
2723            check(content).is_err(),
2724            "lowercase-only separator-delimited high-entropy token near \
2725             'access_key' must be blocked: {:?}",
2726            scan(content)
2727        );
2728    }
2729
2730    #[test]
2731    fn blocks_adversarial_digit_and_word_mixed_token_near_api_key() {
2732        // A mix of pure-digit runs and letters-then-digits runs (both
2733        // individually word-shaped) whose combined alphabet diversity crosses
2734        // the entropy threshold.
2735        let content = "api_key attaycofrsm827/festwqjhc493/8261947350/qwikjzx982";
2736        assert!(
2737            check(content).is_err(),
2738            "digit-and-word-mixed high-entropy token near 'api_key' must be blocked: {:?}",
2739            scan(content)
2740        );
2741    }
2742
2743    #[test]
2744    fn blocks_adversarial_token_assignment_separator_delimited_secret() {
2745        let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk";
2746        assert!(
2747            check(content).is_err(),
2748            "token= with lowercase-only separator-delimited high-entropy value \
2749             must be blocked: {:?}",
2750            scan(content)
2751        );
2752    }
2753
2754    #[test]
2755    fn blocks_extension_suffix_bypass_secret_access_key() {
2756        // A file-extension check alone would exempt this: appending `.md`
2757        // to a random credential must not bypass detection.
2758        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
2759        assert!(
2760            check(content).is_err(),
2761            "extension-suffixed AWS Secret Access Key shaped bypass must be blocked: {:?}",
2762            scan(content)
2763        );
2764    }
2765
2766    #[test]
2767    fn blocks_extension_suffix_bypass_token_assignment() {
2768        let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk.rs";
2769        assert!(
2770            check(content).is_err(),
2771            "extension-suffixed token= bypass must be blocked: {:?}",
2772            scan(content)
2773        );
2774    }
2775
2776    #[test]
2777    fn blocks_round1_bypass_strings_still_without_extension() {
2778        let cases = [
2779            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk",
2780            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk",
2781        ];
2782        for content in cases {
2783            assert!(
2784                check(content).is_err(),
2785                "round-1 bypass string must still be blocked: {content:?}, got: {:?}",
2786                scan(content)
2787            );
2788        }
2789    }
2790
2791    #[test]
2792    fn blocks_digit_run_suffix_bypass_attempt() {
2793        let cases = [
2794            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk2024",
2795            "secret_access_key abcdefghij2024/klmnopqrst/uvwxyzabcd/efghijk.md",
2796        ];
2797        for content in cases {
2798            assert!(
2799                check(content).is_err(),
2800                "digit-run-suffixed bypass attempt must be blocked: {content:?}, got: {:?}",
2801                scan(content)
2802            );
2803        }
2804    }
2805
2806    #[test]
2807    fn blocks_round3_padding_run_bypass_attempts() {
2808        // A low-entropy padding run (`aaaa`) inserted before short/digit-shaped
2809        // runs would drag any AVERAGE per-run entropy signal below its
2810        // threshold. With the exemption dropped entirely, these must be
2811        // blocked purely on full-token entropy, same as any other
2812        // near-trigger high-entropy token.
2813        let cases = [
2814            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/R1.md",
2815            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/R1.rs",
2816            "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/bbbb/R1.md",
2817            "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/bbbb/R1.rs",
2818        ];
2819        for content in cases {
2820            assert!(
2821                check(content).is_err(),
2822                "round-3 padding-run bypass attempt must be blocked: {content:?}, got: {:?}",
2823                scan(content)
2824            );
2825        }
2826    }
2827
2828    #[test]
2829    fn blocks_run_splitting_bypass_attempts() {
2830        // Splitting a credential into short (4-6 char) runs drives EVERY
2831        // run's own letters-only entropy toward log2(run_len), which ordinary
2832        // short English path words already sit at or near: this is exactly
2833        // why any per-run entropy ceiling is unsound as an exemption signal.
2834        // With the exemption dropped, these are blocked on full-token entropy
2835        // regardless of run shape.
2836        let cases = [
2837            "secret_access_key abcd/efgh/ijkl/mnop/qrst/uvwx/yzab/cdef.md",
2838            "secret_access_key abcde/fghij/klmno/pqrst/uvwxy/zabcd.md",
2839            "secret_access_key abcdef/ghijkl/mnopqr/stuvwx/yzabcd.md",
2840        ];
2841        for content in cases {
2842            assert!(
2843                check(content).is_err(),
2844                "run-splitting bypass attempt must be blocked: {content:?}, got: {:?}",
2845                scan(content)
2846            );
2847        }
2848    }
2849
2850    #[test]
2851    fn allows_fp_paths_whose_full_token_entropy_is_already_below_threshold() {
2852        // 4 of the 7 original FP-repro paths stay OK near a trigger word even
2853        // with NO structured-identifier exemption at all, because their own
2854        // full-token Shannon entropy already reads below ENTROPY_THRESHOLD
2855        // (4.5) — the exemption was never load-bearing for these regardless
2856        // of which version of it existed.
2857        let paths = [
2858            "review_pr335_round2.md",
2859            "docs/adr/ADR-055-epistemic-edge-relations.md",
2860            "crates/khive-pack-session/src/mirror/ingest.rs",
2861            "check_entropy_heuristic_impl",
2862        ];
2863        for p in paths {
2864            let content = format!("api_key handling in {p}");
2865            assert!(
2866                check(&content).is_ok(),
2867                "{p} must stay allowed near 'api_key' (full-token entropy already \
2868                 below threshold): fired {:?}",
2869                scan(&content)
2870            );
2871        }
2872    }
2873
2874    #[test]
2875    fn accepted_false_positive_adr_draft_path_near_trigger() {
2876        // Accepted tradeoff: this path's full-token Shannon entropy (4.5994)
2877        // exceeds ENTROPY_THRESHOLD (4.5) on its own. With the
2878        // structured-identifier exemption dropped in trigger context, it is
2879        // blocked near an explicit credential trigger word: a deliberate,
2880        // documented false positive, not a regression to fix, since no sound
2881        // signal exists to distinguish this from a chopped/padded credential
2882        // of the same shape.
2883        let content = "api_key handling in fable-ops/ADR-DRAFT-adr079-slices234.md";
2884        assert!(
2885            check(content).is_err(),
2886            "accepted FP: ADR-DRAFT path near 'api_key' is now blocked post round-4; \
2887             got {:?}",
2888            scan(content)
2889        );
2890    }
2891
2892    #[test]
2893    fn accepted_false_positive_workspace_packet_path_near_trigger() {
2894        // Same tradeoff as above: full-token entropy 4.7938 > 4.5.
2895        let content = "api_key handling in internal/workspaces/20260701/adr079-slices234/PACKET.md";
2896        assert!(
2897            check(content).is_err(),
2898            "accepted FP: PACKET.md workspace path near 'api_key' is now blocked \
2899             post round-4; got {:?}",
2900            scan(content)
2901        );
2902    }
2903
2904    #[test]
2905    fn accepted_false_positive_r1_repo_audit_path_near_trigger() {
2906        // Same tradeoff as above: full-token entropy 4.5955 > 4.5.
2907        let content =
2908            "api_key handling in internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
2909        assert!(
2910            check(content).is_err(),
2911            "accepted FP: R1-repo-audit path near 'api_key' is now blocked post \
2912             round-4; got {:?}",
2913            scan(content)
2914        );
2915    }
2916
2917    // ── UUID / content-hash allowlists are prose-context only ───────────────
2918
2919    #[test]
2920    fn blocks_uuid_directly_labeled_as_api_key() {
2921        let content = "api_key 550e8400-e29b-41d4-a716-446655440000";
2922        assert!(
2923            check(content).is_err(),
2924            "UUID-shaped token labeled api_key must be blocked; got {:?}",
2925            scan(content)
2926        );
2927    }
2928
2929    #[test]
2930    fn blocks_sha256_content_hash_labeled_as_secret() {
2931        let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
2932        assert!(
2933            check(content).is_err(),
2934            "sha256-prefixed hash labeled secret must be blocked; got {:?}",
2935            scan(content)
2936        );
2937    }
2938
2939    #[test]
2940    fn blocks_sha384_content_hash_labeled_as_api_key() {
2941        let content =
2942            "api_key sha384-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
2943        assert!(
2944            check(content).is_err(),
2945            "sha384-prefixed hash labeled api_key must be blocked; got {:?}",
2946            scan(content)
2947        );
2948    }
2949
2950    #[test]
2951    fn blocks_sha512_content_hash_labeled_as_auth() {
2952        let content = "auth sha512-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUV";
2953        assert!(
2954            check(content).is_err(),
2955            "sha512-prefixed hash labeled auth must be blocked; got {:?}",
2956            scan(content)
2957        );
2958    }
2959
2960    #[test]
2961    fn allows_uuid_with_no_trigger_within_window() {
2962        // Common benign shape: a UUID (e.g. an internal record id) with no
2963        // credential trigger word anywhere in the surrounding window stays
2964        // allowed — the allowlist still applies outside trigger context.
2965        let content =
2966            "task 550e8400-e29b-41d4-a716-446655440000 was created and assigned to the team";
2967        assert!(
2968            check(content).is_ok(),
2969            "UUID with no nearby trigger word must stay allowed; got {:?}",
2970            scan(content)
2971        );
2972    }
2973
2974    #[test]
2975    fn allows_internal_area_id_uuid_near_auth_substring_round5() {
2976        // An internal task `area_id` UUID field sitting within the trigger
2977        // window of the SUBSTRING "auth" inside
2978        // `authorized_write_requires_dominance` is not a genuine mention of
2979        // the word "auth": it is a pure substring collision with
2980        // "authorized". Bare trigger words match at a word boundary (see
2981        // `contains_bounded_word`), so `auth` does not match inside
2982        // `authorized`; this UUID has no trigger in its window and passes via
2983        // the ordinary out-of-context UUID allowlist.
2984        let content = "area_id: cfcea31d-6f50-4fd1-ad6d-5f160de1694c\n\n## Problem\nReduce Lion microkernel axioms. Converted authorized_write_requires_dominance from axiom to theorem.";
2985        assert!(
2986            check(content).is_ok(),
2987            "round-5 fix: internal area_id UUID near the 'authorized' substring \
2988             (not a genuine 'auth' mention) must now pass; got {:?}",
2989            scan(content)
2990        );
2991    }
2992
2993    // ── UUID/hash value extraction from assignment and wrapper syntax ───────
2994
2995    #[test]
2996    fn blocks_uuid_glued_to_assignment_equals() {
2997        let content = "api_key=550e8400-e29b-41d4-a716-446655440000";
2998        assert!(
2999            check(content).is_err(),
3000            "UUID glued via '=' to a trigger word must be blocked; got {:?}",
3001            scan(content)
3002        );
3003    }
3004
3005    #[test]
3006    fn blocks_uuid_with_trailing_sentence_period() {
3007        let content = "api_key 550e8400-e29b-41d4-a716-446655440000.";
3008        assert!(
3009            check(content).is_err(),
3010            "UUID with a trailing sentence period near a trigger must be blocked; got {:?}",
3011            scan(content)
3012        );
3013    }
3014
3015    #[test]
3016    fn blocks_uuid_wrapped_in_parens() {
3017        let content = "api_key (550e8400-e29b-41d4-a716-446655440000)";
3018        assert!(
3019            check(content).is_err(),
3020            "UUID wrapped in parens near a trigger must be blocked; got {:?}",
3021            scan(content)
3022        );
3023    }
3024
3025    #[test]
3026    fn blocks_uuid_in_json_object() {
3027        let content = "{\"api_key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
3028        assert!(
3029            check(content).is_err(),
3030            "UUID in a JSON-ish object near a trigger key must be blocked; got {:?}",
3031            scan(content)
3032        );
3033    }
3034
3035    #[test]
3036    fn blocks_content_hash_glued_to_assignment_equals() {
3037        let content = "secret=sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
3038        assert!(
3039            check(content).is_err(),
3040            "sha256-prefixed hash glued via '=' to a trigger word must be blocked; \
3041             got {:?}",
3042            scan(content)
3043        );
3044    }
3045
3046    #[test]
3047    fn blocks_content_hash_with_trailing_sentence_period() {
3048        let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq.";
3049        assert!(
3050            check(content).is_err(),
3051            "sha256-prefixed hash with a trailing period near a trigger must be \
3052             blocked; got {:?}",
3053            scan(content)
3054        );
3055    }
3056
3057    #[test]
3058    fn blocks_content_hash_wrapped_in_parens() {
3059        let content = "secret (sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq)";
3060        assert!(
3061            check(content).is_err(),
3062            "sha256-prefixed hash wrapped in parens near a trigger must be blocked; \
3063             got {:?}",
3064            scan(content)
3065        );
3066    }
3067
3068    #[test]
3069    fn blocks_content_hash_in_json_object() {
3070        let content = "{\"secret\":\"sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq\"}";
3071        assert!(
3072            check(content).is_err(),
3073            "sha256-prefixed hash in a JSON-ish object near a trigger key must be \
3074             blocked; got {:?}",
3075            scan(content)
3076        );
3077    }
3078
3079    #[test]
3080    fn allows_uuid_wrapped_in_parens_with_no_trigger_nearby() {
3081        // Control: the prose allowlist must survive for wrapper syntax when
3082        // there is no credential trigger word anywhere in the window — only
3083        // the trigger-context extraction changed, not the outside-context
3084        // allowlist itself.
3085        let content = "wrapper (550e8400-e29b-41d4-a716-446655440000) present";
3086        assert!(
3087            check(content).is_ok(),
3088            "UUID wrapped in parens with no trigger word nearby must stay allowed; \
3089             got {:?}",
3090            scan(content)
3091        );
3092    }
3093
3094    #[test]
3095    fn blocks_padded_content_hash_glued_to_assignment_with_trailing_period() {
3096        // A padded base64 value ends in its own `=`, which is also a valid
3097        // separator character — `value_candidates` must enumerate the
3098        // suffix after every `=`/`:`, not assume any single separator
3099        // position, so the true value is recovered regardless of which
3100        // separator happens to sit where.
3101        let content = "secret=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
3102        assert!(
3103            check(content).is_err(),
3104            "padded sha256 hash glued via '=' with a trailing period must be \
3105             blocked; got {:?}",
3106            scan(content)
3107        );
3108    }
3109
3110    #[test]
3111    fn blocks_padded_content_hash_in_json_object() {
3112        let content = "{\"secret\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}";
3113        assert!(
3114            check(content).is_err(),
3115            "padded sha256 hash in a JSON-ish object near a trigger key must be \
3116             blocked; got {:?}",
3117            scan(content)
3118        );
3119    }
3120
3121    #[test]
3122    fn blocks_uuid_when_json_label_itself_contains_colon() {
3123        // The label can itself contain the separator character
3124        // (`"api:key"` rather than `"api_key"`); the first `:` after
3125        // wrapper-stripping then lands inside the label, not at the
3126        // label/value boundary. value_candidates must still surface the
3127        // bare UUID as a later suffix candidate.
3128        let content = "{\"api:key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
3129        assert!(
3130            check(content).is_err(),
3131            "UUID must be blocked even when the JSON label contains ':'; got {:?}",
3132            scan(content)
3133        );
3134    }
3135
3136    #[test]
3137    fn blocks_uuid_when_json_label_itself_contains_equals() {
3138        let content = "{\"api=key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
3139        assert!(
3140            check(content).is_err(),
3141            "UUID must be blocked even when the JSON label contains '='; got {:?}",
3142            scan(content)
3143        );
3144    }
3145
3146    #[test]
3147    fn blocks_uuid_behind_doubled_assignment() {
3148        // key=label=value: the first `=` lands between two labels, not at
3149        // the true value boundary.
3150        let content = "api_key=label=550e8400-e29b-41d4-a716-446655440000"; // gitleaks:allow
3151        assert!(
3152            check(content).is_err(),
3153            "UUID must be blocked behind a doubled assignment; got {:?}",
3154            scan(content)
3155        );
3156    }
3157
3158    #[test]
3159    fn blocks_padded_content_hash_behind_doubled_assignment_equals() {
3160        let content = "secret=label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
3161        assert!(
3162            check(content).is_err(),
3163            "padded content hash must be blocked behind a doubled '=' assignment; \
3164             got {:?}",
3165            scan(content)
3166        );
3167    }
3168
3169    #[test]
3170    fn blocks_padded_content_hash_behind_doubled_assignment_colon() {
3171        let content = "secret:label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
3172        assert!(
3173            check(content).is_err(),
3174            "padded content hash must be blocked behind a doubled ':'+'=' \
3175             assignment; got {:?}",
3176            scan(content)
3177        );
3178    }
3179
3180    #[test]
3181    fn allows_benign_url_with_scheme_and_path_separators() {
3182        // `value_candidates`'s any-suffix semantics must not block ordinary
3183        // URLs, whose `://` and `/` characters produce several suffix
3184        // candidates but none of them are UUID- or content-hash-shaped.
3185        // Placed near a real trigger word ("key") so the check actually
3186        // exercises the trigger-context path rather than being skipped
3187        // outright.
3188        let content = "api_key endpoint=https://example.test/resource/for/testing";
3189        assert!(
3190            check(content).is_ok(),
3191            "a benign URL near a trigger word must stay allowed; got {:?}",
3192            scan(content)
3193        );
3194    }
3195
3196    // ── Trigger word-boundary matching ──────────────────────────────────────
3197
3198    #[test]
3199    fn allows_authorized_and_authentication_prose_near_uuid() {
3200        // The word-boundary fix directly: "auth" no longer matches the
3201        // substring inside "authorized"/"authentication", so ordinary prose
3202        // using those words does not poison the trigger window for a nearby
3203        // UUID or other allowlisted shape.
3204        let cases = [
3205            "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
3206            "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
3207        ];
3208        for content in cases {
3209            assert!(
3210                check(content).is_ok(),
3211                "'authorized'/'authentication' substring must not trigger the \
3212                 entropy heuristic: {content:?}, got {:?}",
3213                scan(content)
3214            );
3215        }
3216    }
3217
3218    #[test]
3219    fn allows_turkey_monkey_keyword_prose_near_uuid() {
3220        // Other bare-word substring collisions in TRIGGER_WORDS ("key") must
3221        // likewise not fire on ordinary English words that merely contain it.
3222        let cases = [
3223            "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
3224            "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
3225        ];
3226        for content in cases {
3227            assert!(
3228                check(content).is_ok(),
3229                "'turkey'/'monkey'/'keyword' substring must not trigger the \
3230                 entropy heuristic: {content:?}, got {:?}",
3231                scan(content)
3232            );
3233        }
3234    }
3235
3236    #[test]
3237    fn blocks_standalone_auth_and_key_words_unchanged_by_round5() {
3238        // Word-boundary matching only removes SUBSTRING collisions; a genuine
3239        // standalone trigger word must still dominate exactly as before.
3240        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3241        let cases = [
3242            format!("auth header {opaque}"),
3243            format!("the key is {opaque}"),
3244            format!("secret value: {opaque}"),
3245        ];
3246        for content in &cases {
3247            assert!(
3248                check(content).is_err(),
3249                "a genuine standalone trigger word must still block: {content:?}, \
3250                 got {:?}",
3251                scan(content)
3252            );
3253        }
3254    }
3255
3256    #[test]
3257    fn accepted_false_positive_workspace_artifact_path_near_standalone_secret_round5() {
3258        // A dot-prefixed root + date segment + hyphenated topic dir +
3259        // SCREAMING_SNAKE filename, discussed in prose that genuinely (not by
3260        // substring collision) mentions "secret". `secret` here is a real
3261        // standalone word, not a substring collision, so it still dominates
3262        // and the path still falls through to the entropy heuristic like any
3263        // other near-trigger token. A documented accepted tradeoff, not a
3264        // regression.
3265        let content = "writing up the secret gate false positive repro: \
3266             .workspace/20260101/fix-secret-gate-trigger-false-positive/MEASUREMENT_REPORT.md";
3267        assert!(
3268            check(content).is_err(),
3269            "accepted FP (round 5 unchanged): workspace artifact path near a \
3270             genuine standalone 'secret' mention is still blocked; got {:?}",
3271            scan(content)
3272        );
3273    }
3274
3275    #[test]
3276    fn accepted_false_positive_archive_doc_path_near_standalone_secret_round5() {
3277        // An archive-style doc path discussed near a genuine standalone
3278        // "secret" mention.
3279        let content =
3280            "secret scanner archive notes: docs/_archive/ADR051-TenantEncryption-v2Notes.md";
3281        assert!(
3282            check(content).is_err(),
3283            "accepted FP (round 5 unchanged): archive doc path near a genuine \
3284             standalone 'secret' mention is still blocked; got {:?}",
3285            scan(content)
3286        );
3287    }
3288
3289    #[test]
3290    fn accepted_false_positive_absolute_path_near_standalone_auth_round5() {
3291        // An absolute path written as one unbroken token, with the
3292        // surrounding message genuinely (not via substring collision)
3293        // discussing "auth".
3294        let content = "the auth scanner flagged this file: /home/user/projects/workspace/SessionNotes20260107/AuthGateFollowup2.md";
3295        assert!(
3296            check(content).is_err(),
3297            "accepted FP (round 5 unchanged): absolute path near a genuine \
3298             standalone 'auth' mention is still blocked; got {:?}",
3299            scan(content)
3300        );
3301    }
3302
3303    #[test]
3304    fn blocks_assignment_shaped_credential_disguised_as_path_near_api_key() {
3305        // Adversarial negative: a credential-shaped value glued via '='
3306        // directly to a trigger word must not be exempted just because it is
3307        // path-shaped (separator-delimited, word-shaped runs) and looks
3308        // superficially like the accepted-FP repro paths above. The compound
3309        // entry `api_key` is a plain substring match regardless of
3310        // word-boundary rules, and the structured-identifier exemption is
3311        // unconditionally dropped in trigger context, so this must block.
3312        let content = "api_key=/home/user/workspaces/2026/topic-name-example/SECRET_VALUE_HERE.md";
3313        assert!(
3314            check(content).is_err(),
3315            "assignment-shaped credential disguised as a path must still be \
3316             blocked: {content:?}, got {:?}",
3317            scan(content)
3318        );
3319    }
3320
3321    #[test]
3322    fn blocks_secret_access_key_bypass_compound_entry_unaffected_by_round5() {
3323        // Adversarial negative: a separator-split bypass shape must still be
3324        // blocked. It fires via TWO independent paths: the compound
3325        // `access_key` entry (`COMPOUND_TRIGGER_WORDS`, plain substring,
3326        // always matched regardless of word-boundary rules), AND the bare
3327        // `secret` entry, because underscore is a BOUNDARY for bare
3328        // `TRIGGER_WORDS`: so `secret` in `secret_access_key` is itself a
3329        // bounded word (bounded by the following `_`), not merely a
3330        // substring collision. Either path alone is sufficient; this asserts
3331        // the end-to-end outcome.
3332        let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
3333        assert!(
3334            check(content).is_err(),
3335            "secret_access_key bypass shape must still be blocked: {content:?}, \
3336             got {:?}",
3337            scan(content)
3338        );
3339    }
3340
3341    // ── Underscore is a BOUNDARY for bare TRIGGER_WORDS, not a continuation ─
3342    //
3343    // Bare TRIGGER_WORDS are word-boundary-aware, but underscore must be
3344    // treated as a boundary rather than a word character (continuation) for
3345    // this set specifically: the opposite of `has_standalone_token`'s rule
3346    // for `token`. Treating underscore as a continuation would silently drop
3347    // detection of extremely common underscore-joined credential-config
3348    // compounds (`SECRET_KEY=`, `auth_token=`, `signing_key=`,
3349    // `session_secret_...`), since `secret`/`key`/`auth` would never be
3350    // bounded by `_` under that rule. Fixed by treating `_` as a boundary for
3351    // the bare `TRIGGER_WORDS` check specifically (see `contains_word`'s
3352    // `underscore_is_word_char` parameter), while leaving
3353    // `has_standalone_token`'s `token`-specific underscore-as-continuation
3354    // rule (the `tokenizer`/`next_token`/`token_count` exemption) unchanged.
3355
3356    #[test]
3357    fn blocks_django_style_secret_key_assignment_round5b() {
3358        // `SECRET_KEY=<value>` must block via the plain-substring `secret`
3359        // trigger even though `secret` is followed by `_` rather than a
3360        // non-word-char boundary.
3361        let content = "SECRET_KEY=dGhpc2lzYXNlY3JldGtleXZhbHVlMTIzNDU2Nzg5MA=="; // gitleaks:allow
3362        assert!(
3363            check(content).is_err(),
3364            "SECRET_KEY=<value> (Django/Flask-style config) must still be \
3365             blocked: {content:?}, got {:?}",
3366            scan(content)
3367        );
3368    }
3369
3370    #[test]
3371    fn blocks_auth_token_assignment_round5b() {
3372        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3373        let content = format!("auth_token={opaque}");
3374        assert!(
3375            check(&content).is_err(),
3376            "auth_token=<value> must still be blocked: {content:?}, got {:?}",
3377            scan(&content)
3378        );
3379    }
3380
3381    #[test]
3382    fn blocks_session_secret_and_signing_key_compounds_round5b() {
3383        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3384        let cases = [
3385            format!("session_secret_{opaque}"),
3386            format!("signing_key={opaque}"),
3387        ];
3388        for content in &cases {
3389            assert!(
3390                check(content).is_err(),
3391                "underscore-joined credential compound must still be blocked: \
3392                 {content:?}, got {:?}",
3393                scan(content)
3394            );
3395        }
3396    }
3397
3398    #[test]
3399    fn allows_authorized_authentication_keyword_prose_unaffected_by_round5b() {
3400        // The letter-joined substring-collision exemption must survive the
3401        // underscore-as-boundary change, since that change only affects the
3402        // underscore character, not letter-joined words.
3403        let cases = [
3404            "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
3405            "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
3406            "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
3407            "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
3408        ];
3409        for content in cases {
3410            assert!(
3411                check(content).is_ok(),
3412                "letter-joined substring collision must stay exempt after \
3413                 round-5b: {content:?}, got {:?}",
3414                scan(content)
3415            );
3416        }
3417    }
3418
3419    #[test]
3420    fn block_message_carries_actionable_guidance() {
3421        let fake = "AKIAFAKEKEY1234567890";
3422        let m = scan(fake).unwrap();
3423        let rendered = m.to_string();
3424        assert!(
3425            rendered.contains("real credential"),
3426            "block message must carry actionable guidance: {rendered}"
3427        );
3428    }
3429
3430    #[test]
3431    fn block_message_shape_guidance_mentions_rewording() {
3432        let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; // gitleaks:allow
3433        let content = format!("auth header {opaque}");
3434        let m = scan(&content).unwrap();
3435        let rendered = m.to_string();
3436        assert!(
3437            rendered.contains("reword") || rendered.contains("own line"),
3438            "shape-based detector guidance must suggest rewording/splitting: {rendered}"
3439        );
3440    }
3441}
3442
3443// ─── Corpus replay harness (manual, opt-in) ─────────────────────────────────
3444//
3445// Measures how many real note/entity strings the gate blocks, so a detector
3446// change can be evaluated against production content rather than intuition
3447// (see the module doc). Opens the target database
3448// STRICTLY read-only (`SQLITE_OPEN_READ_ONLY`) and never mutates it. Point
3449// `KHIVE_REPLAY_DB` at a copy or a live KG database file path; the harness
3450// never writes, locks aggressively, or deletes anything.
3451//
3452// Run with: `KHIVE_REPLAY_DB=/path/to/khive.db cargo test -p khive-runtime \
3453//   --release -- --ignored --nocapture corpus_replay`
3454#[cfg(test)]
3455mod corpus_replay {
3456    use super::*;
3457    use rusqlite::{Connection, OpenFlags};
3458
3459    #[test]
3460    #[ignore]
3461    fn replay_against_corpus() {
3462        let db_path = std::env::var("KHIVE_REPLAY_DB")
3463            .expect("set KHIVE_REPLAY_DB=/path/to/khive.db to run the corpus replay (read-only)");
3464        let conn = Connection::open_with_flags(
3465            &db_path,
3466            OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
3467        )
3468        .expect("open corpus DB read-only");
3469
3470        let mut total = 0usize;
3471        let mut blocked = 0usize;
3472        let mut samples: Vec<String> = Vec::new();
3473
3474        let mut collect = |sql: &str| {
3475            let mut stmt = conn.prepare(sql).expect("prepare replay query");
3476            let mut rows = stmt.query([]).expect("query replay rows");
3477            while let Some(row) = rows.next().expect("read replay row") {
3478                let content: Option<String> = row.get(0).unwrap_or(None);
3479                let Some(content) = content else { continue };
3480                if content.is_empty() {
3481                    continue;
3482                }
3483                total += 1;
3484                if let Some(m) = scan(&content) {
3485                    blocked += 1;
3486                    if samples.len() < 30 {
3487                        samples.push(format!(
3488                            "{} :: {}",
3489                            m,
3490                            content.chars().take(160).collect::<String>()
3491                        ));
3492                    }
3493                }
3494            }
3495        };
3496
3497        collect("SELECT content FROM notes WHERE deleted_at IS NULL");
3498        collect("SELECT description FROM entities WHERE deleted_at IS NULL");
3499
3500        eprintln!("corpus replay: {blocked}/{total} strings blocked");
3501        for s in &samples {
3502            eprintln!("  BLOCKED: {s}");
3503        }
3504    }
3505}