1use crate::error::{RuntimeError, RuntimeResult};
54
55mod submitted_atom_digest;
56
57pub use submitted_atom_digest::masked_submitted_atom_digest_v1;
58
59#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct SecretMatch {
67 pub detector: &'static str,
69 pub trigger: Option<&'static str>,
71 pub masked: String,
73 pub location: Option<String>,
79}
80
81impl std::fmt::Display for SecretMatch {
82 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
83 write!(f, "content matches secret pattern {}", self.detector)?;
84 if let Some(trigger) = self.trigger {
85 write!(f, " near '{trigger}'")?;
86 }
87 if let Some(location) = &self.location {
88 write!(f, " in {location}")?;
89 }
90 write!(f, ". {}", block_guidance(self.detector))
91 }
92}
93
94fn block_guidance(detector: &'static str) -> &'static str {
103 match detector {
104 "url-userinfo" => {
105 "Placeholders in URL credential positions still match this pattern. \
106 Replace the whole URL with an environment-variable name or config key, \
107 or remove the entire user/password segment before writing."
108 }
109 "high-entropy-token"
110 | "uuid-near-trigger"
111 | "content-hash-near-trigger"
112 | "hex-credential-token" => {
113 "If this is a real credential, remove it before writing. If it is not \
114 (e.g. a file path, UUID, or hash that happens to sit near a word like \
115 key/secret/auth/token), put the candidate in a separate sentence or \
116 paragraph from those words, or express a source hash as an explicit \
117 commit/revision reference."
118 }
119 _ => {
120 "If this is a real credential, remove it before writing; store secrets \
121 in an env var or secrets manager instead."
122 }
123 }
124}
125
126pub fn check(content: &str) -> RuntimeResult<()> {
131 if let Some(m) = scan(content) {
132 return Err(RuntimeError::SecretDetected(m));
133 }
134 Ok(())
135}
136
137pub fn check_json(value: &serde_json::Value) -> RuntimeResult<()> {
143 scan_json_value(value)
144}
145
146pub fn check_tags(tags: &[String]) -> RuntimeResult<()> {
150 for tag in tags {
151 check(tag)?;
152 }
153 Ok(())
154}
155
156pub fn locate<T>(result: RuntimeResult<T>, record: &str, field: &str) -> RuntimeResult<T> {
170 result.map_err(|error| match error {
171 RuntimeError::SecretDetected(matched) => RuntimeError::SecretDetected(SecretMatch {
172 location: Some(format!("{record}.{field}")),
173 ..matched
174 }),
175 other => other,
176 })
177}
178
179pub fn check_at(content: &str, record: &str, field: &str) -> RuntimeResult<()> {
182 locate(check(content), record, field)
183}
184
185pub fn check_json_at(value: &serde_json::Value, record: &str, field: &str) -> RuntimeResult<()> {
188 locate(check_json(value), record, field)
189}
190
191pub fn check_tags_at(tags: &[String], record: &str, field: &str) -> RuntimeResult<()> {
193 locate(check_tags(tags), record, field)
194}
195
196pub const RESERVED_SECRET_GATE_KEY: &str = "khive:secret_gate";
209
210pub fn reject_reserved_secret_gate_property(
222 properties: Option<&serde_json::Value>,
223) -> RuntimeResult<()> {
224 if let Some(serde_json::Value::Object(map)) = properties {
225 if map.contains_key(RESERVED_SECRET_GATE_KEY) {
226 return Err(RuntimeError::InvalidInput(format!(
227 "property key `{RESERVED_SECRET_GATE_KEY}` is runtime-owned and cannot be \
228 created, replaced, merged, or removed by callers"
229 )));
230 }
231 }
232 Ok(())
233}
234
235fn scan_json_value(value: &serde_json::Value) -> RuntimeResult<()> {
236 match value {
237 serde_json::Value::String(s) => check(s),
238 serde_json::Value::Array(arr) => {
239 for v in arr {
240 scan_json_value(v)?;
241 }
242 Ok(())
243 }
244 serde_json::Value::Object(map) => {
245 for (k, v) in map {
246 check(k)?;
249 scan_json_value(v)?;
250 }
251 Ok(())
252 }
253 _ => Ok(()),
254 }
255}
256
257const REDACTION_MARKER: &str = "***MASKED***";
261
262const MAX_MASK_SCAN_WORK_BYTES: usize = MAX_LOG_TEXT_MASK_INPUT_CHARS * 2;
269
270#[cfg(test)]
271thread_local! {
272 static ENTROPY_TOKENIZATION_COUNT: std::cell::Cell<usize> = const { std::cell::Cell::new(0) };
273}
274
275#[cfg(test)]
287fn scan_match(text: &str) -> Option<(&str, &'static str)> {
288 let context = EntropyScanContext::new(text);
289 scan_from(text, 0, &context)
290}
291
292fn scan_from<'a>(
301 text: &'a str,
302 from: usize,
303 context: &EntropyScanContext<'a>,
304) -> Option<(&'a str, &'static str)> {
305 scan_from_with_trigger(text, from, context).map(|(slice, detector, _)| (slice, detector))
306}
307
308fn scan_from_with_trigger<'a>(
309 text: &'a str,
310 from: usize,
311 context: &EntropyScanContext<'a>,
312) -> Option<(&'a str, &'static str, Option<&'static str>)> {
313 let mut best =
314 check_known_patterns(&text[from..]).map(|(slice, detector)| (slice, detector, None));
315 if let Some(candidate) = check_entropy_heuristic(text, from, context) {
316 if best
317 .as_ref()
318 .is_none_or(|current| candidate.0.as_ptr() < current.0.as_ptr())
319 {
320 best = Some(candidate);
321 }
322 }
323 best
324}
325
326fn keep_leftmost<'a>(
332 best: &mut Option<(&'a str, &'static str)>,
333 cand: Option<(&'a str, &'static str)>,
334 base: usize,
335) {
336 if let Some((slice, name)) = cand {
337 let start = slice.as_ptr() as usize - base;
338 let replace = match *best {
339 Some((incumbent, _)) => start < (incumbent.as_ptr() as usize - base),
340 None => true,
341 };
342 if replace {
343 *best = Some((slice, name));
344 }
345 }
346}
347
348fn scan(text: &str) -> Option<SecretMatch> {
350 let context = EntropyScanContext::new(text);
351 scan_from_with_trigger(text, 0, &context).map(|(slice, detector, trigger)| {
352 let mut matched = build_match(detector, slice);
353 matched.trigger = trigger;
354 matched
355 })
356}
357
358#[derive(Debug, Clone, Copy, PartialEq, Eq)]
361pub enum RedactionSurface {
362 GitIngest,
364 SessionMirror,
366 McpDiagnostic,
368 GateProbe,
370}
371
372#[derive(Debug, Clone, Copy, PartialEq, Eq)]
374pub enum RedactionSurfaceMode {
375 PermanentMaskOnly,
377}
378
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
381pub struct RedactionSurfaceContract {
382 pub mode: RedactionSurfaceMode,
383 pub final_stored_target: Option<&'static str>,
385 pub stamp_property: Option<&'static str>,
387 pub atomic_success_event: Option<&'static str>,
389}
390
391pub const GIT_INGEST_STORED_TARGET: &str = "final git-ingest entity/note fields";
394
395pub const SESSION_MIRROR_STORED_TARGET: &str =
402 "session_messages.text, session_messages.raw, sessions.cwd, sessions.git_branch, \
403 and sessions.slug";
404
405pub const fn redaction_surface_contract(surface: RedactionSurface) -> RedactionSurfaceContract {
407 let final_stored_target = match surface {
408 RedactionSurface::GitIngest => Some(GIT_INGEST_STORED_TARGET),
409 RedactionSurface::SessionMirror => Some(SESSION_MIRROR_STORED_TARGET),
410 RedactionSurface::McpDiagnostic | RedactionSurface::GateProbe => None,
411 };
412
413 RedactionSurfaceContract {
414 mode: RedactionSurfaceMode::PermanentMaskOnly,
415 final_stored_target,
416 stamp_property: None,
417 atomic_success_event: None,
418 }
419}
420
421pub fn mask_for_redaction_surface(
427 surface: RedactionSurface,
428 text: &str,
429) -> std::borrow::Cow<'_, str> {
430 match redaction_surface_contract(surface).mode {
431 RedactionSurfaceMode::PermanentMaskOnly => mask_secrets(text),
432 }
433}
434
435pub const MASK_WINDOW_CHARS: usize = 4_096;
445
446#[derive(Debug, Clone, PartialEq, Eq)]
450pub struct BoundedMask {
451 pub text: String,
455 pub truncated: bool,
460 pub redacted: bool,
467}
468
469pub fn mask_bounded(
499 surface: RedactionSurface,
500 text: &str,
501 window_chars: usize,
502 output_cap_chars: usize,
503) -> BoundedMask {
504 debug_assert!(
505 window_chars >= output_cap_chars,
506 "the input window must stay at least as large as the output cap"
507 );
508 let output_cap_chars = output_cap_chars.min(window_chars);
509
510 let input_truncated = text.chars().nth(window_chars).is_some();
511 let mut window: String = text.chars().take(window_chars).collect();
512
513 if input_truncated {
514 match window
515 .char_indices()
516 .rev()
517 .find(|(_, ch)| ch.is_whitespace())
518 {
519 Some((idx, ch)) => window.truncate(idx + ch.len_utf8()),
521 None => window.clear(),
524 }
525 }
526
527 if input_truncated && !window.is_empty() {
528 if let Some(cut) = trailing_bridge_fragment_cut(&window) {
529 window.truncate(cut);
530 }
531 }
532
533 if input_truncated && window.is_empty() {
534 return BoundedMask {
535 text: TRUNCATION_MARKER.to_string(),
536 truncated: true,
537 redacted: true,
538 };
539 }
540
541 debug_assert!(window.chars().count() <= window_chars);
542 let masked = mask_for_redaction_surface(surface, &window);
543 let redacted = masked.as_ref() != window.as_str();
544
545 let mut chars = masked.chars();
546 let mut bounded: String = chars.by_ref().take(output_cap_chars).collect();
547 let output_capped = chars.next().is_some();
548 if output_capped || input_truncated {
549 bounded.push_str(TRUNCATION_MARKER);
550 }
551
552 BoundedMask {
553 text: bounded,
554 truncated: input_truncated || output_capped,
555 redacted,
556 }
557}
558
559const TRUNCATION_MARKER: &str = "…";
560
561pub fn mask_secrets(text: &str) -> std::borrow::Cow<'_, str> {
579 let (spans, _scan_work_bytes) = collect_mask_spans(text);
580 if spans.is_empty() {
581 return std::borrow::Cow::Borrowed(text);
582 }
583 let mut out = String::with_capacity(text.len());
584 let mut cursor = 0;
585 for (start, end) in spans {
586 let start = start.max(cursor);
589 out.push_str(&text[cursor..start]);
590 out.push_str(REDACTION_MARKER);
591 cursor = end.max(cursor);
592 }
593 out.push_str(&text[cursor..]);
594 std::borrow::Cow::Owned(out)
595}
596
597fn collect_mask_spans(text: &str) -> (Vec<(usize, usize)>, usize) {
600 let base = text.as_ptr() as usize;
601 let context = EntropyScanContext::new(text);
602 let tokens = &context.tokens;
603 let mut spans: Vec<(usize, usize)> = Vec::new();
607 let mut from = 0;
608 let mut scan_work_bytes = 0usize;
609 while from < text.len() {
610 let token_index = tokens.partition_point(|&(offset, raw)| offset + raw.len() <= from);
614 let scan_start = tokens
615 .get(token_index)
616 .map_or(from, |&(offset, _)| offset.min(from));
617 let scan_len = text.len() - scan_start;
618 let next_scan_work = scan_work_bytes.saturating_add(scan_len);
619 if scan_work_bytes > 0 && next_scan_work > MAX_MASK_SCAN_WORK_BYTES {
620 spans.last_mut().expect("a prior scan found a span").1 = text.len();
623 break;
624 }
625 scan_work_bytes = next_scan_work;
626 match scan_from(text, from, &context) {
627 Some((sub, _detector)) => {
628 let start = sub.as_ptr() as usize - base;
629 let core_len = sub
637 .trim_end_matches(['"', '\'', '`', '}', ']', ')', ',', ';'])
638 .len();
639 let end = extend_across_invisible_bridge(text, start + core_len.max(1));
640 push_mask_spans(text, start, end, &mut spans);
641 from = end;
644 }
645 None => break,
646 }
647 }
648 (spans, scan_work_bytes)
649}
650
651fn is_invisible_bridge_separator(c: char) -> bool {
658 !c.is_ascii() && !c.is_alphanumeric()
659}
660
661fn extend_across_invisible_bridge(text: &str, end: usize) -> usize {
673 let mut end = end;
674 for _ in 1..MAX_BRIDGE_FRAGMENTS {
675 let rest = &text[end..];
676 let Some(gap_len) = rest.find(|c: char| c.is_ascii_alphanumeric()) else {
677 break;
678 };
679 let gap = &rest[..gap_len];
680 if gap.is_empty() || !gap.chars().all(is_invisible_bridge_separator) {
681 break;
682 }
683 let fragment = &rest[gap_len..];
684 let fragment_len = fragment
685 .find(|c: char| !c.is_ascii_alphanumeric())
686 .unwrap_or(fragment.len());
687 if fragment_len < MIN_BRIDGE_FRAGMENT_LEN {
688 break;
689 }
690 end += gap_len + fragment_len;
691 }
692 end
693}
694
695fn push_mask_spans(text: &str, start: usize, end: usize, spans: &mut Vec<(usize, usize)>) {
706 let span = &text[start..end];
707 if !span.chars().any(is_invisible_bridge_separator) {
708 spans.push((start, end));
709 return;
710 }
711 let before = spans.len();
712 let mut run_start: Option<usize> = None;
713 for (offset, ch) in span.char_indices() {
714 if is_invisible_bridge_separator(ch) {
715 if let Some(run) = run_start.take() {
716 spans.push((start + run, start + offset));
717 }
718 } else {
719 run_start.get_or_insert(offset);
720 }
721 }
722 if let Some(run) = run_start {
723 spans.push((start + run, end));
724 }
725 if spans.len() == before {
726 spans.push((start, end));
727 }
728}
729
730const MAX_LOG_TEXT_MASK_INPUT_CHARS: usize = 1_048_576;
743const MAX_LOG_TEXT_OUTPUT_CHARS: usize = 1_024;
745
746pub fn bounded_masked_log_text(text: &str) -> String {
780 let mask_input_truncated = text.chars().nth(MAX_LOG_TEXT_MASK_INPUT_CHARS).is_some();
781 let bounded_input: std::borrow::Cow<'_, str> = if mask_input_truncated {
782 std::borrow::Cow::Owned(text.chars().take(MAX_LOG_TEXT_MASK_INPUT_CHARS).collect())
783 } else {
784 std::borrow::Cow::Borrowed(text)
785 };
786 let masked = mask_secrets(&bounded_input);
787 let masked = if mask_input_truncated {
788 redact_crossing_boundary_url_userinfo(&masked)
789 } else {
790 masked
791 };
792 let neutralized = neutralize_log_unsafe_chars(&masked);
793
794 let mut chars = neutralized.chars();
795 let mut bounded: String = chars.by_ref().take(MAX_LOG_TEXT_OUTPUT_CHARS).collect();
796 if chars.next().is_some() || mask_input_truncated {
797 bounded.push('…');
798 }
799 bounded
800}
801
802fn redact_crossing_boundary_url_userinfo(text: &str) -> std::borrow::Cow<'_, str> {
827 let mut search_from = 0usize;
828 while let Some(rel) = text[search_from..].find("://") {
829 let scheme_pos = search_from + rel;
830 let rest = &text[scheme_pos + 3..];
831 let terminated =
832 rest.contains('@') || rest.contains(' ') || rest.contains('\n') || rest.contains('\r');
833 if !terminated {
834 let authority_end = rest.find(['/', '?', '#']).unwrap_or(rest.len());
842 if let Some(colon) = rest[..authority_end].find(':') {
843 let pass = &rest[colon + 1..];
844 if !pass.is_empty() {
845 let redact_from = scheme_pos + 3 + colon;
846 let mut out = String::with_capacity(redact_from + REDACTION_MARKER.len());
847 out.push_str(&text[..redact_from]);
848 out.push_str(REDACTION_MARKER);
849 return std::borrow::Cow::Owned(out);
850 }
851 }
852 }
853 search_from = scheme_pos + 3;
854 }
855 std::borrow::Cow::Borrowed(text)
856}
857
858fn is_log_unsafe_char(c: char) -> bool {
867 if c == '\t' {
868 return false;
869 }
870 matches!(
871 unicode_general_category::get_general_category(c),
872 unicode_general_category::GeneralCategory::Control
873 | unicode_general_category::GeneralCategory::Format
874 | unicode_general_category::GeneralCategory::LineSeparator
875 | unicode_general_category::GeneralCategory::ParagraphSeparator
876 )
877}
878
879fn neutralize_log_unsafe_chars(text: &str) -> std::borrow::Cow<'_, str> {
887 if !text.chars().any(is_log_unsafe_char) {
888 return std::borrow::Cow::Borrowed(text);
889 }
890 let mut out = String::with_capacity(text.len());
891 for c in text.chars() {
892 if is_log_unsafe_char(c) {
893 out.push_str(&format!("\\u{{{:04x}}}", c as u32));
894 } else {
895 out.push(c);
896 }
897 }
898 std::borrow::Cow::Owned(out)
899}
900
901const PREFIX_DETECTORS: &[(&str, &str, usize)] = &[
909 ("aws-access-key-id", "AKIA", 20),
911 ("aws-access-key-id", "ASIA", 20),
912 ("github-token", "ghp_", 36),
917 ("github-token", "gho_", 36),
918 ("github-token", "ghu_", 36),
919 ("github-token", "ghs_", 36),
920 ("github-token", "ghr_", 36),
921 ("github-token", "github_pat_", 93),
922 ("openai-api-key", "sk-proj-", 88),
924 ("anthropic-api-key", "sk-ant-", 108),
929 ("stripe-secret-key", "sk_live_", 30),
931 ("stripe-restricted-key", "rk_live_", 30),
932 ("fly-token", "fm2_", 20),
934 ("vercel-token", "vercel_", 20),
936 ("slack-token", "xoxb-", 40),
938 ("slack-token", "xoxa-", 40),
939 ("slack-token", "xoxp-", 40),
940 ("slack-token", "xoxr-", 40),
941 ("slack-token", "xoxs-", 40),
942 ("age-secret-key", "AGE-SECRET-KEY-", 60),
944];
945
946const SK_SAFE_PREFIXES: &[&str] = &["sk-learn", "sk-image", "sk-lego", "sk-base", "sk-misc"];
949
950fn check_known_patterns(text: &str) -> Option<(&str, &'static str)> {
957 let base = text.as_ptr() as usize;
958 let mut best: Option<(&str, &'static str)> = None;
959
960 for &(name, needle, min_len) in PREFIX_DETECTORS {
962 keep_leftmost(
963 &mut best,
964 find_prefix_token(text, needle, min_len).map(|m| (m, name)),
965 base,
966 );
967 }
968
969 if let Some(token) = find_bare_sk_token(text) {
972 keep_leftmost(&mut best, Some((token, "openai-api-key")), base);
973 }
974
975 let mut from = 0;
980 while let Some(rel) = text[from..].find("FlyV1 ") {
981 let pos = from + rel;
982 let at_boundary = pos == 0 || {
983 text[..pos]
984 .chars()
985 .next_back()
986 .is_none_or(|c| !c.is_ascii_alphanumeric())
987 };
988 if at_boundary {
989 let payload_start = pos + 6; let payload = extract_token(&text[payload_start..]);
991 if payload.len() >= 4 {
992 let candidate = &text[pos..payload_start + payload.len()];
993 keep_leftmost(&mut best, Some((candidate, "fly-token")), base);
994 break;
995 }
996 }
997 from = pos + "FlyV1 ".len();
998 }
999
1000 keep_leftmost(
1003 &mut best,
1004 find_pem_private_key_block(text).map(|m| (m, "pem-private-key")),
1005 base,
1006 );
1007
1008 keep_leftmost(&mut best, find_jwt(text).map(|m| (m, "jwt")), base);
1011
1012 keep_leftmost(
1014 &mut best,
1015 find_url_userinfo(text).map(|m| (m, "url-userinfo")),
1016 base,
1017 );
1018
1019 best
1020}
1021
1022fn find_prefix_token<'a>(text: &'a str, needle: &str, min_len: usize) -> Option<&'a str> {
1025 let mut start = 0;
1026 while let Some(rel) = text[start..].find(needle) {
1027 let abs = start + rel;
1028 let at_boundary = abs == 0 || {
1035 let prev = text[..abs].chars().next_back().unwrap_or(' ');
1036 !prev.is_ascii_alphanumeric()
1037 };
1038 if at_boundary {
1039 let token = extract_token(&text[abs..]);
1040 if token.len() >= min_len && !is_filename_shaped_prefix_match(token, needle) {
1041 return Some(token);
1042 }
1043 }
1044 start = abs + needle.len().max(1);
1045 }
1046 None
1047}
1048
1049fn find_bare_sk_token(text: &str) -> Option<&str> {
1052 let base = text.as_ptr() as usize;
1053 let mut from = 0;
1054 while from < text.len() {
1055 let token = find_prefix_token(&text[from..], "sk-", 30)?;
1056 let belongs_to_specific_detector = PREFIX_DETECTORS
1057 .iter()
1058 .any(|&(_, needle, _)| needle.starts_with("sk-") && token.starts_with(needle));
1059 let is_safe_compound = SK_SAFE_PREFIXES.iter().any(|safe| token.starts_with(safe));
1060 if !belongs_to_specific_detector && !is_safe_compound {
1061 return Some(token);
1062 }
1063
1064 let token_start = token.as_ptr() as usize - base;
1065 from = token_start + "sk-".len();
1069 }
1070 None
1071}
1072
1073const SOURCE_FILE_EXTENSIONS: &[&str] =
1077 &[".py", ".rs", ".ts", ".js", ".sh", ".md", ".toml", ".json"];
1078
1079fn is_filename_shaped_prefix_match(token: &str, needle: &str) -> bool {
1104 let token = token.trim_end_matches(|c: char| {
1105 matches!(
1106 c,
1107 '`' | '"' | '\'' | ')' | ']' | '}' | '>' | ',' | ';' | ':' | '!' | '?'
1108 )
1109 });
1110 let token = token.strip_suffix('.').unwrap_or(token);
1111 let Some(payload) = token.strip_prefix(needle) else {
1112 return false;
1113 };
1114 let payload = strip_source_citation_line_reference(payload);
1115 let Some(stem) = SOURCE_FILE_EXTENSIONS
1116 .iter()
1117 .find_map(|extension| payload.strip_suffix(extension))
1118 else {
1119 return false;
1120 };
1121
1122 stem.bytes().any(|byte| byte.is_ascii_lowercase())
1123 && stem
1124 .bytes()
1125 .any(|byte| matches!(byte, b'_' | b'-' | b'/' | b'.'))
1126 && stem
1127 .bytes()
1128 .all(|byte| byte.is_ascii_lowercase() || matches!(byte, b'_' | b'-' | b'/' | b'.'))
1129}
1130
1131fn strip_source_citation_line_reference(payload: &str) -> &str {
1147 let Some(colon) = payload.rfind(':') else {
1148 return payload;
1149 };
1150 let (head, reference) = (&payload[..colon], &payload[colon + 1..]);
1151
1152 let is_digit_run = |s: &str| !s.is_empty() && s.bytes().all(|b| b.is_ascii_digit());
1153 let is_line_reference = match reference.split_once('-') {
1154 None => is_digit_run(reference),
1155 Some((start, end)) => is_digit_run(start) && is_digit_run(end),
1156 };
1157
1158 if is_line_reference {
1159 head
1160 } else {
1161 payload
1162 }
1163}
1164
1165const PEM_BODY_LINE_MIN: usize = 40;
1170
1171fn is_pem_body_line(line: &str) -> bool {
1174 line.len() >= PEM_BODY_LINE_MIN
1175 && line
1176 .bytes()
1177 .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'=')
1178}
1179
1180fn next_line_break(text: &str, from: usize) -> Option<(usize, usize)> {
1186 let rest = &text[from..];
1187 let real = rest.find('\n').map(|i| (from + i, from + i + 1));
1188 let escaped = rest.find("\\n").map(|i| (from + i, from + i + 2));
1189 match (real, escaped) {
1190 (Some(r), Some(e)) => Some(if r.0 <= e.0 { r } else { e }),
1191 (r, e) => r.or(e),
1192 }
1193}
1194
1195fn line_bounds(text: &str, from: usize) -> (usize, usize) {
1198 match next_line_break(text, from) {
1199 Some((end, next)) => (end, next),
1200 None => (text.len(), text.len()),
1201 }
1202}
1203
1204fn find_pem_private_key_block(text: &str) -> Option<&str> {
1215 let mut search = 0;
1216 while let Some(rel) = text[search..].find("-----BEGIN") {
1217 let pos = search + rel;
1218 let (header_end, body_start) = line_bounds(text, pos);
1219 let header = &text[pos..header_end];
1220 search = header_end;
1222 if !header.contains("PRIVATE KEY-----") {
1223 continue;
1224 }
1225 let next_begin = text[body_start..]
1228 .find("-----BEGIN")
1229 .map(|r| body_start + r)
1230 .unwrap_or(text.len());
1231 if let Some(end_rel) = text[body_start..next_begin].find("-----END") {
1232 let end_pos = body_start + end_rel;
1233 let (end_line, end_next) = line_bounds(text, end_pos);
1234 if text[end_pos..end_line].contains("PRIVATE KEY-----") {
1235 return Some(&text[pos..end_next]);
1236 }
1237 }
1238 let mut body_end = None;
1239 let mut cursor = body_start;
1240 while cursor < text.len() {
1241 let (line_end, next) = line_bounds(text, cursor);
1242 let line = text[cursor..line_end]
1243 .trim_end_matches('\r')
1244 .trim_end_matches("\\r");
1245 if is_pem_body_line(line) {
1246 body_end = Some(next);
1247 cursor = next;
1248 continue;
1249 }
1250 let run = line
1257 .bytes()
1258 .take_while(|b| b.is_ascii_alphanumeric() || *b == b'+' || *b == b'/' || *b == b'=')
1259 .count();
1260 let whole_line = run == line.len();
1261 let json_end = line[run..].starts_with('"');
1262 if run > 0 && (whole_line || json_end) {
1263 if body_end.is_some() {
1264 body_end = Some(if whole_line { next } else { cursor + run });
1265 } else if run >= PEM_BODY_LINE_MIN && json_end {
1266 body_end = Some(cursor + run);
1267 }
1268 }
1269 break;
1270 }
1271 if let Some(end) = body_end {
1272 return Some(&text[pos..end]);
1273 }
1274 }
1275 None
1276}
1277
1278fn find_jwt(text: &str) -> Option<&str> {
1281 let bytes = text.as_bytes();
1282 let mut i = 0;
1283 while i + 4 < bytes.len() {
1284 if bytes[i..].starts_with(b"eyJ") {
1285 let end = bytes[i..]
1287 .iter()
1288 .position(|&b| b == b' ' || b == b'\n' || b == b'\r' || b == b'\t')
1289 .map(|p| i + p)
1290 .unwrap_or(bytes.len());
1291 let candidate = &text[i..end];
1292 let dots = candidate.as_bytes().iter().filter(|&&b| b == b'.').count();
1294 if dots >= 2 {
1295 let parts: Vec<&str> = candidate.splitn(3, '.').collect();
1296 if parts.len() == 3
1297 && parts[0].starts_with("eyJ")
1298 && parts[1].starts_with("eyJ")
1299 && parts[0].len() >= 10
1300 && parts[1].len() >= 10
1301 {
1302 return Some(candidate);
1303 }
1304 }
1305 i = end + 1;
1306 } else {
1307 i += 1;
1308 }
1309 }
1310 None
1311}
1312
1313fn find_url_userinfo(text: &str) -> Option<&str> {
1318 let mut search = text;
1319 let mut base = 0usize;
1320 while let Some(at_rel) = search.find("://") {
1321 let at_abs = base + at_rel;
1322 let rest_start = at_abs + 3;
1327 let rest = &text[rest_start..];
1328 let authority_end = rest
1329 .find(['/', '?', '#', ' ', '\n', '\r'])
1330 .unwrap_or(rest.len());
1331 if let Some(at_pos) = rest[..authority_end].rfind('@') {
1332 let userinfo = &rest[..at_pos];
1333 if let Some(colon) = userinfo.find(':') {
1335 let pass = &userinfo[colon + 1..];
1336 if !pass.is_empty() {
1337 let scheme_start = text[..at_abs]
1344 .char_indices()
1345 .rev()
1346 .find(|(_, c)| {
1347 !c.is_ascii_alphanumeric() && *c != '+' && *c != '-' && *c != '.'
1348 })
1349 .map(|(idx, c)| idx + c.len_utf8())
1350 .unwrap_or(0);
1351 if !userinfo.contains(' ') && !userinfo.contains('\n') {
1353 let end = rest_start
1354 + at_pos
1355 + 1
1356 + rest[at_pos + 1..]
1357 .find([' ', '\n', '\r'])
1358 .unwrap_or(rest[at_pos + 1..].len());
1359 return Some(&text[scheme_start..end.min(text.len())]);
1360 }
1361 }
1362 }
1363 }
1364 base = at_abs + 3;
1365 search = &text[base..];
1366 }
1367 None
1368}
1369
1370const TRIGGER_WORDS: &[&str] = &[
1378 "key",
1379 "secret",
1380 "password",
1381 "passwd",
1382 "credential",
1383 "bearer",
1384 "auth",
1385 "apikey",
1386];
1387
1388const COMPOUND_TRIGGER_WORDS: &[&str] = &["api_key", "access_key", "private_key"];
1392
1393const MIN_ENTROPY_LEN: usize = 24;
1395
1396const ENTROPY_THRESHOLD: f64 = 4.5;
1400
1401const TRIGGER_WINDOW: usize = 120;
1403
1404const HEX_CREDENTIAL_LENGTHS: &[usize] = &[32, 40, 64, 128];
1410
1411const MAX_BRIDGE_FRAGMENTS: usize = 6;
1414
1415const MAX_BRIDGE_GLUE_TOKENS: usize = 6;
1418
1419const MIN_BRIDGE_FRAGMENT_LEN: usize = 8;
1422
1423fn floor_char_boundary(s: &str, i: usize) -> usize {
1427 let mut i = i.min(s.len());
1428 while i > 0 && !s.is_char_boundary(i) {
1429 i -= 1;
1430 }
1431 i
1432}
1433
1434fn tokenize_entropy_tokens(text: &str) -> Vec<(usize, &str)> {
1437 #[cfg(test)]
1438 ENTROPY_TOKENIZATION_COUNT.with(|count| count.set(count.get() + 1));
1439
1440 text.split(|c: char| c.is_ascii_whitespace() || !c.is_ascii())
1445 .filter(|t| !t.is_empty())
1446 .map(|t| {
1447 let offset = t.as_ptr() as usize - text.as_ptr() as usize;
1448 (offset, t)
1449 })
1450 .collect()
1451}
1452
1453#[derive(Clone, Copy)]
1454struct JsonScalarContext {
1455 value_start: usize,
1456 end: usize,
1457 label: Option<(usize, &'static str)>,
1458}
1459
1460struct EntropyScanContext<'a> {
1461 tokens: Vec<(usize, &'a str)>,
1462 scalars: Vec<JsonScalarContext>,
1463}
1464
1465impl<'a> EntropyScanContext<'a> {
1466 fn new(text: &'a str) -> Self {
1467 Self {
1468 tokens: tokenize_entropy_tokens(text),
1469 scalars: json_scalar_contexts(text),
1470 }
1471 }
1472
1473 fn scalar_for(&self, text: &str, value: &str) -> Option<JsonScalarContext> {
1474 let value = wrapper_strip_repeated(value);
1475 if value.is_empty() {
1476 return None;
1477 }
1478 let start = value.as_ptr() as usize - text.as_ptr() as usize;
1479 let end = start + value.len();
1480 let index = self.scalars.partition_point(|scalar| scalar.end <= start);
1481 self.scalars
1482 .get(index)
1483 .copied()
1484 .filter(|scalar| end > scalar.value_start && end <= scalar.end)
1485 }
1486}
1487
1488fn json_scalar_contexts(text: &str) -> Vec<JsonScalarContext> {
1492 if !text.trim_start().starts_with(['{', '['])
1493 || serde_json::from_str::<serde::de::IgnoredAny>(text).is_err()
1494 {
1495 return Vec::new();
1496 }
1497 let bytes = text.as_bytes();
1498 let mut scalars = Vec::new();
1499 let mut containers: Vec<Option<(usize, &'static str)>> = Vec::new();
1500 let mut pending_label = None;
1501 let mut index = 0;
1502 while index < bytes.len() {
1503 match bytes[index] {
1504 b'{' | b'[' => {
1505 let label = pending_label
1506 .take()
1507 .or_else(|| containers.last().copied().flatten());
1508 containers.push(label);
1509 index += 1;
1510 }
1511 b'}' | b']' => {
1512 containers.pop();
1513 pending_label = None;
1514 index += 1;
1515 }
1516 b',' | b':' | b' ' | b'\t' | b'\r' | b'\n' => index += 1,
1517 _ => {
1518 let start = index;
1519 let quoted = bytes[index] == b'"';
1520 let value_start = start + usize::from(quoted);
1521 let end = if quoted {
1522 index += 1;
1523 while index < bytes.len() && bytes[index] != b'"' {
1524 index += if bytes[index] == b'\\' { 2 } else { 1 };
1525 }
1526 let end = index;
1527 index += 1;
1528 end
1529 } else {
1530 while index < bytes.len()
1531 && !bytes[index].is_ascii_whitespace()
1532 && !matches!(bytes[index], b',' | b'}' | b']')
1533 {
1534 index += 1;
1535 }
1536 index
1537 };
1538 if index > bytes.len() {
1539 return Vec::new();
1540 }
1541 if quoted && text[index..].trim_start().starts_with(':') {
1542 let Ok(key) = serde_json::from_str::<String>(&text[start..index]) else {
1543 return Vec::new();
1544 };
1545 let label =
1546 find_trigger(&format!("{key}:"), true).map(|trigger| (end, trigger));
1547 pending_label = label;
1548 continue;
1549 }
1550 let label = pending_label
1551 .take()
1552 .or_else(|| containers.last().copied().flatten());
1553 scalars.push(JsonScalarContext {
1554 value_start,
1555 end,
1556 label,
1557 });
1558 }
1559 }
1560 }
1561 scalars
1562}
1563
1564fn entropy_trigger(
1568 text: &str,
1569 tokens: &[(usize, &str)],
1570 index: usize,
1571 credential_label_only: bool,
1572 candidate: EntropyCandidate<'_>,
1573) -> Option<&'static str> {
1574 let (mut offset, raw) = tokens[index];
1575 let mut token_end = offset + raw.len();
1576 if let Some(scalar) = candidate.scalar {
1577 offset =
1578 (candidate.value.as_ptr() as usize - text.as_ptr() as usize).max(scalar.value_start);
1579 token_end = (candidate.value.as_ptr() as usize - text.as_ptr() as usize
1580 + candidate.value.len())
1581 .min(scalar.end);
1582 }
1583 let mut window_start = floor_char_boundary(text, offset.saturating_sub(TRIGGER_WINDOW));
1584 let mut window_end = floor_char_boundary(text, token_end + TRIGGER_WINDOW);
1585 if let Some(scalar) = candidate.scalar {
1586 window_start = window_start.max(scalar.value_start);
1587 window_end = window_end.min(scalar.end);
1588 }
1589 let token = strip_delimiters(raw);
1590 let standalone_revision = token.len() == 40
1591 && token.bytes().all(|b| b.is_ascii_hexdigit())
1592 && bridge_fragment_chain(tokens, text, index).len() == 1;
1593 let (start, end, preceding_label) = if standalone_revision {
1594 let line_start = text[window_start..offset]
1595 .rfind(['\r', '\n'])
1596 .map_or(window_start, |i| window_start + i + 1);
1597 let line_end = text[token_end..window_end]
1598 .find(['\r', '\n'])
1599 .map_or(window_end, |i| token_end + i);
1600 let before_line = &text[window_start..line_start];
1601 let previous = before_line
1602 .strip_suffix("\r\n")
1603 .or_else(|| before_line.strip_suffix(['\r', '\n']))
1604 .unwrap_or("");
1605 let previous_start = previous.rfind(['\r', '\n']).map_or(0, |i| i + 1);
1606 let previous = previous[previous_start..].trim_end();
1607 let label = if previous.ends_with([':', '=']) {
1608 find_trigger(previous, credential_label_only)
1609 } else {
1610 None
1611 };
1612 (
1613 line_start.max(window_start),
1614 line_end.min(window_end),
1615 label,
1616 )
1617 } else {
1618 (window_start, window_end, None)
1619 };
1620 find_trigger(
1625 after_last_sentence_boundary(&text[start..offset]),
1626 credential_label_only,
1627 )
1628 .or_else(|| {
1629 find_trigger(
1630 before_first_sentence_boundary(&text[token_end..end]),
1631 credential_label_only,
1632 )
1633 })
1634 .or(candidate.inline_trigger)
1635 .or(preceding_label)
1636}
1637
1638#[derive(Clone, Copy)]
1640struct EntropyCandidate<'a> {
1641 value: &'a str,
1642 member: &'a str,
1643 inline_trigger: Option<&'static str>,
1644 bridge_anchor: bool,
1645 scalar: Option<JsonScalarContext>,
1646}
1647
1648fn entropy_candidates(raw: &str) -> Vec<EntropyCandidate<'_>> {
1649 let mut candidates = Vec::new();
1650 if raw.contains([',', ';', '&']) {
1653 candidates.push(EntropyCandidate {
1654 value: raw,
1655 member: raw,
1656 inline_trigger: None,
1657 bridge_anchor: true,
1658 scalar: None,
1659 });
1660 }
1661 for member in raw
1662 .split([',', ';', '&'])
1663 .filter(|member| !member.is_empty())
1664 {
1665 let has_assignment = member.contains([':', '=']);
1666 candidates.push(EntropyCandidate {
1667 value: member,
1668 member,
1669 inline_trigger: (!has_assignment)
1670 .then(|| inline_credential_trigger(member))
1671 .flatten(),
1672 bridge_anchor: member.len() == raw.len(),
1673 scalar: None,
1674 });
1675 if !has_assignment {
1676 continue;
1677 }
1678 for segment in member.split([':', '=']) {
1683 if let Some(inline_trigger) = inline_credential_trigger(segment) {
1684 candidates.push(EntropyCandidate {
1685 value: wrapper_strip_repeated(segment),
1686 member,
1687 inline_trigger: Some(inline_trigger),
1688 bridge_anchor: false,
1689 scalar: None,
1690 });
1691 }
1692 }
1693 let low = member.to_ascii_lowercase();
1694 let mut assignment_start = 0;
1695 for (offset, separator) in member.char_indices() {
1696 if !matches!(separator, ':' | '=') {
1697 continue;
1698 }
1699 let end = offset + separator.len_utf8();
1700 let inline_trigger = assignment_credential_trigger(&low[assignment_start..end]);
1701 assignment_start = end;
1702 if inline_trigger.is_none() {
1703 continue;
1704 }
1705 let value = wrapper_strip_repeated(&member[end..]);
1706 if value.is_empty() {
1707 continue;
1708 }
1709 candidates.push(EntropyCandidate {
1714 value,
1715 member,
1716 inline_trigger,
1717 bridge_anchor: member.len() == raw.len(),
1718 scalar: None,
1719 });
1720 break;
1721 }
1722 }
1723 candidates
1724}
1725
1726fn entropy_bridge_fragments<'a>(
1731 tokens: &[(usize, &'a str)],
1732 text: &'a str,
1733 index: usize,
1734 candidate: EntropyCandidate<'a>,
1735) -> Vec<&'a str> {
1736 let fragments = bridge_fragment_chain(tokens, text, index);
1737 if candidate.inline_trigger.is_none() {
1738 return fragments;
1739 }
1740 let raw = tokens[index].1;
1741 let raw_start = raw.as_ptr() as usize;
1742 let raw_end = raw_start + raw.len();
1743 let value_start = candidate.value.as_ptr() as usize;
1744 let member_end = candidate.member.as_ptr() as usize + candidate.member.len();
1745 fragments
1746 .into_iter()
1747 .filter_map(|fragment| {
1748 let start = fragment.as_ptr() as usize;
1749 if (raw_start..raw_end).contains(&start) {
1750 Some(strip_delimiters(candidate.value))
1751 } else if (start < raw_start && value_start == raw_start)
1752 || (start >= raw_end && member_end == raw_end)
1753 {
1754 Some(fragment)
1755 } else {
1756 None
1757 }
1758 })
1759 .collect()
1760}
1761
1762fn check_entropy_heuristic<'a>(
1765 text: &'a str,
1766 from: usize,
1767 context: &EntropyScanContext<'a>,
1768) -> Option<(&'a str, &'static str, Option<&'static str>)> {
1769 let tokens = &context.tokens;
1770 let first_token = tokens.partition_point(|&(offset, raw)| offset + raw.len() <= from);
1771 for (idx, &(context_offset, raw)) in tokens.iter().enumerate().skip(first_token) {
1772 let token = strip_delimiters(raw);
1773 if token.len() < MIN_ENTROPY_LEN && !is_bridge_fragment_shape(token) {
1774 continue;
1775 }
1776 let mut best: Option<(&str, &'static str, Option<&'static str>)> = None;
1777 for mut candidate in entropy_candidates(raw) {
1778 candidate.scalar = context.scalar_for(text, candidate.value);
1779 if candidate.scalar.is_none() && !context.scalars.is_empty() {
1780 let core = wrapper_strip_repeated(candidate.value);
1781 let start = core.as_ptr() as usize - text.as_ptr() as usize;
1782 let end = start + core.len();
1783 let first = context
1784 .scalars
1785 .partition_point(|scalar| scalar.end <= start);
1786 if context
1787 .scalars
1788 .get(first)
1789 .is_some_and(|scalar| scalar.value_start < end && scalar.end < end)
1790 {
1791 continue;
1794 }
1795 }
1796 if let Some(scalar) = candidate.scalar {
1797 let offset = candidate.value.as_ptr() as usize - text.as_ptr() as usize;
1798 let label = scalar
1799 .label
1800 .filter(|(end, _)| offset.saturating_sub(*end) <= TRIGGER_WINDOW)
1801 .map(|(_, trigger)| trigger);
1802 candidate.inline_trigger = candidate.inline_trigger.or(label);
1803 }
1804 let offset = candidate.value.as_ptr() as usize - text.as_ptr() as usize;
1805 if offset + candidate.value.len() <= from {
1806 continue;
1807 }
1808 if let Some(found) =
1809 check_entropy_candidate(text, from, tokens, idx, context_offset, candidate)
1810 {
1811 if best
1812 .as_ref()
1813 .is_none_or(|current| found.0.as_ptr() < current.0.as_ptr())
1814 {
1815 best = Some(found);
1816 }
1817 }
1818 }
1819 if best.is_some() {
1820 return best;
1821 }
1822 }
1823 None
1824}
1825
1826fn check_entropy_candidate<'a>(
1827 text: &'a str,
1828 from: usize,
1829 tokens: &[(usize, &'a str)],
1830 idx: usize,
1831 context_offset: usize,
1832 candidate: EntropyCandidate<'a>,
1833) -> Option<(&'a str, &'static str, Option<&'static str>)> {
1834 let raw_token = candidate.value;
1835 let original_offset = raw_token.as_ptr() as usize - text.as_ptr() as usize;
1837 let remaining = &raw_token[from.saturating_sub(original_offset).min(raw_token.len())..];
1838 let token = strip_delimiters(remaining);
1839 let token_offset = token.as_ptr() as usize - text.as_ptr() as usize;
1842 if token_offset < from {
1843 return None;
1844 }
1845 let is_bridge_candidate = is_bridge_fragment_shape(token);
1849 if token.len() < MIN_ENTROPY_LEN && !is_bridge_candidate {
1850 return None;
1851 }
1852
1853 let trigger = entropy_trigger(text, tokens, idx, false, candidate);
1861 let near_trigger = trigger.is_some();
1862 let uuid_trigger = entropy_trigger(text, tokens, idx, true, candidate);
1863 let uuid_near_credential_label = uuid_trigger.is_some();
1864
1865 let has_uuid_candidate = value_candidates(token).any(is_uuid_canonical);
1871 if uuid_near_credential_label && has_uuid_candidate {
1872 return Some((token, "uuid-near-trigger", uuid_trigger));
1873 }
1874 if near_trigger && value_candidates(token).any(is_base64_content_hash) {
1875 return Some((token, "content-hash-near-trigger", trigger));
1876 }
1877 if !uuid_near_credential_label && is_uuid_canonical(token) {
1878 return None;
1879 }
1880 if !near_trigger && is_base64_content_hash(token) {
1881 return None;
1882 }
1883
1884 if !near_trigger && is_pure_hex(token) {
1887 return None;
1888 }
1889
1890 let labeled_sha256_digest = near_trigger
1894 && candidate.inline_trigger.is_none()
1895 && is_labeled_sha256_digest(text, token_offset, token);
1896
1897 if is_vcs_marker_before_hex(text, candidate.member)
1900 && !has_clause_credential_label_with_inline(
1901 text,
1902 context_offset,
1903 candidate.inline_trigger.is_some(),
1904 ClauseValueKind::VcsReference,
1905 )
1906 {
1907 return None;
1908 }
1909
1910 let repository_revision_reference = is_repository_revision_reference(candidate.member);
1911 let vcs_reference_exempt = if repository_revision_reference {
1912 !has_direct_repository_credential_label_with_inline(
1913 text,
1914 context_offset,
1915 candidate.inline_trigger.is_some(),
1916 )
1917 } else {
1918 is_git_revision_reference(text, context_offset, candidate.member)
1919 && !has_clause_credential_label_with_inline(
1920 text,
1921 context_offset,
1922 candidate.inline_trigger.is_some(),
1923 ClauseValueKind::VcsReference,
1924 )
1925 };
1926
1927 if near_trigger
1933 && is_latex_fragment_without_credential_run(token)
1934 && !(candidate.inline_trigger.is_some()
1935 || has_immediate_credential_label(text, context_offset))
1936 {
1937 return None;
1938 }
1939
1940 if !vcs_reference_exempt
1943 && near_trigger
1944 && !labeled_sha256_digest
1945 && is_pure_hex(token)
1946 && HEX_CREDENTIAL_LENGTHS.contains(&token.len())
1947 {
1948 return Some((token, "hex-credential-token", trigger));
1949 }
1950
1951 if near_trigger {
1956 for run in token.split(|c: char| !c.is_ascii_alphanumeric()) {
1959 if run.len() < MIN_ENTROPY_LEN {
1960 continue;
1961 }
1962 if !vcs_reference_exempt
1963 && !labeled_sha256_digest
1964 && is_pure_hex(run)
1965 && HEX_CREDENTIAL_LENGTHS.contains(&run.len())
1966 {
1967 return Some((run, "hex-credential-token", trigger));
1968 }
1969 if shannon_entropy(run.as_bytes()) >= ENTROPY_THRESHOLD {
1970 return Some((token, "high-entropy-token", trigger));
1971 }
1972 }
1973
1974 if !vcs_reference_exempt && !labeled_sha256_digest {
1978 if let Some(candidate) = normalized_hex_credential_span(token) {
1979 return Some((candidate, "hex-credential-token", trigger));
1980 }
1981 }
1982
1983 if !vcs_reference_exempt
1990 && (candidate.bridge_anchor || candidate.inline_trigger.is_some())
1991 && tokens.len() > 1
1992 {
1993 let fragments = entropy_bridge_fragments(tokens, text, idx, candidate);
1994 if fragments.len() > 1 {
1995 let first = fragments[0];
1996 let last = fragments[fragments.len() - 1];
1997 let chain_start = first.as_ptr() as usize - text.as_ptr() as usize;
1998 let chain_end = last.as_ptr() as usize - text.as_ptr() as usize + last.len();
1999 let search_start = chain_start.max(from);
2000 if let Some(candidate) =
2001 normalized_hex_credential_span(&text[search_start..chain_end])
2002 {
2003 return Some((candidate, "hex-credential-token", trigger));
2004 }
2005 let concatenated: String = fragments.concat();
2006 if concatenated.len() >= MIN_ENTROPY_LEN
2007 && concatenated.bytes().all(|b| b.is_ascii_alphanumeric())
2008 && shannon_entropy(concatenated.as_bytes()) >= ENTROPY_THRESHOLD
2009 {
2010 return Some((token, "high-entropy-token", trigger));
2011 }
2012 }
2013 }
2014
2015 if is_plausible_file_path(token)
2016 && !has_clause_credential_label_with_inline(
2017 text,
2018 context_offset,
2019 candidate.inline_trigger.is_some(),
2020 ClauseValueKind::FilePath,
2021 )
2022 {
2023 return None;
2024 }
2025
2026 if labeled_sha256_digest
2030 || is_prose_code_reference(candidate.member, token)
2031 || is_environment_name(token)
2032 || is_latex_prose_macro(token)
2033 || is_aws_resource_name(token)
2034 {
2035 return None;
2036 }
2037 }
2038
2039 if vcs_reference_exempt {
2044 return None;
2045 }
2046
2047 if !near_trigger && is_structured_identifier(token) {
2051 return None;
2052 }
2053
2054 let entropy = shannon_entropy(token.as_bytes());
2055 if entropy < ENTROPY_THRESHOLD {
2056 return None;
2057 }
2058
2059 if near_trigger {
2061 return Some((token, "high-entropy-token", trigger));
2062 }
2063 None
2064}
2065
2066fn is_hex_run(run: &str) -> bool {
2073 !run.is_empty() && run.bytes().all(|b| b.is_ascii_hexdigit())
2074}
2075
2076const VCS_MARKERS: &[&str] = &["commit", "revision", "rev", "sha"];
2077
2078fn is_vcs_marker_before_hex(text: &str, raw_token: &str) -> bool {
2081 let token = wrapper_strip_repeated(raw_token);
2082 let marker = strip_delimiters(token);
2083 if !VCS_MARKERS
2084 .iter()
2085 .any(|candidate| marker.eq_ignore_ascii_case(candidate))
2086 {
2087 return false;
2088 }
2089 let raw_offset = raw_token.as_ptr() as usize - text.as_ptr() as usize;
2090 let next = text[raw_offset + raw_token.len()..].trim_start();
2091 let next = wrapper_strip_repeated(extract_token(next));
2092 next.len() == 40 && next.bytes().all(|b| b.is_ascii_hexdigit())
2093}
2094
2095fn is_git_revision_reference(text: &str, token_offset: usize, raw_token: &str) -> bool {
2096 const MARKERS: &[&str] = VCS_MARKERS;
2097
2098 let token = wrapper_strip_repeated(raw_token);
2099 if is_vcs_marker_before_hex(text, raw_token) {
2100 return true;
2101 }
2102
2103 if token.len() == 40 && token.bytes().all(|b| b.is_ascii_hexdigit()) {
2104 let marker = trailing_identifier(&text[..token_offset]);
2105 return MARKERS
2106 .iter()
2107 .any(|candidate| marker.eq_ignore_ascii_case(candidate));
2108 }
2109
2110 let Some((label, value)) = token.rsplit_once(':') else {
2111 return false;
2112 };
2113 let label = wrapper_strip_repeated(label);
2114 let value = wrapper_strip_repeated(value);
2115 MARKERS
2116 .iter()
2117 .any(|candidate| label.eq_ignore_ascii_case(candidate))
2118 && value.len() == 40
2119 && value.bytes().all(|b| b.is_ascii_hexdigit())
2120}
2121
2122fn is_exact_hex_revision(value: &str) -> bool {
2123 value.len() == 40 && value.bytes().all(|byte| byte.is_ascii_hexdigit())
2124}
2125
2126fn is_repository_revision_reference(raw_token: &str) -> bool {
2133 let token = wrapper_strip_repeated(raw_token);
2134 let low = token.to_ascii_lowercase();
2135
2136 for marker in ["/blob/", "/tree/", "/commit/", "/commits/"] {
2137 let mut from = 0usize;
2138 while let Some(relative) = low[from..].find(marker) {
2139 let value_start = from + relative + marker.len();
2140 let remainder = &token[value_start..];
2141 let value_end = remainder
2142 .bytes()
2143 .position(|byte| !byte.is_ascii_hexdigit())
2144 .unwrap_or(remainder.len());
2145 if is_exact_hex_revision(&remainder[..value_end]) {
2146 return true;
2147 }
2148 from = value_start.min(low.len());
2149 if from == low.len() {
2150 break;
2151 }
2152 }
2153 }
2154
2155 for (prefix, quote) in [("href=\"", '"'), ("href='", '\'')] {
2156 if let Some(start) = low.find(prefix) {
2157 let value = &token[start + prefix.len()..];
2158 if let Some(end) = value.find(quote) {
2159 if is_exact_hex_revision(&value[..end]) {
2160 return true;
2161 }
2162 }
2163 }
2164 }
2165
2166 false
2167}
2168
2169fn trailing_identifier(text: &str) -> &str {
2170 let trimmed = text.trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
2171 trimmed
2172 .rsplit(|c: char| !c.is_ascii_alphanumeric() && c != '_')
2173 .next()
2174 .unwrap_or_default()
2175}
2176
2177fn has_immediate_credential_label(text: &str, token_offset: usize) -> bool {
2182 let before = text[..token_offset].trim_end();
2183 let before = before
2184 .strip_suffix(':')
2185 .or_else(|| before.strip_suffix('='))
2186 .unwrap_or(before);
2187 let label = trailing_identifier(before).to_ascii_lowercase();
2188
2189 label == "token"
2190 || COMPOUND_TRIGGER_WORDS
2191 .iter()
2192 .any(|trigger| label.contains(trigger))
2193 || TRIGGER_WORDS
2194 .iter()
2195 .any(|trigger| contains_bounded_word(&label, trigger))
2196}
2197
2198fn has_direct_repository_credential_label_with_inline(
2204 text: &str,
2205 token_offset: usize,
2206 inline_trigger: bool,
2207) -> bool {
2208 if inline_trigger {
2209 return true;
2210 }
2211 let before = text[..token_offset].trim_end();
2212 if before.ends_with(':') || before.ends_with('=') {
2213 return has_immediate_credential_label(text, token_offset);
2214 }
2215 has_clause_credential_label_with_inline(
2216 text,
2217 token_offset,
2218 false,
2219 ClauseValueKind::VcsReference,
2220 )
2221}
2222
2223const LABEL_CLAUSE_SKIP_WORDS: &[&str] = &[
2232 "commit",
2233 "revision",
2234 "rev",
2235 "sha",
2236 "is",
2237 "was",
2238 "are",
2239 "were",
2240 "be",
2241 "been",
2242 "being",
2243 "value",
2244 "values",
2245 "the",
2246 "a",
2247 "an",
2248 "this",
2249 "that",
2250 "it",
2251 "its",
2252 "as",
2253 "here",
2254 "now",
2255 "currently",
2256 "equals",
2257 "for",
2258 "of",
2259 "to",
2260 "in",
2261 "on",
2262 "at",
2263 "by",
2264 "with",
2265 "from",
2266 "per",
2267 "and",
2268 "or",
2269 "our",
2270 "my",
2271 "your",
2272 "their",
2273];
2274
2275const LABEL_CLAUSE_WALK_LIMIT: usize = 8;
2285
2286const FILE_PATH_NO_DELIMITER_CONTENT_LIMIT: usize = 2;
2290
2291#[derive(Clone, Copy, Debug, Eq, PartialEq)]
2296enum ClauseValueKind {
2297 VcsReference,
2298 FilePath,
2299}
2300
2301fn sentence_boundary_end_at(bytes: &[u8], index: usize) -> Option<usize> {
2309 match bytes.get(index).copied()? {
2310 b';' | b'!' | b'?' => Some(index + 1),
2311 b'.' if bytes
2312 .get(index + 1)
2313 .is_some_and(|next| !next.is_ascii_alphanumeric()) =>
2314 {
2315 Some(index + 1)
2316 }
2317 b'\n' if bytes.get(index + 1) == Some(&b'\n') => Some(index + 2),
2318 b'\r'
2319 if bytes
2320 .get(index..index + 4)
2321 .is_some_and(|window| window == b"\r\n\r\n") =>
2322 {
2323 Some(index + 4)
2324 }
2325 _ => None,
2326 }
2327}
2328
2329fn after_last_sentence_boundary(text: &str) -> &str {
2331 let bytes = text.as_bytes();
2332 let mut start = 0usize;
2333 for index in 0..bytes.len() {
2334 if let Some(end) = sentence_boundary_end_at(bytes, index) {
2335 start = end;
2336 }
2337 }
2338 &text[start..]
2339}
2340
2341fn before_first_sentence_boundary(text: &str) -> &str {
2343 let bytes = text.as_bytes();
2344 for index in 0..bytes.len() {
2345 if sentence_boundary_end_at(bytes, index).is_some() {
2346 return &text[..index];
2347 }
2348 }
2349 text
2350}
2351
2352fn gap_has_sentence_boundary(gap: &str) -> bool {
2359 let bytes = gap.as_bytes();
2360 (0..bytes.len()).any(|index| sentence_boundary_end_at(bytes, index).is_some())
2361}
2362
2363fn is_version_fragment(word: &str) -> bool {
2368 let digits = word
2369 .strip_prefix('v')
2370 .or_else(|| word.strip_prefix('V'))
2371 .unwrap_or(word);
2372 !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit())
2373}
2374
2375fn is_hex_fragment_word(word: &str) -> bool {
2381 word.len() >= 12 && word.bytes().all(|b| b.is_ascii_hexdigit())
2382}
2383
2384fn contains_ascii_case_insensitive(haystack: &str, needle: &str) -> bool {
2386 haystack
2387 .as_bytes()
2388 .windows(needle.len())
2389 .any(|window| window.eq_ignore_ascii_case(needle.as_bytes()))
2390}
2391
2392fn clause_label_has_credential_trigger(label: &str) -> bool {
2396 label.eq_ignore_ascii_case("token")
2397 || COMPOUND_TRIGGER_WORDS
2398 .iter()
2399 .any(|trigger| contains_ascii_case_insensitive(label, trigger))
2400 || label.split('_').any(|part| {
2401 TRIGGER_WORDS
2402 .iter()
2403 .any(|trigger| part.eq_ignore_ascii_case(trigger))
2404 })
2405}
2406
2407fn is_label_clause_skip_word(label: &str) -> bool {
2408 LABEL_CLAUSE_SKIP_WORDS
2409 .iter()
2410 .any(|word| label.eq_ignore_ascii_case(word))
2411}
2412
2413fn is_clause_narrative_participle(label: &str) -> bool {
2419 const NON_PARTICIPLE_ED_WORDS: &[&str] = &["hundred"];
2420
2421 label.len() >= 5
2422 && label
2423 .get(label.len().saturating_sub(2)..)
2424 .is_some_and(|suffix| suffix.eq_ignore_ascii_case("ed"))
2425 && !NON_PARTICIPLE_ED_WORDS
2426 .iter()
2427 .any(|word| label.eq_ignore_ascii_case(word))
2428}
2429
2430fn is_clause_narrative_gerund(label: &str) -> bool {
2435 label.len() >= 6
2436 && label
2437 .get(label.len().saturating_sub(3)..)
2438 .is_some_and(|suffix| suffix.eq_ignore_ascii_case("ing"))
2439}
2440
2441fn has_clause_credential_label_with_inline(
2471 text: &str,
2472 token_offset: usize,
2473 inline_trigger: bool,
2474 value_kind: ClauseValueKind,
2475) -> bool {
2476 if inline_trigger {
2477 return true;
2478 }
2479
2480 let mut rest = &text[..token_offset];
2481 let mut crossed_value_delimiter = false;
2482 let mut no_delimiter_content_words = 0usize;
2483 let mut arrived_through_in_preposition = false;
2487 let mut arrived_through_connector = true;
2491 for step in 0..LABEL_CLAUSE_WALK_LIMIT {
2492 let label = trailing_identifier(rest);
2493 if label.is_empty() {
2494 return false;
2495 }
2496 let gap = &rest[label.as_ptr() as usize - rest.as_ptr() as usize + label.len()..];
2497 if step > 0 && gap_has_sentence_boundary(gap) {
2498 return false;
2499 }
2500 if gap.contains([':', '=']) {
2501 crossed_value_delimiter = true;
2502 }
2503 if clause_label_has_credential_trigger(label) {
2504 return true;
2505 }
2506 let skippable = is_label_clause_skip_word(label)
2507 || is_version_fragment(label)
2508 || is_hex_fragment_word(label);
2509 if !skippable {
2510 let no_delimiter_file_path_narrative = !crossed_value_delimiter
2515 && value_kind == ClauseValueKind::FilePath
2516 && arrived_through_in_preposition
2517 && is_clause_narrative_gerund(label);
2518 let regular_participle_narrative = is_clause_narrative_participle(label)
2524 && (crossed_value_delimiter || value_kind != ClauseValueKind::FilePath || step > 0);
2525 if arrived_through_connector
2526 && (regular_participle_narrative || no_delimiter_file_path_narrative)
2527 {
2528 return false;
2529 }
2530 if !crossed_value_delimiter {
2531 if value_kind != ClauseValueKind::FilePath
2532 || no_delimiter_content_words >= FILE_PATH_NO_DELIMITER_CONTENT_LIMIT
2533 {
2534 return false;
2535 }
2536 no_delimiter_content_words += 1;
2537 }
2538 }
2539 if !label.eq_ignore_ascii_case("and") && !label.eq_ignore_ascii_case("or") {
2545 arrived_through_connector = skippable;
2546 }
2547 arrived_through_in_preposition = label.eq_ignore_ascii_case("in");
2548 let start = label.as_ptr() as usize - rest.as_ptr() as usize;
2549 rest = &rest[..start];
2550 }
2551 crossed_value_delimiter
2556}
2557
2558fn is_plausible_file_path(token: &str) -> bool {
2559 let token = token.trim_start_matches(|c: char| {
2560 matches!(
2561 c,
2562 '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';'
2563 )
2564 });
2565 let token = token.trim_end_matches(|c: char| {
2566 matches!(
2567 c,
2568 '"' | '\'' | '`' | '(' | ')' | '[' | ']' | '{' | '}' | ',' | ';' | '.'
2569 )
2570 });
2571 let path = if let Some(angle_path) = token.strip_prefix('<') {
2572 let Some(close) = angle_path.rfind('>') else {
2573 return false;
2574 };
2575 if !is_line_location_suffix(&angle_path[close + 1..]) {
2576 return false;
2577 }
2578 &angle_path[..close]
2579 } else if let Some((path, suffix)) = token.rsplit_once(':') {
2580 if is_line_location_suffix(suffix) {
2581 path
2582 } else {
2583 token
2584 }
2585 } else {
2586 token
2587 };
2588
2589 if path.contains("://")
2590 || !path.contains('/')
2591 || !path
2592 .bytes()
2593 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'/' | b'.' | b'_' | b'-' | b'~'))
2594 {
2595 return false;
2596 }
2597
2598 let segment_count = path
2599 .split('/')
2600 .filter(|segment| !segment.is_empty())
2601 .count();
2602 segment_count >= 2 || (path.starts_with('/') && segment_count == 1)
2603}
2604
2605fn is_latex_fragment_without_credential_run(token: &str) -> bool {
2612 let bytes = token.as_bytes();
2613 let has_control_sequence = bytes
2614 .windows(2)
2615 .any(|pair| pair[0] == b'\\' && pair[1].is_ascii_alphabetic());
2616 let structural_count = bytes
2617 .iter()
2618 .filter(|byte| matches!(byte, b'\\' | b'{' | b'}' | b'^' | b'_'))
2619 .count();
2620 if !has_control_sequence || structural_count < 3 {
2621 return false;
2622 }
2623
2624 if normalized_hex_credential_span(token).is_some() {
2625 return false;
2626 }
2627
2628 !token
2629 .split(|ch: char| !ch.is_ascii_alphanumeric())
2630 .filter(|run| run.len() >= MIN_ENTROPY_LEN)
2631 .any(|run| shannon_entropy(run.as_bytes()) >= ENTROPY_THRESHOLD)
2632}
2633
2634fn is_labeled_sha256_digest(text: &str, token_offset: usize, token: &str) -> bool {
2637 if token.len() != 64 || !token.bytes().all(|byte| byte.is_ascii_hexdigit()) {
2638 return false;
2639 }
2640 let before = text[..token_offset]
2641 .trim_end_matches(|ch: char| ch.is_ascii_whitespace() || matches!(ch, ':' | '='));
2642 let marker = ["sha256 key digest", "sha256 digest", "sha256"]
2643 .into_iter()
2644 .find(|marker| {
2645 before
2646 .get(before.len().saturating_sub(marker.len())..)
2647 .is_some_and(|tail| tail.eq_ignore_ascii_case(marker))
2648 });
2649 let Some(marker) = marker else {
2650 return false;
2651 };
2652 let marker_start = before.len() - marker.len();
2653 if marker_start > 0
2654 && text[..marker_start]
2655 .chars()
2656 .next_back()
2657 .is_some_and(|ch| ch.is_ascii_alphanumeric() || ch == '_')
2658 {
2659 return false;
2660 }
2661 !has_clause_credential_label_with_inline(text, marker_start, false, ClauseValueKind::FilePath)
2662}
2663
2664fn is_prose_code_reference(member: &str, token: &str) -> bool {
2668 let Some(code) = member.strip_prefix('`').and_then(|s| s.strip_suffix('`')) else {
2669 return false;
2670 };
2671 if code != token || !code.ends_with("()") {
2672 return false;
2673 }
2674 let Some((owner, method)) = code[..code.len() - 2].split_once("::") else {
2675 return false;
2676 };
2677 owner
2678 .as_bytes()
2679 .first()
2680 .is_some_and(|b| b.is_ascii_alphabetic())
2681 && owner
2682 .bytes()
2683 .all(|b| b.is_ascii_alphanumeric() || b == b'_')
2684 && method
2685 .as_bytes()
2686 .first()
2687 .is_some_and(|b| b.is_ascii_alphabetic())
2688 && method
2689 .bytes()
2690 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b':' | b'<' | b'>'))
2691 && !method.contains("::")
2692}
2693
2694fn is_environment_name(token: &str) -> bool {
2698 let mut components = token.split('_');
2699 let Some(first) = components.next() else {
2700 return false;
2701 };
2702 if first.is_empty() || !first.bytes().all(|b| b.is_ascii_uppercase()) {
2703 return false;
2704 }
2705 let mut count = 0;
2706 let mut last = "";
2707 for part in components {
2708 if part.is_empty()
2709 || part.len() > 16
2710 || !part
2711 .bytes()
2712 .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
2713 {
2714 return false;
2715 }
2716 count += 1;
2717 last = part;
2718 }
2719 count >= 3 && matches!(last, "PATH" | "FILE" | "NAME" | "TTL")
2720}
2721
2722fn is_latex_prose_macro(token: &str) -> bool {
2725 [
2726 "\\mathsf{",
2727 "\\mathbf{",
2728 "\\mathcal{",
2729 "\\mathrm{",
2730 "\\operatorname{",
2731 ]
2732 .iter()
2733 .any(|prefix| token.starts_with(prefix))
2734 && is_latex_fragment_without_credential_run(token)
2735}
2736
2737fn is_aws_resource_name(token: &str) -> bool {
2741 let mut parts = token.splitn(6, ':');
2742 let (Some("arn"), Some("aws"), Some(service), Some(region), Some(account), Some(resource)) = (
2743 parts.next(),
2744 parts.next(),
2745 parts.next(),
2746 parts.next(),
2747 parts.next(),
2748 parts.next(),
2749 ) else {
2750 return false;
2751 };
2752 let valid_resource = !resource.is_empty()
2753 && resource.split('/').all(|segment| {
2754 !segment.is_empty()
2755 && segment.len() <= 24
2756 && segment
2757 .bytes()
2758 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'-' | b'_' | b'.'))
2759 });
2760 valid_resource
2761 && ((service == "s3" && region.is_empty() && account.is_empty())
2762 || (service == "iam"
2763 && region.is_empty()
2764 && account.len() == 12
2765 && account.bytes().all(|b| b.is_ascii_digit())
2766 && (resource.starts_with("role/") || resource.starts_with("policy/"))))
2767}
2768
2769fn is_line_location_suffix(suffix: &str) -> bool {
2770 if suffix.is_empty() {
2771 return true;
2772 }
2773 let suffix = suffix.strip_prefix(':').unwrap_or(suffix);
2774 let suffix = suffix.strip_prefix('~').unwrap_or(suffix);
2775 let mut parts = suffix.split('-');
2776 let Some(start) = parts.next() else {
2777 return false;
2778 };
2779 !start.is_empty()
2780 && start.bytes().all(|b| b.is_ascii_digit())
2781 && parts
2782 .next()
2783 .is_none_or(|end| !end.is_empty() && end.bytes().all(|b| b.is_ascii_digit()))
2784 && parts.next().is_none()
2785}
2786
2787fn normalized_hex_credential_span(token: &str) -> Option<&str> {
2799 let mut concatenated_len = 0usize;
2800 let mut span_start = 0usize;
2801 for run in token.split(|c: char| !c.is_ascii_alphanumeric()) {
2802 if run.is_empty() {
2803 continue;
2804 }
2805 if is_hex_run(run) {
2806 let run_start = run.as_ptr() as usize - token.as_ptr() as usize;
2807 if concatenated_len == 0 {
2808 span_start = run_start;
2809 }
2810 concatenated_len += run.len();
2811 if HEX_CREDENTIAL_LENGTHS.contains(&concatenated_len) {
2812 return Some(&token[span_start..run_start + run.len()]);
2813 }
2814 } else {
2815 concatenated_len = 0;
2816 }
2817 }
2818 None
2819}
2820
2821fn adjacent_gap_is_bridgeable(text: &str, gap_start: usize, gap_end: usize) -> bool {
2834 gap_end >= gap_start && !text[gap_start..gap_end].contains(|c: char| c.is_ascii_alphanumeric())
2835}
2836
2837fn is_bridge_fragment_shape(s: &str) -> bool {
2846 s.len() >= MIN_BRIDGE_FRAGMENT_LEN && s.bytes().all(|b| b.is_ascii_alphanumeric())
2847}
2848
2849fn is_delimiter_only_token(s: &str) -> bool {
2861 !s.bytes().any(|b| b.is_ascii_alphanumeric())
2862}
2863
2864fn probe_bridge_fragment(
2874 tokens: &[(usize, &str)],
2875 text: &str,
2876 edge: usize,
2877 dir: isize,
2878) -> Option<usize> {
2879 let mut i = edge;
2880 let mut glue_skipped = 0usize;
2881 loop {
2882 let next_i = i.checked_add_signed(dir)?;
2883 if next_i >= tokens.len() {
2884 return None;
2885 }
2886 let (lo, hi) = if dir < 0 { (next_i, i) } else { (i, next_i) };
2887 let (lo_offset, lo_raw) = tokens[lo];
2888 let (hi_offset, _) = tokens[hi];
2889 let gap_start = lo_offset + lo_raw.len();
2890 if !adjacent_gap_is_bridgeable(text, gap_start, hi_offset) {
2891 return None;
2892 }
2893 let candidate = strip_delimiters(tokens[next_i].1);
2894 if is_bridge_fragment_shape(candidate) {
2895 return Some(next_i);
2896 }
2897 if is_delimiter_only_token(candidate) && glue_skipped < MAX_BRIDGE_GLUE_TOKENS {
2898 glue_skipped += 1;
2899 i = next_i;
2900 continue;
2901 }
2902 return None;
2903 }
2904}
2905
2906fn bridge_fragment_chain<'a>(
2924 tokens: &[(usize, &'a str)],
2925 text: &str,
2926 anchor_idx: usize,
2927) -> Vec<&'a str> {
2928 let mut start = anchor_idx;
2929 let mut end = anchor_idx;
2930 let mut fragment_count = 1usize;
2931
2932 loop {
2933 let mut extended = false;
2934 if fragment_count < MAX_BRIDGE_FRAGMENTS && start > 0 {
2935 if let Some(new_start) = probe_bridge_fragment(tokens, text, start, -1) {
2936 start = new_start;
2937 fragment_count += 1;
2938 extended = true;
2939 }
2940 }
2941 if fragment_count < MAX_BRIDGE_FRAGMENTS && end + 1 < tokens.len() {
2942 if let Some(new_end) = probe_bridge_fragment(tokens, text, end, 1) {
2943 end = new_end;
2944 fragment_count += 1;
2945 extended = true;
2946 }
2947 }
2948 if !extended {
2949 break;
2950 }
2951 }
2952
2953 tokens[start..=end]
2954 .iter()
2955 .map(|&(_, raw)| strip_delimiters(raw))
2956 .filter(|stripped| !is_delimiter_only_token(stripped))
2957 .collect()
2958}
2959
2960fn trailing_bridge_fragment_cut(window: &str) -> Option<usize> {
2999 let tokens = tokenize_entropy_tokens(window);
3000 let mut idx = tokens.len().checked_sub(1)?;
3001 let mut fragment_budget = MAX_BRIDGE_FRAGMENTS - 1;
3002 let mut glue_budget = MAX_BRIDGE_GLUE_TOKENS;
3003 let mut cut_at = None;
3004
3005 loop {
3006 let (offset, raw) = tokens[idx];
3007 let candidate = strip_delimiters(raw);
3008 if is_bridge_fragment_shape(candidate) {
3009 if fragment_budget == 0 {
3010 break;
3011 }
3012 fragment_budget -= 1;
3013 glue_budget = MAX_BRIDGE_GLUE_TOKENS;
3014 cut_at = Some(offset);
3015 } else if is_delimiter_only_token(candidate) {
3016 if glue_budget == 0 {
3017 break;
3018 }
3019 glue_budget -= 1;
3020 } else {
3021 break;
3022 }
3023
3024 if idx == 0 {
3025 break;
3026 }
3027 let (prev_offset, prev_raw) = tokens[idx - 1];
3028 let gap_start = prev_offset + prev_raw.len();
3029 if !adjacent_gap_is_bridgeable(window, gap_start, offset) {
3030 break;
3031 }
3032 idx -= 1;
3033 }
3034
3035 cut_at
3036}
3037
3038fn contains_word(low_window: &str, needle: &str, underscore_is_word_char: bool) -> bool {
3045 let is_word_char = |c: char| c.is_ascii_alphanumeric() || (underscore_is_word_char && c == '_');
3046 let mut start = 0;
3047 while let Some(rel) = low_window[start..].find(needle) {
3048 let abs = start + rel;
3049 let before_ok = abs == 0
3050 || low_window[..abs]
3051 .chars()
3052 .next_back()
3053 .is_none_or(|c| !is_word_char(c));
3054 let after_end = abs + needle.len();
3055 let after_ok = after_end >= low_window.len()
3056 || low_window[after_end..]
3057 .chars()
3058 .next()
3059 .is_none_or(|c| !is_word_char(c));
3060 if before_ok && after_ok {
3061 return true;
3062 }
3063 start = abs + needle.len().max(1);
3064 }
3065 false
3066}
3067
3068fn contains_bounded_word(low_window: &str, needle: &str) -> bool {
3074 if needle != "key" {
3075 return contains_word(low_window, needle, false);
3076 }
3077 low_window
3078 .split(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3079 .any(|label| {
3080 if !contains_word(label, needle, false) {
3081 return false;
3082 }
3083 let end = label.as_ptr() as usize - low_window.as_ptr() as usize + label.len();
3084 let after = low_window[end..].trim_start_matches(is_assignment_label_gap);
3085 !(is_lookup_key_label(label) && after.starts_with([':', '=']))
3086 })
3087}
3088
3089fn is_assignment_label_gap(c: char) -> bool {
3090 matches!(c, '"' | '\'' | '`')
3091}
3092
3093const LOOKUP_KEY_LABELS: &[&str] = &[
3103 "association_key",
3104 "cache_key",
3105 "composite_key",
3106 "dedup_key",
3107 "foreign_key",
3108 "idempotency_key",
3109 "index_key",
3110 "lookup_key",
3111 "map_key",
3112 "natural_key",
3113 "partition_key",
3114 "primary_key",
3115 "range_key",
3116 "routing_key",
3117 "row_key",
3118 "search_key",
3119 "shard_key",
3120 "sort_key",
3121 "surrogate_key",
3122 "unique_key",
3123];
3124
3125fn is_lookup_key_label(label: &str) -> bool {
3126 LOOKUP_KEY_LABELS.contains(&label)
3127}
3128
3129fn compound_trigger(low_text: &str) -> Option<&'static str> {
3133 COMPOUND_TRIGGER_WORDS.iter().copied().find(|needle| {
3134 let mut start = 0;
3135 while let Some(rel) = low_text[start..].find(needle) {
3136 let abs = start + rel;
3137 let before_ok = abs == 0
3138 || low_text[..abs]
3139 .chars()
3140 .next_back()
3141 .is_none_or(|c| !c.is_ascii_alphanumeric());
3142 if before_ok {
3143 return true;
3144 }
3145 start = abs + needle.len();
3146 }
3147 false
3148 })
3149}
3150
3151fn find_trigger(text: &str, credential_label_only: bool) -> Option<&'static str> {
3152 let low = text.to_ascii_lowercase();
3153 TRIGGER_WORDS
3154 .iter()
3155 .copied()
3156 .find(|tw| contains_bounded_word(&low, tw))
3157 .or_else(|| compound_trigger(&low))
3158 .or_else(|| {
3159 ((!credential_label_only && has_standalone_token(&low)) || has_token_assignment(&low))
3160 .then_some("token")
3161 })
3162 .or_else(|| assignment_credential_trigger(&low))
3163}
3164
3165fn assignment_credential_trigger(low_text: &str) -> Option<&'static str> {
3171 low_text.char_indices().find_map(|(index, ch)| {
3172 if !matches!(ch, '=' | ':') {
3173 return None;
3174 }
3175 let before =
3176 low_text[..index].trim_end_matches(|c: char| !c.is_ascii_alphanumeric() && c != '_');
3177 let label = before
3178 .rsplit(|c: char| !c.is_ascii_alphanumeric() && c != '_')
3179 .next()
3180 .unwrap_or_default();
3181 COMPOUND_TRIGGER_WORDS
3182 .iter()
3183 .copied()
3184 .find(|needle| label.contains(needle))
3185 .or_else(|| {
3186 TRIGGER_WORDS.iter().copied().find(|tw| {
3187 (*tw != "key"
3188 || !is_lookup_key_label(label)
3189 || !low_text[before.len()..index]
3190 .chars()
3191 .all(is_assignment_label_gap))
3192 && contains_bounded_word(label, tw)
3193 })
3194 })
3195 .or_else(|| (label == "token").then_some("token"))
3196 })
3197}
3198
3199fn inline_credential_trigger(raw_token: &str) -> Option<&'static str> {
3208 let low = raw_token.to_ascii_lowercase();
3209 assignment_credential_trigger(&low).or_else(|| {
3210 if !low.contains(['/', '-', '.']) && low.contains('_') {
3211 COMPOUND_TRIGGER_WORDS
3212 .iter()
3213 .copied()
3214 .find(|needle| low.contains(needle))
3215 .or_else(|| {
3216 TRIGGER_WORDS
3217 .iter()
3218 .copied()
3219 .find(|tw| contains_bounded_word(&low, tw))
3220 })
3221 } else {
3222 None
3223 }
3224 })
3225}
3226
3227fn has_standalone_token(low_window: &str) -> bool {
3238 contains_word(low_window, "token", true)
3239}
3240
3241fn has_token_assignment(low_window: &str) -> bool {
3253 let needle = "token";
3254 let mut start = 0;
3255 while let Some(rel) = low_window[start..].find(needle) {
3256 let abs = start + rel;
3257 let before_ok = abs == 0
3259 || low_window[..abs]
3260 .chars()
3261 .next_back()
3262 .is_none_or(|c| !c.is_ascii_alphanumeric() && c != '_');
3263 let after_end = abs + needle.len();
3264 let after_char = low_window[after_end..].chars().next();
3267 let after_is_assign = matches!(after_char, Some('=') | Some(':'));
3268 if before_ok && after_is_assign {
3269 return true;
3270 }
3271 start = abs + needle.len().max(1);
3272 }
3273 false
3274}
3275
3276fn is_pure_hex(token: &str) -> bool {
3285 let hex_part = token
3286 .strip_prefix("0x")
3287 .or(token.strip_prefix("0X"))
3288 .unwrap_or(token);
3289 hex_part.len() >= 8 && hex_part.len() <= 128 && hex_part.bytes().all(|b| b.is_ascii_hexdigit())
3290}
3291
3292fn is_base64_content_hash(token: &str) -> bool {
3298 const VENDOR_PREFIXES: &[&str] = &[
3301 "sk-",
3302 "rk_live_",
3303 "fm2_",
3304 "vercel_",
3305 "xoxb-",
3306 "xoxa-",
3307 "xoxp-",
3308 "xoxr-",
3309 "xoxs-",
3310 "ghp_",
3311 "gho_",
3312 "ghu_",
3313 "ghs_",
3314 "ghr_",
3315 "github_pat_",
3316 "AKIA",
3317 "ASIA",
3318 "AGE-SECRET-KEY-",
3319 "FlyV1",
3320 ];
3321 if VENDOR_PREFIXES.iter().any(|p| token.starts_with(p)) {
3322 return false;
3323 }
3324 let body = if let Some(rest) = token.strip_prefix("sha") {
3327 let dash = rest.find('-').unwrap_or(rest.len());
3329 let digits = &rest[..dash];
3330 if !digits.is_empty() && digits.bytes().all(|b| b.is_ascii_digit()) && dash < rest.len() {
3331 &rest[dash + 1..] } else {
3333 return false; }
3335 } else {
3336 return false; };
3338 let stripped = body.trim_end_matches('=');
3340 let pad_removed = body.len() - stripped.len();
3341 if pad_removed > 2 {
3342 return false;
3343 }
3344 let n = stripped.len();
3346 if n != 43 && n != 64 && !(86..=88).contains(&n) {
3347 return false;
3348 }
3349 stripped
3351 .bytes()
3352 .all(|b| b.is_ascii_alphanumeric() || b == b'+' || b == b'/' || b == b'-' || b == b'_')
3353}
3354
3355const STRUCTURAL_SEPARATORS: [char; 4] = ['/', '-', '_', '.'];
3360
3361const MAX_RUN_LEN: usize = 24;
3364
3365const DENSITY_EXEMPT_LETTER_LEN: usize = 4;
3369
3370const MAX_CASE_TRANSITION_DENSITY: f64 = 0.3;
3373
3374fn is_structured_identifier(token: &str) -> bool {
3380 if !token.contains(|c: char| STRUCTURAL_SEPARATORS.contains(&c)) {
3381 return false;
3382 }
3383 let runs: Vec<&str> = token
3384 .split(|c: char| !c.is_ascii_alphanumeric())
3385 .filter(|r| !r.is_empty())
3386 .collect();
3387 runs.len() >= 2 && runs.iter().all(|run| is_word_shaped_run(run))
3388}
3389
3390fn is_word_shaped_run(run: &str) -> bool {
3395 if run.is_empty() || run.len() > MAX_RUN_LEN {
3396 return false;
3397 }
3398 let bytes = run.as_bytes();
3399 if bytes.iter().all(|b| b.is_ascii_digit()) {
3400 return true;
3401 }
3402 let letter_end = bytes
3403 .iter()
3404 .position(|b| !b.is_ascii_alphabetic())
3405 .unwrap_or(bytes.len());
3406 if letter_end == 0 {
3410 return false;
3411 }
3412 if !bytes[letter_end..].iter().all(|b| b.is_ascii_digit()) {
3415 return false;
3416 }
3417 case_transition_density_ok(&run[..letter_end])
3418}
3419
3420fn case_transition_density_ok(letters: &str) -> bool {
3426 let chars: Vec<char> = letters.chars().collect();
3427 if chars.len() <= DENSITY_EXEMPT_LETTER_LEN {
3428 return true;
3429 }
3430 let transitions = chars
3431 .windows(2)
3432 .filter(|w| w[0].is_ascii_uppercase() != w[1].is_ascii_uppercase())
3433 .count();
3434 let density = transitions as f64 / (chars.len() - 1) as f64;
3435 density <= MAX_CASE_TRANSITION_DENSITY
3436}
3437
3438fn is_uuid_canonical(s: &str) -> bool {
3440 let b = s.as_bytes();
3441 if b.len() != 36 {
3442 return false;
3443 }
3444 b[8] == b'-'
3445 && b[13] == b'-'
3446 && b[18] == b'-'
3447 && b[23] == b'-'
3448 && b[..8].iter().all(|c| c.is_ascii_hexdigit())
3449 && b[9..13].iter().all(|c| c.is_ascii_hexdigit())
3450 && b[14..18].iter().all(|c| c.is_ascii_hexdigit())
3451 && b[19..23].iter().all(|c| c.is_ascii_hexdigit())
3452 && b[24..].iter().all(|c| c.is_ascii_hexdigit())
3453}
3454
3455fn strip_delimiters(s: &str) -> &str {
3457 s.trim_matches(|c| matches!(c, '"' | '\'' | '`' | ':' | '=' | ',' | ';'))
3458}
3459
3460fn strip_wrappers(s: &str) -> &str {
3463 s.trim_matches(|c: char| {
3464 matches!(
3465 c,
3466 '{' | '}' | '(' | ')' | '[' | ']' | '"' | '\'' | '`' | '.' | ',' | ';'
3467 )
3468 })
3469}
3470
3471fn wrapper_strip_repeated(token: &str) -> &str {
3472 let mut cur = token;
3473 loop {
3474 let next = strip_wrappers(cur);
3475 if next == cur {
3476 return cur;
3477 }
3478 cur = next;
3479 }
3480}
3481
3482fn value_candidates(token: &str) -> impl Iterator<Item = &str> {
3487 let cur = wrapper_strip_repeated(token);
3488 std::iter::once(cur).chain(cur.char_indices().filter_map(move |(i, c)| {
3489 if c == '=' || c == ':' {
3490 let after = strip_wrappers(&cur[i + c.len_utf8()..]);
3491 if !after.is_empty() {
3492 return Some(after);
3493 }
3494 }
3495 None
3496 }))
3497}
3498
3499fn extract_token(s: &str) -> &str {
3503 let end = s
3504 .find(|c: char| c.is_whitespace() || c == '\n' || c == '\r')
3505 .unwrap_or(s.len());
3506 &s[..end]
3507}
3508
3509fn shannon_entropy(bytes: &[u8]) -> f64 {
3513 if bytes.is_empty() {
3514 return 0.0;
3515 }
3516 let mut counts = [0u32; 256];
3517 for &b in bytes {
3518 counts[b as usize] += 1;
3519 }
3520 let len = bytes.len() as f64;
3521 counts
3522 .iter()
3523 .filter(|&&c| c > 0)
3524 .map(|&c| {
3525 let p = c as f64 / len;
3526 -p * p.log2()
3527 })
3528 .sum()
3529}
3530
3531fn build_match(detector: &'static str, candidate: &str) -> SecretMatch {
3536 let chars: Vec<char> = candidate.chars().collect();
3537 let preview: String = chars.iter().take(6).collect();
3538 let masked = format!("{}...{}chars", preview, chars.len());
3539 SecretMatch {
3540 detector,
3541 trigger: None,
3542 masked,
3543 location: None,
3544 }
3545}
3546
3547#[cfg(test)]
3548mod issue_2655_tests {
3549 use super::*;
3550
3551 fn opaque_fixture() -> String {
3552 [
3553 "ABCDEFGHIJKLMNOPQRSTUVWX",
3554 "abcdefghijklmnopqrstuvwxyz",
3555 "0123456789",
3556 ]
3557 .concat()
3558 }
3559
3560 #[test]
3561 fn issue_2655_compact_siblings_keep_all_entropy_shapes_member_local() {
3562 let hex = "0123456789abcdef".repeat(4);
3563 let opaque = opaque_fixture();
3564 let members = [
3565 format!("digest:{hex}"),
3566 "id:550e8400-e29b-41d4-a716-446655440000".to_owned(),
3567 format!("sum:sha256-{}=", "A".repeat(43)),
3568 format!("opaque:{opaque}"),
3569 format!("path:vault/{opaque}/rotate.md"),
3570 format!("digest:{}/{}", &hex[..16], &hex[16..32]),
3571 ];
3572 for separator in [',', ';', '&'] {
3573 for member in &members {
3574 for content in [
3575 format!("a_secret:x{separator}{member}"),
3576 format!("{member}{separator}a_secret:x"),
3577 ] {
3578 assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3579 assert_eq!(mask_secrets(&content), content);
3580 }
3581 }
3582 }
3583 }
3584
3585 #[test]
3586 fn issue_2655_quote_comma_is_an_inline_member_boundary() {
3587 let hex = "0123456789abcdef".repeat(4);
3588 for content in [
3589 format!(r#"{{"a_secret":"x","digest":"{hex}"}}"#),
3590 format!(r#"{{"digest":"{hex}","a_secret":"x"}}"#),
3591 ] {
3592 assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3593 assert_eq!(mask_secrets(&content), content);
3594 }
3595 }
3596
3597 #[test]
3598 fn issue_2655_external_window_keeps_short_spaced_refusals_and_long_controls() {
3599 let hex = "0123456789abcdef".repeat(4);
3600 for content in [
3601 format!("a_secret:x digest:{hex}"),
3602 format!("digest:{hex} a_secret:x"),
3603 ] {
3604 assert!(
3605 check(&content).is_err(),
3606 "short external context: {content}"
3607 );
3608 }
3609 let gap = " documentation".repeat(50);
3610 assert!(gap.len() > 485);
3611 for content in [
3612 format!("a_secret:x{gap} digest:{hex}"),
3613 format!("digest:{hex}{gap} a_secret:x"),
3614 ] {
3615 assert!(check(&content).is_ok(), "long external context: {content}");
3616 assert_eq!(mask_secrets(&content), content);
3617 }
3618 }
3619
3620 #[test]
3621 fn issue_2655_credential_assignments_and_nested_carriers_stay_refused() {
3622 let id = "550e8400-e29b-41d4-a716-446655440000";
3623 let hex = "0123456789abcdef".repeat(4);
3624 let hash = format!("sha256-{}=", "A".repeat(43));
3625 let opaque = opaque_fixture();
3626 for (content, value) in [
3627 (format!("api_key={id}"), id),
3628 (format!("secret={hex}"), hex.as_str()),
3629 (format!("api_key=label={id}"), id),
3630 (format!("secret=label={hash}"), hash.as_str()),
3631 (format!("secret:label={hash}"), hash.as_str()),
3632 (format!("payload=api_key={id}"), id),
3633 (format!("payload=auth={opaque}"), opaque.as_str()),
3634 (format!("secret=vault/{opaque}/rotate.md"), opaque.as_str()),
3635 ] {
3636 assert!(check(&content).is_err(), "{content}");
3637 assert!(!mask_secrets(&content).contains(value), "{content}");
3638 }
3639 }
3640
3641 #[test]
3642 fn issue_2655_later_assignment_does_not_label_an_earlier_value() {
3643 let id = "550e8400-e29b-41d4-a716-446655440000";
3644 let hex = "0123456789abcdef".repeat(4);
3645 let opaque = opaque_fixture();
3646 for value in [id, hex.as_str(), opaque.as_str()] {
3647 let content = format!("record={value}:a_secret=x");
3648 assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3649 assert_eq!(mask_secrets(&content), content);
3650 }
3651 }
3652
3653 #[test]
3654 fn issue_2655_refusal_reports_the_matched_members_trigger() {
3655 let id = "550e8400-e29b-41d4-a716-446655440000";
3656 let opaque = opaque_fixture();
3657 for (content, trigger) in [
3658 (format!("a_secret:x,api_key={id}"), "api_key"),
3659 (format!("api_key:x;auth={opaque}"), "auth"),
3660 (format!("secret:x&payload=auth={opaque}"), "auth"),
3661 ] {
3662 assert_eq!(
3663 scan(&content).and_then(|matched| matched.trigger),
3664 Some(trigger),
3665 "{content}"
3666 );
3667 }
3668 }
3669
3670 #[test]
3671 fn issue_2655_masking_continues_through_multiple_members() {
3672 let id = "550e8400-e29b-41d4-a716-446655440000";
3673 let hex = "0123456789abcdef".repeat(4);
3674 let benign = "fedcba9876543210".repeat(4);
3675 let opaque = opaque_fixture();
3676 for separator in [',', ';', '&'] {
3677 let content = format!("api_key={id}{separator}digest:{benign}{separator}secret={hex}{separator}auth={opaque}");
3678 ENTROPY_TOKENIZATION_COUNT.with(|count| count.set(0));
3679 let masked = mask_secrets(&content);
3680 assert_eq!(ENTROPY_TOKENIZATION_COUNT.with(|count| count.get()), 1);
3681 assert_eq!(masked.matches(REDACTION_MARKER).count(), 3, "{masked}");
3682 for value in [id, hex.as_str(), opaque.as_str()] {
3683 assert!(!masked.contains(value), "{masked}");
3684 }
3685 assert!(masked.contains(&format!("digest:{benign}")), "{masked}");
3686 }
3687 }
3688
3689 #[test]
3690 fn issue_2655_masking_continues_within_a_governed_member() {
3691 let first = "0123456789abcdef".repeat(2);
3692 let second = "fedcba9876543210".repeat(2);
3693 let content = format!("secret={first}/{second}");
3694 let masked = mask_secrets(&content);
3695 assert_eq!(masked.matches(REDACTION_MARKER).count(), 2, "{masked}");
3696 assert!(!masked.contains(&first));
3697 assert!(!masked.contains(&second));
3698 }
3699
3700 #[test]
3701 fn issue_2655_known_prefix_under_a_benign_member_stays_refused() {
3702 let fake = format!("ghp_{}", "A".repeat(36));
3703 let content = format!(r#"{{"a_secret":"x","digest":"{fake}"}}"#);
3704 assert_eq!(
3705 scan(&content).map(|matched| matched.detector),
3706 Some("github-token")
3707 );
3708 assert!(!mask_secrets(&content).contains(&fake));
3709 }
3710
3711 #[test]
3712 fn issue_2655_path_and_revision_guards_do_not_read_sibling_labels() {
3713 let revision = "0123456789abcdef0123456789abcdef01234567";
3714 for member in [
3715 format!("rev:{revision}"),
3716 format!("source=https://example.test/repo/commit/{revision}"),
3717 "path=docs/platform/credentials-and-authorization-architecture.md".to_owned(),
3718 ] {
3719 let content = format!("a_secret:x,{member}");
3720 assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3721 assert_eq!(mask_secrets(&content), content);
3722 }
3723 let credential = format!("secret=https://example.test/repo/commit/{revision}");
3724 assert!(check(&credential).is_err());
3725 assert!(!mask_secrets(&credential).contains(revision));
3726 }
3727
3728 #[test]
3729 fn issue_2655_inline_assignment_keeps_unicode_bridge_reconstruction() {
3730 let first = "0123456789abcdef01";
3733 let second = "fedcba98765432";
3734 let content = format!("secret={first}\u{200b}{second}");
3735 assert!(check(&content).is_err());
3736 let masked = mask_secrets(&content);
3737 assert!(!masked.contains(first));
3738 assert!(!masked.contains(second));
3739 }
3740
3741 #[test]
3742 fn issue_2655_underscore_carriers_keep_nested_and_padded_forms() {
3743 let opaque = opaque_fixture();
3744 for prefix in ["session_secret_", "payload=session_secret_"] {
3745 for padding in ["", "=", "=="] {
3746 let content = format!("{prefix}{opaque}{padding}");
3747 assert!(check(&content).is_err(), "{content}");
3748 assert!(!mask_secrets(&content).contains(&opaque), "{content}");
3749 }
3750 }
3751 }
3752
3753 #[test]
3754 fn issue_2655_inline_bridge_uses_its_final_member() {
3755 let first = "0123456789abcdef01";
3756 let second = "fedcba98765432";
3757 let benign = "fedcba9876543210".repeat(4);
3758 for prefix in ["a:x".to_owned(), format!("digest:{benign}")] {
3759 let content = format!("{prefix},secret={first}\u{200b}{second}");
3760 assert!(check(&content).is_err(), "{content}");
3761 let masked = mask_secrets(&content);
3762 assert!(masked.starts_with(&prefix), "{masked}");
3763 assert!(!masked.contains(first), "{masked}");
3764 assert!(!masked.ends_with(second), "{masked}");
3765 }
3766 }
3767
3768 #[test]
3769 fn issue_2655_inline_bridge_stops_at_sibling_boundaries() {
3770 let first = "0123456789abcdef01";
3771 let second = "fedcba98765432";
3772 let benign = "fedcba9876543210".repeat(4);
3773 for content in [
3774 format!("secret=x,digest:{first}\u{200b}{second}"),
3775 format!("secret={first},digest:x\u{200b}{second}"),
3776 format!("secret={first},digest:{benign}\u{200b}{second}"),
3777 format!("{first}\u{200b}digest:x,secret={second}"),
3778 ] {
3779 assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
3780 assert_eq!(mask_secrets(&content), content);
3781 }
3782 }
3783
3784 #[test]
3785 fn issue_2655_mask_budget_charges_compact_token_prefix_revisits() {
3786 let hex = "0123456789abcdef".repeat(2);
3787 let tail = format!(",secret={hex}").repeat(200);
3788 let content = format!("padding:{}{tail}", "a".repeat(900_000));
3789 let (spans, work) = collect_mask_spans(&content);
3790 assert!(
3791 work >= content.len() * 2,
3792 "each continuation revisits the full compact token"
3793 );
3794 assert!(work <= MAX_MASK_SCAN_WORK_BYTES);
3795 assert_eq!(spans.last().map(|span| span.1), Some(content.len()));
3796 let masked = mask_secrets(&content);
3797 assert!(!masked.contains(&hex));
3798 assert!(masked.matches(REDACTION_MARKER).count() < 200);
3799 assert!(masked.ends_with(REDACTION_MARKER));
3800 }
3801
3802 #[test]
3803 fn issue_2655_later_assignment_bridge_preserves_earlier_record_value() {
3804 let benign = "0123456789abcdef".repeat(4);
3805 let first = "a1b2c3d4e5f6071829";
3806 let second = "30415263748596";
3807 let prefix = format!("record={benign}:a_secret=");
3808 let content = format!("{prefix}{first}\u{200b}{second}");
3809 assert_eq!(
3810 scan(&content).and_then(|matched| matched.trigger),
3811 Some("secret")
3812 );
3813 let masked = mask_secrets(&content);
3814 assert!(masked.starts_with(&prefix), "{masked}");
3815 assert!(!masked.contains(first), "{masked}");
3816 assert!(!masked.contains(second), "{masked}");
3817 assert_eq!(masked.matches(REDACTION_MARKER).count(), 2, "{masked}");
3818 }
3819}
3820
3821#[cfg(test)]
3824mod tests {
3825 use super::*;
3826
3827 fn github_fine_grained_pat_fixture() -> String {
3828 format!("github_pat_{}", "A".repeat(82))
3829 }
3830
3831 fn openai_project_key_fixture() -> String {
3832 format!("sk-proj-{}", "A".repeat(80))
3833 }
3834
3835 fn anthropic_api_key_fixture() -> String {
3836 format!("sk-ant-api03-{}AA", "A".repeat(93))
3837 }
3838
3839 #[test]
3840 fn blocks_aws_akia() {
3841 let fake = "AKIAFAKEKEY1234567890";
3843 assert!(scan(fake).is_some(), "AKIA must be caught");
3844 let m = scan(fake).unwrap();
3845 assert_eq!(m.detector, "aws-access-key-id");
3846 assert!(
3848 !m.masked.contains("FAKEKEY1234567890"),
3849 "must not echo the secret: {}",
3850 m.masked
3851 );
3852 }
3853
3854 #[test]
3855 fn blocks_aws_asia() {
3856 let fake = "ASIAFAKEKEY00000000000";
3857 let m = scan(fake);
3858 assert!(m.is_some(), "ASIA must be caught");
3859 assert_eq!(m.unwrap().detector, "aws-access-key-id");
3860 }
3861
3862 #[test]
3863 fn blocks_github_ghp() {
3864 let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
3866 assert!(scan(fake).is_some(), "ghp_ must be caught");
3867 }
3868
3869 #[test]
3870 fn blocks_github_gho() {
3871 let fake = "gho_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
3872 assert!(scan(fake).is_some(), "gho_ must be caught");
3873 }
3874
3875 #[test]
3876 fn blocks_github_pat() {
3877 let fake = github_fine_grained_pat_fixture();
3878 assert!(scan(&fake).is_some(), "github_pat_ must be caught");
3879 }
3880
3881 #[test]
3882 fn blocks_openai_sk() {
3883 let fake = "sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg";
3884 assert!(scan(fake).is_some(), "sk- must be caught");
3885 }
3886
3887 #[test]
3888 fn blocks_anthropic_sk_ant() {
3889 let fake = anthropic_api_key_fixture();
3890 assert!(scan(&fake).is_some(), "sk-ant- must be caught");
3891 assert_eq!(scan(&fake).unwrap().detector, "anthropic-api-key");
3892 }
3893
3894 #[test]
3895 fn vendor_prefix_minimums_allow_short_documentation_fragments() {
3896 let fragments = [
3897 "github_pat_[A-Za-z0-9_]{82}".to_owned(),
3898 "sk-proj-[A-Za-z0-9_-]{80,}".to_owned(),
3899 "sk-ant-api03-[A-Za-z0-9_-]{93}AA".to_owned(),
3900 "github_pat_FAKE_CANARY".to_owned(),
3901 "sk-ant-api03-FAKE_CANARY".to_owned(),
3902 format!("github_pat_{}", "A".repeat(81)),
3903 format!("sk-proj-{}", "A".repeat(79)),
3904 format!("sk-ant-api03-{}AA", "A".repeat(92)),
3905 ];
3906
3907 for fragment in fragments {
3908 assert!(
3909 check(&fragment).is_ok(),
3910 "short documentation fragment must pass: {fragment}, got {:?}",
3911 scan(&fragment)
3912 );
3913 }
3914 }
3915
3916 #[test]
3917 fn vendor_prefix_minimums_keep_plausible_keys_blocked() {
3918 let github = github_fine_grained_pat_fixture();
3919 let openai = openai_project_key_fixture();
3920 let anthropic = anthropic_api_key_fixture();
3921
3922 for (candidate, detector) in [
3923 (github.as_str(), "github-token"),
3924 (openai.as_str(), "openai-api-key"),
3925 (anthropic.as_str(), "anthropic-api-key"),
3926 ] {
3927 let matched = scan(candidate).expect("plausible-length vendor key must remain blocked");
3928 assert_eq!(matched.detector, detector);
3929 }
3930 }
3931
3932 #[test]
3933 fn short_specialized_sk_prefix_does_not_hide_later_generic_key() {
3934 let short_vendor_fragment = format!("sk-proj-{}", "A".repeat(79));
3935 let generic_key = format!("sk-{}", "A1".repeat(20));
3936 let content = format!("{short_vendor_fragment} {generic_key}");
3937
3938 let (matched, detector) =
3939 scan_match(&content).expect("later generic sk key must remain detectable");
3940 assert_eq!(matched, generic_key);
3941 assert_eq!(detector, "openai-api-key");
3942 }
3943
3944 #[test]
3945 fn glued_short_specialized_prefix_does_not_hide_later_generic_key() {
3946 let generic_key = format!("sk-{}A", "A1".repeat(21));
3947 let content = format!("sk-proj-X,{generic_key}");
3948
3949 assert!(
3950 check(&content).is_err(),
3951 "a generic key after a rejected vendor prefix must remain blocked"
3952 );
3953 let masked = mask_secrets(&content).into_owned();
3954 assert!(
3955 !masked.contains(&generic_key),
3956 "the later generic key must not survive masking: {masked}"
3957 );
3958 assert!(
3959 masked.contains(REDACTION_MARKER),
3960 "the later generic key must be replaced: {masked}"
3961 );
3962 }
3963
3964 #[test]
3965 fn blocks_stripe_live() {
3966 let fake = "sk_live_FAKESTRIPE0000000000000"; assert!(scan(fake).is_some(), "sk_live_ must be caught");
3968 assert_eq!(scan(fake).unwrap().detector, "stripe-secret-key");
3969 }
3970
3971 #[test]
3972 fn blocks_stripe_restricted() {
3973 let fake = "rk_live_FAKESTRIPE0000000000000"; assert!(scan(fake).is_some(), "rk_live_ must be caught");
3975 assert_eq!(scan(fake).unwrap().detector, "stripe-restricted-key");
3976 }
3977
3978 #[test]
3979 fn blocks_fly_flyv1() {
3980 let fake = "FlyV1 FAKEFLYTOKEN000000000000000000";
3981 assert!(scan(fake).is_some(), "FlyV1 must be caught");
3982 assert_eq!(scan(fake).unwrap().detector, "fly-token");
3983 }
3984
3985 #[test]
3986 fn short_flyv1_marker_does_not_hide_later_token() {
3987 let content = "FlyV1 ab FlyV1 FAKEFLYTOKEN000000000000000000";
3988 let (matched, detector) = scan_match(content).expect("later FlyV1 token must be detected");
3989 assert_eq!(detector, "fly-token");
3990 assert_eq!(matched, "FlyV1 FAKEFLYTOKEN000000000000000000");
3991 assert_eq!(
3992 matched.as_ptr() as usize - content.as_ptr() as usize,
3993 content.rfind("FlyV1 ").unwrap()
3994 );
3995 assert_eq!(scan(content).unwrap().detector, "fly-token");
3996 }
3997
3998 #[test]
3999 fn non_boundary_flyv1_marker_does_not_hide_later_token() {
4000 for content in [
4001 "xFlyV1 abc FlyV1 FAKEFLYTOKEN000000000000000000",
4002 "xFlyV1 FAKE FlyV1 FAKEFLYTOKEN000000000000000000",
4003 ] {
4004 let (matched, detector) =
4005 scan_match(content).expect("later FlyV1 token must be detected");
4006 assert_eq!(detector, "fly-token");
4007 assert_eq!(matched, "FlyV1 FAKEFLYTOKEN000000000000000000");
4008 assert_eq!(
4009 matched.as_ptr() as usize - content.as_ptr() as usize,
4010 content.rfind("FlyV1 ").unwrap()
4011 );
4012 assert_eq!(scan(content).unwrap().detector, "fly-token");
4013 }
4014 }
4015
4016 #[test]
4017 fn blocks_fly_fm2() {
4018 let fake = "fm2_FAKEFLYTOKEN00000000000000000";
4019 assert!(scan(fake).is_some(), "fm2_ must be caught");
4020 assert_eq!(scan(fake).unwrap().detector, "fly-token");
4021 }
4022
4023 #[test]
4024 fn blocks_vercel_token() {
4025 let fake = "vercel_FAKETOKEN00000000000000000";
4026 assert!(scan(fake).is_some(), "vercel_ must be caught");
4027 assert_eq!(scan(fake).unwrap().detector, "vercel-token");
4028 }
4029
4030 #[test]
4031 fn prefix_detectors_allow_lowercase_source_filenames() {
4032 for &(_, needle, min_len) in PREFIX_DETECTORS {
4033 let padding_len = min_len.saturating_sub(needle.len() + "provider_.py".len());
4034 let filename_body = format!("provider_{}.py", "a".repeat(padding_len));
4035 let candidate = format!("{needle}{filename_body}");
4036 assert!(
4037 candidate.len() >= min_len,
4038 "negative control must exercise {needle}'s length tier"
4039 );
4040 assert!(
4041 find_prefix_token(&candidate, needle, min_len).is_none(),
4042 "lowercase source filename must not match prefix {needle}: {candidate}"
4043 );
4044 assert!(
4045 check(&candidate).is_ok(),
4046 "lowercase source filename must pass the canonical scanner: {candidate}, got {:?}",
4047 scan(&candidate)
4048 );
4049 }
4050 }
4051
4052 #[test]
4053 fn prefix_detectors_keep_value_shaped_payloads_with_source_suffixes() {
4054 for &(_, needle, min_len) in PREFIX_DETECTORS {
4055 let required_payload_len = min_len.saturating_sub(needle.len()).max(2);
4056 let value_payload = "A1".repeat(required_payload_len.div_ceil(2));
4057 let candidate = format!("{needle}{value_payload}.py");
4058 assert!(
4059 find_prefix_token(&candidate, needle, min_len).is_some(),
4060 "uppercase/digit value evidence must preserve prefix {needle}: {candidate}"
4061 );
4062 }
4063 }
4064
4065 #[test]
4066 fn prefix_detectors_keep_lowercase_single_run_payloads_with_source_suffixes() {
4067 for &(_, needle, min_len) in PREFIX_DETECTORS {
4068 let required_payload_len = min_len.saturating_sub(needle.len()).max(24);
4069 let value_payload = "a".repeat(required_payload_len);
4070 let candidate = format!("{needle}{value_payload}.py");
4071 assert!(
4072 find_prefix_token(&candidate, needle, min_len).is_some(),
4073 "an extension alone must not exempt prefix {needle}: {candidate}"
4074 );
4075 }
4076 }
4077
4078 #[test]
4079 fn allows_vercel_prefixed_source_filename_on_every_scanner_surface() {
4080 let content = "review `vercel_deployment_monitoring_adapter.py` before release";
4081
4082 assert!(check(content).is_ok(), "write gate must allow filename");
4083 assert!(scan(content).is_none(), "scanner must not report filename");
4084 assert_eq!(
4085 mask_secrets(content).as_ref(),
4086 content,
4087 "masking surface must preserve the filename byte-for-byte"
4088 );
4089 }
4090
4091 #[test]
4092 fn admits_source_citation_line_reference_and_still_blocks_random_payload() {
4093 let admitted = [
4097 "vercel_deployment_monitor.py:412",
4098 "vercel_deployment_monitor.py:412-418",
4099 "vercel_src/runtime/checkpoint_loader.rs:97-103",
4100 ];
4101 for content in admitted {
4102 assert!(
4103 find_prefix_token(content, "vercel_", 20).is_none(),
4104 "file-and-line citation must not be flagged as a credential: {content}"
4105 );
4106 assert!(
4107 check(content).is_ok(),
4108 "file-and-line citation must pass the write gate: {content}, got {:?}",
4109 scan(content)
4110 );
4111 }
4112
4113 let random_payload = "vercel_aB3xQ9mK7pL2wZ8nR4tY6uV1"; assert_eq!(random_payload.len() - "vercel_".len(), 24);
4119 assert!(
4120 find_prefix_token(random_payload, "vercel_", 20).is_some(),
4121 "random payload after the provider prefix must still be flagged: {random_payload}"
4122 );
4123 assert_eq!(
4124 scan(random_payload).map(|matched| matched.detector),
4125 Some("vercel-token"),
4126 "random-looking payload after the provider prefix must still be scanned as a credential"
4127 );
4128 }
4129
4130 #[test]
4131 fn source_citation_line_reference_boundary_arms_still_refused() {
4132 let needle = "vercel_";
4133 let cases = [
4134 "vercel_deployment_monitor.py:4a2", "vercel_deployment_monitor.py:12-", "vercel_deployment_monitor.py:12-34-56", "vercel_deployment2monitor.py:412",
4141 "vercel_deployment2_monitor.py:412",
4146 ];
4147 for token in cases {
4148 assert!(
4149 !is_filename_shaped_prefix_match(token, needle),
4150 "must still be refused: {token}"
4151 );
4152 }
4153
4154 assert!(
4163 is_filename_shaped_prefix_match("vercel_deployment_monitor.py:", needle),
4164 "a trailing colon is prose punctuation and was always trimmed"
4165 );
4166 }
4167
4168 #[test]
4169 fn blocks_slack_xoxb() {
4170 let fake = "xoxb-FAKE-SLACKTOKEN-000000000000000000000000";
4171 assert!(scan(fake).is_some(), "xoxb- must be caught");
4172 assert_eq!(scan(fake).unwrap().detector, "slack-token");
4173 }
4174
4175 #[test]
4176 fn blocks_pem_private_key() {
4177 let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); let fake = format!("{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----", header);
4182 assert!(scan(&fake).is_some(), "PEM private key must be caught");
4183 assert_eq!(scan(&fake).unwrap().detector, "pem-private-key");
4184 }
4185
4186 #[test]
4187 fn blocks_pem_ec_private_key() {
4188 let header = ["-----BEGIN EC", " PRIVATE KEY-----"].concat(); let fake = format!("{}\nMHQCAQEE\u{2026}\n-----END EC PRIVATE KEY-----", header);
4190 assert!(scan(&fake).is_some(), "EC PEM must be caught");
4191 }
4192
4193 #[test]
4194 fn pem_header_alone_is_a_mention_not_a_key() {
4195 let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); let doc = format!(
4199 "A PEM private key file starts with the line `{}` and the key\n\
4200 material follows it on wrapped lines. Keep such files out of chat.\n",
4201 header
4202 );
4203 assert!(
4204 scan(&doc).is_none(),
4205 "a header with no body must not be reported as a key"
4206 );
4207 let block = format!("{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----", header);
4210 assert_eq!(scan(&block).unwrap().detector, "pem-private-key");
4211 }
4212
4213 #[test]
4214 fn pem_many_headers_without_bodies_are_accepted() {
4215 let header = ["-----BEGIN EC", " PRIVATE KEY-----"].concat(); let mut doc = String::new();
4219 for kind in [
4220 "RSA",
4221 "EC",
4222 "DSA",
4223 "OPENSSH",
4224 "ENCRYPTED",
4225 "",
4226 "PGP",
4227 "X25519",
4228 ] {
4229 doc.push_str(&format!(
4230 "Use `-----BEGIN {} PRIVATE KEY-----` for this key type.\n",
4231 kind
4232 ));
4233 }
4234 assert!(doc.matches("-----BEGIN").count() == 8);
4235 assert!(scan(&doc).is_none(), "{:?}", scan(&doc));
4236 let block = format!("{header}\nMHQCAQEE\u{2026}\n-----END EC PRIVATE KEY-----\n");
4239 let with_block = format!("{doc}{block}");
4240 let m = scan(&with_block).unwrap();
4241 assert_eq!(m.detector, "pem-private-key");
4242 assert!(
4243 m.masked
4244 .ends_with(&format!("...{}chars", block.chars().count())),
4245 "masked: {}",
4246 m.masked
4247 );
4248 }
4249
4250 #[test]
4251 fn pem_header_with_base64_body_and_no_end_marker_is_refused() {
4252 let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); let body_line = "MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGYPYFqHlZ4kUmUqQ7fEd7Uw";
4255 let trailing =
4256 "and then a long paragraph of ordinary prose that is not part of the key block at all";
4257 let text = format!("{header}\n{body_line}\n{body_line}\n\n{trailing}\n");
4258 let m = scan(&text).expect("a header with a base64 body is a key");
4259 assert_eq!(m.detector, "pem-private-key");
4260 let block_len = header.chars().count() + 1 + (body_line.len() + 1) * 2;
4262 let reported_len: usize = m
4263 .masked
4264 .trim_end_matches("chars")
4265 .rsplit("...")
4266 .next()
4267 .and_then(|s| s.parse().ok())
4268 .expect("masked preview ends in the candidate length");
4269 assert_eq!(reported_len, block_len, "masked: {}", m.masked);
4270 let short = format!("{header}\nMIIEowIBAAKCAQEA\n{trailing}\n");
4272 assert!(scan(&short).is_none(), "{:?}", scan(&short));
4273 let tail = "MIIEowIBAAKCAQEA";
4276 let with_tail = format!("{header}\n{body_line}\n{tail}\n{trailing}\n");
4277 let m = scan(&with_tail).expect("a full line plus a short tail is a key");
4278 let tail_len = header.chars().count() + 1 + body_line.len() + 1 + tail.len() + 1;
4279 assert!(
4280 m.masked.ends_with(&format!("...{tail_len}chars")),
4281 "masked: {}",
4282 m.masked
4283 );
4284 let masked = mask_secrets(&with_tail);
4285 assert!(!masked.contains(tail), "tail survived masking: {masked}");
4286 }
4287
4288 #[test]
4289 fn pem_block_inside_serialized_json_is_still_caught_and_a_mention_is_not() {
4290 let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); let body_line = "MIIEowIBAAKCAQEA0Z3VS5JJcds3xfn/ygWyF8PbnGYPYFqHlZ4kUmUqQ7fEd7Uw";
4295 let block = serde_json::json!({"payload": format!("{header}\n{body_line}\n{body_line}")});
4296 let serialized = serde_json::to_string(&block).unwrap();
4297 assert!(
4298 serialized.contains("\\n"),
4299 "fixture must carry escaped newlines"
4300 );
4301 let m = scan(&serialized).expect("a key block in serialized JSON is a key");
4302 assert_eq!(m.detector, "pem-private-key");
4303 let block_len = header.chars().count() + 2 + (body_line.len() + 2) + body_line.len();
4305 assert!(
4306 m.masked.ends_with(&format!("...{block_len}chars")),
4307 "masked: {}",
4308 m.masked
4309 );
4310 let with_end = serde_json::json!({
4311 "payload": format!("{header}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\ntrailing")
4312 });
4313 let serialized = serde_json::to_string(&with_end).unwrap();
4314 assert_eq!(scan(&serialized).unwrap().detector, "pem-private-key");
4315
4316 let mention = serde_json::json!({
4317 "doc": format!("A key file starts with `{header}` and continues on wrapped lines.")
4318 });
4319 let serialized = serde_json::to_string(&mention).unwrap();
4320 assert!(scan(&serialized).is_none(), "{:?}", scan(&serialized));
4321 }
4322
4323 #[test]
4324 fn pem_block_after_an_unrelated_begin_marker_is_still_caught() {
4325 let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); let text = format!(
4329 "-----BEGIN CERTIFICATE-----\nMIIB\u{2026}\n-----END CERTIFICATE-----\n{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\n",
4330 header
4331 );
4332 let m = scan(&text).expect("the key block must be caught");
4333 assert_eq!(m.detector, "pem-private-key");
4334 assert!(
4335 !m.masked.starts_with("-----BEGIN C"),
4336 "candidate must start at the key header: {}",
4337 m.masked
4338 );
4339 }
4340
4341 #[test]
4342 fn blocks_age_secret_key() {
4343 let fake = "AGE-SECRET-KEY-1QQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQQ";
4345 assert!(scan(fake).is_some(), "AGE-SECRET-KEY- must be caught");
4346 assert_eq!(scan(fake).unwrap().detector, "age-secret-key");
4347 }
4348
4349 #[test]
4350 fn blocks_jwt_triple() {
4351 let fake = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; assert!(scan(fake).is_some(), "JWT triple must be caught");
4354 assert_eq!(scan(fake).unwrap().detector, "jwt");
4355 }
4356
4357 #[test]
4358 fn blocks_url_userinfo() {
4359 let fake = "postgresql://dbuser:S3cr3tP4ss@db.example.com:5432/mydb";
4360 assert!(scan(fake).is_some(), "URL userinfo must be caught");
4361 assert_eq!(scan(fake).unwrap().detector, "url-userinfo");
4362 }
4363
4364 #[test]
4365 fn url_userinfo_placeholder_refusal_explains_effective_remedies() {
4366 for content in [
4367 "postgresql://dbuser:<PASSWORD>@db.example.com:5432/mydb",
4368 "postgresql://<USER>:<PASSWORD>@db.example.com:5432/mydb",
4369 "redis://:<PASSWORD>@cache.example.com:6379",
4370 ] {
4371 let error = check(content).expect_err("URL credential placeholders still match");
4372 let RuntimeError::SecretDetected(matched) = &error else {
4373 panic!("expected a secret refusal, got {error}");
4374 };
4375 assert_eq!(matched.detector, "url-userinfo");
4376 let rendered = error.to_string();
4377 for guidance in [
4378 "Placeholders in URL credential positions still match",
4379 "Replace the whole URL",
4380 "environment-variable name or config key",
4381 "remove the entire user/password segment",
4382 ] {
4383 assert!(
4384 rendered.contains(guidance),
4385 "missing {guidance:?}: {rendered}"
4386 );
4387 }
4388 assert!(!rendered.contains(content), "refusal must not echo the URL");
4389 }
4390 }
4391
4392 #[test]
4393 fn url_userinfo_guidance_remedies_are_accepted() {
4394 for content in [
4395 "Use the DATABASE_URL environment variable.",
4396 "Use the database.connection_url config key.",
4397 "postgresql://db.example.com:5432/mydb",
4398 "redis://cache.example.com:6379",
4399 ] {
4400 assert!(
4401 check(content).is_ok(),
4402 "URL reference or URL without credentials must be accepted: {content:?}"
4403 );
4404 }
4405 }
4406
4407 #[test]
4408 fn blocks_and_masks_url_userinfo_with_short_passwords() {
4409 for password in ["a", "ab", "abc"] {
4410 let content = format!(
4411 "gate backend probe failed: postgres://svc:{password}@internal-host refused"
4412 );
4413
4414 let detected = scan(&content).expect("short URL password must be detected");
4415 assert_eq!(detected.detector, "url-userinfo");
4416 assert!(
4417 check(&content).is_err(),
4418 "write gate must block {content:?}"
4419 );
4420 assert_eq!(
4421 bounded_masked_log_text(&content),
4422 "gate backend probe failed: ***MASKED*** refused",
4423 "log boundary must mask {content:?}"
4424 );
4425 }
4426 }
4427
4428 #[test]
4429 fn keeps_url_without_userinfo() {
4430 let content = "gate backend probe failed: postgres://host:8080/path refused";
4431
4432 assert!(check(content).is_ok(), "host:port is not URL userinfo");
4433 assert_eq!(bounded_masked_log_text(content), content);
4434 }
4435
4436 #[test]
4437 fn blocks_and_masks_empty_username_url_passwords() {
4438 for password in ["a", "ab", "%40", "密码"] {
4441 let content =
4442 format!("gate backend probe failed: redis://:{password}@internal-host refused");
4443
4444 let detected = scan(&content).expect("empty-username URL password must be detected");
4445 assert_eq!(detected.detector, "url-userinfo");
4446 assert!(
4447 check(&content).is_err(),
4448 "write gate must block {content:?}"
4449 );
4450 assert_eq!(
4451 bounded_masked_log_text(&content),
4452 "gate backend probe failed: ***MASKED*** refused",
4453 "log boundary must mask {content:?}"
4454 );
4455 }
4456 }
4457
4458 #[test]
4459 fn keeps_colon_at_pairs_outside_the_authority() {
4460 for content in [
4464 "see https://host/a:x@next for details",
4465 "see https://host?time=12:30@zone for details",
4466 "see https://host#frag:1@anchor for details",
4467 ] {
4468 assert!(
4469 check(content).is_ok(),
4470 "path/query/fragment `:`+`@` text is not userinfo: {content:?}"
4471 );
4472 assert_eq!(bounded_masked_log_text(content), content);
4473 }
4474 }
4475
4476 #[test]
4477 fn blocks_high_entropy_near_bearer_word() {
4478 let fake = "Bearer token: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; assert!(
4481 scan(fake).is_some(),
4482 "high-entropy value near 'bearer' must be caught"
4483 );
4484 assert_eq!(scan(fake).unwrap().detector, "high-entropy-token");
4485 }
4486
4487 #[test]
4488 fn blocks_high_entropy_near_secret_word() {
4489 let fake = "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; assert!(
4491 scan(fake).is_some(),
4492 "high-entropy value near 'secret' must be caught"
4493 );
4494 }
4495
4496 #[test]
4497 fn error_message_masks_secret() {
4498 let fake = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
4499 let m = scan(fake).unwrap();
4500 let masked = &m.masked;
4503 assert!(
4504 !masked.contains("AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"),
4505 "mask must not echo the full secret value; got: {masked}"
4506 );
4507 assert!(
4509 masked.starts_with("ghp_AA"),
4510 "mask must show first 6 chars; got: {masked}"
4511 );
4512 }
4513
4514 #[test]
4517 fn allows_sha256_hex() {
4518 let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
4520 assert!(
4521 scan(sha).is_none(),
4522 "sha256 hex must pass (allowlisted); fired: {:?}",
4523 scan(sha)
4524 );
4525 }
4526
4527 #[test]
4528 fn allows_uuid() {
4529 let uuid = "550e8400-e29b-41d4-a716-446655440000";
4530 assert!(
4531 scan(uuid).is_none(),
4532 "UUID must pass; fired: {:?}",
4533 scan(uuid)
4534 );
4535 }
4536
4537 #[test]
4538 fn allows_git_sha() {
4539 let sha = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
4541 assert!(
4542 scan(sha).is_none(),
4543 "git SHA must pass; fired: {:?}",
4544 scan(sha)
4545 );
4546 }
4547
4548 #[test]
4549 fn allows_normal_prose() {
4550 let prose =
4551 "The FlashAttention paper introduces IO-aware tiling for transformer self-attention.";
4552 assert!(scan(prose).is_none(), "normal prose must pass");
4553 }
4554
4555 #[test]
4556 fn allows_code_snippet() {
4557 let code = r#"fn create_entity(name: &str, kind: &str) -> RuntimeResult<Entity> {
4558 self.validate_entity_kind(kind)?;
4559 Ok(Entity::new("local", kind, name))
4560}"#;
4561 assert!(
4562 scan(code).is_none(),
4563 "code snippet must pass; fired: {:?}",
4564 scan(code)
4565 );
4566 }
4567
4568 #[test]
4569 fn allows_long_url_without_credentials() {
4570 let url = "https://docs.example.com/api/v2/entities?kind=concept&limit=100";
4571 assert!(scan(url).is_none(), "URL without userinfo must pass");
4572 }
4573
4574 #[test]
4575 fn allows_base64_image_stub() {
4576 let b64 = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAC0lEQVQI12NgAAIABQ";
4578 assert!(
4579 scan(b64).is_none(),
4580 "base64 image stub without trigger word must pass; fired: {:?}",
4581 scan(b64)
4582 );
4583 }
4584
4585 #[test]
4586 fn allows_long_plain_url() {
4587 let url = "https://api.github.com/repos/ohdearquant/khive/pulls/76/comments?per_page=100";
4588 assert!(
4589 scan(url).is_none(),
4590 "plain URL must pass; fired: {:?}",
4591 scan(url)
4592 );
4593 }
4594
4595 #[test]
4596 fn allows_manifest_content_hash() {
4597 let line =
4599 "checksum = \"e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855\"";
4600 assert!(
4601 scan(line).is_none(),
4602 "manifest content hash line must pass; fired: {:?}",
4603 scan(line)
4604 );
4605 }
4606
4607 #[test]
4608 fn masked_excerpt_format() {
4609 let fake = "AKIAFAKEKEY1234567890";
4610 let m = scan(fake).unwrap();
4611 assert!(m.masked.contains("..."), "masked must contain '...'");
4613 assert!(m.masked.ends_with("chars"), "masked must end with 'chars'");
4614 }
4615
4616 #[test]
4619 fn check_returns_ok_for_safe_content() {
4620 assert!(check("A normal memory note about LoRA.").is_ok());
4621 }
4622
4623 #[test]
4624 fn check_returns_err_for_secret() {
4625 let fake = "AKIAFAKEKEY1234567890";
4626 let result = check(fake);
4627 assert!(result.is_err(), "check must fail for AKIA key");
4628 let err = result.unwrap_err();
4629 assert!(
4630 matches!(err, RuntimeError::SecretDetected(_)),
4631 "error variant must be SecretDetected"
4632 );
4633 }
4634
4635 #[test]
4638 fn entropy_of_uniform_string_is_zero() {
4639 let s = "aaaaaaaaaaaaaaaa";
4640 assert!(shannon_entropy(s.as_bytes()) < 0.01);
4641 }
4642
4643 #[test]
4644 fn entropy_of_random_bytes_is_high() {
4645 let s = b"X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; assert!(shannon_entropy(s) > 4.5, "entropy={}", shannon_entropy(s));
4648 }
4649
4650 #[test]
4651 fn cjk_prose_near_trigger_is_not_flagged() {
4652 let content = "更新 auth 配置数据库连接管理系统核心模块设计文档";
4659 assert!(
4660 check(content).is_ok(),
4661 "CJK prose near a trigger word must not be flagged as a secret"
4662 );
4663 }
4664
4665 #[test]
4666 fn ascii_secret_near_trigger_still_flagged() {
4667 let content = "api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1";
4670 assert!(
4671 check(content).is_err(),
4672 "ASCII high-entropy token near a trigger word must still be blocked"
4673 );
4674 }
4675
4676 #[test]
4677 fn ascii_secret_in_cjk_context_does_not_panic_and_is_flagged() {
4678 let cjk = "数据库连接管理系统核心模块设计文档".repeat(6); let content = format!("{cjk}x api_key X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1 {cjk}");
4687 assert!(
4688 check(&content).is_err(),
4689 "ASCII secret in CJK context must still be blocked (and must not panic)"
4690 );
4691 }
4692
4693 #[test]
4694 fn ascii_secret_glued_to_cjk_is_still_flagged() {
4695 let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; let cases = [
4704 format!("api_key {secret}数据"), format!("api_key 「{secret}」"), format!("api_key {secret}"), format!("api_key:{secret}"), format!("数据{secret}更新 api_key"), ];
4710 for content in &cases {
4711 assert!(
4712 check(content).is_err(),
4713 "ASCII secret glued to CJK must be blocked: {content:?}"
4714 );
4715 }
4716 }
4717
4718 #[test]
4719 fn high_entropy_ascii_run_without_trigger_is_not_flagged() {
4720 let secret = "X9kZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; let content = format!("数据库连接{secret}核心模块设计文档");
4726 assert!(
4727 check(&content).is_ok(),
4728 "high-entropy ASCII run with no trigger word must not be flagged"
4729 );
4730 }
4731
4732 #[test]
4733 fn known_prefix_secret_glued_after_cjk_is_still_flagged() {
4734 let cases = [
4740 "数据AKIAIOSFODNN7EXAMPLE".to_owned(), format!("令牌{}", github_fine_grained_pat_fixture()),
4742 format!("密钥{}", anthropic_api_key_fixture()),
4743 "配置FlyV1 fm2_AAAABBBBCCCCDDDD".to_owned(), ];
4745 for content in &cases {
4746 assert!(
4747 check(content).is_err(),
4748 "known-prefix secret glued after CJK must be blocked: {content:?}"
4749 );
4750 }
4751 }
4752
4753 #[test]
4754 fn url_userinfo_after_cjk_does_not_panic_and_is_flagged() {
4755 let cases = [
4760 "数据postgresql://dbuser:S3cr3tP4ss@db.example.com/db", "配置mysql://root:hunter2pw@10.0.0.1:3306/app", "连接redis://svc:V3ryS3cretPw@cache.internal:6379", ];
4764 for content in cases {
4765 assert!(
4766 check(content).is_err(),
4767 "credential URL after CJK must be blocked, not panic: {content:?}"
4768 );
4769 }
4770 }
4771
4772 #[test]
4773 fn non_ascii_glued_token_trigger_is_still_flagged() {
4774 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let blocked = [
4782 format!("数据token={opaque}"), format!("配置token: {opaque}"), format!("密钥token {opaque}"), format!("résumétoken: {opaque}"), format!("1token: {opaque}"), ];
4788 for content in &blocked {
4789 assert!(
4790 check(content).is_err(),
4791 "non-ASCII-glued token trigger must flag the value: {content:?}"
4792 );
4793 }
4794 let allowed = [
4798 format!("数据next_token: {opaque}"),
4799 format!("数据token_count: {opaque}"),
4800 format!("servicetoken: {opaque}"),
4801 ];
4802 for content in &allowed {
4803 assert!(
4804 check(content).is_ok(),
4805 "compound token identifier must not be flagged: {content:?}"
4806 );
4807 }
4808 }
4809
4810 #[test]
4811 fn allowlist_passes_sha256() {
4812 let sha = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
4815 assert!(is_pure_hex(sha));
4816 }
4817
4818 #[test]
4819 fn allowlist_passes_uuid_canonical() {
4820 assert!(is_uuid_canonical("550e8400-e29b-41d4-a716-446655440000"));
4821 }
4822
4823 #[test]
4824 fn allowlist_does_not_pass_mixed_token() {
4825 assert!(!is_pure_hex("sk-aaaaaabbbbbbccccccddddddeeeeeeffffgg"));
4827 }
4828
4829 #[test]
4832 fn check_json_blocks_secret_in_object_value() {
4833 let props = serde_json::json!({ "api_key": "AKIAFAKEKEY1234567890" });
4834 assert!(
4835 check_json(&props).is_err(),
4836 "secret in properties object value must be blocked"
4837 );
4838 }
4839
4840 #[test]
4841 fn check_json_blocks_secret_in_nested_object() {
4842 let props = serde_json::json!({
4843 "credentials": { "token": openai_project_key_fixture() }
4844 });
4845 assert!(
4846 check_json(&props).is_err(),
4847 "secret in nested properties object must be blocked"
4848 );
4849 }
4850
4851 #[test]
4852 fn check_json_blocks_secret_in_array() {
4853 let props = serde_json::json!(["normal", "AKIAFAKEKEY1234567890"]);
4854 assert!(
4855 check_json(&props).is_err(),
4856 "secret in JSON array must be blocked"
4857 );
4858 }
4859
4860 #[test]
4863 fn reject_reserved_key_passes_absent_properties() {
4864 assert!(reject_reserved_secret_gate_property(None).is_ok());
4865 }
4866
4867 #[test]
4868 fn reject_reserved_key_passes_unrelated_properties() {
4869 let props = serde_json::json!({"name": "value", "tags": ["a", "b"]});
4870 assert!(reject_reserved_secret_gate_property(Some(&props)).is_ok());
4871 }
4872
4873 #[test]
4874 fn reject_reserved_key_passes_non_object_properties() {
4875 let props = serde_json::json!("just a string");
4877 assert!(reject_reserved_secret_gate_property(Some(&props)).is_ok());
4878 let arr = serde_json::json!(["a", "b"]);
4879 assert!(reject_reserved_secret_gate_property(Some(&arr)).is_ok());
4880 }
4881
4882 #[test]
4883 fn reject_reserved_key_blocks_top_level_key_creation() {
4884 let props = serde_json::json!({"khive:secret_gate": "exempted:content-sha256-manifest-v1"});
4885 let err = reject_reserved_secret_gate_property(Some(&props)).unwrap_err();
4886 assert!(
4887 matches!(err, RuntimeError::InvalidInput(ref msg) if msg.contains("khive:secret_gate") && msg.contains("runtime-owned")),
4888 "unexpected error: {err:?}"
4889 );
4890 }
4891
4892 #[test]
4893 fn reject_reserved_key_blocks_regardless_of_value_shape() {
4894 for value in [
4899 serde_json::json!(null),
4900 serde_json::json!(42),
4901 serde_json::json!({"nested": "object"}),
4902 serde_json::json!("exempted:content-sha256-manifest-v1"),
4903 ] {
4904 let props = serde_json::json!({"khive:secret_gate": value});
4905 assert!(
4906 reject_reserved_secret_gate_property(Some(&props)).is_err(),
4907 "must reject value shape: {props:?}"
4908 );
4909 }
4910 }
4911
4912 #[test]
4913 fn reject_reserved_key_allows_nested_non_top_level_occurrence() {
4914 let props = serde_json::json!({"notes": {"khive:secret_gate": "not-a-stamp"}});
4917 assert!(reject_reserved_secret_gate_property(Some(&props)).is_ok());
4918 }
4919
4920 #[test]
4921 fn reject_reserved_key_blocks_alongside_other_legitimate_keys() {
4922 let props = serde_json::json!({
4923 "name": "value",
4924 "khive:secret_gate": "exempted:content-sha256-manifest-v1",
4925 });
4926 assert!(reject_reserved_secret_gate_property(Some(&props)).is_err());
4927 }
4928
4929 #[test]
4930 fn check_json_passes_safe_properties() {
4931 let props = serde_json::json!({
4932 "domain": "attention",
4933 "status": "researched",
4934 "year": 2024
4935 });
4936 assert!(
4937 check_json(&props).is_ok(),
4938 "normal properties must pass; fired: {:?}",
4939 check_json(&props).err()
4940 );
4941 }
4942
4943 #[test]
4944 fn check_tags_blocks_credential_tag() {
4945 let tags = vec![
4946 "type:concept".to_string(),
4947 "AKIAFAKEKEY1234567890".to_string(),
4948 ];
4949 assert!(
4950 check_tags(&tags).is_err(),
4951 "credential-shaped tag must be blocked"
4952 );
4953 }
4954
4955 #[test]
4956 fn check_tags_passes_normal_tags() {
4957 let tags = vec!["type:concept".to_string(), "domain:attention".to_string()];
4958 assert!(
4959 check_tags(&tags).is_ok(),
4960 "normal tags must pass; fired: {:?}",
4961 check_tags(&tags).err()
4962 );
4963 }
4964
4965 #[test]
4968 fn allows_sk_learn_prose() {
4969 let texts = &[
4971 "sk-learn is a Python machine learning library",
4972 "sk-learn-compatible transformer pipeline reference",
4973 "sk-learn scikit-learn estimator interface",
4974 ];
4975 for t in texts {
4976 assert!(
4977 scan(t).is_none(),
4978 "sk-learn prose must pass; fired: {:?} on {:?}",
4979 scan(t),
4980 t
4981 );
4982 }
4983 }
4984
4985 #[test]
4986 fn blocks_openai_sk_proj_not_confused_with_sk_learn() {
4987 let fake = openai_project_key_fixture();
4989 assert!(
4990 scan(&fake).is_some(),
4991 "sk-proj- key must still be caught after sk-learn exemption"
4992 );
4993 }
4994
4995 #[test]
4998 fn blocks_sri_hash_near_key_word_accepted_fp() {
4999 let line = "integrity key: sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC";
5007 assert!(
5008 scan(line).is_some(),
5009 "SRI hash near trigger word 'key' must now be blocked (accepted FP); passed unexpectedly"
5010 );
5011 }
5012
5013 #[test]
5014 fn allows_base64_tokenizer_hash_metadata() {
5015 let line = "tokenizer_vocab_hash: Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; assert!(
5018 scan(line).is_none(),
5019 "tokenizer hash metadata must pass; fired: {:?}",
5020 scan(line)
5021 );
5022 }
5023
5024 #[test]
5025 fn allows_npm_lockfile_integrity() {
5026 let body_86 = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1234567890abcdefghijklmnopqrstuvwxABCDEFGHIJKLMNOPQRST";
5029 assert_eq!(body_86.len(), 86, "test body must be exactly 86 chars");
5030 let line = format!(
5031 "resolved: https://registry.npmjs.org/foo/-/foo-1.0.0.tgz\nintegrity: sha512-{body_86}=="
5032 );
5033 assert!(
5034 scan(&line).is_none(),
5035 "npm lockfile integrity must pass; fired: {:?}",
5036 scan(&line)
5037 );
5038 }
5039
5040 #[test]
5043 fn allows_tokenizer_vocab_hash_no_block() {
5044 let line = "tokenizer_vocab_hash = Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; assert!(
5049 scan(line).is_none(),
5050 "tokenizer_vocab_hash must pass; 'token' is only standalone-word matched; fired: {:?}",
5051 scan(line)
5052 );
5053 }
5054
5055 #[test]
5058 fn blocks_bare_base64url_43chars_near_key() {
5059 let token_43 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"; assert_eq!(token_43.len(), 43, "test token must be exactly 43 chars");
5063 let line = format!("api key {token_43}");
5064 assert!(
5065 scan(&line).is_some(),
5066 "43-char base64url token near 'key' must be caught (no sha-prefix = not a hash); fired: {:?}",
5067 scan(&line)
5068 );
5069 }
5070
5071 #[test]
5072 fn blocks_bare_base64url_64chars_near_secret() {
5073 let token_64 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENa"; assert_eq!(token_64.len(), 64, "test token must be exactly 64 chars");
5077 let line = format!("secret: {token_64}");
5078 assert!(
5079 scan(&line).is_some(),
5080 "64-char base64url token near 'secret' must be caught; got: {:?}",
5081 scan(&line)
5082 );
5083 }
5084
5085 #[test]
5086 fn blocks_bare_base64url_86chars_near_auth() {
5087 let token_86 = "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123wJalrXUtnFEMI-K7MDENwJalrXUtnFEMI-K7MDENabc"; assert_eq!(token_86.len(), 86, "test token must be exactly 86 chars");
5091 let line = format!("auth header {token_86}");
5092 assert!(
5093 scan(&line).is_some(),
5094 "86-char base64url token near 'auth' must be caught; got: {:?}",
5095 scan(&line)
5096 );
5097 }
5098
5099 #[test]
5102 fn blocks_service_token_opaque_value() {
5103 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; assert!(
5107 opaque.len() >= 24,
5108 "opaque must be long enough for entropy check"
5109 );
5110 let line = format!("service token {opaque}");
5111 assert!(
5112 scan(&line).is_some(),
5113 "service token <opaque> must be caught by standalone 'token' check; got: {:?}",
5114 scan(&line)
5115 );
5116 }
5117
5118 #[test]
5119 fn blocks_token_equals_credential() {
5120 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let line = format!("token={opaque}");
5123 assert!(
5124 scan(&line).is_some(),
5125 "token=<value> must be caught via token= trigger; got: {:?}",
5126 scan(&line)
5127 );
5128 }
5129
5130 #[test]
5131 fn blocks_token_colon_credential() {
5132 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let line = format!("token: {opaque}");
5135 assert!(
5136 scan(&line).is_some(),
5137 "token: <value> must be caught via token: trigger; got: {:?}",
5138 scan(&line)
5139 );
5140 }
5141
5142 #[test]
5143 fn allows_next_token_technical_context() {
5144 let line = "next_token: cursor-page-2-abcdef12345678";
5147 assert!(
5148 scan(line).is_none(),
5149 "next_token technical context must not be blocked; fired: {:?}",
5150 scan(line)
5151 );
5152 }
5153
5154 #[test]
5157 fn allows_next_token_high_entropy_cursor() {
5158 let cursor = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let line = format!("next_token: {cursor}");
5162 assert!(
5163 scan(&line).is_none(),
5164 "next_token with high-entropy cursor must pass (compound identifier); fired: {:?}",
5165 scan(&line)
5166 );
5167 }
5168
5169 #[test]
5170 fn allows_token_count_high_entropy() {
5171 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let line = format!("token_count: {opaque}");
5176 assert!(
5177 scan(&line).is_none(),
5178 "token_count with high-entropy value must pass; fired: {:?}",
5179 scan(&line)
5180 );
5181 }
5182
5183 #[test]
5189 fn hex_near_key_blocked_in_credential_context() {
5190 let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
5195 assert_eq!(hex32.len(), 32);
5196 let line = format!("api key {hex32}");
5197 assert!(
5198 scan(&line).is_some(),
5199 "32-char pure hex near 'api key' must be blocked; got None"
5200 );
5201 }
5202
5203 #[test]
5204 fn issue_2056_hex_bridge_masks_the_matched_candidate() {
5205 let hex32 = "0af7651916cd43dd8448eb211c80319c";
5206 let content = format!("api key values verbatim:\n {hex32}");
5207
5208 let (candidate, detector) = scan_match(&content).expect("credential must have a span");
5209 assert_eq!(detector, "hex-credential-token");
5210 assert_eq!(candidate, hex32);
5211 assert!(candidate.bytes().all(|byte| byte.is_ascii_hexdigit()));
5212
5213 let matched = scan(&content).expect("credential-labeled hex must be blocked");
5214 assert_eq!(matched.detector, "hex-credential-token");
5215 assert_eq!(matched.masked, "0af765...32chars");
5216 }
5217
5218 #[test]
5219 fn issue_2056_detector_name_does_not_trigger_across_sentence_boundary() {
5220 let content = "The hex-credential-token bucket held 18. Six are two values verbatim:\n\
5221 0af7651916cd43dd8448eb211c80319c -- spec example identifier";
5222
5223 assert!(
5224 check(content).is_ok(),
5225 "a detector name in an earlier sentence must not trigger: {:?}",
5226 scan(content)
5227 );
5228 }
5229
5230 #[test]
5231 fn issue_2076_allows_repository_revision_links_near_prose_triggers() {
5232 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5233 let contents = [
5234 format!(
5235 "key-scoped source citation: \
5236 https://github.com/acme/widgets/blob/{revision}/src/runtime/mod.rs"
5237 ),
5238 format!("auth-bound rendering keeps <a href=\"{revision}\">the commit link</a> intact"),
5239 ];
5240
5241 for content in contents {
5242 assert!(
5243 check(&content).is_ok(),
5244 "repository revision reference must pass: {content:?}, got {:?}",
5245 scan(&content)
5246 );
5247 }
5248 }
5249
5250 #[test]
5251 fn issue_2076_repository_revision_links_do_not_hide_labeled_secrets() {
5252 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5253 let contents = [
5254 format!("api key: https://github.com/acme/widgets/blob/{revision}/src/runtime/mod.rs"),
5255 format!("secret key: href=\"{revision}\""),
5256 ];
5257
5258 for content in contents {
5259 assert!(
5260 check(&content).is_err(),
5261 "a direct credential label must still block: {content:?}"
5262 );
5263 }
5264 }
5265
5266 #[test]
5267 fn issue_2076_guidance_names_a_boundary_that_changes_the_predicate() {
5268 let guidance = block_guidance("high-entropy-token");
5269 assert!(guidance.contains("sentence") || guidance.contains("paragraph"));
5270 assert!(
5271 !guidance.contains("own line"),
5272 "a single newline remains inside the trigger window"
5273 );
5274 }
5275
5276 #[test]
5277 fn issue_1988_allows_dense_latex_fragments_near_math_vocabulary() {
5278 let latex = r"\operatorname{Spec}_{H_0^1(\Omega)}(K_N(\tau_{\omega}))^{1/2}";
5279 assert!(latex.len() >= MIN_ENTROPY_LEN);
5280 assert!(shannon_entropy(latex.as_bytes()) >= ENTROPY_THRESHOLD);
5281 let content = format!("the attention key estimate uses {latex}");
5282
5283 assert!(
5284 check(&content).is_ok(),
5285 "LaTeX structure must not be mistaken for a credential: {:?}",
5286 scan(&content)
5287 );
5288 }
5289
5290 #[test]
5291 fn issue_1988_latex_exemption_does_not_hide_credential_runs() {
5292 let credential = "a3f5c2e9d1b8047e63a1f4c2d5b6e8f1a9c3d2e4"; let content = format!(r"api key: \texttt{{{credential}}}");
5294
5295 assert!(
5296 check(&content).is_err(),
5297 "credential-shaped runs inside LaTeX must remain blocked"
5298 );
5299 }
5300
5301 #[test]
5302 fn issue_1988_published_secret_vectors_remain_fail_closed() {
5303 let published = "833fe62409237b9d62ec77587520911e9a759cec1d19755b7da901b96dca3d42";
5307 let content = format!("RFC 8032 secret key test vector: {published}");
5308
5309 assert!(check(&content).is_err());
5310 }
5311
5312 #[test]
5313 fn hex_credential_lengths_blocked_near_trigger() {
5314 let hex40 = "a3f5c2e9d1b8047e63a1f4c2d5b6e8f1a9c3d2e4";
5316 let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
5317 let hex128 = format!("{hex64}{hex64}");
5318 assert_eq!(hex40.len(), 40);
5319 assert_eq!(hex64.len(), 64);
5320 assert_eq!(hex128.len(), 128);
5321
5322 for (label, hex) in &[
5323 ("hex40", hex40),
5324 ("hex64", hex64),
5325 ("hex128", hex128.as_str()),
5326 ] {
5327 let line = format!("secret key: {hex}");
5328 assert!(
5329 scan(&line).is_some(),
5330 "{label} near 'secret key' must be blocked; got None"
5331 );
5332 }
5333 }
5334
5335 #[test]
5336 fn hex_blocked_when_trigger_and_hash_word_coexist() {
5337 let hex32 = "4f9c2e8a1d3b5c7e9f0a2b4d6e8c0a2b";
5342 let key_hash_line = format!("api key hash {hex32}");
5343 let secret_sha_line = format!("secret sha {hex32}");
5344 assert!(
5345 scan(&key_hash_line).is_some(),
5346 "'api key hash <hex32>' must be blocked; got None"
5347 );
5348 assert!(
5349 scan(&secret_sha_line).is_some(),
5350 "'secret sha <hex32>' must be blocked; got None"
5351 );
5352 }
5353
5354 #[test]
5355 fn hex_near_sha_context_word_allowed() {
5356 let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
5359 let sha_line = format!("sha1: {hex40}");
5360 let commit_line = format!("commit sha {hex40}");
5361 assert!(
5362 scan(&sha_line).is_none(),
5363 "hex40 near 'sha1' context must be allowed; fired: {:?}",
5364 scan(&sha_line)
5365 );
5366 assert!(
5367 scan(&commit_line).is_none(),
5368 "hex40 near 'commit sha' context must be allowed; fired: {:?}",
5369 scan(&commit_line)
5370 );
5371 }
5372
5373 const GIT_LENGTH_FIXTURE: &str = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
5374
5375 #[test]
5376 fn bare_forty_hex_line_three_allows_trigger_on_line_one_within_window() {
5377 let content = format!("auth changes\nready\n{GIT_LENGTH_FIXTURE}");
5378 assert!(content.len() < TRIGGER_WINDOW);
5379 assert!(check(&content).is_ok());
5380 assert_eq!(mask_secrets(&content), content);
5381 }
5382
5383 #[test]
5384 fn bare_forty_hex_line_three_allows_trigger_beyond_window() {
5385 let content = format!("auth changes\n{}\n{GIT_LENGTH_FIXTURE}", "-".repeat(150));
5386 assert!(check(&content).is_ok());
5387 }
5388
5389 #[test]
5390 fn forty_hex_allows_direct_sha_or_commit_marker_in_prose() {
5391 for marker in ["sha:", "commit"] {
5392 let content = format!("auth changes\nready\n{marker} {GIT_LENGTH_FIXTURE}");
5393 assert!(check(&content).is_ok(), "{marker}");
5394 }
5395 }
5396
5397 #[test]
5398 fn forty_hex_refuses_same_line_token_assignment() {
5399 let content = format!("token: {GIT_LENGTH_FIXTURE}");
5400 let matched = scan(&content).expect("explicit credential assignment");
5401 assert_eq!(matched.detector, "hex-credential-token");
5402 assert_eq!(matched.trigger, Some("token"));
5403 assert!(check(&content).is_err());
5404 assert!(!mask_secrets(&content).contains(GIT_LENGTH_FIXTURE));
5405 }
5406
5407 #[test]
5408 fn forty_hex_refuses_previous_label_line_ending_colon_or_equals() {
5409 for newline in ["\n", "\r\n", "\r"] {
5410 for delimiter in [":", "="] {
5411 let content = format!("token{delimiter}{newline} `{GIT_LENGTH_FIXTURE}`");
5412 let matched = scan(&content).expect("previous line labels the value");
5413 assert_eq!(matched.detector, "hex-credential-token");
5414 assert_eq!(matched.trigger, Some("token"));
5415 assert!(!mask_secrets(&content).contains(GIT_LENGTH_FIXTURE));
5416 }
5417 }
5418 }
5419
5420 #[test]
5421 fn forty_hex_context_stays_on_line_except_immediate_assignment_label() {
5422 for content in [
5423 format!("token\n{GIT_LENGTH_FIXTURE}"),
5424 format!("token:\n\n{GIT_LENGTH_FIXTURE}"),
5425 format!("{GIT_LENGTH_FIXTURE}\nauth changes"),
5426 format!("token_count:\n{GIT_LENGTH_FIXTURE}"),
5427 format!("authorized:\n{GIT_LENGTH_FIXTURE}"),
5428 format!("{}\n{GIT_LENGTH_FIXTURE}\n密钥", "文".repeat(80)),
5429 ] {
5430 assert!(check(&content).is_ok(), "{content}");
5431 }
5432 for content in [
5433 format!("{GIT_LENGTH_FIXTURE} auth"),
5434 format!("api_keyv2 =\n{GIT_LENGTH_FIXTURE}"),
5435 format!("secret for deploy: \n{GIT_LENGTH_FIXTURE}"),
5436 ] {
5437 assert!(check(&content).is_err(), "{content}");
5438 }
5439 }
5440
5441 #[test]
5442 fn other_hex_lengths_still_refuse_cross_line_trigger_context() {
5443 for length in [32, 64, 128] {
5444 let value = "a".repeat(length);
5445 let content = format!("auth changes\nready\n{value}");
5446 let matched = scan(&content).expect("unchanged cross-line context");
5447 assert_eq!(matched.detector, "hex-credential-token");
5448 assert_eq!(matched.trigger, Some("auth"));
5449 }
5450 }
5451
5452 #[test]
5453 fn prefixed_hex_of_forty_bytes_keeps_cross_line_trigger_context() {
5454 for prefix in ["0x", "0X"] {
5455 let value = format!("{prefix}{}", "a".repeat(38));
5456 let content = format!("auth changes\nready\n{value}");
5457 assert!(check(&content).is_err());
5458 assert!(!mask_secrets(&content).contains(&value));
5459 }
5460 }
5461
5462 #[test]
5463 fn forty_hex_beside_bridgeable_prose_keeps_conservative_trigger_context() {
5464 let content = format!("auth changes\ncompleted\n{GIT_LENGTH_FIXTURE}");
5465 assert!(check(&content).is_err());
5466 assert!(!mask_secrets(&content).contains(GIT_LENGTH_FIXTURE));
5467 }
5468
5469 #[test]
5470 fn forty_hex_bridge_fragments_keep_cross_line_detection_and_full_masking() {
5471 for lengths in [(40, 24), (24, 40), (40, 40)] {
5472 let first = "a".repeat(lengths.0);
5473 let second = "b".repeat(lengths.1);
5474 let content = format!("auth changes\nready\n{first}\u{200B}{second}");
5475 let matched = scan(&content).expect("fragment keeps original trigger context");
5476 assert_eq!(matched.detector, "hex-credential-token");
5477 assert_eq!(matched.trigger, Some("auth"));
5478 let masked = mask_secrets(&content);
5479 assert!(!masked.contains(&first), "first fragment remains");
5480 assert!(!masked.contains(&second), "second fragment remains");
5481 assert_eq!(
5482 masked,
5483 "auth changes\nready\n***MASKED***\u{200B}***MASKED***"
5484 );
5485 }
5486 }
5487
5488 #[test]
5489 fn refusal_names_rule_and_canonical_trigger_without_candidate_text() {
5490 for (label, trigger) in [
5491 ("token", "token"),
5492 ("AUTH", "auth"),
5493 ("api_keyv2", "api_key"),
5494 ] {
5495 let content = format!("{label}: {GIT_LENGTH_FIXTURE}");
5496 let error = check(&content).unwrap_err().to_string();
5497 assert!(error.contains("hex-credential-token"));
5498 assert!(error.contains(&format!("near '{trigger}'")), "{error}");
5499 assert!(!error.contains(&GIT_LENGTH_FIXTURE[..6]));
5500 }
5501 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let error = check(&format!("secret: {opaque}")).unwrap_err().to_string();
5503 assert!(error.contains("high-entropy-token near 'secret'"));
5504 assert!(!error.contains(&opaque[..6]));
5505 let provider = "AKIAFAKEKEY1234567890";
5506 let matched = scan(provider).unwrap();
5507 assert_eq!(matched.trigger, None);
5508 assert!(!matched.to_string().contains(&provider[..6]));
5509 }
5510
5511 #[test]
5512 fn allows_git_revision_reference_near_ordinary_key_and_token_prose() {
5513 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5514 let contents = [
5515 format!("the primary key behavior is pinned to commit {revision}"),
5516 format!("revision: {revision} emits one extra token"),
5517 format!("primary key behavior at revision:{revision};"),
5518 format!("configuration key\n\nrev {revision}."),
5519 format!("one extra token was introduced by sha: {revision}"),
5520 ];
5521 for content in &contents {
5522 assert!(
5523 check(content).is_ok(),
5524 "git revision reference in technical prose must pass: {content:?}, got {:?}",
5525 scan(content)
5526 );
5527 }
5528 }
5529
5530 #[test]
5531 fn git_revision_reference_does_not_exempt_credential_assignments() {
5532 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5533 for content in [
5534 format!("api_key={revision}"),
5535 format!("secret key: {revision}"),
5536 format!("token={revision}"),
5537 format!("api key hash {revision}"),
5538 ] {
5539 assert!(
5540 check(&content).is_err(),
5541 "credential assignment must remain blocked: {content:?}"
5542 );
5543 }
5544 }
5545
5546 #[test]
5547 fn blocks_forty_hex_after_credential_phrase_with_vcs_marker() {
5548 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5549 for content in [
5550 format!("api key value is commit {revision}"),
5551 format!("api key value is revision {revision}"),
5552 format!("the secret is rev {revision}"),
5553 format!("token value sha {revision}"),
5554 ] {
5555 assert_eq!(
5556 scan(&content).map(|matched| matched.detector),
5557 Some("hex-credential-token"),
5558 "a credential phrase must not be hidden by connector words before \
5559 a VCS marker: {content:?}"
5560 );
5561 }
5562 }
5563
5564 #[test]
5565 fn blocks_unicode_punctuation_between_vcs_marker_and_forty_hex() {
5566 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5567 let content = format!("api_key value commit\u{200B}{revision}");
5568 assert!(
5569 check(&content).is_err(),
5570 "a zero-width space between marker and value must not rescue a \
5571 labeled credential: {content:?}, got {:?}",
5572 scan(&content)
5573 );
5574 }
5575
5576 #[test]
5577 fn blocks_slash_bearing_base64_credential_in_value_syntax() {
5578 let content = "api key value is Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5584 assert_eq!(
5585 scan(content).map(|matched| matched.detector),
5586 Some("high-entropy-token"),
5587 "slash-bearing base64 credential in value syntax must be blocked"
5588 );
5589 }
5590
5591 #[test]
5592 fn blocks_angle_bracket_line_range_base64_credential_in_value_syntax() {
5593 let content = "api key value is <Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh>:~97-103";
5594 assert_eq!(
5595 scan(content).map(|matched| matched.detector),
5596 Some("high-entropy-token"),
5597 "angle-bracket/line-range dressing must not exempt a credential in \
5598 value syntax"
5599 );
5600 }
5601
5602 #[test]
5603 fn blocks_split_hex_credential_with_marker_adjacent_fragment() {
5604 let content = concat!(
5611 "api token context: commit ",
5612 "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf",
5613 "\u{200B}",
5614 "0123456789abcdef01234567"
5615 );
5616 assert_eq!(
5617 scan(content).map(|matched| matched.detector),
5618 Some("hex-credential-token"),
5619 "split credential with a marker-adjacent fragment must be blocked"
5620 );
5621 }
5622
5623 #[test]
5624 fn blocks_forty_hex_behind_qualified_label_with_delimiter() {
5625 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5630 for content in [
5631 format!("api key for deploy: commit {revision}"),
5632 format!("prod api key for deploy = commit {revision}"),
5633 ] {
5634 assert_eq!(
5635 scan(&content).map(|matched| matched.detector),
5636 Some("hex-credential-token"),
5637 "a qualified label before a value delimiter must not be \
5638 hidden from the exemption guard: {content:?}"
5639 );
5640 }
5641 }
5642
5643 #[test]
5644 fn blocks_slash_base64_behind_qualified_label_with_delimiter() {
5645 let content = "api key for deploy: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5646 assert_eq!(
5647 scan(content).map(|matched| matched.detector),
5648 Some("high-entropy-token"),
5649 "a qualified label before a value delimiter must refuse the path \
5650 exemption for a slash-bearing base64 credential"
5651 );
5652 }
5653
5654 #[test]
5655 fn blocks_forty_hex_behind_versioned_label() {
5656 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5660 let content = format!("api key v1.2 value is commit {revision}");
5661 assert_eq!(
5662 scan(&content).map(|matched| matched.detector),
5663 Some("hex-credential-token"),
5664 "a versioned credential label must stay reachable through its \
5665 version fragments: {content:?}"
5666 );
5667 }
5668
5669 #[test]
5670 fn blocks_labeled_inline_marker_split_credential() {
5671 for content in [
5676 concat!(
5677 "api token context: rev:",
5678 "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf",
5679 "\u{200B}",
5680 "0123456789abcdef01234567"
5681 )
5682 .to_string(),
5683 concat!(
5684 "api token context: ",
5685 "0123456789abcdef01234567",
5686 "\u{200B}",
5687 "rev:d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf"
5688 )
5689 .to_string(),
5690 ] {
5691 assert!(
5692 check(&content).is_err(),
5693 "a labeled inline-marker split credential must be blocked: \
5694 {content:?}, got {:?}",
5695 scan(&content)
5696 );
5697 }
5698 }
5699
5700 #[test]
5701 fn blocks_forty_hex_behind_multiword_label_and_marker_delimiter() {
5702 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5707 for content in [
5708 format!("api key for deploy sha: {revision}"),
5709 format!("prod api key for deploy sha: {revision}"),
5710 format!("api key for production deploy: commit {revision}"),
5711 ] {
5712 assert_eq!(
5713 scan(&content).map(|matched| matched.detector),
5714 Some("hex-credential-token"),
5715 "a natural multiword credential label must not be hidden by \
5716 glue words or a marker-attached delimiter: {content:?}"
5717 );
5718 }
5719 }
5720
5721 #[test]
5722 fn blocks_slash_base64_behind_possessive_qualified_label() {
5723 let content = "api key for our production deploy: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5724 assert_eq!(
5725 scan(content).map(|matched| matched.detector),
5726 Some("high-entropy-token"),
5727 "possessive-qualified credential label must refuse the path \
5728 exemption"
5729 );
5730 }
5731
5732 #[test]
5733 fn blocks_forty_hex_behind_participial_adjective_qualifier() {
5734 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5739 let content = format!("api key for shared deploy: commit {revision}");
5740 assert_eq!(
5741 scan(&content).map(|matched| matched.detector),
5742 Some("hex-credential-token"),
5743 "a participial-adjective label qualifier must stay walkable: \
5744 {content:?}"
5745 );
5746 }
5747
5748 #[test]
5749 fn blocks_slash_base64_behind_participial_adjective_qualifier() {
5750 let content = "api key for encrypted backup: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5751 assert_eq!(
5752 scan(content).map(|matched| matched.detector),
5753 Some("high-entropy-token"),
5754 "a participial-adjective label qualifier must refuse the path \
5755 exemption"
5756 );
5757 }
5758
5759 #[test]
5760 fn blocks_forty_hex_behind_chained_qualifier_label() {
5761 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5766 let content = format!("api key for shared encrypted deploy: commit {revision}");
5767 assert_eq!(
5768 scan(&content).map(|matched| matched.detector),
5769 Some("hex-credential-token"),
5770 "a chained-qualifier credential label must stay walkable: \
5771 {content:?}"
5772 );
5773 }
5774
5775 #[test]
5776 fn blocks_slash_base64_behind_chained_qualifier_label() {
5777 let content =
5778 "api key for shared encrypted deploy: Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5779 assert_eq!(
5780 scan(content).map(|matched| matched.detector),
5781 Some("high-entropy-token"),
5782 "a chained-qualifier credential label must refuse the path \
5783 exemption"
5784 );
5785 }
5786
5787 #[test]
5788 fn blocks_over_limit_qualified_label_fails_closed() {
5789 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5795 let opaque = "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5796 for content in [
5797 format!("api key for the new shared encrypted staging deploy: commit {revision}"),
5798 format!("api key for the new shared encrypted regional staging deploy: {opaque}"),
5799 format!("api key for the new shared and encrypted staging deploy: {opaque}"),
5800 format!(
5801 "api key for the new shared encrypted regional staging deploy: commit {revision}"
5802 ),
5803 format!("api key for the new shared and encrypted staging deploy: commit {revision}"),
5804 format!("api key for the new shared encrypted staging deploy: {opaque}"),
5805 ] {
5806 assert!(
5807 check(&content).is_err(),
5808 "an over-limit qualified credential label must fail closed: \
5809 {content:?}, got {:?}",
5810 scan(&content)
5811 );
5812 }
5813 }
5814
5815 #[test]
5816 fn accepted_false_positive_topical_trigger_before_delimited_path() {
5817 let content = "results from testing auth against parser: \
5824 internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
5825 assert!(
5826 check(content).is_err(),
5827 "accepted-FP contract changed: topical trigger before a \
5828 delimited path no longer blocks — update the docs if deliberate"
5829 );
5830 }
5831
5832 #[test]
5833 fn blocks_participle_before_trigger_word() {
5834 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5838 let opaque = "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh";
5839 for content in [
5840 format!("shared key: {revision}"),
5841 format!("generated api key: {revision}"),
5842 format!("encrypted token = {opaque}"),
5843 ] {
5844 assert!(
5845 check(&content).is_err(),
5846 "a participle before the trigger word must not shield the \
5847 label: {content:?}, got {:?}",
5848 scan(&content)
5849 );
5850 }
5851 }
5852
5853 #[test]
5854 fn accepted_false_positive_docs_path_behind_attributive_trigger_and_delimiter() {
5855 let content = "see the docs for auth setup: \
5862 internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
5863 assert!(
5864 check(content).is_err(),
5865 "accepted-FP contract changed: attributive trigger before a \
5866 delimited path no longer blocks — update the docs if deliberate"
5867 );
5868 }
5869
5870 #[test]
5871 fn allows_verb_phrase_prose_with_delimiter_before_trigger_word() {
5872 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5877 for content in [
5878 format!("one extra token was introduced by sha: {revision}"),
5879 format!("the api key was rotated. deploy notes reference commit {revision}"),
5880 format!("api key updated: commit {revision}"),
5881 ] {
5882 assert!(
5883 check(&content).is_ok(),
5884 "verb-phrase prose must keep the VCS exemption: {content:?}, \
5885 got {:?}",
5886 scan(&content)
5887 );
5888 }
5889 }
5890
5891 #[test]
5892 fn allows_unlabeled_unknown_connector_without_delimiter() {
5893 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
5897 for content in [
5898 format!("the key changes are in commit {revision}"),
5899 format!("deployed at commit {revision}"),
5900 ] {
5901 assert!(
5902 check(&content).is_ok(),
5903 "prose without a value delimiter must keep the VCS exemption: \
5904 {content:?}, got {:?}",
5905 scan(&content)
5906 );
5907 }
5908 }
5909
5910 #[test]
5911 fn blocks_direct_no_delimiter_labels_for_both_file_path_value_families() {
5912 let values = [
5913 "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
5914 "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
5915 ];
5916
5917 for value in values {
5918 for label in ["api key found", "auth scanner found", "secret note"] {
5919 let content = format!("{label} {value}");
5920 assert!(
5921 check(&content).is_err(),
5922 "a direct no-delimiter label must refuse the file-path exemption: \
5923 {content:?}, got {:?}",
5924 scan(&content)
5925 );
5926 }
5927 }
5928 }
5929
5930 #[test]
5931 fn blocks_direct_gerund_and_see_labels_for_both_file_path_value_families() {
5932 let values = [
5933 "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
5934 "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
5935 ];
5936
5937 for value in values {
5938 for label in [
5939 "api key handling",
5940 "auth scanner handling",
5941 "api key see",
5942 "key: see",
5943 ] {
5944 let content = format!("{label} {value}");
5945 assert!(
5946 check(&content).is_err(),
5947 "narrative-looking adjacency must not shield a direct credential label: \
5948 {content:?}, got {:?}",
5949 scan(&content)
5950 );
5951 }
5952 }
5953 }
5954
5955 #[test]
5956 fn blocks_direct_participle_labels_for_both_file_path_value_families() {
5957 let values = [
5958 "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
5959 "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
5960 ];
5961
5962 for value in values {
5963 for label in ["api key leaked", "auth scanner leaked"] {
5964 let content = format!("{label} {value}");
5965 assert!(
5966 check(&content).is_err(),
5967 "step-zero participle adjacency must not shield a direct credential label: \
5968 {content:?}, got {:?}",
5969 scan(&content)
5970 );
5971 }
5972 }
5973 }
5974
5975 #[test]
5976 fn allows_no_delimiter_narrative_file_path_prose() {
5977 let content = "the auth scanner flagged this file \
5981 internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
5982
5983 assert!(
5984 check(content).is_ok(),
5985 "a regular participle in narrative position must preserve the path: got {:?}",
5986 scan(content)
5987 );
5988 }
5989
5990 #[test]
5991 fn suffix_word_hundred_is_not_a_narrative_participle() {
5992 assert!(is_clause_narrative_participle("flagged"));
5993 assert!(is_clause_narrative_participle("INTRODUCED"));
5994 assert!(!is_clause_narrative_participle("found"));
5995 assert!(!is_clause_narrative_participle("hundred"));
5996 assert!(is_clause_narrative_gerund("HANDLING"));
5997 assert!(!is_clause_narrative_gerund("found"));
5998
5999 for value in [
6000 "Xk9mZ2vQpLrT8nJwYuA/HfBsDcGiONvMabcdefgh",
6001 "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
6002 ] {
6003 let content = format!("api key hundred: {value}");
6004 assert!(
6005 check(&content).is_err(),
6006 "a lexical -ed suffix must not shield a credential label: \
6007 {content:?}, got {:?}",
6008 scan(&content)
6009 );
6010 }
6011 }
6012
6013 #[test]
6014 fn allows_vcs_reference_with_prose_boundary_before_trigger_word() {
6015 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
6019 for content in [
6020 format!("configuration key\n\nrev {revision}."),
6021 format!("rotate the api key. the fix is commit {revision}"),
6022 ] {
6023 assert!(
6024 check(&content).is_ok(),
6025 "boundary-separated trigger prose must not block a VCS \
6026 reference: {content:?}, got {:?}",
6027 scan(&content)
6028 );
6029 }
6030 }
6031
6032 #[test]
6033 fn blocks_sha_revision_marker_immediately_labeled_as_api_key() {
6034 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
6035 let content = format!("api_key sha:{revision}");
6036
6037 assert!(
6038 check(&content).is_err(),
6039 "credential-labeled SHA value must remain blocked: {content:?}"
6040 );
6041 }
6042
6043 #[test]
6044 fn blocks_rev_revision_marker_immediately_labeled_as_token() {
6045 let revision = "d362950a3c9b1a4cb47d97f1623e38f1a1e6bcdf";
6046 let content = format!("token rev:{revision}");
6047
6048 assert!(
6049 check(&content).is_err(),
6050 "credential-labeled revision value must remain blocked: {content:?}"
6051 );
6052 }
6053
6054 #[test]
6055 fn hex64_near_hash_context_allowed() {
6056 let hex64 = "1a2b3c4d5e6f7a8b9c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b";
6059 let sha_line = format!("sha256: {hex64}");
6060 let hash_line = format!("hash value {hex64}");
6061 assert!(
6062 scan(&sha_line).is_none(),
6063 "hex64 near 'sha256' must be allowed; fired: {:?}",
6064 scan(&sha_line)
6065 );
6066 assert!(
6067 scan(&hash_line).is_none(),
6068 "hex64 near 'hash' must be allowed; fired: {:?}",
6069 scan(&hash_line)
6070 );
6071 }
6072
6073 #[test]
6074 fn blocks_high_entropy_hex_like_token_near_key() {
6075 let mixed = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"; assert!(!is_pure_hex(mixed), "test token must not be pure hex");
6082 let line = format!("api key {mixed}");
6083 assert!(
6084 scan(&line).is_some(),
6085 "mixed-charset high-entropy token near 'api key' must be caught; got: {:?}",
6086 scan(&line)
6087 );
6088 }
6089
6090 #[test]
6091 fn allows_hex40_without_trigger() {
6092 let hex40 = "da39a3ee5e6b4b0d3255bfef95601890afd80709";
6095 let line = format!("commit: {hex40}");
6096 assert!(
6097 scan(&line).is_none(),
6098 "40-char hex without trigger word must pass; fired: {:?}",
6099 scan(&line)
6100 );
6101 }
6102
6103 #[test]
6106 fn check_json_blocks_secret_in_object_key() {
6107 let props = serde_json::json!({ "ghp_FakeGitHubToken0000000000000000000": "redacted" }); assert!(
6110 check_json(&props).is_err(),
6111 "credential as JSON object key must be blocked"
6112 );
6113 }
6114
6115 #[test]
6116 fn check_json_blocks_nested_secret_key() {
6117 let props = serde_json::json!({
6119 "metadata": {
6120 "AKIAFAKEKEY000000000": "value" }
6122 });
6123 assert!(
6124 check_json(&props).is_err(),
6125 "nested credential as JSON object key must be blocked"
6126 );
6127 }
6128
6129 #[test]
6132 fn pem_masked_excerpt_reflects_block_length_not_rest_of_string() {
6133 let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); let fake = format!(
6135 "{}\nMIIEo\u{2026}\n-----END RSA PRIVATE KEY-----\nsome trailing text that is very long",
6136 header
6137 );
6138 let m = scan(&fake).unwrap();
6139 assert_eq!(m.detector, "pem-private-key");
6140 let full_len = fake.chars().count();
6145 let reported_len: usize = m
6146 .masked
6147 .trim_end_matches("chars")
6148 .rsplit("...")
6149 .next()
6150 .and_then(|s| s.parse().ok())
6151 .unwrap_or(full_len + 1);
6152 assert!(
6153 reported_len < full_len,
6154 "masked length ({reported_len}) should be less than full string length ({full_len})"
6155 );
6156 }
6157
6158 #[test]
6170 fn utf8_build_match_preview_multibyte_prefix_no_panic() {
6171 let header = ["-----BEGIN RSA", " PRIVATE KEY-----"].concat(); let fake = format!("{}\n🔑密钥\n-----END RSA PRIVATE KEY-----", header);
6179 let m = scan(&fake);
6181 assert!(m.is_some(), "PEM with emoji body must still be caught");
6182 let m = m.unwrap();
6183 assert!(
6184 !m.masked.contains("🔑密钥"),
6185 "mask must not echo the emoji body"
6186 );
6187 }
6188
6189 #[test]
6195 fn utf8_extract_token_multibyte_suffix_no_panic() {
6196 let text = "FlyV1 ABCDEFGHIJ密钥";
6200 let _ = scan(text);
6202 }
6203
6204 #[test]
6208 fn utf8_prefix_detector_multibyte_adjacent_no_panic() {
6209 let text = "🔑AKIAFAKEKEY00000000000000";
6212 let _ = scan(text); let text2 = "éghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
6216 let _ = scan(text2); let text3 = "AKIAFAKEKEY00000000000000🔑";
6223 let _ = scan(text3); }
6225
6226 #[test]
6233 fn utf8_jwt_multibyte_adjacent_no_panic() {
6234 let jwt = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.FAKE_SIG_XXXXXXXXXXXX"; let text = format!("数据{jwt}密钥");
6237 let _ = scan(&text); let text2 = format!("{jwt}\u{3000}morecontent");
6242 let _ = scan(&text2); let text3 = format!("{jwt}🔑");
6246 let _ = scan(&text3); }
6248
6249 #[test]
6255 fn utf8_url_userinfo_multibyte_scheme_no_panic() {
6256 let cases = [
6259 "🔑postgresql://dbuser:S3cr3tP4ss@db.example.com/db", "密钥mysql://root:hunter2pw@10.0.0.1:3306/app", "éredis://svc:V3ryS3cretPw@cache.internal:6379", ];
6263 for text in &cases {
6264 let result = scan(text);
6266 assert!(
6267 result.is_some(),
6268 "URL credential after multibyte must be caught: {text:?}"
6269 );
6270 }
6271 }
6272
6273 #[test]
6277 fn utf8_entropy_window_multibyte_boundary_no_panic() {
6278 let cjk_fill = "数".repeat(39); assert_eq!(cjk_fill.len(), 117);
6284 let secret = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; let content = format!("{cjk_fill}xy key {secret}");
6288 let _ = scan(&content); let content2 = format!("key {secret}{cjk_fill}xy");
6293 let _ = scan(&content2); }
6295
6296 #[test]
6300 fn utf8_no_panic_property_test() {
6301 let multibyte_items = [
6302 "🔑", "密", "é", "\u{3000}", "🇺🇸", ];
6308 let secrets = [
6309 "AKIAFAKEKEY00000000000000".to_owned(),
6310 "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA".to_owned(),
6311 anthropic_api_key_fixture(),
6312 "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1".to_owned(),
6313 "FlyV1 fm2_AAAABBBBCCCCDDDDEEEEFFFF".to_owned(),
6314 ];
6315 for mb in &multibyte_items {
6316 for secret in &secrets {
6317 for sep in &["", " ", "\n"] {
6318 let s = format!("{mb}{sep}{secret}");
6320 let _ = check(&s);
6321 let s = format!("{secret}{sep}{mb}");
6323 let _ = check(&s);
6324 let s = format!("{mb}{sep}{secret}{sep}{mb}");
6326 let _ = check(&s);
6327 let fill = mb.repeat(50);
6329 let s = format!("{fill} api_key {secret} {fill}");
6330 let _ = check(&s);
6331 }
6332 }
6333 }
6334 }
6335
6336 #[test]
6339 fn bounded_masked_log_text_masks_connection_string_credentials() {
6340 let raw =
6341 "gate backend probe failed: postgres://svc:not-a-real-secret@internal-host refused"; let rendered = bounded_masked_log_text(raw);
6343 assert!(
6344 !rendered.contains("not-a-real-secret"),
6345 "credential must be masked: {rendered:?}"
6346 );
6347 assert!(
6348 rendered.contains("***MASKED***"),
6349 "masked marker must record that detail was redacted: {rendered:?}"
6350 );
6351 assert!(
6352 rendered.contains("gate backend probe failed"),
6353 "non-secret diagnostic prose must survive: {rendered:?}"
6354 );
6355 }
6356
6357 #[test]
6358 fn bounded_masked_log_text_bounds_pathological_input() {
6359 let raw = "x".repeat(MAX_LOG_TEXT_OUTPUT_CHARS + 500);
6360 let rendered = bounded_masked_log_text(&raw);
6361 assert!(
6362 rendered.chars().count() <= MAX_LOG_TEXT_OUTPUT_CHARS + 1,
6363 "output must be bounded: {} chars",
6364 rendered.chars().count()
6365 );
6366 assert!(
6367 rendered.ends_with('…'),
6368 "truncated output must declare its own truncation"
6369 );
6370
6371 let short = "gate policy file unreadable";
6372 assert_eq!(
6373 bounded_masked_log_text(short),
6374 short,
6375 "short clean text passes through unchanged"
6376 );
6377 }
6378
6379 #[test]
6380 fn bounded_masked_log_text_masks_low_entropy_password_past_old_truncation_bound() {
6381 let long_low_entropy_password = "a".repeat(5_000);
6390 let raw = format!(
6391 "gate backend probe failed: postgres://svc:{long_low_entropy_password}@internal-host refused"
6392 );
6393 let rendered = bounded_masked_log_text(&raw);
6394 assert!(
6395 !rendered.contains(&"a".repeat(50)),
6396 "long low-entropy password must not survive in the log: {rendered:?}"
6397 );
6398 assert!(
6399 rendered.contains("***MASKED***"),
6400 "masked marker must record that the credential was redacted: {rendered:?}"
6401 );
6402 }
6403
6404 #[test]
6411 fn bounded_masked_log_text_redacts_password_crossing_mask_input_cap() {
6412 let huge_low_entropy_password = "a".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6413 let raw =
6414 format!("gate backend probe failed: postgres://svc:{huge_low_entropy_password}@internal-host refused");
6415 let rendered = bounded_masked_log_text(&raw);
6416 assert!(
6417 !rendered.contains(&"a".repeat(50)),
6418 "no password fragment may survive when the password crosses the mask-input cap: {rendered:?}"
6419 );
6420 assert!(
6421 rendered.contains("***MASKED***"),
6422 "mask marker must record that the credential was redacted: {rendered:?}"
6423 );
6424 assert!(
6425 rendered.ends_with('…'),
6426 "truncated record must declare its own incompleteness: {rendered:?}"
6427 );
6428 }
6429
6430 #[test]
6434 fn bounded_masked_log_text_redacts_crossing_empty_username_password() {
6435 let huge_low_entropy_password = "b".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6436 let raw = format!(
6437 "gate backend probe failed: redis://:{huge_low_entropy_password}@internal-host refused"
6438 );
6439 let rendered = bounded_masked_log_text(&raw);
6440 assert!(
6441 !rendered.contains(&"b".repeat(50)),
6442 "no empty-username password fragment may survive the cap crossing: {rendered:?}"
6443 );
6444 assert!(
6445 rendered.contains("***MASKED***"),
6446 "mask marker must record that the credential was redacted: {rendered:?}"
6447 );
6448 }
6449
6450 #[test]
6455 fn bounded_masked_log_text_keeps_path_colon_text_crossing_the_cap() {
6456 let filler = "z".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6457 let raw = format!("see https://host/a:x{filler}@next for details");
6458 let rendered = bounded_masked_log_text(&raw);
6459 assert!(
6460 !rendered.contains("***MASKED***"),
6461 "path-colon text must not read as a crossing credential: {rendered:?}"
6462 );
6463 assert!(
6464 rendered.starts_with("see https://host/a:x"),
6465 "the non-credential prefix must survive verbatim: {rendered:?}"
6466 );
6467 }
6468
6469 #[test]
6476 fn bounded_masked_log_text_redacts_crossing_password_containing_nested_scheme() {
6477 let mut password = "a".repeat(1000);
6478 password.push_str("://h:");
6479 password.push_str(&"b".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000));
6480 let raw =
6481 format!("gate backend probe failed: postgres://svc:{password}@internal-host refused");
6482 let rendered = bounded_masked_log_text(&raw);
6483 assert!(
6484 !rendered.contains(&"a".repeat(50)),
6485 "password prefix before the nested delimiter must not survive: {rendered:?}"
6486 );
6487 assert!(
6488 !rendered.contains(&"b".repeat(50)),
6489 "password tail after the nested delimiter must not survive: {rendered:?}"
6490 );
6491 assert!(
6492 rendered.contains("***MASKED***"),
6493 "mask marker must record that the credential was redacted: {rendered:?}"
6494 );
6495 }
6496
6497 #[test]
6503 fn bounded_masked_log_text_redacts_crossing_credential_after_complete_url() {
6504 let huge_low_entropy_password = "c".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6505 let raw = format!(
6506 "probe of https://ok-host/health failed; retry hit postgres://svc:{huge_low_entropy_password}@internal-host refused"
6507 );
6508 let rendered = bounded_masked_log_text(&raw);
6509 assert!(
6510 rendered.contains("ok-host"),
6511 "the earlier complete URL must survive untouched: {rendered:?}"
6512 );
6513 assert!(
6514 !rendered.contains(&"c".repeat(50)),
6515 "no password fragment may survive: {rendered:?}"
6516 );
6517 assert!(
6518 rendered.contains("***MASKED***"),
6519 "mask marker must record that the credential was redacted: {rendered:?}"
6520 );
6521 }
6522
6523 #[test]
6528 fn bounded_masked_log_text_masks_password_just_under_cap_with_terminating_at() {
6529 let password_under_cap = "a".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS - 1000);
6530 let raw = format!(
6531 "gate backend probe failed: postgres://svc:{password_under_cap}@internal-host refused"
6532 );
6533 assert!(
6534 raw.chars().count() < MAX_LOG_TEXT_MASK_INPUT_CHARS,
6535 "test precondition: whole input must stay under the mask-input cap so no truncation occurs"
6536 );
6537 let rendered = bounded_masked_log_text(&raw);
6538 assert!(
6539 !rendered.contains(&"a".repeat(50)),
6540 "terminated credential must still be masked normally: {rendered:?}"
6541 );
6542 assert_eq!(
6543 rendered.matches("***MASKED***").count(),
6544 1,
6545 "exactly one mask marker — the crossing fallback must not fire early: {rendered:?}"
6546 );
6547 }
6548
6549 #[test]
6554 fn bounded_masked_log_text_giant_non_credential_text_unaffected_by_crossing_fallback() {
6555 let raw = "x".repeat(MAX_LOG_TEXT_MASK_INPUT_CHARS + 1000);
6556 let rendered = bounded_masked_log_text(&raw);
6557 assert!(
6558 !rendered.contains("***MASKED***"),
6559 "non-credential text must never be redacted: {rendered:?}"
6560 );
6561 assert!(
6562 rendered.ends_with('…'),
6563 "truncated record must still declare its own incompleteness: {rendered:?}"
6564 );
6565 assert!(
6566 rendered.starts_with("xxxx"),
6567 "non-credential content must survive verbatim up to the output bound: {rendered:?}"
6568 );
6569 }
6570
6571 #[test]
6575 fn bounded_masked_log_text_masks_ordinary_short_connection_string() {
6576 let raw = "gate backend probe failed: postgres://svc:hunter2pw@internal-host refused"; let rendered = bounded_masked_log_text(raw);
6578 assert!(
6579 !rendered.contains("hunter2pw"),
6580 "credential must be masked: {rendered:?}"
6581 );
6582 assert_eq!(
6583 rendered.matches("***MASKED***").count(),
6584 1,
6585 "exactly one mask marker for the one credential: {rendered:?}"
6586 );
6587 assert!(
6588 !rendered.ends_with('…'),
6589 "short untruncated text must not declare truncation: {rendered:?}"
6590 );
6591 }
6592
6593 #[test]
6594 fn bounded_masked_log_text_neutralizes_ascii_control_chars() {
6595 let raw = "line one\r\ninjected: \u{1b}[31mFAKE ALERT\u{1b}[0m line two";
6596 let rendered = bounded_masked_log_text(raw);
6597 assert!(
6598 !rendered.contains('\r') && !rendered.contains('\n'),
6599 "CR/LF must be neutralized so a single log line cannot be split/forged: {rendered:?}"
6600 );
6601 assert!(
6602 !rendered.contains('\u{1b}'),
6603 "ESC control character must be neutralized: {rendered:?}"
6604 );
6605 assert!(
6606 rendered.contains("line one") && rendered.contains("line two"),
6607 "surrounding prose must survive neutralization: {rendered:?}"
6608 );
6609 }
6610
6611 #[test]
6612 fn bounded_masked_log_text_neutralizes_each_unsafe_unicode_category() {
6613 let cases = [
6614 ("Cc", '\u{1b}', "\\u{001b}"),
6615 ("Cf", '\u{202e}', "\\u{202e}"),
6616 ("Zl", '\u{2028}', "\\u{2028}"),
6617 ("Zp", '\u{2029}', "\\u{2029}"),
6618 ];
6619
6620 for (category, unsafe_char, escaped) in cases {
6621 let raw = format!("before{unsafe_char}after");
6622 assert_eq!(
6623 bounded_masked_log_text(&raw),
6624 format!("before{escaped}after"),
6625 "Unicode category {category} must be neutralized"
6626 );
6627 }
6628 }
6629
6630 #[test]
6631 fn bounded_masked_log_text_keeps_space_separators_tabs_accented_and_cjk_text() {
6632 let raw = "ordinary whitespace\tcafé résumé 日本語のテキスト\u{3000}数据库连接管理";
6633 assert_eq!(
6634 bounded_masked_log_text(raw),
6635 raw,
6636 "ordinary whitespace, Zs separators, accented text, and CJK prose must pass through unmodified"
6637 );
6638 }
6639
6640 #[test]
6641 fn mask_secrets_borrows_clean_text() {
6642 let clean = "The FlashAttention paper introduces IO-aware tiling.";
6643 let masked = mask_secrets(clean);
6644 assert!(
6645 matches!(masked, std::borrow::Cow::Borrowed(_)),
6646 "clean text must not allocate"
6647 );
6648 assert_eq!(masked, clean);
6649 }
6650
6651 #[test]
6652 fn named_redaction_surfaces_are_permanently_mask_only() {
6653 let contracts = [
6654 (RedactionSurface::GitIngest, Some(GIT_INGEST_STORED_TARGET)),
6655 (
6656 RedactionSurface::SessionMirror,
6657 Some(SESSION_MIRROR_STORED_TARGET),
6658 ),
6659 (RedactionSurface::McpDiagnostic, None),
6660 (RedactionSurface::GateProbe, None),
6661 ];
6662
6663 for (surface, expected_target) in contracts {
6664 let contract = redaction_surface_contract(surface);
6665 assert_eq!(contract.mode, RedactionSurfaceMode::PermanentMaskOnly);
6666 assert_eq!(contract.final_stored_target, expected_target);
6667 assert_eq!(contract.stamp_property, None);
6668 assert_eq!(contract.atomic_success_event, None);
6669 }
6670 }
6671
6672 #[test]
6673 fn named_redaction_surfaces_mask_without_exemption_admission() {
6674 let content = format!("credential: {}", openai_project_key_fixture());
6675
6676 for surface in [
6677 RedactionSurface::GitIngest,
6678 RedactionSurface::SessionMirror,
6679 RedactionSurface::McpDiagnostic,
6680 RedactionSurface::GateProbe,
6681 ] {
6682 let masked = mask_for_redaction_surface(surface, &content);
6683 assert!(masked.contains(REDACTION_MARKER));
6684 assert!(check(masked.as_ref()).is_ok());
6685 }
6686 }
6687
6688 #[test]
6689 fn mask_bounded_matches_unbounded_masking_when_input_fits_the_window() {
6690 let content = format!("credential: {}", openai_project_key_fixture());
6691 let expected = mask_for_redaction_surface(RedactionSurface::McpDiagnostic, &content);
6692 let result = mask_bounded(RedactionSurface::McpDiagnostic, &content, 4_096, 1_024);
6693 assert_eq!(result.text, expected.as_ref());
6694 assert!(!result.truncated);
6695 assert!(result.redacted);
6696 }
6697
6698 #[test]
6699 fn mask_bounded_masks_a_credential_whose_terminator_sits_inside_the_window() {
6700 let password = format!("PlainPassMarker{}", "q".repeat(24));
6704 let url = format!("postgres://svc:{password}@internal-host.example.test/db");
6705 let message = format!("backend probe failed: {url}");
6706 assert!(message.chars().count() < 200, "fixture must fit the window");
6707
6708 let result = mask_bounded(RedactionSurface::McpDiagnostic, &message, 200, 100);
6709 assert!(
6710 !result.text.contains("PlainPassMarker"),
6711 "credential must be masked: {}",
6712 result.text
6713 );
6714 assert!(result.text.contains("***MASKED***"));
6715 assert!(result.redacted);
6716 }
6717
6718 #[test]
6719 fn mask_bounded_drops_a_token_straddling_the_window_boundary_without_partial_leak() {
6720 let window_chars = 200;
6730 let marker = "StraddleMarkerXYZ789";
6731 let padding = "q".repeat(window_chars + 100);
6732 let password = format!("{marker}{padding}");
6733 let url = format!("postgres://svc:{password}@internal-host.example.test/db");
6734 let message = format!("benign leading prose here {url}");
6735
6736 let at_offset = message.find('@').expect("fixture must contain '@'");
6737 assert!(
6738 at_offset > window_chars,
6739 "credential must straddle the window"
6740 );
6741
6742 let result = mask_bounded(RedactionSurface::McpDiagnostic, &message, window_chars, 100);
6743 assert!(
6744 !result.text.contains(marker),
6745 "no fragment of the credential may survive: {}",
6746 result.text
6747 );
6748 assert!(
6749 !result.text.contains("postgres://"),
6750 "the straddling token must be dropped whole, not partially echoed: {}",
6751 result.text
6752 );
6753 assert!(result.truncated);
6754 }
6755
6756 #[test]
6757 fn mask_bounded_replaces_a_single_oversized_token_with_the_truncation_marker_alone() {
6758 let window_chars = 50;
6762 let text = "q".repeat(window_chars * 4);
6763
6764 let result = mask_bounded(RedactionSurface::McpDiagnostic, &text, window_chars, 20);
6765 assert_eq!(result.text, "…");
6766 assert!(result.truncated);
6767 assert!(result.redacted);
6768 }
6769
6770 #[test]
6771 fn mask_bounded_bounds_output_to_the_window_regardless_of_input_size() {
6772 let window_chars = 4_096;
6780 let huge = "benign word ".repeat(500_000);
6781 assert!(huge.len() > 5_000_000, "fixture must be several megabytes");
6782
6783 let result = mask_bounded(RedactionSurface::McpDiagnostic, &huge, window_chars, 500);
6784 assert!(result.truncated);
6785 assert!(result.text.chars().count() <= 501);
6786 }
6787
6788 fn forty_char_hex_fixture() -> String {
6791 "a1b2c3d4e5f6".chars().cycle().take(40).collect()
6792 }
6793
6794 #[test]
6795 fn mask_bounded_masks_a_bridged_credential_whose_fragments_straddle_the_window_boundary() {
6796 let hex = forty_char_hex_fixture();
6805 let (frag1, frag2) = hex.split_at(20);
6806 let message = format!("credential: {frag1} {frag2}");
6807
6808 let full = mask_for_redaction_surface(RedactionSurface::McpDiagnostic, &message);
6811 assert!(
6812 !full.contains(frag1) && !full.contains(frag2),
6813 "control: unbounded masking must reconstruct and mask the split credential: {full}"
6814 );
6815
6816 let window_chars = "credential: ".len() + frag1.len() + 1 + 5;
6819 assert!(
6820 window_chars < message.len(),
6821 "fixture must exceed the window"
6822 );
6823
6824 let result = mask_bounded(
6825 RedactionSurface::McpDiagnostic,
6826 &message,
6827 window_chars,
6828 window_chars,
6829 );
6830 assert!(result.truncated);
6831 assert!(
6832 !result.text.contains(frag1),
6833 "no fragment of a bridged credential may survive a window cut mid-chain: {:?}",
6834 result.text
6835 );
6836 assert!(result.redacted);
6837 }
6838
6839 #[test]
6840 fn mask_bounded_masks_a_bridged_credential_entirely_inside_the_window() {
6841 let hex = forty_char_hex_fixture();
6845 let (frag1, frag2) = hex.split_at(20);
6846 let message = format!("credential: {frag1} {frag2} trailing prose after the secret");
6847 let window_chars = message.chars().count() + 10;
6848
6849 let result = mask_bounded(
6850 RedactionSurface::McpDiagnostic,
6851 &message,
6852 window_chars,
6853 window_chars,
6854 );
6855 assert!(!result.truncated);
6856 assert!(!result.text.contains(frag1));
6857 assert!(!result.text.contains(frag2));
6858 }
6859
6860 #[test]
6861 fn mask_bounded_never_reveals_a_bridged_credential_entirely_outside_the_window() {
6862 let hex = forty_char_hex_fixture();
6865 let (frag1, frag2) = hex.split_at(20);
6866 let prefix = "benign leading prose that pads well past the cut point here ";
6867 let message = format!("{prefix}credential: {frag1} {frag2}");
6868 let window_chars = prefix.chars().count() - 10;
6869
6870 let result = mask_bounded(
6871 RedactionSurface::McpDiagnostic,
6872 &message,
6873 window_chars,
6874 window_chars,
6875 );
6876 assert!(!result.text.contains(frag1));
6877 assert!(!result.text.contains(frag2));
6878 }
6879
6880 #[test]
6881 fn mask_bounded_masks_a_bridged_credential_whose_trigger_follows_the_window() {
6882 let hex = forty_char_hex_fixture();
6892 let (frag1, rest) = hex.split_at(14);
6893 let (frag2, frag3) = rest.split_at(13);
6894 let message = format!("values: {frag1} {frag2} {frag3} is the api key for the service");
6895
6896 let full = mask_for_redaction_surface(RedactionSurface::McpDiagnostic, &message);
6899 assert!(
6900 !full.contains(frag1) && !full.contains(frag2) && !full.contains(frag3),
6901 "control: unbounded masking must reconstruct and mask the split \
6902 credential even though its trigger word comes after the \
6903 fragments: {full}"
6904 );
6905
6906 let window_chars = "values: ".len() + frag1.len() + 1 + frag2.len() + 1 + 5;
6911 assert!(
6912 window_chars < message.len(),
6913 "fixture must exceed the window"
6914 );
6915 assert!(
6916 find_trigger(&message[..window_chars], false).is_none(),
6917 "fixture must carry no trigger word inside the window"
6918 );
6919
6920 let result = mask_bounded(
6921 RedactionSurface::McpDiagnostic,
6922 &message,
6923 window_chars,
6924 window_chars,
6925 );
6926 assert!(result.truncated);
6927 assert!(
6928 !result.text.contains(frag1) && !result.text.contains(frag2),
6929 "no fragment of a bridged credential may survive a window cut \
6930 mid-chain, even when the credential's only trigger word lies \
6931 past the window boundary: {:?}",
6932 result.text
6933 );
6934 }
6935
6936 #[test]
6937 fn mask_bounded_keeps_a_non_fragment_tail_of_an_untriggered_truncated_window() {
6938 let prefix = "benign status update about the lazy owls and cats over ";
6944 let message = format!("{prefix}here while more prose keeps going past the window");
6945 assert!(
6946 find_trigger(&message, false).is_none(),
6947 "fixture must carry no trigger word"
6948 );
6949 let window_chars = prefix.chars().count() + 2;
6950
6951 let result = mask_bounded(
6952 RedactionSurface::McpDiagnostic,
6953 &message,
6954 window_chars,
6955 window_chars,
6956 );
6957 assert!(result.truncated);
6958 assert_eq!(result.text, format!("{prefix}{TRUNCATION_MARKER}"));
6959 }
6960
6961 #[test]
6962 fn mask_bounded_drops_a_fragment_shaped_tail_of_an_untriggered_truncated_window() {
6963 let prefix = "an ordinary status line about the current build before ";
6971 let identifier = "deadbeefcafefeed01234567";
6972 let message = format!("{prefix}{identifier} zzzzzzzzzz");
6973 assert!(
6974 find_trigger(&message, false).is_none(),
6975 "fixture must carry no trigger word"
6976 );
6977 assert!(identifier.len() >= MIN_BRIDGE_FRAGMENT_LEN);
6978
6979 let window_chars = prefix.chars().count() + identifier.chars().count() + 1 + 3;
6980 assert!(
6981 window_chars < message.len(),
6982 "fixture must exceed the window"
6983 );
6984
6985 let result = mask_bounded(
6986 RedactionSurface::McpDiagnostic,
6987 &message,
6988 window_chars,
6989 window_chars,
6990 );
6991 assert!(result.truncated);
6992 assert!(
6993 !result.text.contains(identifier),
6994 "a lone fragment-shaped tail token is dropped even without a \
6995 chained neighbor or a trigger word: {:?}",
6996 result.text
6997 );
6998 }
6999
7000 #[test]
7001 fn mask_bounded_keeps_the_whole_token_prefix_of_an_untriggered_sentence_cut_mid_word() {
7002 let prefix = "the quick brown fox jumps over the lazy ";
7006 let message = format!("{prefix}dogs while writing documentation");
7007 assert!(
7008 find_trigger(&message, false).is_none(),
7009 "fixture must carry no trigger word"
7010 );
7011 let window_chars = prefix.chars().count() + 2;
7012
7013 let result = mask_bounded(
7014 RedactionSurface::McpDiagnostic,
7015 &message,
7016 window_chars,
7017 window_chars,
7018 );
7019 assert!(result.truncated);
7020 assert_eq!(result.text, format!("{prefix}{TRUNCATION_MARKER}"));
7021 }
7022
7023 #[test]
7024 #[cfg(debug_assertions)]
7025 #[should_panic(expected = "the input window must stay at least as large as the output cap")]
7026 fn mask_bounded_debug_asserts_when_window_is_smaller_than_output_cap() {
7027 let _ = mask_bounded(
7028 RedactionSurface::McpDiagnostic,
7029 "some diagnostic text",
7030 10,
7031 50,
7032 );
7033 }
7034
7035 #[test]
7036 #[cfg(not(debug_assertions))]
7037 fn mask_bounded_clamps_output_cap_to_window_chars_outside_debug_assertions() {
7038 let window_chars = 9;
7047 let output_cap_chars = 500;
7048 let text = "a://b:c@d"; assert_eq!(
7050 text.len(),
7051 window_chars,
7052 "fixture must exactly fill the window"
7053 );
7054
7055 let result = mask_bounded(
7056 RedactionSurface::McpDiagnostic,
7057 text,
7058 window_chars,
7059 output_cap_chars,
7060 );
7061 assert!(
7062 result.text.chars().count() <= window_chars + 1,
7063 "output must never exceed the window (plus one truncation-marker char) \
7064 even when output_cap_chars is misconfigured larger than window_chars: {:?}",
7065 result.text
7066 );
7067 }
7068
7069 #[test]
7070 fn mask_secrets_redacts_shapes_the_old_mirror_regex_missed() {
7071 let cases = [
7074 format!("key: {}", openai_project_key_fixture()),
7075 "cred ASIAFAKEKEY00000000000".to_owned(), "stripe sk_live_FAKESTRIPE0000000000000".to_owned(), "db postgresql://dbuser:S3cr3tP4ss@db.example.com/db".to_owned(), ];
7079 for c in &cases {
7080 let masked = mask_secrets(c);
7081 assert!(
7082 masked.contains(REDACTION_MARKER),
7083 "must redact: {c:?} -> {masked:?}"
7084 );
7085 }
7086 }
7087
7088 #[test]
7089 fn mask_secrets_redacts_every_span_and_keeps_prose() {
7090 let line = format!(
7091 "first {} then ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA end",
7092 anthropic_api_key_fixture()
7093 );
7094 let masked = mask_secrets(&line);
7095 assert!(
7096 !masked.contains("sk-ant-api03") && !masked.contains("ghp_AAAA"),
7097 "no secret may survive: {masked}"
7098 );
7099 assert_eq!(
7100 masked.matches(REDACTION_MARKER).count(),
7101 2,
7102 "both secrets must be redacted: {masked}"
7103 );
7104 assert!(masked.starts_with("first "), "prose preserved: {masked}");
7105 assert!(masked.ends_with(" end"), "prose preserved: {masked}");
7106 }
7107
7108 #[test]
7109 fn mask_secrets_public_api_redacts_unscanned_dense_tail() {
7110 let token = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
7111 let segment = format!("{token} keep ");
7112 let line = segment.repeat(512);
7115 assert!(
7116 line.len() >= 20_000,
7117 "fixture must exceed the cumulative scan-work threshold"
7118 );
7119
7120 let masked = mask_secrets(&line);
7121 assert!(
7122 !masked.contains(token),
7123 "no credential may survive fail-closed tail redaction"
7124 );
7125 assert!(
7126 masked.matches("***MASKED***").count() < line.matches(token).count(),
7127 "the public masker must redact the unscanned tail wholesale"
7128 );
7129 assert!(
7130 masked.ends_with("***MASKED***"),
7131 "the fail-closed tail redaction must reach the end of the public result"
7132 );
7133 }
7134
7135 #[test]
7138 fn mask_secrets_tokenizes_concentrated_tail_once() {
7139 let token = "ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
7140 let prefix = "clean ".repeat(1_000_000 / "clean ".len());
7141 let tail: String = (0..200).map(|_| format!("{token} ")).collect();
7142 let line = format!("{prefix}{tail}");
7143 assert_eq!(line.matches(token).count(), 200);
7144
7145 ENTROPY_TOKENIZATION_COUNT.with(|count| count.set(0));
7146 let masked = mask_secrets(&line);
7147 let tokenization_count = ENTROPY_TOKENIZATION_COUNT.with(|count| count.get());
7148
7149 assert!(
7150 !masked.contains(token),
7151 "the public masker must redact every concentrated-tail credential"
7152 );
7153 assert_eq!(
7154 tokenization_count, 1,
7155 "the full input token vector must be built once, not once per tail credential"
7156 );
7157 }
7158
7159 #[test]
7160 fn mask_secrets_output_passes_check() {
7161 let line = "token=ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA and AKIAFAKEKEY1234567890";
7164 let masked = mask_secrets(line).into_owned();
7165 assert!(
7166 check(&masked).is_ok(),
7167 "masked output must pass the gate: {masked}"
7168 );
7169 }
7170
7171 #[test]
7172 fn mask_secrets_redacts_entropy_secret_left_of_known_secret() {
7173 let line =
7179 "secret=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM and ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; let masked = mask_secrets(line).into_owned();
7181 assert!(
7182 !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM") && !masked.contains("ghp_AAAA"),
7183 "neither the entropy secret nor the known secret may survive: {masked}"
7184 );
7185 assert_eq!(
7186 masked.matches(REDACTION_MARKER).count(),
7187 2,
7188 "both secrets must be redacted exactly once: {masked}"
7189 );
7190 assert!(
7191 check(&masked).is_ok(),
7192 "masked output must pass the gate: {masked}"
7193 );
7194 }
7195
7196 #[test]
7197 fn github_app_token_families_are_masked() {
7198 let cases = [
7202 "ghu_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", "ghs_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB", "ghr_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC", ];
7206 for token in &cases {
7207 assert!(
7208 check(token).is_err(),
7209 "gate must hard-block GitHub App token {token}"
7210 );
7211 let line = format!("auth: {token} trailing");
7212 let masked = mask_secrets(&line).into_owned();
7213 assert!(
7214 !masked.contains(token),
7215 "GitHub App token must not survive masking: {masked}"
7216 );
7217 assert!(
7218 check(&masked).is_ok(),
7219 "masked output must pass the gate: {masked}"
7220 );
7221 }
7222 }
7223
7224 #[test]
7225 fn mask_secrets_redacts_entropy_token_whose_trigger_is_left_of_earlier_secret() {
7226 let line =
7232 "api_key ghp_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"; let masked = mask_secrets(line).into_owned();
7234 assert!(
7235 !masked.contains("ghp_AAAA"),
7236 "the known secret must be redacted: {masked}"
7237 );
7238 assert!(
7239 !masked.contains("Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM1"),
7240 "the later entropy token must be redacted even though its trigger \
7241 word sits left of the earlier redaction: {masked}"
7242 );
7243 assert_eq!(
7244 masked.matches(REDACTION_MARKER).count(),
7245 2,
7246 "both secrets must be redacted exactly once: {masked}"
7247 );
7248 assert!(
7249 check(&masked).is_ok(),
7250 "masked output must pass the gate: {masked}"
7251 );
7252 }
7253
7254 #[test]
7257 fn allows_high_entropy_file_path_near_secret_word() {
7258 let content =
7259 "workspace path fable-ops/ADR-DRAFT-adr079-slices234.md for the secret gate bug";
7260 assert!(
7261 check(content).is_ok(),
7262 "structured file path in technical prose must pass; got {:?}",
7263 scan(content)
7264 );
7265 }
7266
7267 #[test]
7268 fn allows_high_entropy_workspace_path_before_later_key_word() {
7269 let content =
7270 "see internal/workspaces/20260701/adr079-slices234/PACKET.md for the key behavior";
7271 assert!(
7272 check(content).is_ok(),
7273 "a path before a later topical key word must pass; got {:?}",
7274 scan(content)
7275 );
7276 }
7277
7278 #[test]
7279 fn allows_high_entropy_short_run_path_near_auth_word() {
7280 let content =
7281 "auth work saved at internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
7282 assert!(
7283 check(content).is_ok(),
7284 "path with a short run in technical prose must pass; got {:?}",
7285 scan(content)
7286 );
7287 }
7288
7289 #[test]
7290 fn allows_branch_and_review_filename_near_key_word() {
7291 let content =
7292 "branch feat-session-mirror pushed, see release_notes_v2.md for the key findings";
7293 assert!(
7294 check(content).is_ok(),
7295 "branch name and review filename near 'key' must not be blocked; fired: {:?}",
7296 scan(content)
7297 );
7298 }
7299
7300 #[test]
7301 fn allows_adr_doc_path_near_password_word() {
7302 let content = "password reset doc: docs/adr/ADR-055-epistemic-edge-relations.md";
7303 assert!(
7304 check(content).is_ok(),
7305 "ADR doc path near 'password' must not be blocked; fired: {:?}",
7306 scan(content)
7307 );
7308 }
7309
7310 #[test]
7311 fn allows_source_file_path_near_credential_word() {
7312 let content = "credential handling code crates/khive-pack-session/src/mirror/ingest.rs";
7313 assert!(
7314 check(content).is_ok(),
7315 "source file path near 'credential' must not be blocked; fired: {:?}",
7316 scan(content)
7317 );
7318 }
7319
7320 #[test]
7321 fn allows_long_snake_case_identifier_near_key_word() {
7322 let content = "api key handling lives in check_entropy_heuristic_impl";
7323 assert!(
7324 check(content).is_ok(),
7325 "snake_case identifier near 'key' must not be blocked; fired: {:?}",
7326 scan(content)
7327 );
7328 }
7329
7330 #[test]
7333 fn hyphenated_random_secret_is_not_a_structured_identifier() {
7334 assert!(!is_structured_identifier(
7340 "wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123"
7341 ));
7342 let line = "api key wJalrXUtnFEMI-K7MDENGbPxRfiCYEXAMPLEKEYX123";
7343 assert!(
7344 scan(line).is_some(),
7345 "hyphenated random secret must still be blocked; got: {:?}",
7346 scan(line)
7347 );
7348 }
7349
7350 #[test]
7353 fn structured_identifier_true_for_repro_paths() {
7354 let paths = [
7355 "fable-ops/ADR-DRAFT-adr079-slices234.md",
7356 "internal/workspaces/20260701/adr079-slices234/PACKET.md",
7357 "internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md",
7358 "release_notes_v2.md",
7359 "docs/adr/ADR-055-epistemic-edge-relations.md",
7360 "crates/khive-pack-session/src/mirror/ingest.rs",
7361 "check_entropy_heuristic_impl",
7362 ];
7363 for p in paths {
7364 assert!(
7365 is_structured_identifier(p),
7366 "expected structured identifier: {p}"
7367 );
7368 }
7369 }
7370
7371 #[test]
7372 fn structured_identifier_false_without_separator() {
7373 assert!(!is_structured_identifier(
7375 "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM"
7376 ));
7377 }
7378
7379 #[test]
7380 fn structured_identifier_false_for_leetspeak_digit_interleaving() {
7381 assert!(!is_structured_identifier("S3cr3t-P4ssw0rd-t0ken-here!"));
7384 }
7385
7386 #[test]
7387 fn structured_identifier_false_for_run_over_length_cap() {
7388 let long_run = "a".repeat(26);
7392 let token = format!("prefix-{long_run}-suffix");
7393 assert!(!is_structured_identifier(&token));
7394 }
7395
7396 #[test]
7405 fn blocks_separator_secret_access_key_bypass() {
7406 let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk";
7409 assert!(
7410 check(content).is_err(),
7411 "AWS Secret Access Key shaped bypass must be blocked: {:?}",
7412 scan(content)
7413 );
7414 }
7415
7416 #[test]
7417 fn blocks_adversarial_lowercase_only_separator_token_near_access_key() {
7418 let content = "access_key qrstuvwxyz/abcdefghij/klmnopqrst/uvwxyzab";
7419 assert!(
7420 check(content).is_err(),
7421 "lowercase-only separator-delimited high-entropy token near \
7422 'access_key' must be blocked: {:?}",
7423 scan(content)
7424 );
7425 }
7426
7427 #[test]
7428 fn blocks_adversarial_digit_and_word_mixed_token_near_api_key() {
7429 let content = "api_key attaycofrsm827/festwqjhc493/8261947350/qwikjzx982";
7433 assert!(
7434 check(content).is_err(),
7435 "digit-and-word-mixed high-entropy token near 'api_key' must be blocked: {:?}",
7436 scan(content)
7437 );
7438 }
7439
7440 #[test]
7441 fn blocks_adversarial_token_assignment_separator_delimited_secret() {
7442 let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk";
7443 assert!(
7444 check(content).is_err(),
7445 "token= with lowercase-only separator-delimited high-entropy value \
7446 must be blocked: {:?}",
7447 scan(content)
7448 );
7449 }
7450
7451 #[test]
7452 fn blocks_extension_suffix_bypass_secret_access_key() {
7453 let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
7456 assert!(
7457 check(content).is_err(),
7458 "extension-suffixed AWS Secret Access Key shaped bypass must be blocked: {:?}",
7459 scan(content)
7460 );
7461 }
7462
7463 #[test]
7464 fn blocks_extension_suffix_bypass_token_assignment() {
7465 let content = "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk.rs";
7466 assert!(
7467 check(content).is_err(),
7468 "extension-suffixed token= bypass must be blocked: {:?}",
7469 scan(content)
7470 );
7471 }
7472
7473 #[test]
7474 fn blocks_unsuffixed_separator_split_credential_bypasses() {
7475 let cases = [
7476 "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk",
7477 "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz-mnbvcxzlk",
7478 ];
7479 for content in cases {
7480 assert!(
7481 check(content).is_err(),
7482 "bypass string must still be blocked: {content:?}, got: {:?}",
7483 scan(content)
7484 );
7485 }
7486 }
7487
7488 #[test]
7489 fn blocks_digit_run_suffix_bypass_attempt() {
7490 let cases = [
7491 "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk2024",
7492 "secret_access_key abcdefghij2024/klmnopqrst/uvwxyzabcd/efghijk.md",
7493 ];
7494 for content in cases {
7495 assert!(
7496 check(content).is_err(),
7497 "digit-run-suffixed bypass attempt must be blocked: {content:?}, got: {:?}",
7498 scan(content)
7499 );
7500 }
7501 }
7502
7503 #[test]
7504 fn blocks_low_entropy_padding_run_bypass_attempts() {
7505 let cases = [
7511 "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/R1.md",
7512 "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/R1.rs",
7513 "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk/aaaa/bbbb/R1.md",
7514 "token=zxkqwmvbpl/trfhysjgnc/dweiaoutkz/mnbvcxzlk/aaaa/bbbb/R1.rs",
7515 ];
7516 for content in cases {
7517 assert!(
7518 check(content).is_err(),
7519 "padding-run bypass attempt must be blocked: {content:?}, got: {:?}",
7520 scan(content)
7521 );
7522 }
7523 }
7524
7525 #[test]
7526 fn blocks_run_splitting_bypass_attempts() {
7527 let cases = [
7534 "secret_access_key abcd/efgh/ijkl/mnop/qrst/uvwx/yzab/cdef.md",
7535 "secret_access_key abcde/fghij/klmno/pqrst/uvwxy/zabcd.md",
7536 "secret_access_key abcdef/ghijkl/mnopqr/stuvwx/yzabcd.md",
7537 ];
7538 for content in cases {
7539 assert!(
7540 check(content).is_err(),
7541 "run-splitting bypass attempt must be blocked: {content:?}, got: {:?}",
7542 scan(content)
7543 );
7544 }
7545 }
7546
7547 #[test]
7548 fn blocks_separator_split_generic_hex_credential_ascii() {
7549 let content = "api key 0123456789abcdef0123/456789abcdef01234567";
7558 assert!(
7559 check(content).is_err(),
7560 "separator-split hex credential must be blocked: got {:?}",
7561 scan(content)
7562 );
7563 }
7564
7565 #[test]
7566 fn blocks_separator_split_generic_hex_credential_unicode_separator() {
7567 let content = "api key 0123456789abcdef0123\u{200B}456789abcdef01234567";
7575 assert!(
7576 check(content).is_err(),
7577 "Unicode-separator split hex credential must be blocked: got {:?}",
7578 scan(content)
7579 );
7580 }
7581
7582 #[test]
7583 fn blocks_unicode_split_hex_credential_with_path_shaped_anchor() {
7584 let content =
7585 "api key handling uses source/x/0123456789abcdef0123\u{200B}456789abcdef01234567";
7586 let tokens: Vec<(usize, &str)> = content
7587 .split(|c: char| c.is_ascii_whitespace() || !c.is_ascii())
7588 .filter(|token| !token.is_empty())
7589 .map(|token| (token.as_ptr() as usize - content.as_ptr() as usize, token))
7590 .collect();
7591 let anchor = tokens
7592 .iter()
7593 .position(|(_, token)| token.starts_with("source/"))
7594 .expect("path anchor must be tokenized");
7595 assert!(is_plausible_file_path(tokens[anchor].1));
7596 let fragments = bridge_fragment_chain(&tokens, content, anchor);
7597 assert_eq!(fragments.len(), 2);
7598 assert!(normalized_hex_credential_span(&fragments.join(" ")).is_some());
7599 assert!(
7600 check(content).is_err(),
7601 "path-shaped anchor must not bypass fragment reconstruction: got {:?}",
7602 scan(content)
7603 );
7604 }
7605
7606 #[test]
7607 fn blocks_separator_split_hex_credential_repeated_unicode_gap() {
7608 let content = "api key 0123456789abcdef0123\u{200B}\u{200B}\u{200B}456789abcdef01234567";
7617 assert!(
7618 check(content).is_err(),
7619 "repeated-Unicode-gap split hex credential must be blocked: got {:?}",
7620 scan(content)
7621 );
7622 }
7623
7624 #[test]
7625 fn blocks_separator_split_three_way_hex_credential_mixed_case() {
7626 let content = "api key AAAA1111bbbb22\u{200B}22cccc3333ddd\u{200B}d4444eeee5555";
7636 assert!(
7637 check(content).is_err(),
7638 "three-way mixed-case Unicode-split hex credential must be blocked: got {:?}",
7639 scan(content)
7640 );
7641 }
7642
7643 #[test]
7644 fn blocks_separator_split_base64_like_unicode_credential() {
7645 let content = "api key Xk9mZ2vQpLrT8nJwYuAe\u{200B}HfBsDcGiONvMabcdefgh";
7656 assert!(
7657 check(content).is_err(),
7658 "base64-like Unicode-split credential must be blocked: got {:?}",
7659 scan(content)
7660 );
7661 }
7662
7663 #[test]
7664 fn blocks_punctuation_glue_between_two_unicode_gaps() {
7665 let content = "api key 0123456789abcdef0123\u{200B}---\u{200B}456789abcdef01234567";
7670 assert!(
7671 check(content).is_err(),
7672 "punctuation-glue split hex credential between two Unicode gaps must be \
7673 blocked: got {:?}",
7674 scan(content)
7675 );
7676 }
7677
7678 #[test]
7679 fn allows_seven_way_hex_split_beyond_fragment_cap_documented_limitation() {
7680 let content = "api key 012345678\u{200B}9abcdef01\u{200B}23456789a\u{200B}bcdef0123\u{200B}456789abc\u{200B}def012345\u{200B}6789abcdef";
7692 assert!(
7693 check(content).is_ok(),
7694 "seven-way hex split beyond MAX_BRIDGE_FRAGMENTS is a documented residual \
7695 limitation and must stay allowed: got {:?}",
7696 scan(content)
7697 );
7698 }
7699
7700 #[test]
7701 fn allows_six_way_sub_floor_hex_split_documented_limitation() {
7702 let content = "api key 0123456\u{200B}789abcd\u{200B}ef01234\u{200B}56789ab\u{200B}cdef01\u{200B}234567";
7711 assert!(
7712 check(content).is_ok(),
7713 "six-way sub-MIN_BRIDGE_FRAGMENT_LEN hex split is a documented residual \
7714 limitation and must stay allowed: got {:?}",
7715 scan(content)
7716 );
7717 }
7718
7719 #[test]
7720 fn allows_unrelated_short_fragments_cited_near_a_trigger_word() {
7721 let content = "api key: see commits abc12345, def67890 for the fix";
7732 assert!(
7733 check(content).is_ok(),
7734 "unrelated short fragments cited near a trigger word must stay allowed: \
7735 fired {:?}",
7736 scan(content)
7737 );
7738 }
7739
7740 #[test]
7741 fn allows_unrelated_short_base64_like_fragments_cited_near_a_trigger_word() {
7742 let content = "api key: build ids Ab3Kf9 and Xy7Lm2 do not match";
7748 assert!(
7749 check(content).is_ok(),
7750 "unrelated short base64-like fragments cited near a trigger word must \
7751 stay allowed: fired {:?}",
7752 scan(content)
7753 );
7754 }
7755
7756 #[test]
7757 fn allows_scattered_short_hex_runs_that_do_not_sum_to_a_credential_length() {
7758 let content = "auth config lives in abc12345/de678901/fa234567.md";
7766 assert!(
7767 check(content).is_ok(),
7768 "scattered short hex runs that never sum to a credential length \
7769 must stay allowed: fired {:?}",
7770 scan(content)
7771 );
7772 }
7773
7774 #[test]
7775 fn allows_fp_paths_whose_full_token_entropy_is_already_below_threshold() {
7776 let paths = [
7782 "release_notes_v2.md",
7783 "docs/adr/ADR-055-epistemic-edge-relations.md",
7784 "crates/khive-pack-session/src/mirror/ingest.rs",
7785 "check_entropy_heuristic_impl",
7786 ];
7787 for p in paths {
7788 let content = format!("api_key handling in {p}");
7789 assert!(
7790 check(&content).is_ok(),
7791 "{p} must stay allowed near 'api_key' (full-token entropy already \
7792 below threshold): fired {:?}",
7793 scan(&content)
7794 );
7795 }
7796 }
7797
7798 #[test]
7799 fn allows_adr_draft_path_near_trigger() {
7800 let content = "api_key handling in fable-ops/ADR-DRAFT-adr079-slices234.md";
7801 assert!(
7802 check(content).is_ok(),
7803 "ADR-DRAFT path in technical prose must pass; got {:?}",
7804 scan(content)
7805 );
7806 }
7807
7808 #[test]
7809 fn allows_workspace_packet_path_near_trigger() {
7810 let content = "api_key handling in internal/workspaces/20260701/adr079-slices234/PACKET.md";
7811 assert!(
7812 check(content).is_ok(),
7813 "workspace path in technical prose must pass; got {:?}",
7814 scan(content)
7815 );
7816 }
7817
7818 #[test]
7819 fn allows_high_entropy_repo_audit_path_near_api_key() {
7820 let content =
7821 "api_key handling in internal/workspaces/20260701/cloud-rebuild/R1-repo-audit.md";
7822 assert!(
7823 check(content).is_ok(),
7824 "repository path in technical prose must pass; got {:?}",
7825 scan(content)
7826 );
7827 }
7828
7829 #[test]
7830 fn allows_source_paths_near_ordinary_key_and_token_prose() {
7831 let contents = [
7832 "see <a/path/to/file.py>:~97-103 lists it as a real checkpoint-supplied key",
7833 "see <a/path/to/file.py>:~97-103 emits one extra token",
7834 "see /workspace/src/checkpoint_loader.rs:97-103\n\nconfiguration key behavior",
7835 "the token behavior is implemented in src/runtime/secret_gate.rs:618-914.",
7836 ];
7837 for content in contents {
7838 assert!(
7839 check(content).is_ok(),
7840 "source path in technical prose must pass: {content:?}, got {:?}",
7841 scan(content)
7842 );
7843 }
7844 }
7845
7846 #[test]
7847 fn allows_adr_authorization_path_in_markdown_ingest() {
7848 let content =
7849 "- Evidence: `docs/adr/C-ADR-007-authorization-server.md:70` defines token handling.";
7850 assert!(
7851 check(content).is_ok(),
7852 "ADR path citation in technical markdown must pass: got {:?}",
7853 scan(content)
7854 );
7855 }
7856
7857 #[test]
7858 fn blocks_markdown_ingest_bare_opaque_value_near_token() {
7859 let content = concat!(
7860 "- Review evidence for token handling: ",
7861 "Xk9mZ2vQpLrT8nJwYuAeHfBs",
7862 "DcGiONvM1qPrStUvWxYz23456789"
7863 );
7864 assert_eq!(
7865 scan(content).map(|matched| matched.detector),
7866 Some("high-entropy-token"),
7867 "bare opaque value near token must remain blocked"
7868 );
7869 }
7870
7871 #[test]
7874 fn blocks_uuid_directly_labeled_as_api_key() {
7875 let content = "api_key 550e8400-e29b-41d4-a716-446655440000";
7876 assert!(
7877 check(content).is_err(),
7878 "UUID-shaped token labeled api_key must be blocked; got {:?}",
7879 scan(content)
7880 );
7881 }
7882
7883 #[test]
7884 fn blocks_sha256_content_hash_labeled_as_secret() {
7885 let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
7886 assert!(
7887 check(content).is_err(),
7888 "sha256-prefixed hash labeled secret must be blocked; got {:?}",
7889 scan(content)
7890 );
7891 }
7892
7893 #[test]
7894 fn blocks_sha384_content_hash_labeled_as_api_key() {
7895 let content =
7896 "api_key sha384-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
7897 assert!(
7898 check(content).is_err(),
7899 "sha384-prefixed hash labeled api_key must be blocked; got {:?}",
7900 scan(content)
7901 );
7902 }
7903
7904 #[test]
7905 fn blocks_sha512_content_hash_labeled_as_auth() {
7906 let content = "auth sha512-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/ABCDEFGHIJKLMNOPQRSTUV";
7907 assert!(
7908 check(content).is_err(),
7909 "sha512-prefixed hash labeled auth must be blocked; got {:?}",
7910 scan(content)
7911 );
7912 }
7913
7914 #[test]
7915 fn allows_uuid_with_no_trigger_within_window() {
7916 let content =
7920 "task 550e8400-e29b-41d4-a716-446655440000 was created and assigned to the team";
7921 assert!(
7922 check(content).is_ok(),
7923 "UUID with no nearby trigger word must stay allowed; got {:?}",
7924 scan(content)
7925 );
7926 }
7927
7928 #[test]
7929 fn allows_area_id_uuid_near_authorized_substring() {
7930 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.";
7939 assert!(
7940 check(content).is_ok(),
7941 "internal area_id UUID near the 'authorized' substring \
7942 (not a genuine 'auth' mention) must now pass; got {:?}",
7943 scan(content)
7944 );
7945 }
7946
7947 #[test]
7948 fn allows_uuid_on_line_after_benign_token_contract_title() {
7949 let content = "Design language and token contract\n550e8400-e29b-41d4-a716-446655440000";
7950 assert!(
7951 check(content).is_ok(),
7952 "a generic token-contract title must not make a next-line UUID look like a secret; \
7953 got {:?}",
7954 scan(content)
7955 );
7956 }
7957
7958 #[test]
7959 fn generic_token_uuid_exemption_keeps_strong_credential_controls() {
7960 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let uuid = "550e8400-e29b-41d4-a716-446655440000";
7962 let cases = [
7963 (format!("service token {opaque}"), "high-entropy-token"),
7964 (format!("token={opaque}"), "high-entropy-token"),
7965 (format!("token={uuid}"), "uuid-near-trigger"),
7966 (format!("api_key {uuid}"), "uuid-near-trigger"),
7967 ];
7968
7969 for (content, detector) in cases {
7970 assert_eq!(
7971 scan(&content).map(|matched| matched.detector),
7972 Some(detector),
7973 "credential-shaped control must remain blocked: {content:?}"
7974 );
7975 }
7976 }
7977
7978 #[test]
7981 fn blocks_uuid_glued_to_assignment_equals() {
7982 let content = "api_key=550e8400-e29b-41d4-a716-446655440000";
7983 assert!(
7984 check(content).is_err(),
7985 "UUID glued via '=' to a trigger word must be blocked; got {:?}",
7986 scan(content)
7987 );
7988 }
7989
7990 #[test]
7991 fn blocks_uuid_with_trailing_sentence_period() {
7992 let content = "api_key 550e8400-e29b-41d4-a716-446655440000.";
7993 assert!(
7994 check(content).is_err(),
7995 "UUID with a trailing sentence period near a trigger must be blocked; got {:?}",
7996 scan(content)
7997 );
7998 }
7999
8000 #[test]
8001 fn blocks_uuid_wrapped_in_parens() {
8002 let content = "api_key (550e8400-e29b-41d4-a716-446655440000)";
8003 assert!(
8004 check(content).is_err(),
8005 "UUID wrapped in parens near a trigger must be blocked; got {:?}",
8006 scan(content)
8007 );
8008 }
8009
8010 #[test]
8011 fn blocks_uuid_in_json_object() {
8012 let content = "{\"api_key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
8013 assert!(
8014 check(content).is_err(),
8015 "UUID in a JSON-ish object near a trigger key must be blocked; got {:?}",
8016 scan(content)
8017 );
8018 }
8019
8020 #[test]
8021 fn blocks_content_hash_glued_to_assignment_equals() {
8022 let content = "secret=sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq";
8023 assert!(
8024 check(content).is_err(),
8025 "sha256-prefixed hash glued via '=' to a trigger word must be blocked; \
8026 got {:?}",
8027 scan(content)
8028 );
8029 }
8030
8031 #[test]
8032 fn blocks_content_hash_with_trailing_sentence_period() {
8033 let content = "secret sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq.";
8034 assert!(
8035 check(content).is_err(),
8036 "sha256-prefixed hash with a trailing period near a trigger must be \
8037 blocked; got {:?}",
8038 scan(content)
8039 );
8040 }
8041
8042 #[test]
8043 fn blocks_content_hash_wrapped_in_parens() {
8044 let content = "secret (sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq)";
8045 assert!(
8046 check(content).is_err(),
8047 "sha256-prefixed hash wrapped in parens near a trigger must be blocked; \
8048 got {:?}",
8049 scan(content)
8050 );
8051 }
8052
8053 #[test]
8054 fn blocks_content_hash_in_json_object() {
8055 let content = "{\"secret\":\"sha256-ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopq\"}";
8056 assert!(
8057 check(content).is_err(),
8058 "sha256-prefixed hash in a JSON-ish object near a trigger key must be \
8059 blocked; got {:?}",
8060 scan(content)
8061 );
8062 }
8063
8064 #[test]
8065 fn allows_uuid_wrapped_in_parens_with_no_trigger_nearby() {
8066 let content = "wrapper (550e8400-e29b-41d4-a716-446655440000) present";
8071 assert!(
8072 check(content).is_ok(),
8073 "UUID wrapped in parens with no trigger word nearby must stay allowed; \
8074 got {:?}",
8075 scan(content)
8076 );
8077 }
8078
8079 #[test]
8080 fn blocks_padded_content_hash_glued_to_assignment_with_trailing_period() {
8081 let content = "secret=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
8087 assert!(
8088 check(content).is_err(),
8089 "padded sha256 hash glued via '=' with a trailing period must be \
8090 blocked; got {:?}",
8091 scan(content)
8092 );
8093 }
8094
8095 #[test]
8096 fn blocks_padded_content_hash_in_json_object() {
8097 let content = "{\"secret\":\"sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=\"}";
8098 assert!(
8099 check(content).is_err(),
8100 "padded sha256 hash in a JSON-ish object near a trigger key must be \
8101 blocked; got {:?}",
8102 scan(content)
8103 );
8104 }
8105
8106 #[test]
8107 fn blocks_uuid_when_json_label_itself_contains_colon() {
8108 let content = "{\"api:key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
8114 assert!(
8115 check(content).is_err(),
8116 "UUID must be blocked even when the JSON label contains ':'; got {:?}",
8117 scan(content)
8118 );
8119 }
8120
8121 #[test]
8122 fn blocks_uuid_when_json_label_itself_contains_equals() {
8123 let content = "{\"api=key\":\"550e8400-e29b-41d4-a716-446655440000\"}";
8124 assert!(
8125 check(content).is_err(),
8126 "UUID must be blocked even when the JSON label contains '='; got {:?}",
8127 scan(content)
8128 );
8129 }
8130
8131 #[test]
8132 fn blocks_uuid_behind_doubled_assignment() {
8133 let content = "api_key=label=550e8400-e29b-41d4-a716-446655440000"; assert!(
8137 check(content).is_err(),
8138 "UUID must be blocked behind a doubled assignment; got {:?}",
8139 scan(content)
8140 );
8141 }
8142
8143 #[test]
8144 fn blocks_padded_content_hash_behind_doubled_assignment_equals() {
8145 let content = "secret=label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
8146 assert!(
8147 check(content).is_err(),
8148 "padded content hash must be blocked behind a doubled '=' assignment; \
8149 got {:?}",
8150 scan(content)
8151 );
8152 }
8153
8154 #[test]
8155 fn blocks_padded_content_hash_behind_doubled_assignment_colon() {
8156 let content = "secret:label=sha256-AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=.";
8157 assert!(
8158 check(content).is_err(),
8159 "padded content hash must be blocked behind a doubled ':'+'=' \
8160 assignment; got {:?}",
8161 scan(content)
8162 );
8163 }
8164
8165 #[test]
8166 fn allows_benign_url_with_scheme_and_path_separators() {
8167 let content = "api_key endpoint=https://example.test/resource/for/testing";
8174 assert!(
8175 check(content).is_ok(),
8176 "a benign URL near a trigger word must stay allowed; got {:?}",
8177 scan(content)
8178 );
8179 }
8180
8181 #[test]
8184 fn allows_trigger_substrings_inside_benign_path_slugs() {
8185 let paths = [
8186 "docs/_archive/adr_v0/ADR-051-cli-auth-and-kg-git-workflow.md",
8187 "docs/platform/oauth-callback-docs-and-redirect-handling-v2.md",
8188 "docs/research/author-attribution-and-collaboration-notes.md",
8189 "docs/security/passwordless-authentication-overview-v3.md",
8190 "docs/platform/private_keynote-authoring-guide-v2.md",
8191 ];
8192 for path in paths {
8193 assert!(
8194 check(path).is_ok(),
8195 "trigger substring inside a benign path slug must not make the path \
8196 its own credential context: {path:?}, got {:?}",
8197 scan(path)
8198 );
8199 }
8200 }
8201
8202 #[test]
8203 fn blocks_inline_auth_assignment_with_high_entropy_value() {
8204 let content = "auth=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; assert!(
8206 check(content).is_err(),
8207 "auth=<high-entropy-value> must still be blocked; got {:?}",
8208 scan(content)
8209 );
8210 }
8211
8212 #[test]
8213 fn blocks_suffix_bearing_compound_credential_assignments() {
8214 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let cases = [
8216 format!("api_keyv2={opaque}"),
8217 format!("access_keyv2={opaque}"),
8218 format!("private_keyv2={opaque}"),
8219 format!("API_KEYV2={opaque}"),
8220 format!(r#"{{"private_keyv2":"{opaque}"}}"#),
8221 ];
8222 for content in &cases {
8223 assert!(
8224 check(content).is_err(),
8225 "suffix-bearing compound credential assignment must be blocked: \
8226 {content:?}, got {:?}",
8227 scan(content)
8228 );
8229 }
8230 }
8231
8232 #[test]
8233 fn blocks_spaced_suffix_bearing_compound_credential_assignments() {
8234 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; for label in ["api_keyv2", "access_keyv2", "private_keyv2"] {
8236 let cases = [
8237 format!("{label} = {opaque}"),
8238 format!("{label} : {opaque}"),
8239 format!(r#"{{"{label}": "{opaque}"}}"#),
8240 ];
8241 for content in &cases {
8242 assert!(
8243 check(content).is_err(),
8244 "spaced suffix-bearing compound credential assignment must be \
8245 blocked: {content:?}, got {:?}",
8246 scan(content)
8247 );
8248 }
8249 }
8250 }
8251
8252 #[test]
8253 fn blocks_suffix_bearing_compound_credentials_without_assignment_separator() {
8254 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; for label in ["api_keyv2", "access_keyv2", "private_keyv2"] {
8256 let cases = [format!("{label} {opaque}"), format!("{label}{opaque}")];
8257 for content in &cases {
8258 assert!(
8259 check(content).is_err(),
8260 "suffix-bearing compound credential without an assignment separator must be \
8261 blocked: {content:?}, got {:?}",
8262 scan(content)
8263 );
8264 assert!(
8265 mask_secrets(content).contains(REDACTION_MARKER),
8266 "shared secret masker must redact separator-free compound credential: \
8267 {content:?}"
8268 );
8269 }
8270 }
8271
8272 let prefixed = "xapi_keyv2=Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; assert!(
8274 check(prefixed).is_err(),
8275 "prefix-bearing compound credential assignment must be blocked: \
8276 {prefixed:?}, got {:?}",
8277 scan(prefixed)
8278 );
8279 }
8280
8281 #[test]
8282 fn allows_authorized_and_authentication_prose_near_uuid() {
8283 let cases = [
8288 "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
8289 "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
8290 ];
8291 for content in cases {
8292 assert!(
8293 check(content).is_ok(),
8294 "'authorized'/'authentication' substring must not trigger the \
8295 entropy heuristic: {content:?}, got {:?}",
8296 scan(content)
8297 );
8298 }
8299 }
8300
8301 #[test]
8302 fn allows_turkey_monkey_keyword_prose_near_uuid() {
8303 let cases = [
8306 "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
8307 "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
8308 ];
8309 for content in cases {
8310 assert!(
8311 check(content).is_ok(),
8312 "'turkey'/'monkey'/'keyword' substring must not trigger the \
8313 entropy heuristic: {content:?}, got {:?}",
8314 scan(content)
8315 );
8316 }
8317 }
8318
8319 #[test]
8320 fn blocks_opaque_tokens_near_standalone_trigger_words() {
8321 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let cases = [
8325 format!("auth header {opaque}"),
8326 format!("the key is {opaque}"),
8327 format!("secret value: {opaque}"),
8328 ];
8329 for content in &cases {
8330 assert!(
8331 check(content).is_err(),
8332 "a genuine standalone trigger word must still block: {content:?}, \
8333 got {:?}",
8334 scan(content)
8335 );
8336 }
8337 }
8338
8339 #[test]
8340 fn issue_2654_lookup_members_accept_record_references() {
8341 let id = "550e8400-e29b-41d4-a716-446655440000";
8342 for label in [
8343 "association_key",
8344 "partition_key",
8345 "sort_key",
8346 "cache_key",
8347 "idempotency_key",
8348 "primary_key",
8349 ] {
8350 for value in [id, "", "runtime/current", "record-slug"] {
8351 for separator in [",", ", "] {
8352 let content = format!(r#"{{"{label}":"{value}"{separator}"neighbor":"{id}"}}"#);
8353 assert!(check(&content).is_ok(), "{content}: {:?}", check(&content));
8354 assert_eq!(mask_secrets(&content), content);
8355 }
8356 }
8357 }
8358 let renamed = format!(r#"{{"association_ref":"{id}","neighbor":"{id}"}}"#);
8359 assert!(check(&renamed).is_ok());
8360 }
8361
8362 #[test]
8363 fn issue_2654_credential_compounds_and_natural_assignments_stay_refused() {
8364 let id = "550e8400-e29b-41d4-a716-446655440000";
8365 for label in [
8366 "key",
8367 "api_key",
8368 "secret_key",
8369 "private_key",
8370 "access_key",
8371 "signing_key",
8372 "encryption_key",
8373 "auth_key",
8374 "service_signing_key",
8375 ] {
8376 for content in [
8377 format!("{label}={id}"),
8378 format!("{label}: {id}"),
8379 format!(r#"{{"{label}":"{id}"}}"#),
8380 ] {
8381 assert!(check(&content).is_err(), "{content}");
8382 assert!(!mask_secrets(&content).contains(id), "{content}");
8383 }
8384 }
8385 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; for content in [
8387 format!("the key is {opaque}"),
8388 format!("api key {opaque}"),
8389 format!("association_key {id}"),
8390 format!("_key_ = {id}"),
8391 format!("association_key=x key={id}"),
8392 ] {
8393 assert!(check(&content).is_err(), "{content}");
8394 }
8395 assert!(check("secret docs/guide.md").is_ok());
8396 assert!(check("auth release-slug").is_ok());
8397 }
8398
8399 #[test]
8400 fn issue_2654_lookup_exception_requires_an_assignment_gap() {
8401 let id = "550e8400-e29b-41d4-a716-446655440000";
8402 for gap in ["***", ")", "}", "\\", "!", " ", "\" "] {
8403 let content = format!("association_key{gap}:{id}");
8404 assert!(check(&content).is_err(), "{content}");
8405 assert!(!mask_secrets(&content).contains(id), "{content}");
8406 }
8407 for gap in ["", "\"", "'", "`"] {
8408 let content = format!("association_key{gap}:{id}");
8409 assert!(check(&content).is_ok(), "{content}");
8410 }
8411 }
8412
8413 #[test]
8414 fn issue_2654_repeated_key_identifier_is_scanned_once() {
8415 let content = format!("{}key=x", "key_".repeat(262_144));
8416 assert!(check(&content).is_ok());
8417 }
8418
8419 #[test]
8420 fn issue_2654_unicode_before_lookup_member_keeps_boundaries() {
8421 let content = "記録association_key: 550e8400-e29b-41d4-a716-446655440000";
8422 assert!(check(content).is_ok(), "{:?}", check(content));
8423 }
8424
8425 #[test]
8426 fn issue_2654_unlisted_key_compounds_stay_credential_labels() {
8427 let hex = "0123456789abcdef".repeat(4);
8430 let id = "550e8400-e29b-41d4-a716-446655440000";
8431 let neighbor = "6ba7b810-9dad-11d1-80b4-00c04fd430c8";
8432 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; for label in [
8434 "hmac_key",
8435 "master_key",
8436 "ssh_key",
8437 "jwt_key",
8438 "webhook_key",
8439 "license_key",
8440 "gpg_key",
8441 "service_key",
8442 "client_key",
8443 "service_hmac_key",
8444 ] {
8445 for value in [hex.as_str(), id, opaque] {
8446 for content in [
8447 format!("{label}={value}"),
8448 format!("{label}: {value}"),
8449 format!(r#"{{"{label}":"{value}"}}"#),
8450 format!(r#"{{"neighbor":"{neighbor}","{label}":"{value}"}}"#),
8451 ] {
8452 assert!(check(&content).is_err(), "{content}");
8453 assert!(!mask_secrets(&content).contains(value), "{content}");
8454 }
8455 }
8456 }
8457 }
8458
8459 #[test]
8460 fn issue_2654_listed_lookup_labels_accept_identifier_shapes() {
8461 let hex = "0123456789abcdef".repeat(4);
8462 let id = "550e8400-e29b-41d4-a716-446655440000";
8463 for label in LOOKUP_KEY_LABELS {
8464 for value in [id, hex.as_str(), "runtime/current", "record-slug"] {
8465 for content in [
8466 format!("{label}={value}"),
8467 format!(r#"{{"{label}":"{value}","neighbor":"{id}"}}"#),
8468 ] {
8469 assert!(check(&content).is_ok(), "{content}: {:?}", scan(&content));
8470 assert_eq!(mask_secrets(&content), content);
8471 }
8472 }
8473 }
8474 assert!(check(&format!("secret_partition_key={id}")).is_err());
8476 assert!(check(&format!("api_key_cache_key={id}")).is_err());
8477 }
8478
8479 #[test]
8480 fn issue_2654_qualified_lookup_labels_stay_refused() {
8481 let hex = "0123456789abcdef".repeat(4);
8487 let id = "550e8400-e29b-41d4-a716-446655440000";
8488 for label in [
8489 "hmac_cache_key",
8490 "master_routing_key",
8491 "jwt_lookup_key",
8492 "ssh_row_key",
8493 "webhook_search_key",
8494 "license_index_key",
8495 "left_association_key",
8496 "record_partition_key",
8497 ] {
8498 for content in [
8499 format!("{label}={hex}"),
8500 format!(r#"{{"{label}":"{hex}","neighbor":"{id}"}}"#),
8501 ] {
8502 assert!(check(&content).is_err(), "{content}");
8503 assert!(!mask_secrets(&content).contains(hex.as_str()), "{content}");
8504 }
8505 }
8506 }
8507
8508 #[test]
8509 fn accepted_false_positive_workspace_artifact_path_behind_attributive_trigger() {
8510 let content = "writing up the secret gate false positive repro: \
8517 .workspace/20260101/fix-secret-gate-trigger-false-positive/MEASUREMENT_REPORT.md";
8518 assert!(
8519 check(content).is_err(),
8520 "accepted-FP contract changed: attributive trigger before a \
8521 delimited path no longer blocks — update the docs if deliberate"
8522 );
8523 }
8524
8525 #[test]
8526 fn accepted_false_positive_archive_doc_path_behind_attributive_trigger() {
8527 let content =
8532 "secret scanner archive notes: docs/_archive/ADR051-TenantEncryption-v2Notes.md";
8533 assert!(
8534 check(content).is_err(),
8535 "accepted-FP contract changed: attributive trigger before a \
8536 delimited path no longer blocks — update the docs if deliberate"
8537 );
8538 }
8539
8540 #[test]
8541 fn allows_absolute_path_near_standalone_auth() {
8542 let content = "the auth scanner flagged this file: /home/user/projects/workspace/SessionNotes20260107/AuthGateFollowup2.md";
8543 assert!(
8544 check(content).is_ok(),
8545 "absolute path in technical prose must pass; got {:?}",
8546 scan(content)
8547 );
8548 }
8549
8550 #[test]
8551 fn blocks_assignment_shaped_credential_disguised_as_path_near_api_key() {
8552 let content = "api_key=/home/user/workspaces/2026/topic-name-example/SECRET_VALUE_HERE.md";
8558 assert!(
8559 check(content).is_err(),
8560 "assignment-shaped credential disguised as a path must still be \
8561 blocked: {content:?}, got {:?}",
8562 scan(content)
8563 );
8564 }
8565
8566 #[test]
8567 fn blocks_separator_split_secret_access_key_compound() {
8568 let content = "secret_access_key abcdefghij/klmnopqrst/uvwxyzabcd/efghijk.md";
8573 assert!(
8574 check(content).is_err(),
8575 "secret_access_key bypass shape must still be blocked: {content:?}, \
8576 got {:?}",
8577 scan(content)
8578 );
8579 }
8580
8581 #[test]
8587 fn blocks_secret_key_assignment_when_underscore_bounds_trigger() {
8588 let content = "SECRET_KEY=dGhpc2lzYXNlY3JldGtleXZhbHVlMTIzNDU2Nzg5MA=="; assert!(
8593 check(content).is_err(),
8594 "SECRET_KEY=<value> (Django/Flask-style config) must still be \
8595 blocked: {content:?}, got {:?}",
8596 scan(content)
8597 );
8598 }
8599
8600 #[test]
8601 fn blocks_auth_token_assignment_when_underscore_bounds_trigger() {
8602 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let content = format!("auth_token={opaque}");
8604 assert!(
8605 check(&content).is_err(),
8606 "auth_token=<value> must still be blocked: {content:?}, got {:?}",
8607 scan(&content)
8608 );
8609 }
8610
8611 #[test]
8612 fn blocks_underscore_joined_session_secret_and_signing_key_compounds() {
8613 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let cases = [
8615 format!("session_secret_{opaque}"),
8616 format!("signing_key={opaque}"),
8617 ];
8618 for content in &cases {
8619 assert!(
8620 check(content).is_err(),
8621 "underscore-joined credential compound must still be blocked: \
8622 {content:?}, got {:?}",
8623 scan(content)
8624 );
8625 }
8626 }
8627
8628 #[test]
8629 fn allows_letter_joined_trigger_substrings_in_benign_prose() {
8630 let cases = [
8634 "authorized_write_requires_dominance was converted from axiom to theorem, id 550e8400-e29b-41d4-a716-446655440000",
8635 "authentication flow diagram lives at 550e8400-e29b-41d4-a716-446655440000",
8636 "the turkey and monkey story references id 550e8400-e29b-41d4-a716-446655440000",
8637 "keyword research doc: 550e8400-e29b-41d4-a716-446655440000",
8638 ];
8639 for content in cases {
8640 assert!(
8641 check(content).is_ok(),
8642 "letter-joined substring collision must stay exempt: \
8643 {content:?}, got {:?}",
8644 scan(content)
8645 );
8646 }
8647 }
8648
8649 #[test]
8650 fn block_message_carries_actionable_guidance() {
8651 let fake = "AKIAFAKEKEY1234567890";
8652 let m = scan(fake).unwrap();
8653 let rendered = m.to_string();
8654 assert!(
8655 rendered.contains("real credential"),
8656 "block message must carry actionable guidance: {rendered}"
8657 );
8658 }
8659
8660 #[test]
8661 fn block_message_shape_guidance_names_effective_boundary() {
8662 let opaque = "Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvMabcdef"; let content = format!("auth header {opaque}");
8664 let m = scan(&content).unwrap();
8665 let rendered = m.to_string();
8666 assert!(
8667 rendered.contains("sentence") || rendered.contains("paragraph"),
8668 "shape-based detector guidance must name an effective context boundary: {rendered}"
8669 );
8670 }
8671}
8672
8673#[cfg(test)]
8685mod corpus_replay {
8686 use super::*;
8687 use rusqlite::{Connection, OpenFlags};
8688
8689 #[test]
8692 fn blocks_hex_credential_diluted_by_filler_path_segments() {
8693 let line = "api key vault/9f8e7d6c5b4a39281706f5e4d3c2b1a09f8e7d6c/rotate.md";
8700 let m = scan(line);
8701 assert!(
8702 m.is_some(),
8703 "hex credential diluted by filler path segments must be blocked; got None"
8704 );
8705 assert_eq!(m.unwrap().detector, "hex-credential-token");
8706 }
8707
8708 #[test]
8709 fn blocks_chopped_secret_padded_by_filler_runs() {
8710 let line = "secret path a/b/c/d/Xk9mZ2vQpLrT8nJwYuAeHfBsDcGiONvM/e/f.rs"; let m = scan(line);
8714 assert!(
8715 m.is_some(),
8716 "chopped/padded secret among filler runs must be blocked; got None"
8717 );
8718 assert_eq!(m.unwrap().detector, "high-entropy-token");
8719 }
8720
8721 #[test]
8722 fn allows_real_paths_with_no_run_reaching_min_entropy_len() {
8723 let contents = [
8727 "branch feat-session-mirror pushed, see release_notes_v2.md for the key findings",
8728 "password reset doc: docs/adr/ADR-055-epistemic-edge-relations.md",
8729 "credential handling code crates/khive-pack-session/src/mirror/ingest.rs",
8730 "api key handling lives in check_entropy_heuristic_impl",
8731 ];
8732 for content in contents {
8733 assert!(
8734 check(content).is_ok(),
8735 "real path with no long run must still pass; fired: {:?}",
8736 scan(content)
8737 );
8738 }
8739 }
8740
8741 #[test]
8742 #[ignore]
8743 fn replay_against_corpus() {
8744 let db_path = std::env::var("KHIVE_REPLAY_DB")
8745 .expect("set KHIVE_REPLAY_DB=/path/to/khive.db to run the corpus replay (read-only)");
8746 let conn = Connection::open_with_flags(
8747 &db_path,
8748 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
8749 )
8750 .expect("open corpus DB read-only");
8751
8752 let mut total = 0usize;
8753 let mut blocked = 0usize;
8754 let mut samples: Vec<String> = Vec::new();
8755
8756 let mut collect = |sql: &str| {
8757 let mut stmt = conn.prepare(sql).expect("prepare replay query");
8758 let mut rows = stmt.query([]).expect("query replay rows");
8759 while let Some(row) = rows.next().expect("read replay row") {
8760 let content: Option<String> = row.get(0).unwrap_or(None);
8761 let Some(content) = content else { continue };
8762 if content.is_empty() {
8763 continue;
8764 }
8765 total += 1;
8766 if let Some(m) = scan(&content) {
8767 blocked += 1;
8768 if samples.len() < 30 {
8769 samples.push(format!(
8770 "{} :: {}",
8771 m,
8772 content.chars().take(160).collect::<String>()
8773 ));
8774 }
8775 }
8776 }
8777 };
8778
8779 collect("SELECT content FROM notes WHERE deleted_at IS NULL");
8780 collect("SELECT description FROM entities WHERE deleted_at IS NULL");
8781
8782 eprintln!("corpus replay: {blocked}/{total} strings blocked");
8783 for s in &samples {
8784 eprintln!(" BLOCKED: {s}");
8785 }
8786 }
8787
8788 #[test]
8795 #[ignore]
8796 fn generate_corpus_manifest() {
8797 use sha2::{Digest, Sha256};
8798 use std::collections::BTreeMap;
8799
8800 let db_path = std::env::var("KHIVE_REPLAY_DB")
8801 .expect("set KHIVE_REPLAY_DB=/path/to/khive.db to run the corpus replay (read-only)");
8802 let conn = Connection::open_with_flags(
8803 &db_path,
8804 OpenFlags::SQLITE_OPEN_READ_ONLY | OpenFlags::SQLITE_OPEN_NO_MUTEX,
8805 )
8806 .expect("open corpus DB read-only");
8807
8808 let mut total = 0usize;
8809 let mut counts_by_detector: BTreeMap<&'static str, usize> = BTreeMap::new();
8810 let mut hashes_by_detector: BTreeMap<&'static str, Vec<String>> = BTreeMap::new();
8811 let mut max_run_reaching_min_entropy_len = 0usize;
8812
8813 let mut collect = |sql: &str| {
8814 let mut stmt = conn.prepare(sql).expect("prepare replay query");
8815 let mut rows = stmt.query([]).expect("query replay rows");
8816 while let Some(row) = rows.next().expect("read replay row") {
8817 let content: Option<String> = row.get(0).unwrap_or(None);
8818 let Some(content) = content else { continue };
8819 if content.is_empty() {
8820 continue;
8821 }
8822 total += 1;
8823 for token in content.split_whitespace() {
8829 for run in token.split(|c: char| !c.is_ascii_alphanumeric()) {
8830 max_run_reaching_min_entropy_len =
8831 max_run_reaching_min_entropy_len.max(run.len());
8832 }
8833 }
8834 if let Some(m) = scan(&content) {
8835 *counts_by_detector.entry(m.detector).or_insert(0) += 1;
8836 let hash = format!("{:x}", Sha256::digest(content.as_bytes()));
8837 hashes_by_detector.entry(m.detector).or_default().push(hash);
8838 }
8839 }
8840 };
8841
8842 collect("SELECT content FROM notes WHERE deleted_at IS NULL");
8843 collect("SELECT description FROM entities WHERE deleted_at IS NULL");
8844
8845 let blocked: usize = counts_by_detector.values().sum();
8846 println!("total_scanned: {total}");
8847 println!("total_blocked: {blocked}");
8848 println!("longest_alphanumeric_run_in_corpus: {max_run_reaching_min_entropy_len}");
8849 println!("counts_by_detector:");
8850 for (detector, count) in &counts_by_detector {
8851 println!(" {detector}: {count}");
8852 }
8853 println!("blocked_content_sha256_by_detector:");
8854 for (detector, hashes) in &hashes_by_detector {
8855 println!(" {detector}:");
8856 for hash in hashes {
8857 println!(" {hash}");
8858 }
8859 }
8860 }
8861}