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            | "protectedvalues"
557            | "protecteddisclosure"
558    ) || compact.ends_with("token")
559        || compact.ends_with("secret")
560        || compact.ends_with("password")
561        || compact.ends_with("apikey")
562}
563
564fn compact_secret_name(lower: &str) -> String {
565    lower
566        .chars()
567        .filter(|ch| *ch != '_' && *ch != '-')
568        .collect()
569}
570
571fn has_secret_prefix(trimmed: &str) -> bool {
572    trimmed.starts_with("sk-")
573        || trimmed.starts_with("ghp_")
574        || trimmed.starts_with("ghs_")
575        || trimmed.starts_with("xoxb-")
576        || trimmed.starts_with("xoxp-")
577        || trimmed.starts_with("AKIA")
578}
579
580fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
581    trimmed.len() > 48
582        && trimmed
583            .chars()
584            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
585}
586
587thread_local! {
588    static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
589}
590
591/// Push a policy onto the thread-local stack. Pair every push with a
592/// [`pop_policy`] call (or use [`PolicyGuard`]).
593pub fn push_policy(policy: RedactionPolicy) {
594    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
595}
596
597/// Pop the most recently pushed policy. Safe to call when the stack is
598/// empty.
599pub fn pop_policy() {
600    REDACTION_POLICY_STACK.with(|stack| {
601        stack.borrow_mut().pop();
602    });
603}
604
605/// Drop all installed policies, custom token-redaction patterns, the
606/// audit sink, and the per-thread audit ring. Used by
607/// `reset_thread_local_state` so test runs that share a thread cannot
608/// leak policy overrides into each other.
609pub fn clear_policy_stack() {
610    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
611    patterns::clear_custom_patterns();
612    let _ = patterns::install_audit_sink(None);
613    patterns::clear_audit_ring();
614}
615
616/// Return the currently installed policy, falling back to
617/// [`RedactionPolicy::default`] when the stack is empty. Always returns
618/// an owned clone so callers can drop the borrow before recursing.
619pub fn current_policy() -> RedactionPolicy {
620    REDACTION_POLICY_STACK.with(|stack| {
621        stack
622            .borrow()
623            .last()
624            .cloned()
625            .unwrap_or_else(RedactionPolicy::default)
626    })
627}
628
629/// RAII guard that pushes a policy on construction and pops it on drop.
630///
631/// ```ignore
632/// let _guard = harn_vm::redact::PolicyGuard::new(RedactionPolicy::default());
633/// // … emit receipts, transcripts, etc.
634/// ```
635pub struct PolicyGuard;
636
637impl PolicyGuard {
638    pub fn new(policy: RedactionPolicy) -> Self {
639        push_policy(policy);
640        Self
641    }
642}
643
644impl Drop for PolicyGuard {
645    fn drop(&mut self) {
646        pop_policy();
647    }
648}
649
650#[cfg(test)]
651mod tests {
652    use super::*;
653    use serde_json::json;
654
655    fn sample_headers() -> BTreeMap<String, String> {
656        BTreeMap::from([
657            ("Authorization".to_string(), "Bearer secret123".to_string()),
658            ("Cookie".to_string(), "session=abc".to_string()),
659            ("Content-Type".to_string(), "application/json".to_string()),
660            ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
661            (
662                "X-Slack-Signature".to_string(),
663                "v0=abcdef123456".to_string(),
664            ),
665            ("User-Agent".to_string(), "Harn/1.0".to_string()),
666            ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
667        ])
668    }
669
670    #[test]
671    fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
672        let policy = RedactionPolicy::default();
673        let redacted = policy.redact_headers(&sample_headers());
674        assert_eq!(
675            redacted.get("Authorization").unwrap(),
676            REDACTED_HEADER_VALUE
677        );
678        assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
679        assert_eq!(
680            redacted.get("X-Webhook-Token").unwrap(),
681            REDACTED_HEADER_VALUE
682        );
683        assert_eq!(
684            redacted.get("X-Slack-Signature").unwrap(),
685            REDACTED_HEADER_VALUE
686        );
687        assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
688        assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
689        assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
690    }
691
692    #[test]
693    fn passthrough_policy_redacts_nothing() {
694        let policy = RedactionPolicy::passthrough();
695        let redacted = policy.redact_headers(&sample_headers());
696        assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
697    }
698
699    #[test]
700    fn host_can_extend_safe_and_deny_headers() {
701        let policy = RedactionPolicy::default()
702            .with_safe_header("X-Webhook-Token")
703            .with_deny_header_substring("delivery");
704        let redacted = policy.redact_headers(&sample_headers());
705        assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
706        assert_eq!(
707            redacted.get("X-GitHub-Delivery").unwrap(),
708            REDACTED_HEADER_VALUE,
709            "host explicitly forced delivery to be sensitive"
710        );
711    }
712
713    #[test]
714    fn redact_url_strips_userinfo_and_sensitive_query_params() {
715        let policy = RedactionPolicy::default();
716        let redacted = policy.redact_url(
717            "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
718        );
719        assert!(redacted.contains("api_key=%5Bredacted%5D"));
720        assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
721        assert!(redacted.contains("page=2"));
722        assert!(!redacted.contains("user:pw@"));
723    }
724
725    #[test]
726    fn redact_url_leaves_clean_urls_alone() {
727        let policy = RedactionPolicy::default();
728        let url = "https://api.example.com/v1?page=2";
729        assert_eq!(policy.redact_url(url), url);
730    }
731
732    #[test]
733    fn redact_urls_in_text_strips_embedded_sensitive_urls() {
734        let policy = RedactionPolicy::default();
735        let redacted = policy.redact_urls_in_text(
736            "clean https://status.example.com/health then \
737             redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
738             to http://public.example.com/next?client_secret=target-secret.",
739        );
740        assert!(redacted.starts_with("clean https://status.example.com/health then "));
741        assert!(redacted.contains("access_token=%5Bredacted%5D"));
742        assert!(redacted.contains("client_secret=%5Bredacted%5D"));
743        assert!(!redacted.contains("source-secret"));
744        assert!(!redacted.contains("target-secret"));
745        assert!(!redacted.contains("user:pw@"));
746        assert!(redacted.ends_with('.'));
747    }
748
749    #[test]
750    fn redact_json_strips_sensitive_field_names_recursively() {
751        let policy = RedactionPolicy::default();
752        let mut value = json!({
753            "headers": {
754                "authorization": "Bearer abc",
755                "X-Amz-Security-Token": "session",
756                "x-trace-id": "trace_1",
757            },
758            "list": [
759                { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
760                { "name": "bob" },
761            ],
762            "clientSecret": "camel-secret",
763            "protected_values": {"legal_identity": {"given_name": "PersonalSentinel"}},
764            "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
765            "url": "https://api.example.com/v1?api_key=hideme",
766        });
767        policy.redact_json_in_place(&mut value);
768        assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
769        assert_eq!(
770            value["headers"]["X-Amz-Security-Token"],
771            REDACTED_PLACEHOLDER
772        );
773        assert_eq!(value["headers"]["x-trace-id"], "trace_1");
774        assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
775        assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
776        assert_eq!(value["list"][0]["name"], "alice");
777        assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
778        assert_eq!(value["protected_values"], REDACTED_PLACEHOLDER);
779        let free_form = value["free_form"].as_str().unwrap();
780        // Free-form pattern matches produce the OA-06 named placeholder
781        // `<redacted:<pattern>:<len>>` so audit logs can attribute leaks to a
782        // specific provider.
783        assert!(
784            free_form.contains("<redacted:"),
785            "expected named placeholder, got: {free_form}"
786        );
787        assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
788    }
789
790    #[test]
791    fn policy_guard_pushes_and_pops_thread_local() {
792        clear_policy_stack();
793        assert_eq!(current_policy(), RedactionPolicy::default());
794        {
795            let policy = RedactionPolicy::default().with_extra_field("custom_token");
796            let _guard = PolicyGuard::new(policy.clone());
797            assert_eq!(current_policy(), policy);
798        }
799        assert_eq!(current_policy(), RedactionPolicy::default());
800    }
801
802    #[test]
803    fn redact_string_replaces_known_secret_patterns() {
804        let policy = RedactionPolicy::default();
805        let input =
806            "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
807        let out = policy.redact_string(input);
808        // Each provider pattern emits its own `<redacted:<name>:<len>>`
809        // placeholder so audit logs can attribute the leak.
810        assert!(out.contains("<redacted:openai_key:"));
811        assert!(out.contains("<redacted:aws_access_key:"));
812        assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
813        assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
814    }
815
816    #[test]
817    fn looks_like_secret_value_accepts_logical_secret_references() {
818        let policy = RedactionPolicy::default();
819        assert!(policy.looks_like_secret_value("sk-live-secret"));
820        assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
821        assert!(!policy.looks_like_secret_value("github/webhook-secret"));
822        assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
823    }
824}