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    #[expect(
290        clippy::string_slice,
291        reason = "cursors are find offsets or char_indices token ends on the same text"
292    )]
293    pub fn redact_urls_in_text<'a>(&self, value: &'a str) -> Cow<'a, str> {
294        let mut scan_cursor = 0;
295        let mut emit_cursor = 0;
296        let mut output: Option<String> = None;
297
298        while let Some(relative_start) = find_http_url_start(&value[scan_cursor..]) {
299            let start = scan_cursor + relative_start;
300            let token_end = http_url_token_end(value, start);
301            let token = &value[start..token_end];
302            let Some((url, suffix)) = split_url_token(token) else {
303                scan_cursor = token_end;
304                continue;
305            };
306            let redacted = self.redact_url(url);
307            if redacted != url {
308                let output = output.get_or_insert_with(|| String::with_capacity(value.len()));
309                output.push_str(&value[emit_cursor..start]);
310                output.push_str(&redacted);
311                output.push_str(suffix);
312                emit_cursor = token_end;
313            }
314            scan_cursor = token_end;
315        }
316
317        match output {
318            Some(mut output) => {
319                output.push_str(&value[emit_cursor..]);
320                Cow::Owned(output)
321            }
322            None => Cow::Borrowed(value),
323        }
324    }
325
326    /// Conservative predicate for fields that must contain logical
327    /// secret references rather than raw credential material.
328    ///
329    /// This is intentionally broader than [`redact_string`]: short
330    /// fake-looking values such as `sk-live-secret` are useful test
331    /// sentinels and should be rejected from `required_secrets` /
332    /// context-pack manifests even though the free-form string
333    /// redactor avoids replacing such short text globally.
334    pub fn looks_like_secret_value(&self, value: &str) -> bool {
335        let trimmed = value.trim();
336        !trimmed.is_empty()
337            && (self.redact_string(trimmed).as_ref() != trimmed
338                || has_secret_prefix(trimmed)
339                || is_long_bare_secret_candidate(trimmed))
340    }
341
342    /// If `value` is a single URL with credentials or sensitive query
343    /// params, return the redacted form. Standalone URLs are common in
344    /// logged request envelopes; we don't try to walk arbitrary text
345    /// for embedded URLs because that turns into ad-hoc tokenization.
346    fn redact_url_in_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
347        if !self.redact_url_userinfo
348            || !(value.starts_with("http://") || value.starts_with("https://"))
349        {
350            return Cow::Borrowed(value);
351        }
352        let trimmed = value.trim();
353        if trimmed.contains(char::is_whitespace) {
354            return Cow::Borrowed(value);
355        }
356        let redacted = self.redact_url(trimmed);
357        if redacted == trimmed {
358            Cow::Borrowed(value)
359        } else {
360            Cow::Owned(redacted)
361        }
362    }
363
364    /// Recursively walk a JSON value, redacting sensitive object fields
365    /// and string contents in place.
366    pub fn redact_json_in_place(&self, value: &mut JsonValue) {
367        match value {
368            JsonValue::Object(map) => {
369                let mut keys_to_redact: Vec<String> = Vec::new();
370                for (key, child) in map.iter_mut() {
371                    if self.field_is_sensitive(key) {
372                        keys_to_redact.push(key.clone());
373                    } else {
374                        self.redact_json_in_place(child);
375                    }
376                }
377                for key in keys_to_redact {
378                    map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
379                }
380            }
381            JsonValue::Array(items) => {
382                for item in items.iter_mut() {
383                    self.redact_json_in_place(item);
384                }
385            }
386            JsonValue::String(s) => {
387                let redacted = self.redact_string(s);
388                if let Cow::Owned(replacement) = redacted {
389                    *s = replacement;
390                }
391            }
392            _ => {}
393        }
394    }
395
396    /// Convenience for callers that have an immutable JSON value: clone
397    /// once and redact.
398    pub fn redact_json(&self, value: &JsonValue) -> JsonValue {
399        let mut clone = value.clone();
400        self.redact_json_in_place(&mut clone);
401        clone
402    }
403}
404
405impl harn_session_store::EventRedactor for RedactionPolicy {
406    fn redact_json_in_place(&self, value: &mut JsonValue) {
407        Self::redact_json_in_place(self, value);
408    }
409
410    fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
411        Self::redact_headers(self, headers)
412    }
413}
414
415fn find_http_url_start(value: &str) -> Option<usize> {
416    match (value.find("http://"), value.find("https://")) {
417        (Some(http), Some(https)) => Some(http.min(https)),
418        (Some(http), None) => Some(http),
419        (None, Some(https)) => Some(https),
420        (None, None) => None,
421    }
422}
423
424#[expect(
425    clippy::string_slice,
426    reason = "start is a find offset of an ASCII scheme prefix"
427)]
428fn http_url_token_end(value: &str, start: usize) -> usize {
429    value[start..]
430        .char_indices()
431        .find_map(|(offset, character)| {
432            (offset > 0 && is_url_text_delimiter(character)).then_some(start + offset)
433        })
434        .unwrap_or(value.len())
435}
436
437fn is_url_text_delimiter(character: char) -> bool {
438    character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '`')
439}
440
441#[expect(
442    clippy::string_slice,
443    reason = "prose_end/end retreat from token.len() by whole trailing chars"
444)]
445fn split_url_token(token: &str) -> Option<(&str, &str)> {
446    let mut prose_end = token.len();
447    while prose_end > 0 {
448        let candidate = &token[..prose_end];
449        let last = candidate.chars().last()?;
450        if !is_trailing_prose_punctuation(last) {
451            break;
452        }
453        prose_end -= last.len_utf8();
454    }
455    if prose_end > 0 {
456        let candidate = &token[..prose_end];
457        if Url::parse(candidate).is_ok() {
458            return Some((candidate, &token[prose_end..]));
459        }
460    }
461
462    let mut end = token.len();
463    while end > 0 {
464        let candidate = &token[..end];
465        if Url::parse(candidate).is_ok() {
466            return Some((candidate, &token[end..]));
467        }
468        let last = candidate.chars().last()?;
469        if !is_trailing_prose_punctuation(last) {
470            return None;
471        }
472        end -= last.len_utf8();
473    }
474    None
475}
476
477fn is_trailing_prose_punctuation(character: char) -> bool {
478    matches!(
479        character,
480        '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'
481    )
482}
483
484fn default_safe_headers() -> BTreeSet<String> {
485    BTreeSet::from([
486        "content-length".to_string(),
487        "content-type".to_string(),
488        "request-id".to_string(),
489        "user-agent".to_string(),
490        "x-a2a-delivery".to_string(),
491        "x-correlation-id".to_string(),
492        "x-github-delivery".to_string(),
493        "x-github-event".to_string(),
494        "x-github-hook-id".to_string(),
495        "x-request-id".to_string(),
496        "x-slack-request-timestamp".to_string(),
497    ])
498}
499
500fn default_deny_header_substrings() -> BTreeSet<String> {
501    BTreeSet::from([
502        "authorization".to_string(),
503        "cookie".to_string(),
504        "secret".to_string(),
505        "signature".to_string(),
506        "token".to_string(),
507        "key".to_string(),
508    ])
509}
510
511fn is_default_sensitive_url_param(lower: &str) -> bool {
512    let compact = compact_secret_name(lower);
513    matches!(
514        compact.as_str(),
515        "apikey"
516            | "accesstoken"
517            | "refreshtoken"
518            | "idtoken"
519            | "clientsecret"
520            | "password"
521            | "secret"
522            | "token"
523            | "auth"
524            | "bearer"
525            | "sig"
526            | "signature"
527    ) || compact.ends_with("token")
528        || compact.ends_with("secret")
529        || compact.ends_with("password")
530}
531
532fn is_default_sensitive_field(lower: &str) -> bool {
533    let compact = compact_secret_name(lower);
534    matches!(
535        compact.as_str(),
536        "authorization"
537            | "proxyauthorization"
538            | "cookie"
539            | "setcookie"
540            | "apikey"
541            | "xamzsecuritytoken"
542            | "xapikey"
543            | "xauthtoken"
544            | "xcsrftoken"
545            | "xxsrftoken"
546            | "accesstoken"
547            | "refreshtoken"
548            | "idtoken"
549            | "bearertoken"
550            | "clientsecret"
551            | "password"
552            | "secret"
553            | "passwd"
554            | "privatekey"
555            | "sessiontoken"
556    ) || compact.ends_with("token")
557        || compact.ends_with("secret")
558        || compact.ends_with("password")
559        || compact.ends_with("apikey")
560}
561
562fn compact_secret_name(lower: &str) -> String {
563    lower
564        .chars()
565        .filter(|ch| *ch != '_' && *ch != '-')
566        .collect()
567}
568
569fn has_secret_prefix(trimmed: &str) -> bool {
570    trimmed.starts_with("sk-")
571        || trimmed.starts_with("ghp_")
572        || trimmed.starts_with("ghs_")
573        || trimmed.starts_with("xoxb-")
574        || trimmed.starts_with("xoxp-")
575        || trimmed.starts_with("AKIA")
576}
577
578fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
579    trimmed.len() > 48
580        && trimmed
581            .chars()
582            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
583}
584
585thread_local! {
586    static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
587}
588
589/// Push a policy onto the thread-local stack. Pair every push with a
590/// [`pop_policy`] call (or use [`PolicyGuard`]).
591pub fn push_policy(policy: RedactionPolicy) {
592    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
593}
594
595/// Pop the most recently pushed policy. Safe to call when the stack is
596/// empty.
597pub fn pop_policy() {
598    REDACTION_POLICY_STACK.with(|stack| {
599        stack.borrow_mut().pop();
600    });
601}
602
603/// Drop all installed policies, custom token-redaction patterns, the
604/// audit sink, and the per-thread audit ring. Used by
605/// `reset_thread_local_state` so test runs that share a thread cannot
606/// leak policy overrides into each other.
607pub fn clear_policy_stack() {
608    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
609    patterns::clear_custom_patterns();
610    let _ = patterns::install_audit_sink(None);
611    patterns::clear_audit_ring();
612}
613
614/// Return the currently installed policy, falling back to
615/// [`RedactionPolicy::default`] when the stack is empty. Always returns
616/// an owned clone so callers can drop the borrow before recursing.
617pub fn current_policy() -> RedactionPolicy {
618    REDACTION_POLICY_STACK.with(|stack| {
619        stack
620            .borrow()
621            .last()
622            .cloned()
623            .unwrap_or_else(RedactionPolicy::default)
624    })
625}
626
627/// RAII guard that pushes a policy on construction and pops it on drop.
628///
629/// ```ignore
630/// let _guard = harn_vm::redact::PolicyGuard::new(RedactionPolicy::default());
631/// // … emit receipts, transcripts, etc.
632/// ```
633pub struct PolicyGuard;
634
635impl PolicyGuard {
636    pub fn new(policy: RedactionPolicy) -> Self {
637        push_policy(policy);
638        Self
639    }
640}
641
642impl Drop for PolicyGuard {
643    fn drop(&mut self) {
644        pop_policy();
645    }
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651    use serde_json::json;
652
653    fn sample_headers() -> BTreeMap<String, String> {
654        BTreeMap::from([
655            ("Authorization".to_string(), "Bearer secret123".to_string()),
656            ("Cookie".to_string(), "session=abc".to_string()),
657            ("Content-Type".to_string(), "application/json".to_string()),
658            ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
659            (
660                "X-Slack-Signature".to_string(),
661                "v0=abcdef123456".to_string(),
662            ),
663            ("User-Agent".to_string(), "Harn/1.0".to_string()),
664            ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
665        ])
666    }
667
668    #[test]
669    fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
670        let policy = RedactionPolicy::default();
671        let redacted = policy.redact_headers(&sample_headers());
672        assert_eq!(
673            redacted.get("Authorization").unwrap(),
674            REDACTED_HEADER_VALUE
675        );
676        assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
677        assert_eq!(
678            redacted.get("X-Webhook-Token").unwrap(),
679            REDACTED_HEADER_VALUE
680        );
681        assert_eq!(
682            redacted.get("X-Slack-Signature").unwrap(),
683            REDACTED_HEADER_VALUE
684        );
685        assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
686        assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
687        assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
688    }
689
690    #[test]
691    fn passthrough_policy_redacts_nothing() {
692        let policy = RedactionPolicy::passthrough();
693        let redacted = policy.redact_headers(&sample_headers());
694        assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
695    }
696
697    #[test]
698    fn host_can_extend_safe_and_deny_headers() {
699        let policy = RedactionPolicy::default()
700            .with_safe_header("X-Webhook-Token")
701            .with_deny_header_substring("delivery");
702        let redacted = policy.redact_headers(&sample_headers());
703        assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
704        assert_eq!(
705            redacted.get("X-GitHub-Delivery").unwrap(),
706            REDACTED_HEADER_VALUE,
707            "host explicitly forced delivery to be sensitive"
708        );
709    }
710
711    #[test]
712    fn redact_url_strips_userinfo_and_sensitive_query_params() {
713        let policy = RedactionPolicy::default();
714        let redacted = policy.redact_url(
715            "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
716        );
717        assert!(redacted.contains("api_key=%5Bredacted%5D"));
718        assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
719        assert!(redacted.contains("page=2"));
720        assert!(!redacted.contains("user:pw@"));
721    }
722
723    #[test]
724    fn redact_url_leaves_clean_urls_alone() {
725        let policy = RedactionPolicy::default();
726        let url = "https://api.example.com/v1?page=2";
727        assert_eq!(policy.redact_url(url), url);
728    }
729
730    #[test]
731    fn redact_urls_in_text_strips_embedded_sensitive_urls() {
732        let policy = RedactionPolicy::default();
733        let redacted = policy.redact_urls_in_text(
734            "clean https://status.example.com/health then \
735             redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
736             to http://public.example.com/next?client_secret=target-secret.",
737        );
738        assert!(redacted.starts_with("clean https://status.example.com/health then "));
739        assert!(redacted.contains("access_token=%5Bredacted%5D"));
740        assert!(redacted.contains("client_secret=%5Bredacted%5D"));
741        assert!(!redacted.contains("source-secret"));
742        assert!(!redacted.contains("target-secret"));
743        assert!(!redacted.contains("user:pw@"));
744        assert!(redacted.ends_with('.'));
745    }
746
747    #[test]
748    fn redact_json_strips_sensitive_field_names_recursively() {
749        let policy = RedactionPolicy::default();
750        let mut value = json!({
751            "headers": {
752                "authorization": "Bearer abc",
753                "X-Amz-Security-Token": "session",
754                "x-trace-id": "trace_1",
755            },
756            "list": [
757                { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
758                { "name": "bob" },
759            ],
760            "clientSecret": "camel-secret",
761            "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
762            "url": "https://api.example.com/v1?api_key=hideme",
763        });
764        policy.redact_json_in_place(&mut value);
765        assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
766        assert_eq!(
767            value["headers"]["X-Amz-Security-Token"],
768            REDACTED_PLACEHOLDER
769        );
770        assert_eq!(value["headers"]["x-trace-id"], "trace_1");
771        assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
772        assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
773        assert_eq!(value["list"][0]["name"], "alice");
774        assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
775        let free_form = value["free_form"].as_str().unwrap();
776        // Free-form pattern matches produce the OA-06 named placeholder
777        // `<redacted:<pattern>:<len>>` so audit logs can attribute leaks to a
778        // specific provider.
779        assert!(
780            free_form.contains("<redacted:"),
781            "expected named placeholder, got: {free_form}"
782        );
783        assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
784    }
785
786    #[test]
787    fn policy_guard_pushes_and_pops_thread_local() {
788        clear_policy_stack();
789        assert_eq!(current_policy(), RedactionPolicy::default());
790        {
791            let policy = RedactionPolicy::default().with_extra_field("custom_token");
792            let _guard = PolicyGuard::new(policy.clone());
793            assert_eq!(current_policy(), policy);
794        }
795        assert_eq!(current_policy(), RedactionPolicy::default());
796    }
797
798    #[test]
799    fn redact_string_replaces_known_secret_patterns() {
800        let policy = RedactionPolicy::default();
801        let input =
802            "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
803        let out = policy.redact_string(input);
804        // Each provider pattern emits its own `<redacted:<name>:<len>>`
805        // placeholder so audit logs can attribute the leak.
806        assert!(out.contains("<redacted:openai_key:"));
807        assert!(out.contains("<redacted:aws_access_key:"));
808        assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
809        assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
810    }
811
812    #[test]
813    fn looks_like_secret_value_accepts_logical_secret_references() {
814        let policy = RedactionPolicy::default();
815        assert!(policy.looks_like_secret_value("sk-live-secret"));
816        assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
817        assert!(!policy.looks_like_secret_value("github/webhook-secret"));
818        assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
819    }
820}