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 looks_like_secret_value(&self, value: &str) -> bool {
293 let trimmed = value.trim();
294 !trimmed.is_empty()
295 && (self.redact_string(trimmed).as_ref() != trimmed
296 || has_secret_prefix(trimmed)
297 || is_long_bare_secret_candidate(trimmed))
298 }
299
300 fn redact_url_in_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
305 if !self.redact_url_userinfo
306 || !(value.starts_with("http://") || value.starts_with("https://"))
307 {
308 return Cow::Borrowed(value);
309 }
310 let trimmed = value.trim();
311 if trimmed.contains(char::is_whitespace) {
312 return Cow::Borrowed(value);
313 }
314 let redacted = self.redact_url(trimmed);
315 if redacted == trimmed {
316 Cow::Borrowed(value)
317 } else {
318 Cow::Owned(redacted)
319 }
320 }
321
322 pub fn redact_json_in_place(&self, value: &mut JsonValue) {
325 match value {
326 JsonValue::Object(map) => {
327 let mut keys_to_redact: Vec<String> = Vec::new();
328 for (key, child) in map.iter_mut() {
329 if self.field_is_sensitive(key) {
330 keys_to_redact.push(key.clone());
331 } else {
332 self.redact_json_in_place(child);
333 }
334 }
335 for key in keys_to_redact {
336 map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
337 }
338 }
339 JsonValue::Array(items) => {
340 for item in items.iter_mut() {
341 self.redact_json_in_place(item);
342 }
343 }
344 JsonValue::String(s) => {
345 let redacted = self.redact_string(s);
346 if let Cow::Owned(replacement) = redacted {
347 *s = replacement;
348 }
349 }
350 _ => {}
351 }
352 }
353
354 pub fn redact_json(&self, value: &JsonValue) -> JsonValue {
357 let mut clone = value.clone();
358 self.redact_json_in_place(&mut clone);
359 clone
360 }
361}
362
363fn default_safe_headers() -> BTreeSet<String> {
364 BTreeSet::from([
365 "content-length".to_string(),
366 "content-type".to_string(),
367 "request-id".to_string(),
368 "user-agent".to_string(),
369 "x-a2a-delivery".to_string(),
370 "x-correlation-id".to_string(),
371 "x-github-delivery".to_string(),
372 "x-github-event".to_string(),
373 "x-github-hook-id".to_string(),
374 "x-request-id".to_string(),
375 "x-slack-request-timestamp".to_string(),
376 ])
377}
378
379fn default_deny_header_substrings() -> BTreeSet<String> {
380 BTreeSet::from([
381 "authorization".to_string(),
382 "cookie".to_string(),
383 "secret".to_string(),
384 "signature".to_string(),
385 "token".to_string(),
386 "key".to_string(),
387 ])
388}
389
390fn is_default_sensitive_url_param(lower: &str) -> bool {
391 let compact = compact_secret_name(lower);
392 matches!(
393 compact.as_str(),
394 "apikey"
395 | "accesstoken"
396 | "refreshtoken"
397 | "idtoken"
398 | "clientsecret"
399 | "password"
400 | "secret"
401 | "token"
402 | "auth"
403 | "bearer"
404 | "sig"
405 | "signature"
406 ) || compact.ends_with("token")
407 || compact.ends_with("secret")
408 || compact.ends_with("password")
409}
410
411fn is_default_sensitive_field(lower: &str) -> bool {
412 let compact = compact_secret_name(lower);
413 matches!(
414 compact.as_str(),
415 "authorization"
416 | "proxyauthorization"
417 | "cookie"
418 | "setcookie"
419 | "apikey"
420 | "xamzsecuritytoken"
421 | "xapikey"
422 | "xauthtoken"
423 | "xcsrftoken"
424 | "xxsrftoken"
425 | "accesstoken"
426 | "refreshtoken"
427 | "idtoken"
428 | "bearertoken"
429 | "clientsecret"
430 | "password"
431 | "secret"
432 | "passwd"
433 | "privatekey"
434 | "sessiontoken"
435 ) || compact.ends_with("token")
436 || compact.ends_with("secret")
437 || compact.ends_with("password")
438 || compact.ends_with("apikey")
439}
440
441fn compact_secret_name(lower: &str) -> String {
442 lower
443 .chars()
444 .filter(|ch| *ch != '_' && *ch != '-')
445 .collect()
446}
447
448fn has_secret_prefix(trimmed: &str) -> bool {
449 trimmed.starts_with("sk-")
450 || trimmed.starts_with("ghp_")
451 || trimmed.starts_with("ghs_")
452 || trimmed.starts_with("xoxb-")
453 || trimmed.starts_with("xoxp-")
454 || trimmed.starts_with("AKIA")
455}
456
457fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
458 trimmed.len() > 48
459 && trimmed
460 .chars()
461 .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
462}
463
464thread_local! {
465 static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
466}
467
468pub fn push_policy(policy: RedactionPolicy) {
471 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
472}
473
474pub fn pop_policy() {
477 REDACTION_POLICY_STACK.with(|stack| {
478 stack.borrow_mut().pop();
479 });
480}
481
482pub fn clear_policy_stack() {
487 REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
488 patterns::clear_custom_patterns();
489 let _ = patterns::install_audit_sink(None);
490 patterns::clear_audit_ring();
491}
492
493pub fn current_policy() -> RedactionPolicy {
497 REDACTION_POLICY_STACK.with(|stack| {
498 stack
499 .borrow()
500 .last()
501 .cloned()
502 .unwrap_or_else(RedactionPolicy::default)
503 })
504}
505
506pub struct PolicyGuard;
513
514impl PolicyGuard {
515 pub fn new(policy: RedactionPolicy) -> Self {
516 push_policy(policy);
517 Self
518 }
519}
520
521impl Drop for PolicyGuard {
522 fn drop(&mut self) {
523 pop_policy();
524 }
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530 use serde_json::json;
531
532 fn sample_headers() -> BTreeMap<String, String> {
533 BTreeMap::from([
534 ("Authorization".to_string(), "Bearer secret123".to_string()),
535 ("Cookie".to_string(), "session=abc".to_string()),
536 ("Content-Type".to_string(), "application/json".to_string()),
537 ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
538 (
539 "X-Slack-Signature".to_string(),
540 "v0=abcdef123456".to_string(),
541 ),
542 ("User-Agent".to_string(), "Harn/1.0".to_string()),
543 ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
544 ])
545 }
546
547 #[test]
548 fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
549 let policy = RedactionPolicy::default();
550 let redacted = policy.redact_headers(&sample_headers());
551 assert_eq!(
552 redacted.get("Authorization").unwrap(),
553 REDACTED_HEADER_VALUE
554 );
555 assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
556 assert_eq!(
557 redacted.get("X-Webhook-Token").unwrap(),
558 REDACTED_HEADER_VALUE
559 );
560 assert_eq!(
561 redacted.get("X-Slack-Signature").unwrap(),
562 REDACTED_HEADER_VALUE
563 );
564 assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
565 assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
566 assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
567 }
568
569 #[test]
570 fn passthrough_policy_redacts_nothing() {
571 let policy = RedactionPolicy::passthrough();
572 let redacted = policy.redact_headers(&sample_headers());
573 assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
574 }
575
576 #[test]
577 fn host_can_extend_safe_and_deny_headers() {
578 let policy = RedactionPolicy::default()
579 .with_safe_header("X-Webhook-Token")
580 .with_deny_header_substring("delivery");
581 let redacted = policy.redact_headers(&sample_headers());
582 assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
583 assert_eq!(
584 redacted.get("X-GitHub-Delivery").unwrap(),
585 REDACTED_HEADER_VALUE,
586 "host explicitly forced delivery to be sensitive"
587 );
588 }
589
590 #[test]
591 fn redact_url_strips_userinfo_and_sensitive_query_params() {
592 let policy = RedactionPolicy::default();
593 let redacted = policy.redact_url(
594 "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
595 );
596 assert!(redacted.contains("api_key=%5Bredacted%5D"));
597 assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
598 assert!(redacted.contains("page=2"));
599 assert!(!redacted.contains("user:pw@"));
600 }
601
602 #[test]
603 fn redact_url_leaves_clean_urls_alone() {
604 let policy = RedactionPolicy::default();
605 let url = "https://api.example.com/v1?page=2";
606 assert_eq!(policy.redact_url(url), url);
607 }
608
609 #[test]
610 fn redact_json_strips_sensitive_field_names_recursively() {
611 let policy = RedactionPolicy::default();
612 let mut value = json!({
613 "headers": {
614 "authorization": "Bearer abc",
615 "X-Amz-Security-Token": "session",
616 "x-trace-id": "trace_1",
617 },
618 "list": [
619 { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
620 { "name": "bob" },
621 ],
622 "clientSecret": "camel-secret",
623 "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
624 "url": "https://api.example.com/v1?api_key=hideme",
625 });
626 policy.redact_json_in_place(&mut value);
627 assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
628 assert_eq!(
629 value["headers"]["X-Amz-Security-Token"],
630 REDACTED_PLACEHOLDER
631 );
632 assert_eq!(value["headers"]["x-trace-id"], "trace_1");
633 assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
634 assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
635 assert_eq!(value["list"][0]["name"], "alice");
636 assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
637 let free_form = value["free_form"].as_str().unwrap();
638 assert!(
642 free_form.contains("<redacted:"),
643 "expected named placeholder, got: {free_form}"
644 );
645 assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
646 }
647
648 #[test]
649 fn policy_guard_pushes_and_pops_thread_local() {
650 clear_policy_stack();
651 assert_eq!(current_policy(), RedactionPolicy::default());
652 {
653 let policy = RedactionPolicy::default().with_extra_field("custom_token");
654 let _guard = PolicyGuard::new(policy.clone());
655 assert_eq!(current_policy(), policy);
656 }
657 assert_eq!(current_policy(), RedactionPolicy::default());
658 }
659
660 #[test]
661 fn redact_string_replaces_known_secret_patterns() {
662 let policy = RedactionPolicy::default();
663 let input =
664 "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
665 let out = policy.redact_string(input);
666 assert!(out.contains("<redacted:openai_key:"));
669 assert!(out.contains("<redacted:aws_access_key:"));
670 assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
671 assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
672 }
673
674 #[test]
675 fn looks_like_secret_value_accepts_logical_secret_references() {
676 let policy = RedactionPolicy::default();
677 assert!(policy.looks_like_secret_value("sk-live-secret"));
678 assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
679 assert!(!policy.looks_like_secret_value("github/webhook-secret"));
680 assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
681 }
682}