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