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}