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 ) || compact.ends_with("token")
557 || compact.ends_with("secret")
558 || compact.ends_with("password")
559 || compact.ends_with("apikey")
560}
561
562fn compact_secret_name(lower: &str) -> String {
563 lower
564 .chars()
565 .filter(|ch| *ch != '_' && *ch != '-')
566 .collect()
567}
568
569fn has_secret_prefix(trimmed: &str) -> bool {
570 trimmed.starts_with("sk-")
571 || trimmed.starts_with("ghp_")
572 || trimmed.starts_with("ghs_")
573 || trimmed.starts_with("xoxb-")
574 || trimmed.starts_with("xoxp-")
575 || trimmed.starts_with("AKIA")
576}
577
578fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
579 trimmed.len() > 48
580 && trimmed
581 .chars()
582 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
583}
584
585thread_local! {
586 static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
587}
588
589pub fn push_policy(policy: RedactionPolicy) {
592 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
593}
594
595pub fn pop_policy() {
598 REDACTION_POLICY_STACK.with(|stack| {
599 stack.borrow_mut().pop();
600 });
601}
602
603pub fn clear_policy_stack() {
608 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
609 patterns::clear_custom_patterns();
610 let _ = patterns::install_audit_sink(None);
611 patterns::clear_audit_ring();
612}
613
614pub fn current_policy() -> RedactionPolicy {
618 REDACTION_POLICY_STACK.with(|stack| {
619 stack
620 .borrow()
621 .last()
622 .cloned()
623 .unwrap_or_else(RedactionPolicy::default)
624 })
625}
626
627pub struct PolicyGuard;
634
635impl PolicyGuard {
636 pub fn new(policy: RedactionPolicy) -> Self {
637 push_policy(policy);
638 Self
639 }
640}
641
642impl Drop for PolicyGuard {
643 fn drop(&mut self) {
644 pop_policy();
645 }
646}
647
648#[cfg(test)]
649mod tests {
650 use super::*;
651 use serde_json::json;
652
653 fn sample_headers() -> BTreeMap<String, String> {
654 BTreeMap::from([
655 ("Authorization".to_string(), "Bearer secret123".to_string()),
656 ("Cookie".to_string(), "session=abc".to_string()),
657 ("Content-Type".to_string(), "application/json".to_string()),
658 ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
659 (
660 "X-Slack-Signature".to_string(),
661 "v0=abcdef123456".to_string(),
662 ),
663 ("User-Agent".to_string(), "Harn/1.0".to_string()),
664 ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
665 ])
666 }
667
668 #[test]
669 fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
670 let policy = RedactionPolicy::default();
671 let redacted = policy.redact_headers(&sample_headers());
672 assert_eq!(
673 redacted.get("Authorization").unwrap(),
674 REDACTED_HEADER_VALUE
675 );
676 assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
677 assert_eq!(
678 redacted.get("X-Webhook-Token").unwrap(),
679 REDACTED_HEADER_VALUE
680 );
681 assert_eq!(
682 redacted.get("X-Slack-Signature").unwrap(),
683 REDACTED_HEADER_VALUE
684 );
685 assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
686 assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
687 assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
688 }
689
690 #[test]
691 fn passthrough_policy_redacts_nothing() {
692 let policy = RedactionPolicy::passthrough();
693 let redacted = policy.redact_headers(&sample_headers());
694 assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
695 }
696
697 #[test]
698 fn host_can_extend_safe_and_deny_headers() {
699 let policy = RedactionPolicy::default()
700 .with_safe_header("X-Webhook-Token")
701 .with_deny_header_substring("delivery");
702 let redacted = policy.redact_headers(&sample_headers());
703 assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
704 assert_eq!(
705 redacted.get("X-GitHub-Delivery").unwrap(),
706 REDACTED_HEADER_VALUE,
707 "host explicitly forced delivery to be sensitive"
708 );
709 }
710
711 #[test]
712 fn redact_url_strips_userinfo_and_sensitive_query_params() {
713 let policy = RedactionPolicy::default();
714 let redacted = policy.redact_url(
715 "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
716 );
717 assert!(redacted.contains("api_key=%5Bredacted%5D"));
718 assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
719 assert!(redacted.contains("page=2"));
720 assert!(!redacted.contains("user:pw@"));
721 }
722
723 #[test]
724 fn redact_url_leaves_clean_urls_alone() {
725 let policy = RedactionPolicy::default();
726 let url = "https://api.example.com/v1?page=2";
727 assert_eq!(policy.redact_url(url), url);
728 }
729
730 #[test]
731 fn redact_urls_in_text_strips_embedded_sensitive_urls() {
732 let policy = RedactionPolicy::default();
733 let redacted = policy.redact_urls_in_text(
734 "clean https://status.example.com/health then \
735 redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
736 to http://public.example.com/next?client_secret=target-secret.",
737 );
738 assert!(redacted.starts_with("clean https://status.example.com/health then "));
739 assert!(redacted.contains("access_token=%5Bredacted%5D"));
740 assert!(redacted.contains("client_secret=%5Bredacted%5D"));
741 assert!(!redacted.contains("source-secret"));
742 assert!(!redacted.contains("target-secret"));
743 assert!(!redacted.contains("user:pw@"));
744 assert!(redacted.ends_with('.'));
745 }
746
747 #[test]
748 fn redact_json_strips_sensitive_field_names_recursively() {
749 let policy = RedactionPolicy::default();
750 let mut value = json!({
751 "headers": {
752 "authorization": "Bearer abc",
753 "X-Amz-Security-Token": "session",
754 "x-trace-id": "trace_1",
755 },
756 "list": [
757 { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
758 { "name": "bob" },
759 ],
760 "clientSecret": "camel-secret",
761 "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
762 "url": "https://api.example.com/v1?api_key=hideme",
763 });
764 policy.redact_json_in_place(&mut value);
765 assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
766 assert_eq!(
767 value["headers"]["X-Amz-Security-Token"],
768 REDACTED_PLACEHOLDER
769 );
770 assert_eq!(value["headers"]["x-trace-id"], "trace_1");
771 assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
772 assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
773 assert_eq!(value["list"][0]["name"], "alice");
774 assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
775 let free_form = value["free_form"].as_str().unwrap();
776 assert!(
780 free_form.contains("<redacted:"),
781 "expected named placeholder, got: {free_form}"
782 );
783 assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
784 }
785
786 #[test]
787 fn policy_guard_pushes_and_pops_thread_local() {
788 clear_policy_stack();
789 assert_eq!(current_policy(), RedactionPolicy::default());
790 {
791 let policy = RedactionPolicy::default().with_extra_field("custom_token");
792 let _guard = PolicyGuard::new(policy.clone());
793 assert_eq!(current_policy(), policy);
794 }
795 assert_eq!(current_policy(), RedactionPolicy::default());
796 }
797
798 #[test]
799 fn redact_string_replaces_known_secret_patterns() {
800 let policy = RedactionPolicy::default();
801 let input =
802 "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
803 let out = policy.redact_string(input);
804 assert!(out.contains("<redacted:openai_key:"));
807 assert!(out.contains("<redacted:aws_access_key:"));
808 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
809 assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
810 }
811
812 #[test]
813 fn looks_like_secret_value_accepts_logical_secret_references() {
814 let policy = RedactionPolicy::default();
815 assert!(policy.looks_like_secret_value("sk-live-secret"));
816 assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
817 assert!(!policy.looks_like_secret_value("github/webhook-secret"));
818 assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
819 }
820}