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
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
577/// Push a policy onto the thread-local stack. Pair every push with a
578/// [`pop_policy`] call (or use [`PolicyGuard`]).
579pub fn push_policy(policy: RedactionPolicy) {
580    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
581}
582
583/// Pop the most recently pushed policy. Safe to call when the stack is
584/// empty.
585pub fn pop_policy() {
586    REDACTION_POLICY_STACK.with(|stack| {
587        stack.borrow_mut().pop();
588    });
589}
590
591/// Drop all installed policies, custom token-redaction patterns, the
592/// audit sink, and the per-thread audit ring. Used by
593/// `reset_thread_local_state` so test runs that share a thread cannot
594/// leak policy overrides into each other.
595pub 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
602/// Return the currently installed policy, falling back to
603/// [`RedactionPolicy::default`] when the stack is empty. Always returns
604/// an owned clone so callers can drop the borrow before recursing.
605pub 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
615/// RAII guard that pushes a policy on construction and pops it on drop.
616///
617/// ```ignore
618/// let _guard = harn_vm::redact::PolicyGuard::new(RedactionPolicy::default());
619/// // … emit receipts, transcripts, etc.
620/// ```
621pub 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        // Free-form pattern matches produce the OA-06 named placeholder
765        // `<redacted:<pattern>:<len>>` so audit logs can attribute leaks to a
766        // specific provider.
767        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        // Each provider pattern emits its own `<redacted:<name>:<len>>`
793        // placeholder so audit logs can attribute the leak.
794        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}