1mod manifest;
33mod patterns;
34
35use std::borrow::Cow;
36use std::cell::RefCell;
37use std::collections::{BTreeMap, BTreeSet};
38
39use serde_json::Value as JsonValue;
40use url::Url;
41
42pub(crate) use manifest::json_path_child;
43pub use manifest::{RedactionEntry, UnredactedSecret};
44pub use patterns::{
45 clear_audit_ring, clear_custom_patterns, custom_pattern_names, default_pattern_names,
46 drain_audit_ring, install_audit_sink, register_custom_pattern, scan_secret_patterns, AuditSink,
47 NamedPattern, RedactionEvent, TOKEN_REDACTION_AUDIT_TOPIC, TOKEN_REDACTION_DIAGNOSTIC,
48};
49
50pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
54
55pub const REDACTED_HEADER_VALUE: &str = REDACTED_PLACEHOLDER;
59
60#[derive(Clone, Debug, PartialEq, Eq)]
61pub struct RedactionPolicy {
62 safe_headers: BTreeSet<String>,
63 deny_header_substrings: BTreeSet<String>,
64 extra_deny_header_substrings: BTreeSet<String>,
65 extra_field_names: BTreeSet<String>,
66 extra_url_params: BTreeSet<String>,
67 scan_strings: bool,
68 redact_url_userinfo: bool,
69}
70
71impl Default for RedactionPolicy {
72 fn default() -> Self {
73 Self {
74 safe_headers: default_safe_headers(),
75 deny_header_substrings: default_deny_header_substrings(),
76 extra_deny_header_substrings: BTreeSet::new(),
77 extra_field_names: BTreeSet::new(),
78 extra_url_params: BTreeSet::new(),
79 scan_strings: true,
80 redact_url_userinfo: true,
81 }
82 }
83}
84
85impl RedactionPolicy {
86 pub fn passthrough() -> Self {
89 Self {
90 safe_headers: BTreeSet::new(),
91 deny_header_substrings: BTreeSet::new(),
92 extra_deny_header_substrings: BTreeSet::new(),
93 extra_field_names: BTreeSet::new(),
94 extra_url_params: BTreeSet::new(),
95 scan_strings: false,
96 redact_url_userinfo: false,
97 }
98 }
99
100 pub fn with_safe_header(mut self, name: impl Into<String>) -> Self {
105 self.safe_headers.insert(name.into().to_ascii_lowercase());
106 self
107 }
108
109 pub fn with_deny_header_substring(mut self, fragment: impl Into<String>) -> Self {
114 self.extra_deny_header_substrings
115 .insert(fragment.into().to_ascii_lowercase());
116 self
117 }
118
119 pub fn with_extra_field(mut self, name: impl Into<String>) -> Self {
123 self.extra_field_names
124 .insert(name.into().to_ascii_lowercase());
125 self
126 }
127
128 pub fn with_extra_url_param(mut self, name: impl Into<String>) -> Self {
130 self.extra_url_params
131 .insert(name.into().to_ascii_lowercase());
132 self
133 }
134
135 pub fn disable_string_scan(mut self) -> Self {
139 self.scan_strings = false;
140 self
141 }
142
143 fn header_is_safe(&self, lower_name: &str) -> bool {
144 if self.safe_headers.contains(lower_name) {
148 return true;
149 }
150 lower_name.ends_with("-event")
151 || lower_name.ends_with("-delivery")
152 || lower_name.contains("timestamp")
153 || lower_name.contains("request-id")
154 }
155
156 pub fn header_is_sensitive(&self, name: &str) -> bool {
164 let lower = name.to_ascii_lowercase();
165 if self
166 .extra_deny_header_substrings
167 .iter()
168 .any(|fragment| lower.contains(fragment))
169 {
170 return true;
171 }
172 if self.header_is_safe(&lower) {
173 return false;
174 }
175 self.deny_header_substrings
176 .iter()
177 .any(|fragment| lower.contains(fragment))
178 }
179
180 pub fn field_is_sensitive(&self, name: &str) -> bool {
183 let lower = name.to_ascii_lowercase();
184 if self.extra_field_names.contains(&lower) {
185 return true;
186 }
187 is_default_sensitive_field(&lower)
188 }
189
190 pub fn url_param_is_sensitive(&self, name: &str) -> bool {
193 let lower = name.to_ascii_lowercase();
194 if self.extra_url_params.contains(&lower) {
195 return true;
196 }
197 is_default_sensitive_url_param(&lower)
198 }
199
200 pub fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
203 headers
204 .iter()
205 .map(|(name, value)| {
206 if self.header_is_sensitive(name) {
207 (name.clone(), REDACTED_HEADER_VALUE.to_string())
208 } else {
209 (name.clone(), value.clone())
210 }
211 })
212 .collect()
213 }
214
215 pub fn redact_url(&self, url: &str) -> String {
219 let Ok(mut parsed) = Url::parse(url) else {
220 return self.redact_string(url).into_owned();
221 };
222 let mut changed = false;
223
224 if self.redact_url_userinfo
225 && (!parsed.username().is_empty() || parsed.password().is_some())
226 {
227 if parsed.set_username("").is_ok() {
230 changed = true;
231 }
232 if parsed.set_password(None).is_ok() {
233 changed = true;
234 }
235 }
236
237 let pairs: Vec<(String, String)> = parsed
238 .query_pairs()
239 .map(|(key, value)| {
240 if self.url_param_is_sensitive(&key) {
241 changed = true;
242 (key.into_owned(), REDACTED_PLACEHOLDER.to_string())
243 } else {
244 (key.into_owned(), value.into_owned())
245 }
246 })
247 .collect();
248 let original_query = parsed.query().map(str::to_string);
249 if !pairs.is_empty() {
250 parsed.set_query(None);
251 let mut query = parsed.query_pairs_mut();
252 for (key, value) in &pairs {
253 query.append_pair(key, value);
254 }
255 }
256 if !changed {
260 parsed.set_query(original_query.as_deref());
261 return parsed.to_string();
262 }
263 parsed.to_string()
264 }
265
266 pub fn redact_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
271 if !self.scan_strings {
272 return Cow::Borrowed(value);
273 }
274 match self.redact_url_in_string(value) {
275 Cow::Borrowed(_) => scan_secret_patterns(value, REDACTED_PLACEHOLDER),
276 Cow::Owned(url_scrubbed) => {
277 let pattern_scrubbed =
278 scan_secret_patterns(&url_scrubbed, REDACTED_PLACEHOLDER).into_owned();
279 Cow::Owned(pattern_scrubbed)
280 }
281 }
282 }
283
284 pub fn redact_urls_in_text<'a>(&self, value: &'a str) -> Cow<'a, str> {
290 let mut scan_cursor = 0;
291 let mut emit_cursor = 0;
292 let mut output: Option<String> = None;
293
294 while let Some(relative_start) = find_http_url_start(&value[scan_cursor..]) {
295 let start = scan_cursor + relative_start;
296 let token_end = http_url_token_end(value, start);
297 let token = &value[start..token_end];
298 let Some((url, suffix)) = split_url_token(token) else {
299 scan_cursor = token_end;
300 continue;
301 };
302 let redacted = self.redact_url(url);
303 if redacted != url {
304 let output = output.get_or_insert_with(|| String::with_capacity(value.len()));
305 output.push_str(&value[emit_cursor..start]);
306 output.push_str(&redacted);
307 output.push_str(suffix);
308 emit_cursor = token_end;
309 }
310 scan_cursor = token_end;
311 }
312
313 match output {
314 Some(mut output) => {
315 output.push_str(&value[emit_cursor..]);
316 Cow::Owned(output)
317 }
318 None => Cow::Borrowed(value),
319 }
320 }
321
322 pub fn looks_like_secret_value(&self, value: &str) -> bool {
331 let trimmed = value.trim();
332 !trimmed.is_empty()
333 && (self.redact_string(trimmed).as_ref() != trimmed
334 || has_secret_prefix(trimmed)
335 || is_long_bare_secret_candidate(trimmed))
336 }
337
338 fn redact_url_in_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
343 if !self.redact_url_userinfo
344 || !(value.starts_with("http://") || value.starts_with("https://"))
345 {
346 return Cow::Borrowed(value);
347 }
348 let trimmed = value.trim();
349 if trimmed.contains(char::is_whitespace) {
350 return Cow::Borrowed(value);
351 }
352 let redacted = self.redact_url(trimmed);
353 if redacted == trimmed {
354 Cow::Borrowed(value)
355 } else {
356 Cow::Owned(redacted)
357 }
358 }
359
360 pub fn redact_json_in_place(&self, value: &mut JsonValue) {
363 match value {
364 JsonValue::Object(map) => {
365 let mut keys_to_redact: Vec<String> = Vec::new();
366 for (key, child) in map.iter_mut() {
367 if self.field_is_sensitive(key) {
368 keys_to_redact.push(key.clone());
369 } else {
370 self.redact_json_in_place(child);
371 }
372 }
373 for key in keys_to_redact {
374 map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
375 }
376 }
377 JsonValue::Array(items) => {
378 for item in items.iter_mut() {
379 self.redact_json_in_place(item);
380 }
381 }
382 JsonValue::String(s) => {
383 let redacted = self.redact_string(s);
384 if let Cow::Owned(replacement) = redacted {
385 *s = replacement;
386 }
387 }
388 _ => {}
389 }
390 }
391
392 pub fn redact_json(&self, value: &JsonValue) -> JsonValue {
395 let mut clone = value.clone();
396 self.redact_json_in_place(&mut clone);
397 clone
398 }
399}
400
401fn find_http_url_start(value: &str) -> Option<usize> {
402 match (value.find("http://"), value.find("https://")) {
403 (Some(http), Some(https)) => Some(http.min(https)),
404 (Some(http), None) => Some(http),
405 (None, Some(https)) => Some(https),
406 (None, None) => None,
407 }
408}
409
410fn http_url_token_end(value: &str, start: usize) -> usize {
411 value[start..]
412 .char_indices()
413 .find_map(|(offset, character)| {
414 (offset > 0 && is_url_text_delimiter(character)).then_some(start + offset)
415 })
416 .unwrap_or(value.len())
417}
418
419fn is_url_text_delimiter(character: char) -> bool {
420 character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '`')
421}
422
423fn split_url_token(token: &str) -> Option<(&str, &str)> {
424 let mut prose_end = token.len();
425 while prose_end > 0 {
426 let candidate = &token[..prose_end];
427 let last = candidate.chars().last()?;
428 if !is_trailing_prose_punctuation(last) {
429 break;
430 }
431 prose_end -= last.len_utf8();
432 }
433 if prose_end > 0 {
434 let candidate = &token[..prose_end];
435 if Url::parse(candidate).is_ok() {
436 return Some((candidate, &token[prose_end..]));
437 }
438 }
439
440 let mut end = token.len();
441 while end > 0 {
442 let candidate = &token[..end];
443 if Url::parse(candidate).is_ok() {
444 return Some((candidate, &token[end..]));
445 }
446 let last = candidate.chars().last()?;
447 if !is_trailing_prose_punctuation(last) {
448 return None;
449 }
450 end -= last.len_utf8();
451 }
452 None
453}
454
455fn is_trailing_prose_punctuation(character: char) -> bool {
456 matches!(
457 character,
458 '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'
459 )
460}
461
462fn default_safe_headers() -> BTreeSet<String> {
463 BTreeSet::from([
464 "content-length".to_string(),
465 "content-type".to_string(),
466 "request-id".to_string(),
467 "user-agent".to_string(),
468 "x-a2a-delivery".to_string(),
469 "x-correlation-id".to_string(),
470 "x-github-delivery".to_string(),
471 "x-github-event".to_string(),
472 "x-github-hook-id".to_string(),
473 "x-request-id".to_string(),
474 "x-slack-request-timestamp".to_string(),
475 ])
476}
477
478fn default_deny_header_substrings() -> BTreeSet<String> {
479 BTreeSet::from([
480 "authorization".to_string(),
481 "cookie".to_string(),
482 "secret".to_string(),
483 "signature".to_string(),
484 "token".to_string(),
485 "key".to_string(),
486 ])
487}
488
489fn is_default_sensitive_url_param(lower: &str) -> bool {
490 let compact = compact_secret_name(lower);
491 matches!(
492 compact.as_str(),
493 "apikey"
494 | "accesstoken"
495 | "refreshtoken"
496 | "idtoken"
497 | "clientsecret"
498 | "password"
499 | "secret"
500 | "token"
501 | "auth"
502 | "bearer"
503 | "sig"
504 | "signature"
505 ) || compact.ends_with("token")
506 || compact.ends_with("secret")
507 || compact.ends_with("password")
508}
509
510fn is_default_sensitive_field(lower: &str) -> bool {
511 let compact = compact_secret_name(lower);
512 matches!(
513 compact.as_str(),
514 "authorization"
515 | "proxyauthorization"
516 | "cookie"
517 | "setcookie"
518 | "apikey"
519 | "xamzsecuritytoken"
520 | "xapikey"
521 | "xauthtoken"
522 | "xcsrftoken"
523 | "xxsrftoken"
524 | "accesstoken"
525 | "refreshtoken"
526 | "idtoken"
527 | "bearertoken"
528 | "clientsecret"
529 | "password"
530 | "secret"
531 | "passwd"
532 | "privatekey"
533 | "sessiontoken"
534 ) || compact.ends_with("token")
535 || compact.ends_with("secret")
536 || compact.ends_with("password")
537 || compact.ends_with("apikey")
538}
539
540fn compact_secret_name(lower: &str) -> String {
541 lower
542 .chars()
543 .filter(|ch| *ch != '_' && *ch != '-')
544 .collect()
545}
546
547fn has_secret_prefix(trimmed: &str) -> bool {
548 trimmed.starts_with("sk-")
549 || trimmed.starts_with("ghp_")
550 || trimmed.starts_with("ghs_")
551 || trimmed.starts_with("xoxb-")
552 || trimmed.starts_with("xoxp-")
553 || trimmed.starts_with("AKIA")
554}
555
556fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
557 trimmed.len() > 48
558 && trimmed
559 .chars()
560 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
561}
562
563thread_local! {
564 static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
565}
566
567pub fn push_policy(policy: RedactionPolicy) {
570 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
571}
572
573pub fn pop_policy() {
576 REDACTION_POLICY_STACK.with(|stack| {
577 stack.borrow_mut().pop();
578 });
579}
580
581pub fn clear_policy_stack() {
586 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
587 patterns::clear_custom_patterns();
588 let _ = patterns::install_audit_sink(None);
589 patterns::clear_audit_ring();
590}
591
592pub fn current_policy() -> RedactionPolicy {
596 REDACTION_POLICY_STACK.with(|stack| {
597 stack
598 .borrow()
599 .last()
600 .cloned()
601 .unwrap_or_else(RedactionPolicy::default)
602 })
603}
604
605pub struct PolicyGuard;
612
613impl PolicyGuard {
614 pub fn new(policy: RedactionPolicy) -> Self {
615 push_policy(policy);
616 Self
617 }
618}
619
620impl Drop for PolicyGuard {
621 fn drop(&mut self) {
622 pop_policy();
623 }
624}
625
626#[cfg(test)]
627mod tests {
628 use super::*;
629 use serde_json::json;
630
631 fn sample_headers() -> BTreeMap<String, String> {
632 BTreeMap::from([
633 ("Authorization".to_string(), "Bearer secret123".to_string()),
634 ("Cookie".to_string(), "session=abc".to_string()),
635 ("Content-Type".to_string(), "application/json".to_string()),
636 ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
637 (
638 "X-Slack-Signature".to_string(),
639 "v0=abcdef123456".to_string(),
640 ),
641 ("User-Agent".to_string(), "Harn/1.0".to_string()),
642 ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
643 ])
644 }
645
646 #[test]
647 fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
648 let policy = RedactionPolicy::default();
649 let redacted = policy.redact_headers(&sample_headers());
650 assert_eq!(
651 redacted.get("Authorization").unwrap(),
652 REDACTED_HEADER_VALUE
653 );
654 assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
655 assert_eq!(
656 redacted.get("X-Webhook-Token").unwrap(),
657 REDACTED_HEADER_VALUE
658 );
659 assert_eq!(
660 redacted.get("X-Slack-Signature").unwrap(),
661 REDACTED_HEADER_VALUE
662 );
663 assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
664 assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
665 assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
666 }
667
668 #[test]
669 fn passthrough_policy_redacts_nothing() {
670 let policy = RedactionPolicy::passthrough();
671 let redacted = policy.redact_headers(&sample_headers());
672 assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
673 }
674
675 #[test]
676 fn host_can_extend_safe_and_deny_headers() {
677 let policy = RedactionPolicy::default()
678 .with_safe_header("X-Webhook-Token")
679 .with_deny_header_substring("delivery");
680 let redacted = policy.redact_headers(&sample_headers());
681 assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
682 assert_eq!(
683 redacted.get("X-GitHub-Delivery").unwrap(),
684 REDACTED_HEADER_VALUE,
685 "host explicitly forced delivery to be sensitive"
686 );
687 }
688
689 #[test]
690 fn redact_url_strips_userinfo_and_sensitive_query_params() {
691 let policy = RedactionPolicy::default();
692 let redacted = policy.redact_url(
693 "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
694 );
695 assert!(redacted.contains("api_key=%5Bredacted%5D"));
696 assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
697 assert!(redacted.contains("page=2"));
698 assert!(!redacted.contains("user:pw@"));
699 }
700
701 #[test]
702 fn redact_url_leaves_clean_urls_alone() {
703 let policy = RedactionPolicy::default();
704 let url = "https://api.example.com/v1?page=2";
705 assert_eq!(policy.redact_url(url), url);
706 }
707
708 #[test]
709 fn redact_urls_in_text_strips_embedded_sensitive_urls() {
710 let policy = RedactionPolicy::default();
711 let redacted = policy.redact_urls_in_text(
712 "clean https://status.example.com/health then \
713 redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
714 to http://public.example.com/next?client_secret=target-secret.",
715 );
716 assert!(redacted.starts_with("clean https://status.example.com/health then "));
717 assert!(redacted.contains("access_token=%5Bredacted%5D"));
718 assert!(redacted.contains("client_secret=%5Bredacted%5D"));
719 assert!(!redacted.contains("source-secret"));
720 assert!(!redacted.contains("target-secret"));
721 assert!(!redacted.contains("user:pw@"));
722 assert!(redacted.ends_with('.'));
723 }
724
725 #[test]
726 fn redact_json_strips_sensitive_field_names_recursively() {
727 let policy = RedactionPolicy::default();
728 let mut value = json!({
729 "headers": {
730 "authorization": "Bearer abc",
731 "X-Amz-Security-Token": "session",
732 "x-trace-id": "trace_1",
733 },
734 "list": [
735 { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
736 { "name": "bob" },
737 ],
738 "clientSecret": "camel-secret",
739 "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
740 "url": "https://api.example.com/v1?api_key=hideme",
741 });
742 policy.redact_json_in_place(&mut value);
743 assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
744 assert_eq!(
745 value["headers"]["X-Amz-Security-Token"],
746 REDACTED_PLACEHOLDER
747 );
748 assert_eq!(value["headers"]["x-trace-id"], "trace_1");
749 assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
750 assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
751 assert_eq!(value["list"][0]["name"], "alice");
752 assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
753 let free_form = value["free_form"].as_str().unwrap();
754 assert!(
758 free_form.contains("<redacted:"),
759 "expected named placeholder, got: {free_form}"
760 );
761 assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
762 }
763
764 #[test]
765 fn policy_guard_pushes_and_pops_thread_local() {
766 clear_policy_stack();
767 assert_eq!(current_policy(), RedactionPolicy::default());
768 {
769 let policy = RedactionPolicy::default().with_extra_field("custom_token");
770 let _guard = PolicyGuard::new(policy.clone());
771 assert_eq!(current_policy(), policy);
772 }
773 assert_eq!(current_policy(), RedactionPolicy::default());
774 }
775
776 #[test]
777 fn redact_string_replaces_known_secret_patterns() {
778 let policy = RedactionPolicy::default();
779 let input =
780 "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
781 let out = policy.redact_string(input);
782 assert!(out.contains("<redacted:openai_key:"));
785 assert!(out.contains("<redacted:aws_access_key:"));
786 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
787 assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
788 }
789
790 #[test]
791 fn looks_like_secret_value_accepts_logical_secret_references() {
792 let policy = RedactionPolicy::default();
793 assert!(policy.looks_like_secret_value("sk-live-secret"));
794 assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
795 assert!(!policy.looks_like_secret_value("github/webhook-secret"));
796 assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
797 }
798}