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 #[expect(
290 clippy::string_slice,
291 reason = "cursors are find offsets or char_indices token ends on the same text"
292 )]
293 pub fn redact_urls_in_text<'a>(&self, value: &'a str) -> Cow<'a, str> {
294 let mut scan_cursor = 0;
295 let mut emit_cursor = 0;
296 let mut output: Option<String> = None;
297
298 while let Some(relative_start) = find_http_url_start(&value[scan_cursor..]) {
299 let start = scan_cursor + relative_start;
300 let token_end = http_url_token_end(value, start);
301 let token = &value[start..token_end];
302 let Some((url, suffix)) = split_url_token(token) else {
303 scan_cursor = token_end;
304 continue;
305 };
306 let redacted = self.redact_url(url);
307 if redacted != url {
308 let output = output.get_or_insert_with(|| String::with_capacity(value.len()));
309 output.push_str(&value[emit_cursor..start]);
310 output.push_str(&redacted);
311 output.push_str(suffix);
312 emit_cursor = token_end;
313 }
314 scan_cursor = token_end;
315 }
316
317 match output {
318 Some(mut output) => {
319 output.push_str(&value[emit_cursor..]);
320 Cow::Owned(output)
321 }
322 None => Cow::Borrowed(value),
323 }
324 }
325
326 pub fn looks_like_secret_value(&self, value: &str) -> bool {
335 let trimmed = value.trim();
336 !trimmed.is_empty()
337 && (self.redact_string(trimmed).as_ref() != trimmed
338 || has_secret_prefix(trimmed)
339 || is_long_bare_secret_candidate(trimmed))
340 }
341
342 fn redact_url_in_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
347 if !self.redact_url_userinfo
348 || !(value.starts_with("http://") || value.starts_with("https://"))
349 {
350 return Cow::Borrowed(value);
351 }
352 let trimmed = value.trim();
353 if trimmed.contains(char::is_whitespace) {
354 return Cow::Borrowed(value);
355 }
356 let redacted = self.redact_url(trimmed);
357 if redacted == trimmed {
358 Cow::Borrowed(value)
359 } else {
360 Cow::Owned(redacted)
361 }
362 }
363
364 pub fn redact_json_in_place(&self, value: &mut JsonValue) {
367 match value {
368 JsonValue::Object(map) => {
369 let mut keys_to_redact: Vec<String> = Vec::new();
370 for (key, child) in map.iter_mut() {
371 if self.field_is_sensitive(key) {
372 keys_to_redact.push(key.clone());
373 } else {
374 self.redact_json_in_place(child);
375 }
376 }
377 for key in keys_to_redact {
378 map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
379 }
380 }
381 JsonValue::Array(items) => {
382 for item in items.iter_mut() {
383 self.redact_json_in_place(item);
384 }
385 }
386 JsonValue::String(s) => {
387 let redacted = self.redact_string(s);
388 if let Cow::Owned(replacement) = redacted {
389 *s = replacement;
390 }
391 }
392 _ => {}
393 }
394 }
395
396 pub fn redact_json(&self, value: &JsonValue) -> JsonValue {
399 let mut clone = value.clone();
400 self.redact_json_in_place(&mut clone);
401 clone
402 }
403}
404
405impl harn_session_store::EventRedactor for RedactionPolicy {
406 fn redact_json_in_place(&self, value: &mut JsonValue) {
407 Self::redact_json_in_place(self, value);
408 }
409
410 fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
411 Self::redact_headers(self, headers)
412 }
413}
414
415fn find_http_url_start(value: &str) -> Option<usize> {
416 match (value.find("http://"), value.find("https://")) {
417 (Some(http), Some(https)) => Some(http.min(https)),
418 (Some(http), None) => Some(http),
419 (None, Some(https)) => Some(https),
420 (None, None) => None,
421 }
422}
423
424#[expect(
425 clippy::string_slice,
426 reason = "start is a find offset of an ASCII scheme prefix"
427)]
428fn http_url_token_end(value: &str, start: usize) -> usize {
429 value[start..]
430 .char_indices()
431 .find_map(|(offset, character)| {
432 (offset > 0 && is_url_text_delimiter(character)).then_some(start + offset)
433 })
434 .unwrap_or(value.len())
435}
436
437fn is_url_text_delimiter(character: char) -> bool {
438 character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '`')
439}
440
441#[expect(
442 clippy::string_slice,
443 reason = "prose_end/end retreat from token.len() by whole trailing chars"
444)]
445fn split_url_token(token: &str) -> Option<(&str, &str)> {
446 let mut prose_end = token.len();
447 while prose_end > 0 {
448 let candidate = &token[..prose_end];
449 let last = candidate.chars().last()?;
450 if !is_trailing_prose_punctuation(last) {
451 break;
452 }
453 prose_end -= last.len_utf8();
454 }
455 if prose_end > 0 {
456 let candidate = &token[..prose_end];
457 if Url::parse(candidate).is_ok() {
458 return Some((candidate, &token[prose_end..]));
459 }
460 }
461
462 let mut end = token.len();
463 while end > 0 {
464 let candidate = &token[..end];
465 if Url::parse(candidate).is_ok() {
466 return Some((candidate, &token[end..]));
467 }
468 let last = candidate.chars().last()?;
469 if !is_trailing_prose_punctuation(last) {
470 return None;
471 }
472 end -= last.len_utf8();
473 }
474 None
475}
476
477fn is_trailing_prose_punctuation(character: char) -> bool {
478 matches!(
479 character,
480 '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'
481 )
482}
483
484fn default_safe_headers() -> BTreeSet<String> {
485 BTreeSet::from([
486 "content-length".to_string(),
487 "content-type".to_string(),
488 "request-id".to_string(),
489 "user-agent".to_string(),
490 "x-a2a-delivery".to_string(),
491 "x-correlation-id".to_string(),
492 "x-github-delivery".to_string(),
493 "x-github-event".to_string(),
494 "x-github-hook-id".to_string(),
495 "x-request-id".to_string(),
496 "x-slack-request-timestamp".to_string(),
497 ])
498}
499
500fn default_deny_header_substrings() -> BTreeSet<String> {
501 BTreeSet::from([
502 "authorization".to_string(),
503 "cookie".to_string(),
504 "secret".to_string(),
505 "signature".to_string(),
506 "token".to_string(),
507 "key".to_string(),
508 ])
509}
510
511fn is_default_sensitive_url_param(lower: &str) -> bool {
512 let compact = compact_secret_name(lower);
513 matches!(
514 compact.as_str(),
515 "apikey"
516 | "accesstoken"
517 | "refreshtoken"
518 | "idtoken"
519 | "clientsecret"
520 | "password"
521 | "secret"
522 | "token"
523 | "auth"
524 | "bearer"
525 | "sig"
526 | "signature"
527 ) || compact.ends_with("token")
528 || compact.ends_with("secret")
529 || compact.ends_with("password")
530}
531
532fn is_default_sensitive_field(lower: &str) -> bool {
533 let compact = compact_secret_name(lower);
534 matches!(
535 compact.as_str(),
536 "authorization"
537 | "proxyauthorization"
538 | "cookie"
539 | "setcookie"
540 | "apikey"
541 | "xamzsecuritytoken"
542 | "xapikey"
543 | "xauthtoken"
544 | "xcsrftoken"
545 | "xxsrftoken"
546 | "accesstoken"
547 | "refreshtoken"
548 | "idtoken"
549 | "bearertoken"
550 | "clientsecret"
551 | "password"
552 | "secret"
553 | "passwd"
554 | "privatekey"
555 | "sessiontoken"
556 | "protectedvalues"
557 | "protecteddisclosure"
558 ) || compact.ends_with("token")
559 || compact.ends_with("secret")
560 || compact.ends_with("password")
561 || compact.ends_with("apikey")
562}
563
564fn compact_secret_name(lower: &str) -> String {
565 lower
566 .chars()
567 .filter(|ch| *ch != '_' && *ch != '-')
568 .collect()
569}
570
571fn has_secret_prefix(trimmed: &str) -> bool {
572 trimmed.starts_with("sk-")
573 || trimmed.starts_with("ghp_")
574 || trimmed.starts_with("ghs_")
575 || trimmed.starts_with("xoxb-")
576 || trimmed.starts_with("xoxp-")
577 || trimmed.starts_with("AKIA")
578}
579
580fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
581 trimmed.len() > 48
582 && trimmed
583 .chars()
584 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
585}
586
587thread_local! {
588 static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
589}
590
591pub fn push_policy(policy: RedactionPolicy) {
594 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
595}
596
597pub fn pop_policy() {
600 REDACTION_POLICY_STACK.with(|stack| {
601 stack.borrow_mut().pop();
602 });
603}
604
605pub fn clear_policy_stack() {
610 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
611 patterns::clear_custom_patterns();
612 let _ = patterns::install_audit_sink(None);
613 patterns::clear_audit_ring();
614}
615
616pub fn current_policy() -> RedactionPolicy {
620 REDACTION_POLICY_STACK.with(|stack| {
621 stack
622 .borrow()
623 .last()
624 .cloned()
625 .unwrap_or_else(RedactionPolicy::default)
626 })
627}
628
629pub struct PolicyGuard;
636
637impl PolicyGuard {
638 pub fn new(policy: RedactionPolicy) -> Self {
639 push_policy(policy);
640 Self
641 }
642}
643
644impl Drop for PolicyGuard {
645 fn drop(&mut self) {
646 pop_policy();
647 }
648}
649
650#[cfg(test)]
651mod tests {
652 use super::*;
653 use serde_json::json;
654
655 fn sample_headers() -> BTreeMap<String, String> {
656 BTreeMap::from([
657 ("Authorization".to_string(), "Bearer secret123".to_string()),
658 ("Cookie".to_string(), "session=abc".to_string()),
659 ("Content-Type".to_string(), "application/json".to_string()),
660 ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
661 (
662 "X-Slack-Signature".to_string(),
663 "v0=abcdef123456".to_string(),
664 ),
665 ("User-Agent".to_string(), "Harn/1.0".to_string()),
666 ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
667 ])
668 }
669
670 #[test]
671 fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
672 let policy = RedactionPolicy::default();
673 let redacted = policy.redact_headers(&sample_headers());
674 assert_eq!(
675 redacted.get("Authorization").unwrap(),
676 REDACTED_HEADER_VALUE
677 );
678 assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
679 assert_eq!(
680 redacted.get("X-Webhook-Token").unwrap(),
681 REDACTED_HEADER_VALUE
682 );
683 assert_eq!(
684 redacted.get("X-Slack-Signature").unwrap(),
685 REDACTED_HEADER_VALUE
686 );
687 assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
688 assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
689 assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
690 }
691
692 #[test]
693 fn passthrough_policy_redacts_nothing() {
694 let policy = RedactionPolicy::passthrough();
695 let redacted = policy.redact_headers(&sample_headers());
696 assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
697 }
698
699 #[test]
700 fn host_can_extend_safe_and_deny_headers() {
701 let policy = RedactionPolicy::default()
702 .with_safe_header("X-Webhook-Token")
703 .with_deny_header_substring("delivery");
704 let redacted = policy.redact_headers(&sample_headers());
705 assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
706 assert_eq!(
707 redacted.get("X-GitHub-Delivery").unwrap(),
708 REDACTED_HEADER_VALUE,
709 "host explicitly forced delivery to be sensitive"
710 );
711 }
712
713 #[test]
714 fn redact_url_strips_userinfo_and_sensitive_query_params() {
715 let policy = RedactionPolicy::default();
716 let redacted = policy.redact_url(
717 "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
718 );
719 assert!(redacted.contains("api_key=%5Bredacted%5D"));
720 assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
721 assert!(redacted.contains("page=2"));
722 assert!(!redacted.contains("user:pw@"));
723 }
724
725 #[test]
726 fn redact_url_leaves_clean_urls_alone() {
727 let policy = RedactionPolicy::default();
728 let url = "https://api.example.com/v1?page=2";
729 assert_eq!(policy.redact_url(url), url);
730 }
731
732 #[test]
733 fn redact_urls_in_text_strips_embedded_sensitive_urls() {
734 let policy = RedactionPolicy::default();
735 let redacted = policy.redact_urls_in_text(
736 "clean https://status.example.com/health then \
737 redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
738 to http://public.example.com/next?client_secret=target-secret.",
739 );
740 assert!(redacted.starts_with("clean https://status.example.com/health then "));
741 assert!(redacted.contains("access_token=%5Bredacted%5D"));
742 assert!(redacted.contains("client_secret=%5Bredacted%5D"));
743 assert!(!redacted.contains("source-secret"));
744 assert!(!redacted.contains("target-secret"));
745 assert!(!redacted.contains("user:pw@"));
746 assert!(redacted.ends_with('.'));
747 }
748
749 #[test]
750 fn redact_json_strips_sensitive_field_names_recursively() {
751 let policy = RedactionPolicy::default();
752 let mut value = json!({
753 "headers": {
754 "authorization": "Bearer abc",
755 "X-Amz-Security-Token": "session",
756 "x-trace-id": "trace_1",
757 },
758 "list": [
759 { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
760 { "name": "bob" },
761 ],
762 "clientSecret": "camel-secret",
763 "protected_values": {"legal_identity": {"given_name": "PersonalSentinel"}},
764 "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
765 "url": "https://api.example.com/v1?api_key=hideme",
766 });
767 policy.redact_json_in_place(&mut value);
768 assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
769 assert_eq!(
770 value["headers"]["X-Amz-Security-Token"],
771 REDACTED_PLACEHOLDER
772 );
773 assert_eq!(value["headers"]["x-trace-id"], "trace_1");
774 assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
775 assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
776 assert_eq!(value["list"][0]["name"], "alice");
777 assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
778 assert_eq!(value["protected_values"], REDACTED_PLACEHOLDER);
779 let free_form = value["free_form"].as_str().unwrap();
780 assert!(
784 free_form.contains("<redacted:"),
785 "expected named placeholder, got: {free_form}"
786 );
787 assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
788 }
789
790 #[test]
791 fn policy_guard_pushes_and_pops_thread_local() {
792 clear_policy_stack();
793 assert_eq!(current_policy(), RedactionPolicy::default());
794 {
795 let policy = RedactionPolicy::default().with_extra_field("custom_token");
796 let _guard = PolicyGuard::new(policy.clone());
797 assert_eq!(current_policy(), policy);
798 }
799 assert_eq!(current_policy(), RedactionPolicy::default());
800 }
801
802 #[test]
803 fn redact_string_replaces_known_secret_patterns() {
804 let policy = RedactionPolicy::default();
805 let input =
806 "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
807 let out = policy.redact_string(input);
808 assert!(out.contains("<redacted:openai_key:"));
811 assert!(out.contains("<redacted:aws_access_key:"));
812 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
813 assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
814 }
815
816 #[test]
817 fn looks_like_secret_value_accepts_logical_secret_references() {
818 let policy = RedactionPolicy::default();
819 assert!(policy.looks_like_secret_value("sk-live-secret"));
820 assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
821 assert!(!policy.looks_like_secret_value("github/webhook-secret"));
822 assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
823 }
824}