1use std::borrow::Cow;
30use std::cell::RefCell;
31use std::collections::BTreeMap;
32use std::sync::LazyLock;
33
34use regex::Regex;
35
36use crate::secret_patterns::DEFAULT_SECRET_PATTERN_SPECS;
37
38pub const TOKEN_REDACTION_DIAGNOSTIC: &str = "HARN-OAU-001";
41
42pub const TOKEN_REDACTION_AUDIT_TOPIC: &str = "audit.token_redaction";
44
45const MAX_SCAN_INPUT_BYTES: usize = 256 * 1024;
52
53const SCAN_WINDOW_OVERLAP_BYTES: usize = 8 * 1024;
60
61#[derive(Clone)]
63pub struct NamedPattern {
64 pub name: &'static str,
68 pub regex: Regex,
71}
72
73impl std::fmt::Debug for NamedPattern {
74 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75 f.debug_struct("NamedPattern")
76 .field("name", &self.name)
77 .field("regex", &self.regex.as_str())
78 .finish()
79 }
80}
81
82pub static DEFAULT_PATTERNS: LazyLock<Vec<NamedPattern>> = LazyLock::new(|| {
86 DEFAULT_SECRET_PATTERN_SPECS
87 .iter()
88 .map(|spec| NamedPattern {
89 name: spec.redaction_name,
90 regex: Regex::new(spec.regex).unwrap_or_else(|error| {
91 panic!("invalid {} secret regex: {error}", spec.redaction_name)
92 }),
93 })
94 .collect()
95});
96
97thread_local! {
98 static CUSTOM_PATTERNS: RefCell<Vec<NamedPattern>> = const { RefCell::new(Vec::new()) };
102
103 static AUDIT_SINK: RefCell<Option<AuditSink>> = const { RefCell::new(None) };
111
112 static AUDIT_RING: RefCell<Vec<RedactionEvent>> = const { RefCell::new(Vec::new()) };
118}
119
120#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct RedactionEvent {
123 pub pattern_name: String,
124 pub match_count: usize,
125 pub bytes_redacted: usize,
127}
128
129pub type AuditSink = std::rc::Rc<dyn Fn(&RedactionEvent)>;
132
133pub fn register_custom_pattern(name: impl Into<String>, regex_source: &str) -> Result<(), String> {
138 let regex = Regex::new(regex_source).map_err(|error| format!("invalid regex: {error}"))?;
139 let name_static: &'static str = Box::leak(name.into().into_boxed_str());
145 CUSTOM_PATTERNS.with(|cell| {
146 cell.borrow_mut().push(NamedPattern {
147 name: name_static,
148 regex,
149 });
150 });
151 Ok(())
152}
153
154pub fn clear_custom_patterns() {
157 CUSTOM_PATTERNS.with(|cell| cell.borrow_mut().clear());
158}
159
160pub fn default_pattern_names() -> Vec<&'static str> {
162 DEFAULT_PATTERNS.iter().map(|p| p.name).collect()
163}
164
165pub fn custom_pattern_names() -> Vec<String> {
168 CUSTOM_PATTERNS.with(|cell| cell.borrow().iter().map(|p| p.name.to_string()).collect())
169}
170
171pub fn install_audit_sink(sink: Option<AuditSink>) -> Option<AuditSink> {
174 AUDIT_SINK.with(|cell| std::mem::replace(&mut *cell.borrow_mut(), sink))
175}
176
177fn emit_audit(events: &[RedactionEvent]) {
178 if events.is_empty() {
179 return;
180 }
181 AUDIT_RING.with(|ring| {
186 let mut ring = ring.borrow_mut();
187 for event in events {
188 if ring.len() >= 1024 {
193 ring.remove(0);
194 }
195 ring.push(event.clone());
196 }
197 });
198 let sink = AUDIT_SINK.with(|cell| cell.borrow().clone());
199 if let Some(sink) = sink {
200 for event in events {
201 sink(event);
202 }
203 }
204}
205
206pub fn drain_audit_ring() -> Vec<RedactionEvent> {
209 AUDIT_RING.with(|ring| std::mem::take(&mut *ring.borrow_mut()))
210}
211
212pub fn clear_audit_ring() {
216 AUDIT_RING.with(|ring| ring.borrow_mut().clear());
217}
218
219fn replacement_for(name: &str, matched: &str) -> String {
223 format!("<redacted:{name}:{}>", matched.len())
224}
225
226pub fn scan_secret_patterns<'a>(input: &'a str, placeholder: &str) -> Cow<'a, str> {
238 if input.is_empty() {
239 return Cow::Borrowed(input);
240 }
241 let use_named_placeholder = placeholder == crate::redact::REDACTED_PLACEHOLDER;
242
243 if input.len() > MAX_SCAN_INPUT_BYTES {
250 return scan_secret_patterns_windowed(input, use_named_placeholder, placeholder);
251 }
252
253 let mut owned: Option<String> = None;
254 let mut audit_events: BTreeMap<&'static str, RedactionEvent> = BTreeMap::new();
255
256 let custom: Vec<NamedPattern> = CUSTOM_PATTERNS.with(|cell| cell.borrow().clone());
260 let all_patterns = DEFAULT_PATTERNS.iter().chain(custom.iter());
261
262 for pattern in all_patterns {
263 let target: &str = owned.as_deref().unwrap_or(input);
264 let matches: Vec<(usize, usize)> = pattern
265 .regex
266 .find_iter(target)
267 .map(|m| (m.start(), m.end()))
268 .collect();
269 if matches.is_empty() {
270 continue;
271 }
272 let total_bytes: usize = matches.iter().map(|(s, e)| e - s).sum();
273 audit_events.insert(
274 pattern.name,
275 RedactionEvent {
276 pattern_name: pattern.name.to_string(),
277 match_count: matches.len(),
278 bytes_redacted: total_bytes,
279 },
280 );
281
282 let mut buffer = target.to_string();
285 for (start, end) in matches.into_iter().rev() {
286 let matched_slice = &buffer[start..end];
287 let replacement = if use_named_placeholder {
288 replacement_for(pattern.name, matched_slice)
289 } else {
290 placeholder.to_string()
291 };
292 buffer.replace_range(start..end, &replacement);
293 }
294 owned = Some(buffer);
295 }
296
297 let result = match owned {
298 Some(value) if value == input => Cow::Borrowed(input),
299 Some(value) => Cow::Owned(value),
300 None => Cow::Borrowed(input),
301 };
302
303 if matches!(result, Cow::Owned(_)) {
304 let events: Vec<RedactionEvent> = audit_events.into_values().collect();
305 emit_audit(&events);
306 }
307
308 result
309}
310
311fn floor_char_boundary(s: &str, mut offset: usize) -> usize {
313 if offset >= s.len() {
314 return s.len();
315 }
316 while offset > 0 && !s.is_char_boundary(offset) {
317 offset -= 1;
318 }
319 offset
320}
321
322fn ceil_char_boundary(s: &str, mut offset: usize) -> usize {
324 if offset >= s.len() {
325 return s.len();
326 }
327 while offset < s.len() && !s.is_char_boundary(offset) {
328 offset += 1;
329 }
330 offset
331}
332
333fn scan_secret_patterns_windowed<'a>(
346 input: &'a str,
347 use_named_placeholder: bool,
348 placeholder: &str,
349) -> Cow<'a, str> {
350 let step = MAX_SCAN_INPUT_BYTES - SCAN_WINDOW_OVERLAP_BYTES;
351 let custom: Vec<NamedPattern> = CUSTOM_PATTERNS.with(|cell| cell.borrow().clone());
352
353 struct Claim {
356 start: usize,
357 end: usize,
358 replacement: String,
359 pattern: &'static str,
360 }
361 let mut claims: Vec<Claim> = Vec::new();
362
363 for pattern in DEFAULT_PATTERNS.iter().chain(custom.iter()) {
364 let mut window_start = 0usize;
365 loop {
366 let ws = floor_char_boundary(input, window_start);
367 let we = ceil_char_boundary(
368 input,
369 (window_start + MAX_SCAN_INPUT_BYTES).min(input.len()),
370 );
371 for m in pattern.regex.find_iter(&input[ws..we]) {
372 let gs = ws + m.start();
373 let ge = ws + m.end();
374 if (gs == ws && ws != 0) || (ge == we && we != input.len()) {
377 continue;
378 }
379 if claims.iter().any(|c| gs < c.end && c.start < ge) {
382 continue;
383 }
384 let replacement = if use_named_placeholder {
385 replacement_for(pattern.name, &input[gs..ge])
386 } else {
387 placeholder.to_string()
388 };
389 claims.push(Claim {
390 start: gs,
391 end: ge,
392 replacement,
393 pattern: pattern.name,
394 });
395 }
396 if we >= input.len() {
397 break;
398 }
399 window_start += step;
400 }
401 }
402
403 if claims.is_empty() {
404 return Cow::Borrowed(input);
405 }
406
407 claims.sort_by_key(|c| c.start);
408
409 let mut audit_events: BTreeMap<&'static str, RedactionEvent> = BTreeMap::new();
411 for claim in &claims {
412 let event = audit_events
413 .entry(claim.pattern)
414 .or_insert_with(|| RedactionEvent {
415 pattern_name: claim.pattern.to_string(),
416 match_count: 0,
417 bytes_redacted: 0,
418 });
419 event.match_count += 1;
420 event.bytes_redacted += claim.end - claim.start;
421 }
422
423 let mut out = input.to_string();
424 for claim in claims.iter().rev() {
425 out.replace_range(claim.start..claim.end, &claim.replacement);
426 }
427
428 emit_audit(&audit_events.into_values().collect::<Vec<_>>());
429 Cow::Owned(out)
430}
431
432#[cfg(test)]
433mod tests {
434 use super::*;
435
436 fn run_clean() {
437 clear_custom_patterns();
438 install_audit_sink(None);
439 clear_audit_ring();
440 }
441
442 #[test]
443 fn returns_borrowed_when_clean() {
444 run_clean();
445 let out = scan_secret_patterns("just plain text", crate::redact::REDACTED_PLACEHOLDER);
446 assert!(matches!(out, Cow::Borrowed(_)));
447 }
448
449 #[test]
450 fn replaces_aws_and_github_tokens_with_named_placeholder() {
451 run_clean();
452 let input = "AKIAABCDEFGHIJKLMNOP and ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
453 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
454 let rendered = out.into_owned();
455 assert!(rendered.contains("<redacted:aws_access_key:20>"));
456 assert!(rendered.contains("<redacted:github_token:40>"));
457 assert!(!rendered.contains("AKIAABCDEFGHIJKLMNOP"));
458 }
459
460 #[test]
461 fn legacy_placeholder_path_still_works_for_url_param_values() {
462 run_clean();
463 let input = "AKIAABCDEFGHIJKLMNOP";
464 let out = scan_secret_patterns(input, "%5Bredacted%5D");
467 assert!(out.contains("%5Bredacted%5D"));
468 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
469 }
470
471 #[test]
472 fn replaces_bearer_token_inside_text() {
473 run_clean();
474 let input = "header: Authorization: Bearer abcDEFghi123_-+/=xyz tail";
475 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
476 assert!(out.contains("<redacted:bearer_token:"));
477 assert!(!out.contains("abcDEFghi123_-+/=xyz"));
478 assert!(out.contains("tail"));
479 }
480
481 #[test]
482 fn replaces_sensitive_assignments_inside_text() {
483 run_clean();
484 let input = "retry with token=abc123 and max_tokens=200";
485 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
486 assert!(out.contains("<redacted:sensitive_assignment:"));
487 assert!(!out.contains("token=abc123"));
488 assert!(out.contains("max_tokens=200"));
489 }
490
491 #[test]
492 fn sensitive_assignment_preserves_source_declarations() {
493 run_clean();
494 let input = "pub const Token = struct { kind: u8 };\nconst Secret = enum { a, b };";
495 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
496 assert!(matches!(out, Cow::Borrowed(_)));
497 }
498
499 #[test]
500 fn sensitive_assignment_redacts_placeholder_secret_words() {
501 run_clean();
502 let input = "Checkout incident needed the same query token=secret";
503 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
504 assert!(out.contains("<redacted:sensitive_assignment:"));
505 assert!(!out.contains("token=secret"));
506 }
507
508 #[test]
509 fn replaces_jwt_tokens() {
510 run_clean();
511 let input = "token=eyJabcd.eyJefgh.signature_pad here";
512 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
513 assert!(out.contains("<redacted:jwt:"));
514 assert!(!out.contains("eyJabcd.eyJefgh.signature_pad"));
515 }
516
517 #[test]
518 fn replaces_private_key_blocks() {
519 run_clean();
520 let input =
521 "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret-material\n-----END OPENSSH PRIVATE KEY-----";
522 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
523 assert!(out.contains("<redacted:private_key_block:"));
524 assert!(!out.contains("secret-material"));
525 }
526
527 #[test]
528 fn replaces_ai_provider_tokens() {
529 run_clean();
530 let huggingface = format!("hf_{}", "a".repeat(24));
531 let cerebras = format!("csk-{}", "b".repeat(48));
532 let together = format!("tgp_v1_{}", "c".repeat(32));
533 let google = format!("AIza{}", "D".repeat(35));
534 let input = format!("{huggingface} {cerebras} {together} {google}");
535
536 let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
537 let rendered = out.into_owned();
538
539 assert!(rendered.contains("<redacted:huggingface_token:"));
540 assert!(rendered.contains("<redacted:cerebras_key:"));
541 assert!(rendered.contains("<redacted:together_key:"));
542 assert!(rendered.contains("<redacted:google_api_key:"));
543 assert!(!rendered.contains(&huggingface));
544 assert!(!rendered.contains(&cerebras));
545 assert!(!rendered.contains(&together));
546 assert!(!rendered.contains(&google));
547 }
548
549 #[test]
550 fn custom_pattern_redacts_and_is_introspectable() {
551 run_clean();
552 register_custom_pattern("acme_token", r"\bACME-[A-Z0-9]{8}\b").unwrap();
553 assert_eq!(custom_pattern_names(), vec!["acme_token".to_string()]);
554 let out = scan_secret_patterns(
555 "header ACME-12345678 trailer",
556 crate::redact::REDACTED_PLACEHOLDER,
557 );
558 assert!(
559 out.contains("<redacted:acme_token:13>"),
560 "expected acme_token redaction, got: {out}"
561 );
562 clear_custom_patterns();
563 assert!(custom_pattern_names().is_empty());
564 }
565
566 #[test]
567 fn audit_sink_receives_one_event_per_matching_pattern() {
568 use std::cell::RefCell;
569 use std::rc::Rc;
570 run_clean();
571 let captured: Rc<RefCell<Vec<RedactionEvent>>> = Rc::new(RefCell::new(Vec::new()));
572 let sink_captured = captured.clone();
573 install_audit_sink(Some(Rc::new(move |event| {
574 sink_captured.borrow_mut().push(event.clone());
575 })));
576 let input =
577 "AKIAABCDEFGHIJKLMNOP AKIA0000000000000000 ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
578 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
579 assert!(matches!(out, Cow::Owned(_)));
580 let events = captured.borrow();
581 assert_eq!(events.len(), 2);
582 let by_name: BTreeMap<&str, &RedactionEvent> = events
583 .iter()
584 .map(|event| (event.pattern_name.as_str(), event))
585 .collect();
586 assert_eq!(by_name.get("aws_access_key").unwrap().match_count, 2);
587 assert_eq!(by_name.get("github_token").unwrap().match_count, 1);
588 drop(events);
592 install_audit_sink(None);
593 let ring = drain_audit_ring();
594 assert_eq!(ring.len(), 2);
595 }
596
597 #[test]
598 fn audit_ring_records_events_even_without_a_sink() {
599 run_clean();
600 let _ = scan_secret_patterns("AKIAABCDEFGHIJKLMNOP", crate::redact::REDACTED_PLACEHOLDER);
601 let ring = drain_audit_ring();
602 assert_eq!(ring.len(), 1);
603 assert_eq!(ring[0].pattern_name, "aws_access_key");
604 assert!(drain_audit_ring().is_empty());
606 }
607
608 const AWS_KEY: &str = "AKIAABCDEFGHIJKLMNOP";
609
610 #[test]
611 fn secret_past_the_scan_cap_is_redacted() {
612 run_clean();
615 let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 4096);
616 input.push_str(AWS_KEY);
617 input.push(' ');
618 assert!(input.len() > MAX_SCAN_INPUT_BYTES);
619 let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
620 assert!(matches!(out, Cow::Owned(_)), "oversized secret must redact");
621 assert!(
622 !out.contains(AWS_KEY),
623 "secret leaked: {}",
624 &out[out.len().saturating_sub(64)..]
625 );
626 assert!(out.contains("<redacted:aws_access_key:20>"));
627 }
628
629 #[test]
630 fn secret_straddling_a_window_boundary_is_redacted() {
631 run_clean();
635 let prefix_len = MAX_SCAN_INPUT_BYTES - (AWS_KEY.len() / 2);
636 let mut input = " ".repeat(prefix_len);
637 input.push_str(AWS_KEY);
638 input.push_str(&" ".repeat(SCAN_WINDOW_OVERLAP_BYTES)); let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
640 assert!(!out.contains(AWS_KEY), "straddling secret leaked");
641 assert!(out.contains("<redacted:aws_access_key:20>"));
642 assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
644 }
645
646 #[test]
647 fn oversized_non_secret_blob_is_not_over_redacted() {
648 run_clean();
651 let blob = "lorem ipsum dolor sit amet ".repeat(MAX_SCAN_INPUT_BYTES / 20);
652 assert!(blob.len() > MAX_SCAN_INPUT_BYTES);
653 let out = scan_secret_patterns(&blob, crate::redact::REDACTED_PLACEHOLDER);
654 assert!(
655 matches!(out, Cow::Borrowed(_)),
656 "clean blob must not be rewritten"
657 );
658 assert_eq!(out.as_ref(), blob);
659 }
660
661 #[test]
662 fn oversized_scan_records_audit_event() {
663 run_clean();
664 let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 100);
665 input.push_str(AWS_KEY);
666 input.push(' ');
667 let _ = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
668 let ring = drain_audit_ring();
669 assert_eq!(ring.len(), 1);
670 assert_eq!(ring[0].pattern_name, "aws_access_key");
671 assert_eq!(ring[0].match_count, 1);
672 assert_eq!(ring[0].bytes_redacted, 20);
673 }
674
675 #[test]
676 fn multi_megabyte_scan_stays_linear_and_redacts() {
677 run_clean();
682 let mut input = "x ".repeat(5 * 1024 * 1024 / 2);
683 input.push_str(AWS_KEY);
684 input.push(' ');
685 let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
686 assert!(!out.contains(AWS_KEY));
687 assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
688 }
689
690 #[test]
691 fn default_pattern_names_are_stable() {
692 let names = default_pattern_names();
693 assert!(names.contains(&"jwt"));
694 assert!(names.contains(&"github_token"));
695 assert!(names.contains(&"github_pat_fine"));
696 assert!(names.contains(&"slack_token"));
697 assert!(names.contains(&"aws_access_key"));
698 assert!(names.contains(&"huggingface_token"));
699 assert!(names.contains(&"cerebras_key"));
700 assert!(names.contains(&"together_key"));
701 assert!(names.contains(&"google_api_key"));
702 assert!(names.contains(&"private_key_block"));
703 assert!(names.contains(&"bearer_token"));
704 assert!(names.contains(&"sensitive_assignment"));
705 }
706}