1mod 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 use patterns::{
43 clear_audit_ring, clear_custom_patterns, custom_pattern_names, default_pattern_names,
44 drain_audit_ring, install_audit_sink, register_custom_pattern, scan_secret_patterns, AuditSink,
45 NamedPattern, RedactionEvent, TOKEN_REDACTION_AUDIT_TOPIC, TOKEN_REDACTION_DIAGNOSTIC,
46};
47
48pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
52
53pub const REDACTED_HEADER_VALUE: &str = REDACTED_PLACEHOLDER;
57
58#[derive(Clone, Debug, PartialEq, Eq)]
59pub struct RedactionPolicy {
60 safe_headers: BTreeSet<String>,
61 deny_header_substrings: BTreeSet<String>,
62 extra_deny_header_substrings: BTreeSet<String>,
63 extra_field_names: BTreeSet<String>,
64 extra_url_params: BTreeSet<String>,
65 scan_strings: bool,
66 redact_url_userinfo: bool,
67}
68
69impl Default for RedactionPolicy {
70 fn default() -> Self {
71 Self {
72 safe_headers: default_safe_headers(),
73 deny_header_substrings: default_deny_header_substrings(),
74 extra_deny_header_substrings: BTreeSet::new(),
75 extra_field_names: BTreeSet::new(),
76 extra_url_params: BTreeSet::new(),
77 scan_strings: true,
78 redact_url_userinfo: true,
79 }
80 }
81}
82
83impl RedactionPolicy {
84 pub fn passthrough() -> Self {
87 Self {
88 safe_headers: BTreeSet::new(),
89 deny_header_substrings: BTreeSet::new(),
90 extra_deny_header_substrings: BTreeSet::new(),
91 extra_field_names: BTreeSet::new(),
92 extra_url_params: BTreeSet::new(),
93 scan_strings: false,
94 redact_url_userinfo: false,
95 }
96 }
97
98 pub fn with_safe_header(mut self, name: impl Into<String>) -> Self {
103 self.safe_headers.insert(name.into().to_ascii_lowercase());
104 self
105 }
106
107 pub fn with_deny_header_substring(mut self, fragment: impl Into<String>) -> Self {
112 self.extra_deny_header_substrings
113 .insert(fragment.into().to_ascii_lowercase());
114 self
115 }
116
117 pub fn with_extra_field(mut self, name: impl Into<String>) -> Self {
121 self.extra_field_names
122 .insert(name.into().to_ascii_lowercase());
123 self
124 }
125
126 pub fn with_extra_url_param(mut self, name: impl Into<String>) -> Self {
128 self.extra_url_params
129 .insert(name.into().to_ascii_lowercase());
130 self
131 }
132
133 pub fn disable_string_scan(mut self) -> Self {
137 self.scan_strings = false;
138 self
139 }
140
141 fn header_is_safe(&self, lower_name: &str) -> bool {
142 if self.safe_headers.contains(lower_name) {
146 return true;
147 }
148 lower_name.ends_with("-event")
149 || lower_name.ends_with("-delivery")
150 || lower_name.contains("timestamp")
151 || lower_name.contains("request-id")
152 }
153
154 pub fn header_is_sensitive(&self, name: &str) -> bool {
162 let lower = name.to_ascii_lowercase();
163 if self
164 .extra_deny_header_substrings
165 .iter()
166 .any(|fragment| lower.contains(fragment))
167 {
168 return true;
169 }
170 if self.header_is_safe(&lower) {
171 return false;
172 }
173 self.deny_header_substrings
174 .iter()
175 .any(|fragment| lower.contains(fragment))
176 }
177
178 pub fn field_is_sensitive(&self, name: &str) -> bool {
181 let lower = name.to_ascii_lowercase();
182 if self.extra_field_names.contains(&lower) {
183 return true;
184 }
185 is_default_sensitive_field(&lower)
186 }
187
188 pub fn url_param_is_sensitive(&self, name: &str) -> bool {
191 let lower = name.to_ascii_lowercase();
192 if self.extra_url_params.contains(&lower) {
193 return true;
194 }
195 is_default_sensitive_url_param(&lower)
196 }
197
198 pub fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
201 headers
202 .iter()
203 .map(|(name, value)| {
204 if self.header_is_sensitive(name) {
205 (name.clone(), REDACTED_HEADER_VALUE.to_string())
206 } else {
207 (name.clone(), value.clone())
208 }
209 })
210 .collect()
211 }
212
213 pub fn redact_url(&self, url: &str) -> String {
217 let Ok(mut parsed) = Url::parse(url) else {
218 return self.redact_string(url).into_owned();
219 };
220 let mut changed = false;
221
222 if self.redact_url_userinfo
223 && (!parsed.username().is_empty() || parsed.password().is_some())
224 {
225 if parsed.set_username("").is_ok() {
228 changed = true;
229 }
230 if parsed.set_password(None).is_ok() {
231 changed = true;
232 }
233 }
234
235 let pairs: Vec<(String, String)> = parsed
236 .query_pairs()
237 .map(|(key, value)| {
238 if self.url_param_is_sensitive(&key) {
239 changed = true;
240 (key.into_owned(), REDACTED_PLACEHOLDER.to_string())
241 } else {
242 (key.into_owned(), value.into_owned())
243 }
244 })
245 .collect();
246 let original_query = parsed.query().map(str::to_string);
247 if !pairs.is_empty() {
248 parsed.set_query(None);
249 let mut query = parsed.query_pairs_mut();
250 for (key, value) in &pairs {
251 query.append_pair(key, value);
252 }
253 }
254 if !changed {
258 parsed.set_query(original_query.as_deref());
259 return parsed.to_string();
260 }
261 parsed.to_string()
262 }
263
264 pub fn redact_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
269 if !self.scan_strings {
270 return Cow::Borrowed(value);
271 }
272 match self.redact_url_in_string(value) {
273 Cow::Borrowed(_) => scan_secret_patterns(value, REDACTED_PLACEHOLDER),
274 Cow::Owned(url_scrubbed) => {
275 let pattern_scrubbed =
276 scan_secret_patterns(&url_scrubbed, REDACTED_PLACEHOLDER).into_owned();
277 Cow::Owned(pattern_scrubbed)
278 }
279 }
280 }
281
282 pub fn looks_like_secret_value(&self, value: &str) -> bool {
291 let trimmed = value.trim();
292 !trimmed.is_empty()
293 && (self.redact_string(trimmed).as_ref() != trimmed
294 || has_secret_prefix(trimmed)
295 || is_long_bare_secret_candidate(trimmed))
296 }
297
298 fn redact_url_in_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
303 if !self.redact_url_userinfo
304 || !(value.starts_with("http://") || value.starts_with("https://"))
305 {
306 return Cow::Borrowed(value);
307 }
308 let trimmed = value.trim();
309 if trimmed.contains(char::is_whitespace) {
310 return Cow::Borrowed(value);
311 }
312 let redacted = self.redact_url(trimmed);
313 if redacted == trimmed {
314 Cow::Borrowed(value)
315 } else {
316 Cow::Owned(redacted)
317 }
318 }
319
320 pub fn redact_json_in_place(&self, value: &mut JsonValue) {
323 match value {
324 JsonValue::Object(map) => {
325 let mut keys_to_redact: Vec<String> = Vec::new();
326 for (key, child) in map.iter_mut() {
327 if self.field_is_sensitive(key) {
328 keys_to_redact.push(key.clone());
329 } else {
330 self.redact_json_in_place(child);
331 }
332 }
333 for key in keys_to_redact {
334 map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
335 }
336 }
337 JsonValue::Array(items) => {
338 for item in items.iter_mut() {
339 self.redact_json_in_place(item);
340 }
341 }
342 JsonValue::String(s) => {
343 let redacted = self.redact_string(s);
344 if let Cow::Owned(replacement) = redacted {
345 *s = replacement;
346 }
347 }
348 _ => {}
349 }
350 }
351
352 pub fn redact_json(&self, value: &JsonValue) -> JsonValue {
355 let mut clone = value.clone();
356 self.redact_json_in_place(&mut clone);
357 clone
358 }
359}
360
361fn default_safe_headers() -> BTreeSet<String> {
362 BTreeSet::from([
363 "content-length".to_string(),
364 "content-type".to_string(),
365 "request-id".to_string(),
366 "user-agent".to_string(),
367 "x-a2a-delivery".to_string(),
368 "x-correlation-id".to_string(),
369 "x-github-delivery".to_string(),
370 "x-github-event".to_string(),
371 "x-github-hook-id".to_string(),
372 "x-request-id".to_string(),
373 "x-slack-request-timestamp".to_string(),
374 ])
375}
376
377fn default_deny_header_substrings() -> BTreeSet<String> {
378 BTreeSet::from([
379 "authorization".to_string(),
380 "cookie".to_string(),
381 "secret".to_string(),
382 "signature".to_string(),
383 "token".to_string(),
384 "key".to_string(),
385 ])
386}
387
388fn is_default_sensitive_url_param(lower: &str) -> bool {
389 let compact = compact_secret_name(lower);
390 matches!(
391 compact.as_str(),
392 "apikey"
393 | "accesstoken"
394 | "refreshtoken"
395 | "idtoken"
396 | "clientsecret"
397 | "password"
398 | "secret"
399 | "token"
400 | "auth"
401 | "bearer"
402 | "sig"
403 | "signature"
404 ) || compact.ends_with("token")
405 || compact.ends_with("secret")
406 || compact.ends_with("password")
407}
408
409fn is_default_sensitive_field(lower: &str) -> bool {
410 let compact = compact_secret_name(lower);
411 matches!(
412 compact.as_str(),
413 "authorization"
414 | "proxyauthorization"
415 | "cookie"
416 | "setcookie"
417 | "apikey"
418 | "xamzsecuritytoken"
419 | "xapikey"
420 | "xauthtoken"
421 | "xcsrftoken"
422 | "xxsrftoken"
423 | "accesstoken"
424 | "refreshtoken"
425 | "idtoken"
426 | "bearertoken"
427 | "clientsecret"
428 | "password"
429 | "secret"
430 | "passwd"
431 | "privatekey"
432 | "sessiontoken"
433 ) || compact.ends_with("token")
434 || compact.ends_with("secret")
435 || compact.ends_with("password")
436 || compact.ends_with("apikey")
437}
438
439fn compact_secret_name(lower: &str) -> String {
440 lower
441 .chars()
442 .filter(|ch| *ch != '_' && *ch != '-')
443 .collect()
444}
445
446fn has_secret_prefix(trimmed: &str) -> bool {
447 trimmed.starts_with("sk-")
448 || trimmed.starts_with("ghp_")
449 || trimmed.starts_with("ghs_")
450 || trimmed.starts_with("xoxb-")
451 || trimmed.starts_with("xoxp-")
452 || trimmed.starts_with("AKIA")
453}
454
455fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
456 trimmed.len() > 48
457 && trimmed
458 .chars()
459 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
460}
461
462thread_local! {
463 static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
464}
465
466pub fn push_policy(policy: RedactionPolicy) {
469 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
470}
471
472pub fn pop_policy() {
475 REDACTION_POLICY_STACK.with(|stack| {
476 stack.borrow_mut().pop();
477 });
478}
479
480pub fn clear_policy_stack() {
485 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
486 patterns::clear_custom_patterns();
487 let _ = patterns::install_audit_sink(None);
488 patterns::clear_audit_ring();
489}
490
491pub fn current_policy() -> RedactionPolicy {
495 REDACTION_POLICY_STACK.with(|stack| {
496 stack
497 .borrow()
498 .last()
499 .cloned()
500 .unwrap_or_else(RedactionPolicy::default)
501 })
502}
503
504pub struct PolicyGuard;
511
512impl PolicyGuard {
513 pub fn new(policy: RedactionPolicy) -> Self {
514 push_policy(policy);
515 Self
516 }
517}
518
519impl Drop for PolicyGuard {
520 fn drop(&mut self) {
521 pop_policy();
522 }
523}
524
525#[cfg(test)]
526mod tests {
527 use super::*;
528 use serde_json::json;
529
530 fn sample_headers() -> BTreeMap<String, String> {
531 BTreeMap::from([
532 ("Authorization".to_string(), "Bearer secret123".to_string()),
533 ("Cookie".to_string(), "session=abc".to_string()),
534 ("Content-Type".to_string(), "application/json".to_string()),
535 ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
536 (
537 "X-Slack-Signature".to_string(),
538 "v0=abcdef123456".to_string(),
539 ),
540 ("User-Agent".to_string(), "Harn/1.0".to_string()),
541 ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
542 ])
543 }
544
545 #[test]
546 fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
547 let policy = RedactionPolicy::default();
548 let redacted = policy.redact_headers(&sample_headers());
549 assert_eq!(
550 redacted.get("Authorization").unwrap(),
551 REDACTED_HEADER_VALUE
552 );
553 assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
554 assert_eq!(
555 redacted.get("X-Webhook-Token").unwrap(),
556 REDACTED_HEADER_VALUE
557 );
558 assert_eq!(
559 redacted.get("X-Slack-Signature").unwrap(),
560 REDACTED_HEADER_VALUE
561 );
562 assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
563 assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
564 assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
565 }
566
567 #[test]
568 fn passthrough_policy_redacts_nothing() {
569 let policy = RedactionPolicy::passthrough();
570 let redacted = policy.redact_headers(&sample_headers());
571 assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
572 }
573
574 #[test]
575 fn host_can_extend_safe_and_deny_headers() {
576 let policy = RedactionPolicy::default()
577 .with_safe_header("X-Webhook-Token")
578 .with_deny_header_substring("delivery");
579 let redacted = policy.redact_headers(&sample_headers());
580 assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
581 assert_eq!(
582 redacted.get("X-GitHub-Delivery").unwrap(),
583 REDACTED_HEADER_VALUE,
584 "host explicitly forced delivery to be sensitive"
585 );
586 }
587
588 #[test]
589 fn redact_url_strips_userinfo_and_sensitive_query_params() {
590 let policy = RedactionPolicy::default();
591 let redacted = policy.redact_url(
592 "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
593 );
594 assert!(redacted.contains("api_key=%5Bredacted%5D"));
595 assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
596 assert!(redacted.contains("page=2"));
597 assert!(!redacted.contains("user:pw@"));
598 }
599
600 #[test]
601 fn redact_url_leaves_clean_urls_alone() {
602 let policy = RedactionPolicy::default();
603 let url = "https://api.example.com/v1?page=2";
604 assert_eq!(policy.redact_url(url), url);
605 }
606
607 #[test]
608 fn redact_json_strips_sensitive_field_names_recursively() {
609 let policy = RedactionPolicy::default();
610 let mut value = json!({
611 "headers": {
612 "authorization": "Bearer abc",
613 "X-Amz-Security-Token": "session",
614 "x-trace-id": "trace_1",
615 },
616 "list": [
617 { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
618 { "name": "bob" },
619 ],
620 "clientSecret": "camel-secret",
621 "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
622 "url": "https://api.example.com/v1?api_key=hideme",
623 });
624 policy.redact_json_in_place(&mut value);
625 assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
626 assert_eq!(
627 value["headers"]["X-Amz-Security-Token"],
628 REDACTED_PLACEHOLDER
629 );
630 assert_eq!(value["headers"]["x-trace-id"], "trace_1");
631 assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
632 assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
633 assert_eq!(value["list"][0]["name"], "alice");
634 assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
635 let free_form = value["free_form"].as_str().unwrap();
636 assert!(
640 free_form.contains("<redacted:"),
641 "expected named placeholder, got: {free_form}"
642 );
643 assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
644 }
645
646 #[test]
647 fn policy_guard_pushes_and_pops_thread_local() {
648 clear_policy_stack();
649 assert_eq!(current_policy(), RedactionPolicy::default());
650 {
651 let policy = RedactionPolicy::default().with_extra_field("custom_token");
652 let _guard = PolicyGuard::new(policy.clone());
653 assert_eq!(current_policy(), policy);
654 }
655 assert_eq!(current_policy(), RedactionPolicy::default());
656 }
657
658 #[test]
659 fn redact_string_replaces_known_secret_patterns() {
660 let policy = RedactionPolicy::default();
661 let input =
662 "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
663 let out = policy.redact_string(input);
664 assert!(out.contains("<redacted:openai_key:"));
667 assert!(out.contains("<redacted:aws_access_key:"));
668 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
669 assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
670 }
671
672 #[test]
673 fn looks_like_secret_value_accepts_logical_secret_references() {
674 let policy = RedactionPolicy::default();
675 assert!(policy.looks_like_secret_value("sk-live-secret"));
676 assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
677 assert!(!policy.looks_like_secret_value("github/webhook-secret"));
678 assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
679 }
680}