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
401impl harn_session_store::EventRedactor for RedactionPolicy {
402 fn redact_json_in_place(&self, value: &mut JsonValue) {
403 Self::redact_json_in_place(self, value);
404 }
405
406 fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
407 Self::redact_headers(self, headers)
408 }
409}
410
411fn find_http_url_start(value: &str) -> Option<usize> {
412 match (value.find("http://"), value.find("https://")) {
413 (Some(http), Some(https)) => Some(http.min(https)),
414 (Some(http), None) => Some(http),
415 (None, Some(https)) => Some(https),
416 (None, None) => None,
417 }
418}
419
420fn http_url_token_end(value: &str, start: usize) -> usize {
421 value[start..]
422 .char_indices()
423 .find_map(|(offset, character)| {
424 (offset > 0 && is_url_text_delimiter(character)).then_some(start + offset)
425 })
426 .unwrap_or(value.len())
427}
428
429fn is_url_text_delimiter(character: char) -> bool {
430 character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '`')
431}
432
433fn split_url_token(token: &str) -> Option<(&str, &str)> {
434 let mut prose_end = token.len();
435 while prose_end > 0 {
436 let candidate = &token[..prose_end];
437 let last = candidate.chars().last()?;
438 if !is_trailing_prose_punctuation(last) {
439 break;
440 }
441 prose_end -= last.len_utf8();
442 }
443 if prose_end > 0 {
444 let candidate = &token[..prose_end];
445 if Url::parse(candidate).is_ok() {
446 return Some((candidate, &token[prose_end..]));
447 }
448 }
449
450 let mut end = token.len();
451 while end > 0 {
452 let candidate = &token[..end];
453 if Url::parse(candidate).is_ok() {
454 return Some((candidate, &token[end..]));
455 }
456 let last = candidate.chars().last()?;
457 if !is_trailing_prose_punctuation(last) {
458 return None;
459 }
460 end -= last.len_utf8();
461 }
462 None
463}
464
465fn is_trailing_prose_punctuation(character: char) -> bool {
466 matches!(
467 character,
468 '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'
469 )
470}
471
472fn default_safe_headers() -> BTreeSet<String> {
473 BTreeSet::from([
474 "content-length".to_string(),
475 "content-type".to_string(),
476 "request-id".to_string(),
477 "user-agent".to_string(),
478 "x-a2a-delivery".to_string(),
479 "x-correlation-id".to_string(),
480 "x-github-delivery".to_string(),
481 "x-github-event".to_string(),
482 "x-github-hook-id".to_string(),
483 "x-request-id".to_string(),
484 "x-slack-request-timestamp".to_string(),
485 ])
486}
487
488fn default_deny_header_substrings() -> BTreeSet<String> {
489 BTreeSet::from([
490 "authorization".to_string(),
491 "cookie".to_string(),
492 "secret".to_string(),
493 "signature".to_string(),
494 "token".to_string(),
495 "key".to_string(),
496 ])
497}
498
499fn is_default_sensitive_url_param(lower: &str) -> bool {
500 let compact = compact_secret_name(lower);
501 matches!(
502 compact.as_str(),
503 "apikey"
504 | "accesstoken"
505 | "refreshtoken"
506 | "idtoken"
507 | "clientsecret"
508 | "password"
509 | "secret"
510 | "token"
511 | "auth"
512 | "bearer"
513 | "sig"
514 | "signature"
515 ) || compact.ends_with("token")
516 || compact.ends_with("secret")
517 || compact.ends_with("password")
518}
519
520fn is_default_sensitive_field(lower: &str) -> bool {
521 let compact = compact_secret_name(lower);
522 matches!(
523 compact.as_str(),
524 "authorization"
525 | "proxyauthorization"
526 | "cookie"
527 | "setcookie"
528 | "apikey"
529 | "xamzsecuritytoken"
530 | "xapikey"
531 | "xauthtoken"
532 | "xcsrftoken"
533 | "xxsrftoken"
534 | "accesstoken"
535 | "refreshtoken"
536 | "idtoken"
537 | "bearertoken"
538 | "clientsecret"
539 | "password"
540 | "secret"
541 | "passwd"
542 | "privatekey"
543 | "sessiontoken"
544 ) || compact.ends_with("token")
545 || compact.ends_with("secret")
546 || compact.ends_with("password")
547 || compact.ends_with("apikey")
548}
549
550fn compact_secret_name(lower: &str) -> String {
551 lower
552 .chars()
553 .filter(|ch| *ch != '_' && *ch != '-')
554 .collect()
555}
556
557fn has_secret_prefix(trimmed: &str) -> bool {
558 trimmed.starts_with("sk-")
559 || trimmed.starts_with("ghp_")
560 || trimmed.starts_with("ghs_")
561 || trimmed.starts_with("xoxb-")
562 || trimmed.starts_with("xoxp-")
563 || trimmed.starts_with("AKIA")
564}
565
566fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
567 trimmed.len() > 48
568 && trimmed
569 .chars()
570 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
571}
572
573thread_local! {
574 static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
575}
576
577pub fn push_policy(policy: RedactionPolicy) {
580 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
581}
582
583pub fn pop_policy() {
586 REDACTION_POLICY_STACK.with(|stack| {
587 stack.borrow_mut().pop();
588 });
589}
590
591pub fn clear_policy_stack() {
596 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
597 patterns::clear_custom_patterns();
598 let _ = patterns::install_audit_sink(None);
599 patterns::clear_audit_ring();
600}
601
602pub fn current_policy() -> RedactionPolicy {
606 REDACTION_POLICY_STACK.with(|stack| {
607 stack
608 .borrow()
609 .last()
610 .cloned()
611 .unwrap_or_else(RedactionPolicy::default)
612 })
613}
614
615pub struct PolicyGuard;
622
623impl PolicyGuard {
624 pub fn new(policy: RedactionPolicy) -> Self {
625 push_policy(policy);
626 Self
627 }
628}
629
630impl Drop for PolicyGuard {
631 fn drop(&mut self) {
632 pop_policy();
633 }
634}
635
636#[cfg(test)]
637mod tests {
638 use super::*;
639 use serde_json::json;
640
641 fn sample_headers() -> BTreeMap<String, String> {
642 BTreeMap::from([
643 ("Authorization".to_string(), "Bearer secret123".to_string()),
644 ("Cookie".to_string(), "session=abc".to_string()),
645 ("Content-Type".to_string(), "application/json".to_string()),
646 ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
647 (
648 "X-Slack-Signature".to_string(),
649 "v0=abcdef123456".to_string(),
650 ),
651 ("User-Agent".to_string(), "Harn/1.0".to_string()),
652 ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
653 ])
654 }
655
656 #[test]
657 fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
658 let policy = RedactionPolicy::default();
659 let redacted = policy.redact_headers(&sample_headers());
660 assert_eq!(
661 redacted.get("Authorization").unwrap(),
662 REDACTED_HEADER_VALUE
663 );
664 assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
665 assert_eq!(
666 redacted.get("X-Webhook-Token").unwrap(),
667 REDACTED_HEADER_VALUE
668 );
669 assert_eq!(
670 redacted.get("X-Slack-Signature").unwrap(),
671 REDACTED_HEADER_VALUE
672 );
673 assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
674 assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
675 assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
676 }
677
678 #[test]
679 fn passthrough_policy_redacts_nothing() {
680 let policy = RedactionPolicy::passthrough();
681 let redacted = policy.redact_headers(&sample_headers());
682 assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
683 }
684
685 #[test]
686 fn host_can_extend_safe_and_deny_headers() {
687 let policy = RedactionPolicy::default()
688 .with_safe_header("X-Webhook-Token")
689 .with_deny_header_substring("delivery");
690 let redacted = policy.redact_headers(&sample_headers());
691 assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
692 assert_eq!(
693 redacted.get("X-GitHub-Delivery").unwrap(),
694 REDACTED_HEADER_VALUE,
695 "host explicitly forced delivery to be sensitive"
696 );
697 }
698
699 #[test]
700 fn redact_url_strips_userinfo_and_sensitive_query_params() {
701 let policy = RedactionPolicy::default();
702 let redacted = policy.redact_url(
703 "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
704 );
705 assert!(redacted.contains("api_key=%5Bredacted%5D"));
706 assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
707 assert!(redacted.contains("page=2"));
708 assert!(!redacted.contains("user:pw@"));
709 }
710
711 #[test]
712 fn redact_url_leaves_clean_urls_alone() {
713 let policy = RedactionPolicy::default();
714 let url = "https://api.example.com/v1?page=2";
715 assert_eq!(policy.redact_url(url), url);
716 }
717
718 #[test]
719 fn redact_urls_in_text_strips_embedded_sensitive_urls() {
720 let policy = RedactionPolicy::default();
721 let redacted = policy.redact_urls_in_text(
722 "clean https://status.example.com/health then \
723 redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
724 to http://public.example.com/next?client_secret=target-secret.",
725 );
726 assert!(redacted.starts_with("clean https://status.example.com/health then "));
727 assert!(redacted.contains("access_token=%5Bredacted%5D"));
728 assert!(redacted.contains("client_secret=%5Bredacted%5D"));
729 assert!(!redacted.contains("source-secret"));
730 assert!(!redacted.contains("target-secret"));
731 assert!(!redacted.contains("user:pw@"));
732 assert!(redacted.ends_with('.'));
733 }
734
735 #[test]
736 fn redact_json_strips_sensitive_field_names_recursively() {
737 let policy = RedactionPolicy::default();
738 let mut value = json!({
739 "headers": {
740 "authorization": "Bearer abc",
741 "X-Amz-Security-Token": "session",
742 "x-trace-id": "trace_1",
743 },
744 "list": [
745 { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
746 { "name": "bob" },
747 ],
748 "clientSecret": "camel-secret",
749 "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
750 "url": "https://api.example.com/v1?api_key=hideme",
751 });
752 policy.redact_json_in_place(&mut value);
753 assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
754 assert_eq!(
755 value["headers"]["X-Amz-Security-Token"],
756 REDACTED_PLACEHOLDER
757 );
758 assert_eq!(value["headers"]["x-trace-id"], "trace_1");
759 assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
760 assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
761 assert_eq!(value["list"][0]["name"], "alice");
762 assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
763 let free_form = value["free_form"].as_str().unwrap();
764 assert!(
768 free_form.contains("<redacted:"),
769 "expected named placeholder, got: {free_form}"
770 );
771 assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
772 }
773
774 #[test]
775 fn policy_guard_pushes_and_pops_thread_local() {
776 clear_policy_stack();
777 assert_eq!(current_policy(), RedactionPolicy::default());
778 {
779 let policy = RedactionPolicy::default().with_extra_field("custom_token");
780 let _guard = PolicyGuard::new(policy.clone());
781 assert_eq!(current_policy(), policy);
782 }
783 assert_eq!(current_policy(), RedactionPolicy::default());
784 }
785
786 #[test]
787 fn redact_string_replaces_known_secret_patterns() {
788 let policy = RedactionPolicy::default();
789 let input =
790 "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
791 let out = policy.redact_string(input);
792 assert!(out.contains("<redacted:openai_key:"));
795 assert!(out.contains("<redacted:aws_access_key:"));
796 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
797 assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
798 }
799
800 #[test]
801 fn looks_like_secret_value_accepts_logical_secret_references() {
802 let policy = RedactionPolicy::default();
803 assert!(policy.looks_like_secret_value("sk-live-secret"));
804 assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
805 assert!(!policy.looks_like_secret_value("github/webhook-secret"));
806 assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
807 }
808}