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