1macro_rules! static_regex {
2 ($pattern:expr_2021) => {{
3 static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4 RE.get_or_init(|| {
5 regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6 })
7 }};
8}
9
10pub fn redaction_enabled_for_active_role() -> bool {
11 let role = crate::core::roles::active_role();
12 if role.role.name == "admin" {
13 role.io.redact_outputs
14 } else {
15 true
17 }
18}
19
20pub fn redact_text_if_enabled(input: &str) -> String {
26 if !redaction_enabled_for_active_role() {
27 return input.to_string();
28 }
29 let (out, found) = redact_found(input, config_exclude_patterns().as_slice());
30 let hits = found.len();
31 if hits > 0 {
33 crate::core::hive_signals::note_secrets(&found);
34 }
35 crate::core::security_events::note(
36 crate::core::security_events::SecurityKind::SecretRedacted,
37 hits,
38 );
39 out
40}
41
42fn looks_like_number(v: &str) -> bool {
51 if v.is_empty() {
52 return false;
53 }
54 let s = v.trim_start_matches(['+', '-']);
55 if s.is_empty() {
56 return false;
57 }
58 s.parse::<f64>().is_ok()
60 && s.chars()
61 .all(|c| c.is_ascii_digit() || c == '.' || c == 'e' || c == 'E' || c == '+' || c == '-')
62}
63
64fn looks_like_uuid(value: &str) -> bool {
65 let value = value
66 .trim()
67 .trim_matches(|c| c == '"' || c == '\'' || c == '`' || c == '{' || c == '}');
68 let groups: Vec<&str> = value.split('-').collect();
69 groups.len() == 5
70 && [8, 4, 4, 4, 12].iter().zip(&groups).all(|(len, group)| {
71 group.len() == *len && group.chars().all(|ch| ch.is_ascii_hexdigit())
72 })
73}
74
75fn is_env_reference(v: &str) -> bool {
79 if v.starts_with("os.environ/")
80 || v.starts_with("os.getenv(")
81 || v.starts_with("process.env.")
82 || v.starts_with("System.getenv(")
83 || v.starts_with("ENV[")
84 || v.starts_with("env(")
85 {
86 return true;
87 }
88 if (v.starts_with("${") && v.ends_with('}'))
90 || (v.starts_with('$')
91 && v[1..]
92 .chars()
93 .all(|c| c.is_ascii_alphanumeric() || c == '_'))
94 || (v.starts_with('%') && v.ends_with('%') && v.len() > 2)
95 {
96 return true;
97 }
98 if let Some(prefix) = v.split('.').next() {
100 let pl = prefix.to_ascii_lowercase();
101 if matches!(
102 pl.as_str(),
103 "env" | "inputenv" | "serverenv" | "secrets" | "vars" | "environ"
104 ) && v.contains('.')
105 {
106 return true;
107 }
108 }
109 false
110}
111
112fn is_identifier_reference(value: &str) -> bool {
113 let v = value.trim();
114 if v.is_empty() || v.starts_with('"') || v.starts_with('\'') || v.starts_with('`') {
115 return false;
116 }
117 if is_env_reference(v) {
120 return true;
121 }
122 if v.contains(|c: char| c.is_ascii_digit()) {
123 return false;
124 }
125 v.split('.').all(|segment| {
126 let mut chars = segment.chars();
127 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_' || c == '$')
128 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
129 })
130}
131
132fn is_placeholder_value(value: &str) -> bool {
137 let v = value
138 .trim()
139 .trim_matches(|c| c == '"' || c == '\'' || c == '`')
140 .to_ascii_lowercase();
141 if v.starts_with('<') && v.ends_with('>') {
142 return true;
143 }
144 if !v.is_empty() && v.chars().all(|c| c == '*') {
145 return true;
146 }
147 const MARKERS: &[&str] = &[
148 "change_me",
149 "change-me",
150 "changeme",
151 "example",
152 "placeholder",
153 "your_",
154 "your-",
155 "xxx",
156 "dummy",
157 "sample",
158 "todo",
159 "fixme",
160 "replace_me",
161 "replace-me",
162 "redacted",
163 "...",
164 "…",
165 ];
166 MARKERS.iter().any(|m| v.contains(m))
167}
168
169fn is_non_secret_literal(value: &str) -> bool {
174 let v = value
175 .trim()
176 .trim_matches(|c| c == '"' || c == '\'' || c == '`');
177 if looks_like_number(v) {
180 return true;
181 }
182 if v.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
187 return true;
188 }
189 matches!(
190 v.to_ascii_lowercase().as_str(),
191 "" | "undefined"
192 | "null"
193 | "none"
194 | "nil"
195 | "true"
196 | "false"
197 | "string"
198 | "number"
199 | "boolean"
200 | "bigint"
201 | "symbol"
202 | "object"
203 | "any"
204 | "unknown"
205 | "never"
206 | "void"
207 | "nan"
208 | "date"
209 ) || is_primitive_type_name(v)
210}
211
212fn is_primitive_type_name(value: &str) -> bool {
218 matches!(
219 value,
220 "bool"
221 | "char"
222 | "str"
223 | "usize"
224 | "isize"
225 | "u8"
226 | "u16"
227 | "u32"
228 | "u64"
229 | "u128"
230 | "i8"
231 | "i16"
232 | "i32"
233 | "i64"
234 | "i128"
235 | "f32"
236 | "f64"
237 | "int"
238 | "int8"
239 | "int16"
240 | "int32"
241 | "int64"
242 | "uint"
243 | "uint8"
244 | "uint16"
245 | "uint32"
246 | "uint64"
247 | "uintptr"
248 | "float"
249 | "float32"
250 | "float64"
251 | "double"
252 | "long"
253 | "short"
254 | "byte"
255 | "rune"
256 | "error"
257 | "Self"
258 )
259}
260
261struct Rule {
263 label: &'static str,
264 re: &'static regex::Regex,
265 guard_value: bool,
271}
272
273fn normalize_for_secret_matching(input: &str) -> (String, Vec<usize>) {
277 let mut normalized = String::with_capacity(input.len());
278 let mut original_boundaries = vec![0usize];
279
280 for (start, ch) in input.char_indices() {
281 let end = start + ch.len_utf8();
282 let Some(folded) = fold_secret_match_char(ch) else {
283 if let Some(boundary) = original_boundaries.last_mut() {
284 *boundary = end;
285 }
286 continue;
287 };
288 let folded_len = folded.len_utf8();
289 normalized.push(folded);
290 for byte in 1..=folded_len {
291 original_boundaries.push(if byte == folded_len { end } else { start });
292 }
293 }
294
295 (normalized, original_boundaries)
296}
297
298fn fold_secret_match_char(ch: char) -> Option<char> {
299 if matches!(
300 ch,
301 '\u{200b}' | '\u{200c}' | '\u{200d}' | '\u{2060}' | '\u{feff}'
302 ) {
303 return None;
304 }
305 Some(match ch {
306 '\u{3000}' => ' ',
307 '\u{ff01}'..='\u{ff5e}' => char::from_u32(ch as u32 - 0xfee0).unwrap_or(ch),
308 _ => ch,
309 })
310}
311
312pub(crate) fn normalize_secret_text(input: &str) -> String {
314 input.chars().filter_map(fold_secret_match_char).collect()
315}
316
317const PRIVATE_KEY_KINDS: &str = r"(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----";
320
321fn private_key_marker(end: bool) -> &'static regex::Regex {
322 static HEADER: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
323 static END: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
324 let (cell, word) = if end {
325 (&END, "END")
326 } else {
327 (&HEADER, "BEGIN")
328 };
329 cell.get_or_init(|| {
330 regex::Regex::new(&format!(r"-----{word}\s+{PRIVATE_KEY_KINDS}"))
331 .expect("BUG: invalid private-key marker")
332 })
333}
334
335pub fn ends_inside_private_key_block(text: &str) -> bool {
339 let normalized = normalize_secret_text(text);
340 let last = |end: bool| {
341 private_key_marker(end)
342 .find_iter(&normalized)
343 .last()
344 .map(|found| found.start())
345 };
346 match (last(false), last(true)) {
347 (Some(header), Some(end)) => header > end,
348 (Some(_), None) => true,
349 (None, _) => false,
350 }
351}
352
353pub fn contains_private_key_end(text: &str) -> bool {
356 private_key_marker(true).is_match(&normalize_secret_text(text))
357}
358
359pub(crate) fn bearer_value_is_credential(value: &str) -> bool {
365 let classes = [
366 value.bytes().any(|b| b.is_ascii_uppercase()),
367 value.bytes().any(|b| b.is_ascii_lowercase()),
368 value.bytes().any(|b| b.is_ascii_digit()),
369 ];
370 value.len() >= 16
371 && classes.iter().filter(|present| **present).count() >= 2
372 && !is_placeholder_value(value)
373}
374
375pub(crate) fn is_benign_secret_value(value: &str) -> bool {
379 is_comparison_operator_residue(value)
380 || is_non_secret_literal(value)
381 || is_identifier_reference(value)
382 || is_placeholder_value(value)
383 || looks_like_uuid(value)
384}
385
386pub(crate) fn is_benign_quoted_value(value: &str) -> bool {
391 is_comparison_operator_residue(value)
392 || is_non_secret_literal(value)
393 || is_placeholder_value(value)
394 || looks_like_uuid(value)
395 || (!value.is_empty() && value.bytes().all(|b| b.is_ascii_digit()))
396}
397
398pub(crate) fn is_fact_key_slug(prefix: &str, value: &str) -> bool {
415 matches!(prefix, "\"key\":\"" | "\"key\": \"") && looks_like_word_slug(value)
416}
417
418fn looks_like_word_slug(value: &str) -> bool {
431 let parts: Vec<&str> = value.split('-').collect();
432 let mut words = 0usize;
433 let mut letter_parts = 0usize;
434 let all_parts_ok = parts.iter().all(|part| {
435 if !part.is_empty() && part.len() <= 8 && part.bytes().all(|b| b.is_ascii_digit()) {
436 return true;
437 }
438 if part.len() < 2 || part.len() > 24 || !part.bytes().all(|b| b.is_ascii_lowercase()) {
439 return false;
440 }
441 letter_parts += 1;
442 let word = is_slug_word(part);
443 words += usize::from(word);
444 word || SLUG_ABBREVIATIONS.contains(part)
445 });
446 parts.len() >= 3 && all_parts_ok && words >= 2 && words * 2 >= letter_parts
447}
448
449const SLUG_ABBREVIATIONS: &[&str] = &[
452 "cd", "cdn", "cfg", "cmd", "csv", "ctx", "db", "dns", "gc", "grpc", "http", "js", "jwt", "mcp",
453 "md", "ml", "mr", "msg", "npm", "pdf", "pkg", "png", "pr", "rs", "sql", "src", "ssh", "svg",
454 "tls", "tmp", "ts", "ttl", "xml",
455];
456
457const SLUG_ONSETS: &[&str] = &[
459 "", "b", "c", "d", "f", "g", "h", "j", "k", "l", "m", "n", "p", "q", "r", "s", "t", "v", "w",
460 "x", "z", "bl", "br", "ch", "cl", "cr", "dr", "fl", "fr", "gl", "gr", "ph", "pl", "pr", "sc",
461 "sh", "sk", "sl", "sm", "sn", "sp", "st", "sw", "th", "tr", "tw", "wh", "wr", "chr", "sch",
462 "scr", "shr", "spl", "spr", "str", "thr",
463];
464
465const SLUG_CODAS: &[&str] = &[
467 "bs", "ch", "ck", "cks", "ct", "cts", "ds", "ft", "fts", "ght", "ghts", "gs", "ks", "ld",
468 "lds", "lf", "lk", "lks", "ll", "lls", "lm", "lp", "ls", "lt", "lts", "mp", "mps", "ms", "nch",
469 "nd", "nds", "ng", "ngs", "ngth", "ngths", "nk", "nks", "ns", "nt", "nth", "nths", "nts", "ps",
470 "pt", "pts", "rch", "rd", "rds", "rk", "rks", "rm", "rms", "rn", "rns", "rst", "rt", "rth",
471 "rts", "sh", "sk", "sks", "sp", "ss", "st", "sts", "tch", "th", "ths", "ts", "ws", "x", "xt",
472 "xts",
473];
474
475fn is_slug_word(part: &str) -> bool {
476 let is_vowel = |b: u8| matches!(b, b'a' | b'e' | b'i' | b'o' | b'u' | b'y');
477 let vowels = part.bytes().filter(|b| is_vowel(*b)).count();
478 let mut run = 0usize;
479 let mut longest_run = 0usize;
480 for b in part.bytes() {
481 run = if is_vowel(b) { 0 } else { run + 1 };
482 longest_run = longest_run.max(run);
483 }
484 if vowels * 5 >= part.len() && longest_run <= 4 {
485 return true;
486 }
487 if vowels != 1 || !(5..=9).contains(&part.len()) {
488 return false;
489 }
490 let Some(at) = part.bytes().position(is_vowel) else {
491 return false;
492 };
493 SLUG_ONSETS.contains(&&part[..at]) && SLUG_CODAS.contains(&&part[at + 1..])
494}
495
496fn is_comparison_operator_residue(value: &str) -> bool {
500 let v = value.trim();
501 matches!(v, "=" | "==" | "!=" | "<=" | ">=" | "===" | "!==")
502}
503
504fn redaction_rules() -> Vec<Rule> {
513 vec![
514 Rule {
515 label: "Bearer token",
516 re: static_regex!(r"(?i)(bearer[ \t]+)([a-zA-Z0-9_\-\.]{8,})"),
519 guard_value: false,
520 },
521 Rule {
522 label: "Authorization header",
523 re: static_regex!(
526 r"(?i)(authorization[ \t]*:[ \t]*(?:basic|bearer|token)[ \t]+)([A-Za-z0-9._~+/=-]+)"
527 ),
528 guard_value: false,
529 },
530 Rule {
533 label: "AWS key",
534 re: static_regex!(r"(?:AKIA|ASIA)[0-9A-Z]{16}"),
535 guard_value: false,
536 },
537 Rule {
538 label: "Private key block",
539 re: static_regex!(
546 r"(?s)-----BEGIN\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----(?:.+?-----END\s+(?:RSA\s+|EC\s+|DSA\s+|OPENSSH\s+)?PRIVATE\s+KEY-----|(?-s:[^\n]*)(?:\r?\n[A-Za-z-]+:(?-s:[^\n]*))*(?:\r?\n[A-Za-z0-9+/=]+[ \t]*(?m:$))*)"
547 ),
548 guard_value: false,
549 },
550 Rule {
551 label: "GitHub token",
552 re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9_]{20,}"),
553 guard_value: false,
554 },
555 Rule {
556 label: "GitHub fine-grained token",
557 re: static_regex!(r"(github_pat_)[A-Za-z0-9_]{20,}"),
558 guard_value: false,
559 },
560 Rule {
561 label: "GitLab token",
562 re: static_regex!(r"(glpat-)[A-Za-z0-9_\-]{20,}"),
563 guard_value: false,
564 },
565 Rule {
569 label: "Anthropic API key",
570 re: crate::core::secret_detection::anthropic_key_re(),
571 guard_value: false,
572 },
573 Rule {
574 label: "OpenAI API key",
575 re: crate::core::secret_detection::openai_key_re(),
576 guard_value: false,
577 },
578 Rule {
579 label: "JWT",
580 re: static_regex!(
581 r"eyJ[A-Za-z0-9_\-]{10,}\.[A-Za-z0-9_\-]{10,}(?:\.[A-Za-z0-9_\-]{8,})?"
582 ),
583 guard_value: false,
584 },
585 Rule {
586 label: "Slack token",
587 re: static_regex!(r"(xox[bpas]-)[0-9a-zA-Z\-]{10,}"),
588 guard_value: false,
589 },
590 Rule {
591 label: "Stripe key",
592 re: static_regex!(r"([sr]k_live_)[0-9a-zA-Z]{10,}"),
593 guard_value: false,
594 },
595 Rule {
596 label: "npm token",
597 re: static_regex!(r"(npm_)[A-Za-z0-9]{10,}"),
598 guard_value: false,
599 },
600 Rule {
603 label: "API key param",
604 re: static_regex!(
605 r#"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|access[_-]?token|client[_-]?secret|token|password|passwd|pwd|secret)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([^\s\r\n,;&"']+)"#
606 ),
607 guard_value: true,
608 },
609 Rule {
610 label: "URL-encoded secret",
611 re: static_regex!(
612 r"(?im)((?:^|[^a-z0-9])(?:aws[_-]?secret[_-]?access[_-]?key|secret[_-]?access[_-]?key|api[_-]?key|apikey|access[_-]?key|secret[_-]?key|access[_-]?token|client[_-]?secret|token|password|passwd|pwd|secret)[ \t]*[=:][ \t]*)(?:((?:%[0-9a-f]{2}|[A-Za-z0-9_\-]){20,}))"
613 ),
614 guard_value: true,
615 },
616 Rule {
620 label: "Generic long secret",
621 re: static_regex!(
622 r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)(?:\\?["'])?[ \t]*[=:][ \t]*(?:\\?["'])?)([a-zA-Z0-9+/=\-_]{32,})"#
623 ),
624 guard_value: true,
625 },
626 ]
627}
628
629pub fn redact_text(input: &str) -> String {
630 redact_text_with_excludes(input, &[])
631}
632
633thread_local! {
634 static VERIFIED_SIGNER_KEY: std::cell::RefCell<Option<String>> =
637 const { std::cell::RefCell::new(None) };
638}
639
640pub fn with_verified_signer_key<T>(key: &str, release: impl FnOnce() -> T) -> T {
651 let well_formed = key.len() == 64
652 && key
653 .bytes()
654 .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte));
655 if !well_formed {
656 return release();
657 }
658 struct Restore(Option<String>);
659 impl Drop for Restore {
660 fn drop(&mut self) {
661 VERIFIED_SIGNER_KEY.with(|slot| *slot.borrow_mut() = self.0.take());
662 }
663 }
664 let _restore = Restore(VERIFIED_SIGNER_KEY.with(|slot| slot.replace(Some(key.to_owned()))));
665 release()
666}
667
668fn is_verified_signer_key(before: &str, value: &str) -> bool {
672 (before.ends_with("\"signer_public") || before.ends_with("\"public"))
673 && VERIFIED_SIGNER_KEY.with(|slot| slot.borrow().as_deref() == Some(value))
674}
675
676pub fn redact_text_with_excludes(input: &str, excludes: &[regex::Regex]) -> String {
681 redact_counting(input, excludes).0
682}
683
684pub(crate) fn database_url_re() -> &'static regex::Regex {
689 static_regex!(
690 r"(?i)((?:postgres(?:ql)?|mysql|mongodb(?:\+srv)?|redis)://)([^:/@?#\s]*):([^/?#\s]+)@([^@/?#\s]+)"
691 )
692}
693
694pub(crate) fn database_url_has_credential(caps: ®ex::Captures) -> bool {
700 caps.get(2)
701 .is_none_or(|user| !user.as_str().starts_with("[REDACTED"))
702 && caps
703 .get(3)
704 .is_some_and(|password| !is_placeholder_value(password.as_str()))
705}
706
707fn redact_counting(input: &str, excludes: &[regex::Regex]) -> (String, usize) {
718 let (out, found) = redact_found(input, excludes);
719 (out, found.len())
720}
721
722fn redact_found(input: &str, excludes: &[regex::Regex]) -> (String, Vec<&'static str>) {
724 let (normalized, boundaries) = normalize_for_secret_matching(input);
725 let mut replacements = Vec::new();
726 let mut labels: Vec<&'static str> = Vec::new();
728
729 let url_re = static_regex!(
733 r"(?i)((?:https?|ftps?|sftp|ssh|amqps?|smtps?|ldaps?|wss?|git)://)([^:/@?#\s]+):([^@/?#\s]+)@([^/?#\s]+)"
734 );
735 for (re, label) in [
736 (database_url_re(), "Database credentials"),
737 (url_re, "URL credentials"),
738 ] {
739 for caps in re.captures_iter(&normalized) {
740 let Some(whole) = caps.get(0) else {
741 continue;
742 };
743 let start = boundaries[whole.start()];
744 let end = boundaries[whole.end()];
745 let original = input.get(start..end).unwrap_or(whole.as_str());
746 if excluded(excludes, whole.as_str()) || excluded(excludes, original) {
747 continue;
748 }
749 if !database_url_has_credential(&caps) {
752 continue;
753 }
754 let scheme = caps
755 .get(1)
756 .map_or("", |m| original_span(input, &boundaries, m));
757 let host = caps
758 .get(4)
759 .map_or("", |m| original_span(input, &boundaries, m));
760 replacements.push((start, end, format!("{scheme}[REDACTED:{label}]@{host}")));
761 labels.push(label);
762 }
763 }
764
765 for rule in redaction_rules() {
766 for caps in rule.re.captures_iter(&normalized) {
767 let Some(whole) = caps.get(0) else {
768 continue;
769 };
770 let start = boundaries[whole.start()];
771 let end = boundaries[whole.end()];
772 let original = input.get(start..end).unwrap_or(whole.as_str());
773 if excluded(excludes, whole.as_str()) || excluded(excludes, original) {
774 continue;
775 }
776 if rule.guard_value
779 && let Some(value) = caps.get(2)
780 && if is_quoted_at(&normalized, value.start()) {
781 is_benign_quoted_value(value.as_str())
782 } else {
783 is_benign_secret_value(value.as_str())
784 }
785 {
786 continue;
787 }
788 if rule.label == "Bearer token"
789 && caps
790 .get(2)
791 .is_some_and(|value| !bearer_value_is_credential(value.as_str()))
792 {
793 continue;
794 }
795 if rule.label == "Generic long secret"
796 && caps.get(2).is_some_and(|value| {
797 caps.get(1)
798 .is_some_and(|prefix| is_fact_key_slug(prefix.as_str(), value.as_str()))
799 || is_verified_signer_key(&normalized[..whole.start()], value.as_str())
800 })
801 {
802 continue;
803 }
804 let overlapping: Vec<usize> = replacements
808 .iter()
809 .enumerate()
810 .filter(|(_, (other_start, other_end, _))| start < *other_end && *other_start < end)
811 .map(|(i, _)| i)
812 .collect();
813 if overlapping.iter().any(|&i| {
814 let (other_start, other_end, _) = &replacements[i];
815 *other_start <= start && end <= *other_end
816 }) {
817 continue;
818 }
819 let start = overlapping
820 .iter()
821 .map(|&i| replacements[i].0)
822 .fold(start, usize::min);
823 let end = overlapping
824 .iter()
825 .map(|&i| replacements[i].1)
826 .fold(end, usize::max);
827 for &i in overlapping.iter().rev() {
828 replacements.remove(i);
829 labels.remove(i);
830 }
831
832 let label = rule.label;
833 let replacement = caps.get(1).map_or_else(
834 || format!("[REDACTED:{label}]"),
835 |prefix| {
836 let original_prefix = original_span(input, &boundaries, prefix);
837 format!("{original_prefix}[REDACTED:{label}]")
838 },
839 );
840 replacements.push((start, end, replacement));
841 labels.push(label);
842 }
843 }
844
845 let mut spans: Vec<((usize, usize, String), &'static str)> =
846 replacements.into_iter().zip(labels).collect();
847 spans.sort_by_key(|((start, _, _), _)| *start);
848 let mut found = Vec::with_capacity(spans.len());
849 let mut out = input.to_string();
850 for ((start, end, replacement), label) in spans.into_iter().rev() {
851 found.push(label);
852 out.replace_range(start..end, &replacement);
853 }
854 found.reverse();
855 (out, found)
856}
857
858fn original_span<'a>(input: &'a str, boundaries: &[usize], matched: regex::Match<'_>) -> &'a str {
859 let start = boundaries.get(matched.start()).copied().unwrap_or_default();
860 let end = boundaries.get(matched.end()).copied().unwrap_or(start);
861 input.get(start..end).unwrap_or("")
862}
863
864fn is_quoted_at(text: &str, pos: usize) -> bool {
866 pos > 0 && matches!(text.as_bytes()[pos - 1], b'"' | b'\'')
867}
868
869fn excluded(excludes: &[regex::Regex], value: &str) -> bool {
870 excludes.iter().any(|pattern| pattern.is_match(value))
871}
872
873pub fn config_exclude_patterns() -> std::sync::Arc<Vec<regex::Regex>> {
884 type Cache = Option<(
885 std::sync::Arc<crate::core::config::Config>,
886 std::sync::Arc<Vec<regex::Regex>>,
887 )>;
888 static CACHE: std::sync::Mutex<Cache> = std::sync::Mutex::new(None);
889
890 let cfg = crate::core::config::Config::load_arc();
891
892 if let Ok(guard) = CACHE.lock()
893 && let Some((cached_cfg, patterns)) = &*guard
894 && std::sync::Arc::ptr_eq(cached_cfg, &cfg)
895 {
896 return std::sync::Arc::clone(patterns);
897 }
898
899 let compiled = std::sync::Arc::new(
900 cfg.secret_detection
901 .exclude_patterns
902 .iter()
903 .filter_map(|p| regex::Regex::new(p).ok())
904 .collect::<Vec<_>>(),
905 );
906
907 if let Ok(mut guard) = CACHE.lock() {
908 *guard = Some((cfg, std::sync::Arc::clone(&compiled)));
909 }
910
911 compiled
912}
913
914#[must_use]
923pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
924 let mut out = input.to_string();
925 let mut hits = 0usize;
926 for (label, re) in patterns {
927 let mut local = 0usize;
928 out = re
929 .replace_all(&out, |_caps: ®ex::Captures| {
930 local += 1;
931 format!("[REDACTED:{label}]")
932 })
933 .to_string();
934 hits += local;
935 }
936 (out, hits)
937}
938
939#[cfg(test)]
940mod tests {
941 use super::*;
942
943 #[test]
947 fn a_verified_package_signer_key_is_not_masked() {
948 let key = "ab".repeat(32);
949 let plain = format!(r#"{{"signature":{{"public_key":"{key}"}}}}"#);
950 let nested = serde_json::to_string(&plain).expect("nested");
951 assert!(
952 !redact_text(&plain).contains(&key),
953 "masked without the exemption"
954 );
955 with_verified_signer_key(&key, || {
956 assert_eq!(redact_text(&plain), plain);
957 assert_eq!(redact_text(&nested), nested);
958 let other = plain.replace(&key, &"cd".repeat(32));
959 assert_ne!(redact_text(&other), other, "only the verified key");
960 });
961 }
962
963 #[test]
964 fn standalone_provider_keys_are_fully_masked_in_nested_values() {
965 let suffix = "aB3_".repeat(9);
966 for prefix in ["sk-proj-", "sk-svcacct-", "sk-ant-"] {
967 let secret = format!("{prefix}{suffix}");
968 let raw = serde_json::json!({"nested":[secret.clone()]}).to_string();
969 let safe = redact_text(&raw);
970 assert!(!safe.contains(&suffix), "must mask the entire token");
971 assert!(!safe.contains(prefix), "must not retain the secret prefix");
972 assert!(safe.contains("REDACTED"));
973 assert!(!crate::core::secret_detection::detect_secrets(&secret).is_empty());
974 }
975 assert_eq!(
976 redact_text("sk-proj-your_key_here"),
977 "sk-proj-your_key_here"
978 );
979 }
980
981 #[test]
984 fn config_exclude_patterns_reuses_compiled_regexes_when_config_unchanged() {
985 let _lock = crate::core::data_dir::test_env_lock();
987 let first = config_exclude_patterns();
988 let second = config_exclude_patterns();
989 assert!(
990 std::sync::Arc::ptr_eq(&first, &second),
991 "unchanged config must reuse the cached compiled patterns, not recompile"
992 );
993 }
994
995 #[test]
996 fn redacts_bearer_token() {
997 let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
998 let out = redact_text(s);
999 assert!(out.contains("[REDACTED"));
1000 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
1001 }
1002
1003 #[test]
1004 fn redacts_private_key_block() {
1005 let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
1006 let out = redact_text(s);
1007 assert!(out.contains("[REDACTED"));
1008 assert!(!out.contains("\nabc\n"));
1009 }
1010
1011 #[test]
1015 fn redacts_a_truncated_private_key_but_not_the_code_around_it() {
1016 let s = concat!(
1017 "config:\n-----BEGIN RSA PRIVATE",
1018 " KEY-----\nProc-Type: 4,ENCRYPTED\nMIIEpAIBAAKCAQEA\nqx7Wmore"
1019 );
1020 let out = redact_text(s);
1021 assert!(out.starts_with("config:\n"), "{out}");
1022 assert!(out.contains("[REDACTED"), "{out}");
1023 for leaked in ["RSA PRIVATE", "Proc-Type", "MIIEpAIBAAKCAQEA", "qx7Wmore"] {
1024 assert!(!out.contains(leaked), "{leaked} leaked: {out}");
1025 }
1026
1027 let source = concat!(
1029 "const HEADER: &str = \"-----BEGIN PRIVATE",
1030 " KEY-----\";\nfn parse(pem: &str) -> bool {\n pem.starts_with(HEADER)\n}\n"
1031 );
1032 let out = redact_text(source);
1033 assert!(out.contains("fn parse(pem: &str) -> bool {"), "{out}");
1034 assert!(out.contains("pem.starts_with(HEADER)"), "{out}");
1035 }
1036
1037 #[test]
1038 fn redacts_api_key_param_value() {
1039 let out = redact_text("password=hunter2-super-secret-value");
1040 assert!(
1041 out.contains("password=[REDACTED:API key param]"),
1042 "got: {out}"
1043 );
1044 assert!(!out.contains("hunter2"));
1045 }
1046
1047 #[test]
1050 fn keeps_non_secret_literals() {
1051 for s in [
1052 "password: undefined",
1053 "secret: string",
1054 "token: null",
1055 "apiKey: boolean",
1056 "password = false",
1057 "secret: any",
1058 "let pwd: number = 1",
1059 ] {
1060 assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
1061 }
1062 }
1063
1064 #[test]
1068 fn keeps_type_annotations() {
1069 for s in [
1070 "password: Promise<string>",
1071 "apiKey: Record<string, unknown>",
1072 "token: string[]",
1073 "secret: () => void",
1074 "password: string | undefined",
1075 "credential: { value: string }",
1076 ] {
1077 assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
1078 }
1079 }
1080
1081 #[test]
1086 fn keeps_primitive_type_declarations() {
1087 for s in [
1088 "const ESTIMATED_CHARS_PER_TOKEN: u64 = 4;",
1089 "const MAX_TOKEN: usize = 4;",
1090 " max_token: u32,",
1091 "var Token int64",
1092 "secret: bool = false",
1093 "fn token(&self) -> Self",
1094 "private long token;",
1095 ] {
1096 assert_eq!(redact_text(s), s, "must not redact a type declaration: {s}");
1097 }
1098 let out = redact_text("password: usize4Hunter2");
1099 assert!(
1100 out.contains("[REDACTED"),
1101 "a real value stays masked: {out}"
1102 );
1103 }
1104
1105 #[test]
1108 fn fully_redacts_aws_key() {
1109 let aws_key = concat!("AK", "IAIOSFODNN7EXAMPLE");
1110 let out = redact_text(aws_key);
1111 assert!(!out.contains(aws_key), "AWS key leaked: {out}");
1112 assert!(out.contains("[REDACTED:AWS key]"));
1113 }
1114
1115 #[test]
1116 fn fully_redacts_generic_long_secret() {
1117 let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; let out = redact_text(&format!("credential={secret}"));
1121 assert!(!out.contains(secret), "long secret leaked: {out}");
1122 assert!(
1123 out.contains("credential=[REDACTED:Generic long secret]"),
1124 "got: {out}"
1125 );
1126 }
1127
1128 #[test]
1129 fn redacts_github_token_keeping_prefix() {
1130 let github_token = concat!("gh", "p_", "abcdefghijklmnopqrstuvwxyz0123");
1131 let out = redact_text(github_token);
1132 assert!(
1133 out.starts_with(concat!("gh", "p_", "[REDACTED:GitHub token]")),
1134 "got: {out}"
1135 );
1136 assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
1137 }
1138
1139 #[test]
1144 fn keeps_prose_identifier_after_keyword() {
1145 let s = "the CSRF token: SvelteKit's native origin-check on form actions";
1146 assert_eq!(redact_text(s), s, "prose must survive verbatim");
1147 }
1148
1149 #[test]
1152 fn keeps_identifier_and_property_references() {
1153 for s in [
1154 "superuserPassword: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
1155 "export const getStripeSecretKey = serverEnv.getStripeSecretKey;",
1156 "const apiKey = config.stripeApiKey",
1157 ] {
1158 assert_eq!(redact_text(s), s, "identifier reference redacted: {s}");
1159 }
1160 }
1161
1162 #[test]
1165 fn keeps_long_schema_identifier() {
1166 let s = "endpoint_key: confirmRequiredEndpointKeySchema,";
1167 assert_eq!(redact_text(s), s, "schema identifier must not be redacted");
1168 }
1169
1170 #[test]
1172 fn keeps_placeholder_values() {
1173 for s in [
1174 concat!("GITHUB_FEEDBACK_TOKEN=gh", "p_change_me"),
1175 "API_KEY=your_key_here",
1176 "password=<insert-password>",
1177 "SECRET_KEY=xxxxxxxx",
1178 ] {
1179 assert_eq!(redact_text(s), s, "placeholder redacted: {s}");
1180 }
1181 }
1182
1183 #[test]
1186 fn a_valueless_key_does_not_reach_into_the_next_line() {
1187 for s in [
1188 "+token:\n+ accesstoken: acc",
1189 "token:\n refreshtoken: ref",
1190 "password =\n- old_value",
1191 "Authorization: Bearer\nnext_line_word",
1192 "credential:\n ABCDEFGHIJKLMNOPQRSTUVWXYZabcdef012345",
1193 ] {
1194 assert_eq!(redact_text(s), s, "crossed a line break: {s:?}");
1195 }
1196 let out = redact_text("token:\n password: hunter2-real-value");
1198 assert!(!out.contains("hunter2"), "leaked: {out}");
1199 assert!(
1200 out.starts_with("token:\n password: [REDACTED"),
1201 "got: {out}"
1202 );
1203 }
1204
1205 #[test]
1208 fn keeps_asterisk_masks() {
1209 for s in [
1210 "password: *****",
1211 "password: ***",
1212 r#"expected := []string{"password: *****", "title: My Home"}"#,
1213 "token=*",
1214 ] {
1215 assert_eq!(redact_text(s), s, "mask redacted: {s}");
1216 }
1217 assert!(redact_text("password: ab*cd1234").contains("[REDACTED"));
1219 }
1220
1221 #[test]
1222 fn key_value_redaction_never_crosses_a_line_break() {
1223 let samples = [
1224 concat!("token:", "\n", "refreshtoken: harmless"),
1225 concat!("Authorization: Bearer", "\n", "next_line_word"),
1226 concat!(
1227 "credential:",
1228 "\n ",
1229 "ABCDEFGHIJKLMNOP",
1230 "QRSTUVWXYZabcdef",
1231 "012345"
1232 ),
1233 ];
1234 for s in samples {
1235 assert_eq!(redact_text(s), s, "redaction crossed a line: {s:?}");
1236 }
1237 let same_line_secret = ["token: ", "abc123", "def456", "ghi789"].concat();
1238 let out = redact_text(&same_line_secret);
1239 assert!(
1240 out.contains("[REDACTED:API key param]"),
1241 "same-line secrets must remain redacted: {out}"
1242 );
1243 }
1244
1245 #[test]
1246 fn asterisk_masks_survive_redaction() {
1247 for s in [
1248 "password: *****",
1249 "password: ***",
1250 r#"expected := []string{"password: *****", "title: My Home"}"#,
1251 "token=*",
1252 ] {
1253 assert_eq!(redact_text(s), s, "mask changed: {s}");
1254 }
1255 }
1256
1257 #[test]
1260 fn still_redacts_real_secret_values() {
1261 let out = redact_text("GITHUB_TOKEN=ghpA1b2c3d4e5f6g7h8");
1263 assert!(!out.contains("ghpA1b2c3d4e5f6g7h8"), "leaked: {out}");
1264 let out = redact_text("MY_SECRET=abc123def456ghi789");
1267 assert!(!out.contains("abc123def456ghi789"), "leaked: {out}");
1268 let quoted = "key: 'abcdefghijklmnopqrstuvwxyzabcdef'";
1271 let out = redact_text(quoted);
1272 assert!(
1273 !out.contains("abcdefghijklmnopqrstuvwxyzabcdef"),
1274 "leaked: {out}"
1275 );
1276 }
1277
1278 #[test]
1280 fn exclude_patterns_skip_matching_redactions() {
1281 let excludes = vec![regex::Regex::new(r"LCTX_TEST_\w+").unwrap()];
1282 let input = "token=LCTX_TEST_a1b2c3d4e5";
1283 assert_eq!(
1284 redact_text_with_excludes(input, &excludes),
1285 input,
1286 "excluded match must stay verbatim"
1287 );
1288 assert!(redact_text(input).contains("[REDACTED"));
1290 }
1291
1292 #[test]
1293 fn identifier_and_placeholder_heuristics() {
1294 assert!(is_identifier_reference("serverEnv.getStripeSecretKey"));
1295 assert!(is_identifier_reference("confirmRequiredEndpointKeySchema"));
1296 assert!(is_identifier_reference("$scope._private"));
1297 assert!(!is_identifier_reference("abc123"), "digits → secret-shaped");
1298 assert!(!is_identifier_reference("\"quoted\""), "literal value");
1299 assert!(!is_identifier_reference("a-b"), "dash is not identifier");
1300 assert!(is_placeholder_value(concat!("gh", "p_change_me")));
1301 assert!(is_placeholder_value("<token>"));
1302 assert!(is_placeholder_value("your_api_key_123"));
1303 assert!(!is_placeholder_value("A1b2C3d4E5f6G7h8"));
1304 }
1305
1306 #[test]
1307 fn policy_patterns_redact_with_label_and_count() {
1308 let patterns = vec![(
1309 "employee_id".to_string(),
1310 regex::Regex::new(r"EMP-\d{4}").unwrap(),
1311 )];
1312 let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
1313 assert_eq!(hits, 2);
1314 assert!(!out.contains("EMP-1234"));
1315 assert!(out.contains("[REDACTED:employee_id]"));
1316 }
1317
1318 #[test]
1319 fn policy_patterns_noop_when_no_match() {
1320 let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
1321 let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
1322 assert_eq!(hits, 0);
1323 assert_eq!(out, "nothing sensitive here");
1324 }
1325
1326 #[test]
1329 fn keeps_numeric_values_827() {
1330 for (input, desc) in [
1331 ("input_cost_per_token: 1.4e-06", "scientific notation"),
1332 ("output_cost_per_token: 4.4e-06", "scientific notation"),
1333 (
1334 "cache_read_input_token_cost: 1.9e-07",
1335 "scientific notation",
1336 ),
1337 ("token: 600", "plain integer"),
1338 ("secret: 0.5", "decimal float"),
1339 ("api_key: 42", "small integer"),
1340 ("password: 3.14", "pi float"),
1341 ] {
1342 assert_eq!(
1343 redact_text(input),
1344 input,
1345 "must not redact numeric value ({desc}): {input}"
1346 );
1347 }
1348 }
1349
1350 #[test]
1353 fn keeps_env_references_827() {
1354 for (input, desc) in [
1355 (
1356 "api_key: os.environ/MY_SERVICE_API_KEY",
1357 "Python os.environ/",
1358 ),
1359 ("secret: os.getenv(MY_KEY)", "Python os.getenv()"),
1360 ("token: process.env.API_TOKEN", "Node process.env"),
1361 (
1362 "password: inputEnv.POCKETBASE_SUPERUSER_PASSWORD",
1363 "inputEnv dot ref",
1364 ),
1365 ("api_key: ${MY_API_KEY}", "shell interpolation ${}"),
1366 ("secret: $MY_SECRET", "shell $VAR"),
1367 ("token: %API_TOKEN%", "Windows %VAR%"),
1368 ("api_key: ENV[API_KEY]", "Ruby ENV[]"),
1369 ("secret: env(SECRET_KEY)", "Laravel env()"),
1370 ("password: System.getenv(DB_PASS)", "Java System.getenv"),
1371 ] {
1372 assert_eq!(
1373 redact_text(input),
1374 input,
1375 "must not redact env ref ({desc}): {input}"
1376 );
1377 }
1378 }
1379
1380 #[test]
1382 fn still_redacts_real_secrets_827() {
1383 for input in [
1384 "api_key: sk-1234567890abcdef1234567890abcdef",
1385 "password: hunter2-super-secret-value",
1386 "token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.payload.signature",
1387 ] {
1388 let out = redact_text(input);
1389 assert!(
1390 out.contains("[REDACTED"),
1391 "must redact real secret: {input} -> {out}"
1392 );
1393 }
1394 }
1395
1396 #[test]
1399 fn keeps_comparison_operators_in_source() {
1400 for s in [
1401 r#"if token == "" {"#,
1402 r#"if token, err = checkPulsares(); token == "" || err != nil {"#,
1403 r#"if password != "" {"#,
1404 r"assert secret == expected_value",
1405 ] {
1406 assert_eq!(redact_text(s), s, "comparison operator corrupted: {s}");
1407 }
1408 }
1409
1410 #[test]
1415 fn only_a_word_slug_as_a_fact_key_is_kept() {
1416 for kept in [
1417 r#"{"key":"governor-design-pull-forward-2026-10-29"}"#,
1418 r#"{"key": "hive-desktop-knowledge-speed-round-one"}"#,
1419 r#"{"key":"gotcha-code-reviewer-no-probe-rights"}"#,
1421 r#"{"key":"hive-mr-ci-scripts-strict-backlog-check"}"#,
1422 ] {
1423 assert_eq!(redact_text(kept), kept);
1424 }
1425 let masked_cases = [
1426 r#"{"key":"qhztvbkr-plmnwxsd-rtvbnmkl-zxcvbnml"}"#.to_owned(),
1428 r#"{"key":"deadbeef-cafebabe-facefeed-0123456789"}"#.to_owned(),
1429 format!(r#"{{"key":"my-pin-{}"}}"#, "4".repeat(26)),
1430 r#"{"key":"zxq-vbk-pwm-rtk-qzx-bnm-lkj-word-also"}"#.to_owned(),
1432 r#"{"auth":"governor-design-pull-forward-2026-10-29"}"#.to_owned(),
1434 r#"{"credential":"governor-design-pull-forward-2026-10-29"}"#.to_owned(),
1435 r#"{"encryption_key":"correct-horse-battery-staple-orbit-lamp"}"#.to_owned(),
1436 "SIGNING_KEY=governor-design-pull-forward-2026-10-29".to_owned(),
1437 "MASTER_KEY=governor-design-pull-forward-2026-10-29".to_owned(),
1438 "key: hive-desktop-knowledge-speed-round-one".to_owned(),
1439 ];
1440 for masked in masked_cases {
1441 assert!(redact_text(&masked).contains("[REDACTED"), "{masked}");
1442 }
1443 }
1444
1445 #[test]
1451 fn random_lowercase_keys_under_a_key_member_stay_masked() {
1452 let mut state = 0x9e37_79b9_7f4a_7c15_u64;
1453 let mut letter = || {
1454 state ^= state << 13;
1455 state ^= state >> 7;
1456 state ^= state << 17;
1457 char::from(b'a' + u8::try_from(state % 26).unwrap_or(0))
1458 };
1459 let mut random_key = |groups: usize, size: usize| {
1460 (0..groups)
1461 .map(|_| (0..size).map(|_| letter()).collect::<String>())
1462 .collect::<Vec<_>>()
1463 .join("-")
1464 };
1465 const SAMPLES: usize = 2000;
1466 const MAX_KEPT: usize = SAMPLES * 6 / 100;
1467 for (groups, size) in [(8, 4), (9, 3), (12, 2), (5, 6)] {
1468 let kept = (0..SAMPLES)
1469 .filter(|_| {
1470 let doc = format!(r#"{{"key":"{}"}}"#, random_key(groups, size));
1471 !redact_text(&doc).contains("[REDACTED")
1472 })
1473 .count();
1474 assert!(
1475 kept <= MAX_KEPT,
1476 "{groups}x{size}: {kept} of {SAMPLES} random keys kept"
1477 );
1478 }
1479 for (groups, size) in [(10, 4), (11, 3), (14, 2), (6, 6)] {
1480 let missed = (0..SAMPLES)
1481 .filter(|_| {
1482 let doc = format!(r#"{{"key":"{}"}}"#, random_key(groups, size));
1483 crate::core::secret_detection::detect_secrets(&doc).is_empty()
1484 })
1485 .count();
1486 assert!(
1487 missed <= MAX_KEPT,
1488 "{groups}x{size}: {missed} of {SAMPLES} random keys undetected"
1489 );
1490 }
1491 }
1492}