Skip to main content

harn_vm/redact/
mod.rs

1//! Unified redaction policy for persisted and rendered operational data.
2//!
3//! Harn writes transcripts, receipts, event logs, portal JSON, connector
4//! status snapshots, and workflow artifacts. This module is the single source
5//! of truth for scrubbing HTTP headers, URL query parameters, JSON tokens, and
6//! free-form strings so the same
7//! representative secret cannot leak through two surfaces by accident.
8//!
9//! # Categories
10//!
11//! - **Auth headers, cookies, signature/proxy tokens** — covered by
12//!   [`RedactionPolicy::redact_headers`].
13//! - **URLs with credentials in userinfo or sensitive query parameters**
14//!   — covered by [`RedactionPolicy::redact_url`].
15//! - **JSON fields whose name is auth/credential-shaped** — covered by
16//!   [`RedactionPolicy::redact_json_in_place`].
17//! - **Free-form strings carrying high-confidence secret patterns**
18//!   (Stripe `sk_live_…`, GitHub `ghp_…`, AWS `AKIA…`, Bearer tokens,
19//!   `-----BEGIN … PRIVATE KEY-----`) — covered by
20//!   [`RedactionPolicy::redact_string`] and applied recursively by
21//!   [`RedactionPolicy::redact_json_in_place`].
22//!
23//! # Host configuration
24//!
25//! Hosts compose policies via the builder methods (`with_safe_header`,
26//! `with_extra_field`, `with_extra_url_param`, `disable_string_scan`).
27//! Active policies are pushed onto a thread-local stack the same way
28//! approval policies are, so a single orchestrator startup site can
29//! install host overrides for every persistence path that calls
30//! [`current_policy`].
31
32mod 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
50/// Placeholder string used everywhere a redacted value would otherwise
51/// appear. Kept as a single constant so portal CSS, downstream parsers,
52/// and humans grepping logs can rely on one form.
53pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
54
55/// Header value for redacted HTTP headers. Identical to
56/// [`REDACTED_PLACEHOLDER`] today, exposed as a separate symbol so the
57/// trigger/event tests that pre-date the unified module remain readable.
58pub 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    /// Permissive policy used by tests that need raw data. No headers,
87    /// fields, or strings are scrubbed.
88    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    /// Add a header (case-insensitive) to the safe-list. Header
101    /// redaction will leave its value untouched even if the name would
102    /// otherwise look auth-shaped (e.g. an `x-…-key` header that is
103    /// actually a request-id).
104    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    /// Add a substring (case-insensitive) that always forces a header
110    /// to be treated as sensitive. Useful for product-specific token
111    /// header names that the default `cookie`/`authorization`/`token`/`secret`/`key`
112    /// substring set would miss.
113    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    /// Add a JSON field name (case-insensitive, exact match) that should
120    /// always be redacted regardless of value contents. Useful when a
121    /// host knows it stores `internal_audit_token` or similar.
122    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    /// Add an extra URL query parameter name to redact.
129    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    /// Disable the heuristic free-form string scanner. The scanner adds
136    /// a small but non-zero cost to every JSON payload walk; turn it off
137    /// for performance-critical paths that have already been audited.
138    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        // Exact-name allowlist is one source of truth in `safe_headers`;
145        // suffix/substring rules below cover the families of debugging
146        // headers that providers emit with arbitrary suffixes.
147        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    /// Whether a given HTTP header name should have its value replaced
157    /// with [`REDACTED_HEADER_VALUE`].
158    ///
159    /// Host-explicit deny substrings always win, even over the built-in
160    /// safe-list — that is how a host says "treat my own webhook
161    /// delivery header as sensitive even though Harn would normally
162    /// keep it for debugging."
163    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    /// Whether a JSON object field name should be replaced with the
181    /// redacted placeholder before the value is even inspected.
182    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    /// Whether a URL query parameter name should have its value
191    /// replaced.
192    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    /// Returns a [`BTreeMap`] of headers with sensitive values replaced
201    /// by [`REDACTED_HEADER_VALUE`].
202    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    /// Redact sensitive query parameters and credentials in URL
216    /// userinfo. Returns the input unchanged if nothing matches or the
217    /// URL fails to parse.
218    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            // url::Url returns Err only when the URL cannot have a
228            // password (e.g. cannot-be-a-base). Treat that as a no-op.
229            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        // `query_pairs_mut` always re-encodes; restore the original
257        // query string when nothing was actually redacted so we don't
258        // perturb otherwise stable URLs.
259        if !changed {
260            parsed.set_query(original_query.as_deref());
261            return parsed.to_string();
262        }
263        parsed.to_string()
264    }
265
266    /// Returns a redacted string. Cheap (`Cow::Borrowed`) when nothing
267    /// matched. Applies, in order: URL-shaped string detection (so the
268    /// userinfo or sensitive query params on `https://user:pw@…?api_key=…`
269    /// are scrubbed), then high-confidence secret pattern replacement.
270    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    /// Redact sensitive credentials and query parameters from HTTP(S) URLs
285    /// embedded in free-form diagnostic text. This is intentionally separate
286    /// from [`Self::redact_string`]: broad text tokenization is useful for
287    /// transport errors that include URLs inside prose, while normal string
288    /// redaction keeps its lower-perturbation standalone-URL behavior.
289    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    /// Conservative predicate for fields that must contain logical
323    /// secret references rather than raw credential material.
324    ///
325    /// This is intentionally broader than [`redact_string`]: short
326    /// fake-looking values such as `sk-live-secret` are useful test
327    /// sentinels and should be rejected from `required_secrets` /
328    /// context-pack manifests even though the free-form string
329    /// redactor avoids replacing such short text globally.
330    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    /// If `value` is a single URL with credentials or sensitive query
339    /// params, return the redacted form. Standalone URLs are common in
340    /// logged request envelopes; we don't try to walk arbitrary text
341    /// for embedded URLs because that turns into ad-hoc tokenization.
342    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    /// Recursively walk a JSON value, redacting sensitive object fields
361    /// and string contents in place.
362    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    /// Convenience for callers that have an immutable JSON value: clone
393    /// once and redact.
394    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
401fn find_http_url_start(value: &str) -> Option<usize> {
402    match (value.find("http://"), value.find("https://")) {
403        (Some(http), Some(https)) => Some(http.min(https)),
404        (Some(http), None) => Some(http),
405        (None, Some(https)) => Some(https),
406        (None, None) => None,
407    }
408}
409
410fn http_url_token_end(value: &str, start: usize) -> usize {
411    value[start..]
412        .char_indices()
413        .find_map(|(offset, character)| {
414            (offset > 0 && is_url_text_delimiter(character)).then_some(start + offset)
415        })
416        .unwrap_or(value.len())
417}
418
419fn is_url_text_delimiter(character: char) -> bool {
420    character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '`')
421}
422
423fn split_url_token(token: &str) -> Option<(&str, &str)> {
424    let mut prose_end = token.len();
425    while prose_end > 0 {
426        let candidate = &token[..prose_end];
427        let last = candidate.chars().last()?;
428        if !is_trailing_prose_punctuation(last) {
429            break;
430        }
431        prose_end -= last.len_utf8();
432    }
433    if prose_end > 0 {
434        let candidate = &token[..prose_end];
435        if Url::parse(candidate).is_ok() {
436            return Some((candidate, &token[prose_end..]));
437        }
438    }
439
440    let mut end = token.len();
441    while end > 0 {
442        let candidate = &token[..end];
443        if Url::parse(candidate).is_ok() {
444            return Some((candidate, &token[end..]));
445        }
446        let last = candidate.chars().last()?;
447        if !is_trailing_prose_punctuation(last) {
448            return None;
449        }
450        end -= last.len_utf8();
451    }
452    None
453}
454
455fn is_trailing_prose_punctuation(character: char) -> bool {
456    matches!(
457        character,
458        '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'
459    )
460}
461
462fn default_safe_headers() -> BTreeSet<String> {
463    BTreeSet::from([
464        "content-length".to_string(),
465        "content-type".to_string(),
466        "request-id".to_string(),
467        "user-agent".to_string(),
468        "x-a2a-delivery".to_string(),
469        "x-correlation-id".to_string(),
470        "x-github-delivery".to_string(),
471        "x-github-event".to_string(),
472        "x-github-hook-id".to_string(),
473        "x-request-id".to_string(),
474        "x-slack-request-timestamp".to_string(),
475    ])
476}
477
478fn default_deny_header_substrings() -> BTreeSet<String> {
479    BTreeSet::from([
480        "authorization".to_string(),
481        "cookie".to_string(),
482        "secret".to_string(),
483        "signature".to_string(),
484        "token".to_string(),
485        "key".to_string(),
486    ])
487}
488
489fn is_default_sensitive_url_param(lower: &str) -> bool {
490    let compact = compact_secret_name(lower);
491    matches!(
492        compact.as_str(),
493        "apikey"
494            | "accesstoken"
495            | "refreshtoken"
496            | "idtoken"
497            | "clientsecret"
498            | "password"
499            | "secret"
500            | "token"
501            | "auth"
502            | "bearer"
503            | "sig"
504            | "signature"
505    ) || compact.ends_with("token")
506        || compact.ends_with("secret")
507        || compact.ends_with("password")
508}
509
510fn is_default_sensitive_field(lower: &str) -> bool {
511    let compact = compact_secret_name(lower);
512    matches!(
513        compact.as_str(),
514        "authorization"
515            | "proxyauthorization"
516            | "cookie"
517            | "setcookie"
518            | "apikey"
519            | "xamzsecuritytoken"
520            | "xapikey"
521            | "xauthtoken"
522            | "xcsrftoken"
523            | "xxsrftoken"
524            | "accesstoken"
525            | "refreshtoken"
526            | "idtoken"
527            | "bearertoken"
528            | "clientsecret"
529            | "password"
530            | "secret"
531            | "passwd"
532            | "privatekey"
533            | "sessiontoken"
534    ) || compact.ends_with("token")
535        || compact.ends_with("secret")
536        || compact.ends_with("password")
537        || compact.ends_with("apikey")
538}
539
540fn compact_secret_name(lower: &str) -> String {
541    lower
542        .chars()
543        .filter(|ch| *ch != '_' && *ch != '-')
544        .collect()
545}
546
547fn has_secret_prefix(trimmed: &str) -> bool {
548    trimmed.starts_with("sk-")
549        || trimmed.starts_with("ghp_")
550        || trimmed.starts_with("ghs_")
551        || trimmed.starts_with("xoxb-")
552        || trimmed.starts_with("xoxp-")
553        || trimmed.starts_with("AKIA")
554}
555
556fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
557    trimmed.len() > 48
558        && trimmed
559            .chars()
560            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
561}
562
563thread_local! {
564    static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
565}
566
567/// Push a policy onto the thread-local stack. Pair every push with a
568/// [`pop_policy`] call (or use [`PolicyGuard`]).
569pub fn push_policy(policy: RedactionPolicy) {
570    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
571}
572
573/// Pop the most recently pushed policy. Safe to call when the stack is
574/// empty.
575pub fn pop_policy() {
576    REDACTION_POLICY_STACK.with(|stack| {
577        stack.borrow_mut().pop();
578    });
579}
580
581/// Drop all installed policies, custom token-redaction patterns, the
582/// audit sink, and the per-thread audit ring. Used by
583/// `reset_thread_local_state` so test runs that share a thread cannot
584/// leak policy overrides into each other.
585pub fn clear_policy_stack() {
586    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
587    patterns::clear_custom_patterns();
588    let _ = patterns::install_audit_sink(None);
589    patterns::clear_audit_ring();
590}
591
592/// Return the currently installed policy, falling back to
593/// [`RedactionPolicy::default`] when the stack is empty. Always returns
594/// an owned clone so callers can drop the borrow before recursing.
595pub fn current_policy() -> RedactionPolicy {
596    REDACTION_POLICY_STACK.with(|stack| {
597        stack
598            .borrow()
599            .last()
600            .cloned()
601            .unwrap_or_else(RedactionPolicy::default)
602    })
603}
604
605/// RAII guard that pushes a policy on construction and pops it on drop.
606///
607/// ```ignore
608/// let _guard = harn_vm::redact::PolicyGuard::new(RedactionPolicy::default());
609/// // … emit receipts, transcripts, etc.
610/// ```
611pub struct PolicyGuard;
612
613impl PolicyGuard {
614    pub fn new(policy: RedactionPolicy) -> Self {
615        push_policy(policy);
616        Self
617    }
618}
619
620impl Drop for PolicyGuard {
621    fn drop(&mut self) {
622        pop_policy();
623    }
624}
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use serde_json::json;
630
631    fn sample_headers() -> BTreeMap<String, String> {
632        BTreeMap::from([
633            ("Authorization".to_string(), "Bearer secret123".to_string()),
634            ("Cookie".to_string(), "session=abc".to_string()),
635            ("Content-Type".to_string(), "application/json".to_string()),
636            ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
637            (
638                "X-Slack-Signature".to_string(),
639                "v0=abcdef123456".to_string(),
640            ),
641            ("User-Agent".to_string(), "Harn/1.0".to_string()),
642            ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
643        ])
644    }
645
646    #[test]
647    fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
648        let policy = RedactionPolicy::default();
649        let redacted = policy.redact_headers(&sample_headers());
650        assert_eq!(
651            redacted.get("Authorization").unwrap(),
652            REDACTED_HEADER_VALUE
653        );
654        assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
655        assert_eq!(
656            redacted.get("X-Webhook-Token").unwrap(),
657            REDACTED_HEADER_VALUE
658        );
659        assert_eq!(
660            redacted.get("X-Slack-Signature").unwrap(),
661            REDACTED_HEADER_VALUE
662        );
663        assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
664        assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
665        assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
666    }
667
668    #[test]
669    fn passthrough_policy_redacts_nothing() {
670        let policy = RedactionPolicy::passthrough();
671        let redacted = policy.redact_headers(&sample_headers());
672        assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
673    }
674
675    #[test]
676    fn host_can_extend_safe_and_deny_headers() {
677        let policy = RedactionPolicy::default()
678            .with_safe_header("X-Webhook-Token")
679            .with_deny_header_substring("delivery");
680        let redacted = policy.redact_headers(&sample_headers());
681        assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
682        assert_eq!(
683            redacted.get("X-GitHub-Delivery").unwrap(),
684            REDACTED_HEADER_VALUE,
685            "host explicitly forced delivery to be sensitive"
686        );
687    }
688
689    #[test]
690    fn redact_url_strips_userinfo_and_sensitive_query_params() {
691        let policy = RedactionPolicy::default();
692        let redacted = policy.redact_url(
693            "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
694        );
695        assert!(redacted.contains("api_key=%5Bredacted%5D"));
696        assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
697        assert!(redacted.contains("page=2"));
698        assert!(!redacted.contains("user:pw@"));
699    }
700
701    #[test]
702    fn redact_url_leaves_clean_urls_alone() {
703        let policy = RedactionPolicy::default();
704        let url = "https://api.example.com/v1?page=2";
705        assert_eq!(policy.redact_url(url), url);
706    }
707
708    #[test]
709    fn redact_urls_in_text_strips_embedded_sensitive_urls() {
710        let policy = RedactionPolicy::default();
711        let redacted = policy.redact_urls_in_text(
712            "clean https://status.example.com/health then \
713             redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
714             to http://public.example.com/next?client_secret=target-secret.",
715        );
716        assert!(redacted.starts_with("clean https://status.example.com/health then "));
717        assert!(redacted.contains("access_token=%5Bredacted%5D"));
718        assert!(redacted.contains("client_secret=%5Bredacted%5D"));
719        assert!(!redacted.contains("source-secret"));
720        assert!(!redacted.contains("target-secret"));
721        assert!(!redacted.contains("user:pw@"));
722        assert!(redacted.ends_with('.'));
723    }
724
725    #[test]
726    fn redact_json_strips_sensitive_field_names_recursively() {
727        let policy = RedactionPolicy::default();
728        let mut value = json!({
729            "headers": {
730                "authorization": "Bearer abc",
731                "X-Amz-Security-Token": "session",
732                "x-trace-id": "trace_1",
733            },
734            "list": [
735                { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
736                { "name": "bob" },
737            ],
738            "clientSecret": "camel-secret",
739            "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
740            "url": "https://api.example.com/v1?api_key=hideme",
741        });
742        policy.redact_json_in_place(&mut value);
743        assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
744        assert_eq!(
745            value["headers"]["X-Amz-Security-Token"],
746            REDACTED_PLACEHOLDER
747        );
748        assert_eq!(value["headers"]["x-trace-id"], "trace_1");
749        assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
750        assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
751        assert_eq!(value["list"][0]["name"], "alice");
752        assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
753        let free_form = value["free_form"].as_str().unwrap();
754        // Free-form pattern matches produce the OA-06 named placeholder
755        // `<redacted:<pattern>:<len>>` so audit logs can attribute leaks to a
756        // specific provider.
757        assert!(
758            free_form.contains("<redacted:"),
759            "expected named placeholder, got: {free_form}"
760        );
761        assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
762    }
763
764    #[test]
765    fn policy_guard_pushes_and_pops_thread_local() {
766        clear_policy_stack();
767        assert_eq!(current_policy(), RedactionPolicy::default());
768        {
769            let policy = RedactionPolicy::default().with_extra_field("custom_token");
770            let _guard = PolicyGuard::new(policy.clone());
771            assert_eq!(current_policy(), policy);
772        }
773        assert_eq!(current_policy(), RedactionPolicy::default());
774    }
775
776    #[test]
777    fn redact_string_replaces_known_secret_patterns() {
778        let policy = RedactionPolicy::default();
779        let input =
780            "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
781        let out = policy.redact_string(input);
782        // Each provider pattern emits its own `<redacted:<name>:<len>>`
783        // placeholder so audit logs can attribute the leak.
784        assert!(out.contains("<redacted:openai_key:"));
785        assert!(out.contains("<redacted:aws_access_key:"));
786        assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
787        assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
788    }
789
790    #[test]
791    fn looks_like_secret_value_accepts_logical_secret_references() {
792        let policy = RedactionPolicy::default();
793        assert!(policy.looks_like_secret_value("sk-live-secret"));
794        assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
795        assert!(!policy.looks_like_secret_value("github/webhook-secret"));
796        assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
797    }
798}