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 let matched_slice = &buffer[start..end];
276 let replacement = if use_named_placeholder {
277 replacement_for(pattern_name, matched_slice)
278 } else {
279 placeholder.to_string()
280 };
281 buffer.replace_range(start..end, &replacement);
282 }
283 owned = Some(buffer);
284 }
285
286 let result = match owned {
287 Some(value) if value == input => Cow::Borrowed(input),
288 Some(value) => Cow::Owned(value),
289 None => Cow::Borrowed(input),
290 };
291
292 if matches!(result, Cow::Owned(_)) {
293 let events: Vec<RedactionEvent> = audit_events.into_values().collect();
294 emit_audit(&events);
295 }
296
297 result
298}
299
300fn floor_char_boundary(s: &str, mut offset: usize) -> usize {
302 if offset >= s.len() {
303 return s.len();
304 }
305 while offset > 0 && !s.is_char_boundary(offset) {
306 offset -= 1;
307 }
308 offset
309}
310
311fn ceil_char_boundary(s: &str, mut offset: usize) -> usize {
313 if offset >= s.len() {
314 return s.len();
315 }
316 while offset < s.len() && !s.is_char_boundary(offset) {
317 offset += 1;
318 }
319 offset
320}
321
322fn scan_secret_patterns_windowed<'a>(
335 input: &'a str,
336 use_named_placeholder: bool,
337 placeholder: &str,
338) -> Cow<'a, str> {
339 let step = MAX_SCAN_INPUT_BYTES - SCAN_WINDOW_OVERLAP_BYTES;
340 let custom: Vec<NamedPattern> = CUSTOM_PATTERNS.with(|cell| cell.borrow().clone());
341
342 struct Claim {
345 start: usize,
346 end: usize,
347 replacement: String,
348 pattern: &'static str,
349 }
350 let mut claims: Vec<Claim> = Vec::new();
351
352 let all_patterns = compiled_default_secret_patterns()
353 .iter()
354 .map(|pattern| (pattern.spec.redaction_name, &pattern.regex))
355 .chain(custom.iter().map(|pattern| (pattern.name, &pattern.regex)));
356 for (pattern_name, regex) in all_patterns {
357 let mut window_start = 0usize;
358 loop {
359 let ws = floor_char_boundary(input, window_start);
360 let we = ceil_char_boundary(
361 input,
362 (window_start + MAX_SCAN_INPUT_BYTES).min(input.len()),
363 );
364 for m in regex.find_iter(&input[ws..we]) {
365 let gs = ws + m.start();
366 let ge = ws + m.end();
367 if (gs == ws && ws != 0) || (ge == we && we != input.len()) {
370 continue;
371 }
372 if claims.iter().any(|c| gs < c.end && c.start < ge) {
375 continue;
376 }
377 let replacement = if use_named_placeholder {
378 replacement_for(pattern_name, &input[gs..ge])
379 } else {
380 placeholder.to_string()
381 };
382 claims.push(Claim {
383 start: gs,
384 end: ge,
385 replacement,
386 pattern: pattern_name,
387 });
388 }
389 if we >= input.len() {
390 break;
391 }
392 window_start += step;
393 }
394 }
395
396 if claims.is_empty() {
397 return Cow::Borrowed(input);
398 }
399
400 claims.sort_by_key(|c| c.start);
401
402 let mut audit_events: BTreeMap<&'static str, RedactionEvent> = BTreeMap::new();
404 for claim in &claims {
405 let event = audit_events
406 .entry(claim.pattern)
407 .or_insert_with(|| RedactionEvent {
408 pattern_name: claim.pattern.to_string(),
409 match_count: 0,
410 bytes_redacted: 0,
411 });
412 event.match_count += 1;
413 event.bytes_redacted += claim.end - claim.start;
414 }
415
416 let mut out = input.to_string();
417 for claim in claims.iter().rev() {
418 out.replace_range(claim.start..claim.end, &claim.replacement);
419 }
420
421 emit_audit(&audit_events.into_values().collect::<Vec<_>>());
422 Cow::Owned(out)
423}
424
425#[cfg(test)]
426mod tests {
427 use super::*;
428
429 fn run_clean() {
430 clear_custom_patterns();
431 install_audit_sink(None);
432 clear_audit_ring();
433 }
434
435 #[test]
436 fn returns_borrowed_when_clean() {
437 run_clean();
438 let out = scan_secret_patterns("just plain text", crate::redact::REDACTED_PLACEHOLDER);
439 assert!(matches!(out, Cow::Borrowed(_)));
440 }
441
442 #[test]
443 fn replaces_aws_and_github_tokens_with_named_placeholder() {
444 run_clean();
445 let input = "AKIAABCDEFGHIJKLMNOP and ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
446 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
447 let rendered = out.into_owned();
448 assert!(rendered.contains("<redacted:aws_access_key:20>"));
449 assert!(rendered.contains("<redacted:github_token:40>"));
450 assert!(!rendered.contains("AKIAABCDEFGHIJKLMNOP"));
451 }
452
453 #[test]
454 fn legacy_placeholder_path_still_works_for_url_param_values() {
455 run_clean();
456 let input = "AKIAABCDEFGHIJKLMNOP";
457 let out = scan_secret_patterns(input, "%5Bredacted%5D");
460 assert!(out.contains("%5Bredacted%5D"));
461 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
462 }
463
464 #[test]
465 fn replaces_bearer_token_inside_text() {
466 run_clean();
467 let input = "header: Authorization: Bearer abcDEFghi123_-+/=xyz tail";
468 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
469 assert!(out.contains("<redacted:bearer_token:"));
470 assert!(!out.contains("abcDEFghi123_-+/=xyz"));
471 assert!(out.contains("tail"));
472 }
473
474 #[test]
475 fn replaces_sensitive_assignments_inside_text() {
476 run_clean();
477 let input = "retry with token=abc123 and max_tokens=200";
478 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
479 assert!(out.contains("<redacted:sensitive_assignment:"));
480 assert!(!out.contains("token=abc123"));
481 assert!(out.contains("max_tokens=200"));
482 }
483
484 #[test]
485 fn sensitive_assignment_preserves_source_declarations() {
486 run_clean();
487 let input = "pub const Token = struct { kind: u8 };\nconst Secret = enum { a, b };";
488 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
489 assert!(matches!(out, Cow::Borrowed(_)));
490 }
491
492 #[test]
493 fn sensitive_assignment_redacts_placeholder_secret_words() {
494 run_clean();
495 let input = "Checkout incident needed the same query token=secret";
496 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
497 assert!(out.contains("<redacted:sensitive_assignment:"));
498 assert!(!out.contains("token=secret"));
499 }
500
501 #[test]
502 fn replaces_jwt_tokens() {
503 run_clean();
504 let input = "token=eyJabcd.eyJefgh.signature_pad here";
505 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
506 assert!(out.contains("<redacted:jwt:"));
507 assert!(!out.contains("eyJabcd.eyJefgh.signature_pad"));
508 }
509
510 #[test]
511 fn replaces_private_key_blocks() {
512 run_clean();
513 let input =
514 "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret-material\n-----END OPENSSH PRIVATE KEY-----";
515 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
516 assert!(out.contains("<redacted:private_key_block:"));
517 assert!(!out.contains("secret-material"));
518 }
519
520 #[test]
521 fn replaces_ai_provider_tokens() {
522 run_clean();
523 let huggingface = format!("hf_{}", "a".repeat(24));
524 let cerebras = format!("csk-{}", "b".repeat(48));
525 let together = format!("tgp_v1_{}", "c".repeat(32));
526 let google = format!("AIza{}", "D".repeat(35));
527 let input = format!("{huggingface} {cerebras} {together} {google}");
528
529 let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
530 let rendered = out.into_owned();
531
532 assert!(rendered.contains("<redacted:huggingface_token:"));
533 assert!(rendered.contains("<redacted:cerebras_key:"));
534 assert!(rendered.contains("<redacted:together_key:"));
535 assert!(rendered.contains("<redacted:google_api_key:"));
536 assert!(!rendered.contains(&huggingface));
537 assert!(!rendered.contains(&cerebras));
538 assert!(!rendered.contains(&together));
539 assert!(!rendered.contains(&google));
540 }
541
542 #[test]
543 fn custom_pattern_redacts_and_is_introspectable() {
544 run_clean();
545 register_custom_pattern("acme_token", r"\bACME-[A-Z0-9]{8}\b").unwrap();
546 assert_eq!(custom_pattern_names(), vec!["acme_token".to_string()]);
547 let out = scan_secret_patterns(
548 "header ACME-12345678 trailer",
549 crate::redact::REDACTED_PLACEHOLDER,
550 );
551 assert!(
552 out.contains("<redacted:acme_token:13>"),
553 "expected acme_token redaction, got: {out}"
554 );
555 clear_custom_patterns();
556 assert!(custom_pattern_names().is_empty());
557 }
558
559 #[test]
560 fn audit_sink_receives_one_event_per_matching_pattern() {
561 use std::cell::RefCell;
562 use std::rc::Rc;
563 run_clean();
564 let captured: Rc<RefCell<Vec<RedactionEvent>>> = Rc::new(RefCell::new(Vec::new()));
565 let sink_captured = captured.clone();
566 install_audit_sink(Some(Rc::new(move |event| {
567 sink_captured.borrow_mut().push(event.clone());
568 })));
569 let input =
570 "AKIAABCDEFGHIJKLMNOP AKIA0000000000000000 ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
571 let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
572 assert!(matches!(out, Cow::Owned(_)));
573 let events = captured.borrow();
574 assert_eq!(events.len(), 2);
575 let by_name: BTreeMap<&str, &RedactionEvent> = events
576 .iter()
577 .map(|event| (event.pattern_name.as_str(), event))
578 .collect();
579 assert_eq!(by_name.get("aws_access_key").unwrap().match_count, 2);
580 assert_eq!(by_name.get("github_token").unwrap().match_count, 1);
581 drop(events);
585 install_audit_sink(None);
586 let ring = drain_audit_ring();
587 assert_eq!(ring.len(), 2);
588 }
589
590 #[test]
591 fn audit_ring_records_events_even_without_a_sink() {
592 run_clean();
593 let _ = scan_secret_patterns("AKIAABCDEFGHIJKLMNOP", crate::redact::REDACTED_PLACEHOLDER);
594 let ring = drain_audit_ring();
595 assert_eq!(ring.len(), 1);
596 assert_eq!(ring[0].pattern_name, "aws_access_key");
597 assert!(drain_audit_ring().is_empty());
599 }
600
601 const AWS_KEY: &str = "AKIAABCDEFGHIJKLMNOP";
602
603 #[test]
604 fn secret_past_the_scan_cap_is_redacted() {
605 run_clean();
608 let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 4096);
609 input.push_str(AWS_KEY);
610 input.push(' ');
611 assert!(input.len() > MAX_SCAN_INPUT_BYTES);
612 let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
613 assert!(matches!(out, Cow::Owned(_)), "oversized secret must redact");
614 assert!(
615 !out.contains(AWS_KEY),
616 "secret leaked: {}",
617 &out[out.len().saturating_sub(64)..]
618 );
619 assert!(out.contains("<redacted:aws_access_key:20>"));
620 }
621
622 #[test]
623 fn secret_straddling_a_window_boundary_is_redacted() {
624 run_clean();
628 let prefix_len = MAX_SCAN_INPUT_BYTES - (AWS_KEY.len() / 2);
629 let mut input = " ".repeat(prefix_len);
630 input.push_str(AWS_KEY);
631 input.push_str(&" ".repeat(SCAN_WINDOW_OVERLAP_BYTES)); let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
633 assert!(!out.contains(AWS_KEY), "straddling secret leaked");
634 assert!(out.contains("<redacted:aws_access_key:20>"));
635 assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
637 }
638
639 #[test]
640 fn oversized_non_secret_blob_is_not_over_redacted() {
641 run_clean();
644 let blob = "lorem ipsum dolor sit amet ".repeat(MAX_SCAN_INPUT_BYTES / 20);
645 assert!(blob.len() > MAX_SCAN_INPUT_BYTES);
646 let out = scan_secret_patterns(&blob, crate::redact::REDACTED_PLACEHOLDER);
647 assert!(
648 matches!(out, Cow::Borrowed(_)),
649 "clean blob must not be rewritten"
650 );
651 assert_eq!(out.as_ref(), blob);
652 }
653
654 #[test]
655 fn oversized_scan_records_audit_event() {
656 run_clean();
657 let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 100);
658 input.push_str(AWS_KEY);
659 input.push(' ');
660 let _ = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
661 let ring = drain_audit_ring();
662 assert_eq!(ring.len(), 1);
663 assert_eq!(ring[0].pattern_name, "aws_access_key");
664 assert_eq!(ring[0].match_count, 1);
665 assert_eq!(ring[0].bytes_redacted, 20);
666 }
667
668 #[test]
669 fn multi_megabyte_scan_stays_linear_and_redacts() {
670 run_clean();
675 let mut input = "x ".repeat(5 * 1024 * 1024 / 2);
676 input.push_str(AWS_KEY);
677 input.push(' ');
678 let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
679 assert!(!out.contains(AWS_KEY));
680 assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
681 }
682
683 #[test]
684 fn default_pattern_names_are_stable() {
685 let names = default_pattern_names();
686 assert!(names.contains(&"jwt"));
687 assert!(names.contains(&"github_token"));
688 assert!(names.contains(&"github_pat_fine"));
689 assert!(names.contains(&"slack_token"));
690 assert!(names.contains(&"aws_access_key"));
691 assert!(names.contains(&"huggingface_token"));
692 assert!(names.contains(&"cerebras_key"));
693 assert!(names.contains(&"together_key"));
694 assert!(names.contains(&"google_api_key"));
695 assert!(names.contains(&"private_key_block"));
696 assert!(names.contains(&"bearer_token"));
697 assert!(names.contains(&"sensitive_assignment"));
698 }
699}