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