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(crate) use patterns::swap_custom_patterns;
45pub use patterns::{
46    clear_audit_ring, clear_custom_patterns, custom_pattern_names, default_pattern_names,
47    drain_audit_ring, install_audit_sink, register_custom_pattern, scan_secret_patterns, AuditSink,
48    NamedPattern, RedactionEvent, TOKEN_REDACTION_AUDIT_TOPIC, TOKEN_REDACTION_DIAGNOSTIC,
49};
50
51/// Placeholder string used everywhere a redacted value would otherwise
52/// appear. Kept as a single constant so portal CSS, downstream parsers,
53/// and humans grepping logs can rely on one form.
54pub const REDACTED_PLACEHOLDER: &str = "[redacted]";
55
56/// Header value for redacted HTTP headers. Identical to
57/// [`REDACTED_PLACEHOLDER`] today, exposed as a separate symbol so the
58/// trigger/event tests that pre-date the unified module remain readable.
59pub const REDACTED_HEADER_VALUE: &str = REDACTED_PLACEHOLDER;
60
61#[derive(Clone, Debug, PartialEq, Eq)]
62pub struct RedactionPolicy {
63    safe_headers: BTreeSet<String>,
64    deny_header_substrings: BTreeSet<String>,
65    extra_deny_header_substrings: BTreeSet<String>,
66    extra_field_names: BTreeSet<String>,
67    extra_url_params: BTreeSet<String>,
68    scan_strings: bool,
69    redact_url_userinfo: bool,
70}
71
72impl Default for RedactionPolicy {
73    fn default() -> Self {
74        Self {
75            safe_headers: default_safe_headers(),
76            deny_header_substrings: default_deny_header_substrings(),
77            extra_deny_header_substrings: BTreeSet::new(),
78            extra_field_names: BTreeSet::new(),
79            extra_url_params: BTreeSet::new(),
80            scan_strings: true,
81            redact_url_userinfo: true,
82        }
83    }
84}
85
86impl RedactionPolicy {
87    /// Permissive policy used by tests that need raw data. No headers,
88    /// fields, or strings are scrubbed.
89    pub fn passthrough() -> Self {
90        Self {
91            safe_headers: BTreeSet::new(),
92            deny_header_substrings: BTreeSet::new(),
93            extra_deny_header_substrings: BTreeSet::new(),
94            extra_field_names: BTreeSet::new(),
95            extra_url_params: BTreeSet::new(),
96            scan_strings: false,
97            redact_url_userinfo: false,
98        }
99    }
100
101    /// Add a header (case-insensitive) to the safe-list. Header
102    /// redaction will leave its value untouched even if the name would
103    /// otherwise look auth-shaped (e.g. an `x-…-key` header that is
104    /// actually a request-id).
105    pub fn with_safe_header(mut self, name: impl Into<String>) -> Self {
106        self.safe_headers.insert(name.into().to_ascii_lowercase());
107        self
108    }
109
110    /// Add a substring (case-insensitive) that always forces a header
111    /// to be treated as sensitive. Useful for product-specific token
112    /// header names that the default `cookie`/`authorization`/`token`/`secret`/`key`
113    /// substring set would miss.
114    pub fn with_deny_header_substring(mut self, fragment: impl Into<String>) -> Self {
115        self.extra_deny_header_substrings
116            .insert(fragment.into().to_ascii_lowercase());
117        self
118    }
119
120    /// Add a JSON field name (case-insensitive, exact match) that should
121    /// always be redacted regardless of value contents. Useful when a
122    /// host knows it stores `internal_audit_token` or similar.
123    pub fn with_extra_field(mut self, name: impl Into<String>) -> Self {
124        self.extra_field_names
125            .insert(name.into().to_ascii_lowercase());
126        self
127    }
128
129    /// Add an extra URL query parameter name to redact.
130    pub fn with_extra_url_param(mut self, name: impl Into<String>) -> Self {
131        self.extra_url_params
132            .insert(name.into().to_ascii_lowercase());
133        self
134    }
135
136    /// Disable the heuristic free-form string scanner. The scanner adds
137    /// a small but non-zero cost to every JSON payload walk; turn it off
138    /// for performance-critical paths that have already been audited.
139    pub fn disable_string_scan(mut self) -> Self {
140        self.scan_strings = false;
141        self
142    }
143
144    fn header_is_safe(&self, lower_name: &str) -> bool {
145        // Exact-name allowlist is one source of truth in `safe_headers`;
146        // suffix/substring rules below cover the families of debugging
147        // headers that providers emit with arbitrary suffixes.
148        if self.safe_headers.contains(lower_name) {
149            return true;
150        }
151        lower_name.ends_with("-event")
152            || lower_name.ends_with("-delivery")
153            || lower_name.contains("timestamp")
154            || lower_name.contains("request-id")
155    }
156
157    /// Whether a given HTTP header name should have its value replaced
158    /// with [`REDACTED_HEADER_VALUE`].
159    ///
160    /// Host-explicit deny substrings always win, even over the built-in
161    /// safe-list — that is how a host says "treat my own webhook
162    /// delivery header as sensitive even though Harn would normally
163    /// keep it for debugging."
164    pub fn header_is_sensitive(&self, name: &str) -> bool {
165        let lower = name.to_ascii_lowercase();
166        if self
167            .extra_deny_header_substrings
168            .iter()
169            .any(|fragment| lower.contains(fragment))
170        {
171            return true;
172        }
173        if self.header_is_safe(&lower) {
174            return false;
175        }
176        self.deny_header_substrings
177            .iter()
178            .any(|fragment| lower.contains(fragment))
179    }
180
181    /// Whether a JSON object field name should be replaced with the
182    /// redacted placeholder before the value is even inspected.
183    pub fn field_is_sensitive(&self, name: &str) -> bool {
184        let lower = name.to_ascii_lowercase();
185        if self.extra_field_names.contains(&lower) {
186            return true;
187        }
188        is_default_sensitive_field(&lower)
189    }
190
191    /// Whether a URL query parameter name should have its value
192    /// replaced.
193    pub fn url_param_is_sensitive(&self, name: &str) -> bool {
194        let lower = name.to_ascii_lowercase();
195        if self.extra_url_params.contains(&lower) {
196            return true;
197        }
198        is_default_sensitive_url_param(&lower)
199    }
200
201    /// Returns a [`BTreeMap`] of headers with sensitive values replaced
202    /// by [`REDACTED_HEADER_VALUE`].
203    pub fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
204        headers
205            .iter()
206            .map(|(name, value)| {
207                if self.header_is_sensitive(name) {
208                    (name.clone(), REDACTED_HEADER_VALUE.to_string())
209                } else {
210                    (name.clone(), value.clone())
211                }
212            })
213            .collect()
214    }
215
216    /// Redact sensitive query parameters and credentials in URL
217    /// userinfo. Returns the input unchanged if nothing matches or the
218    /// URL fails to parse.
219    pub fn redact_url(&self, url: &str) -> String {
220        let Ok(mut parsed) = Url::parse(url) else {
221            return self.redact_string(url).into_owned();
222        };
223        let mut changed = false;
224
225        if self.redact_url_userinfo
226            && (!parsed.username().is_empty() || parsed.password().is_some())
227        {
228            // url::Url returns Err only when the URL cannot have a
229            // password (e.g. cannot-be-a-base). Treat that as a no-op.
230            if parsed.set_username("").is_ok() {
231                changed = true;
232            }
233            if parsed.set_password(None).is_ok() {
234                changed = true;
235            }
236        }
237
238        let pairs: Vec<(String, String)> = parsed
239            .query_pairs()
240            .map(|(key, value)| {
241                if self.url_param_is_sensitive(&key) {
242                    changed = true;
243                    (key.into_owned(), REDACTED_PLACEHOLDER.to_string())
244                } else {
245                    (key.into_owned(), value.into_owned())
246                }
247            })
248            .collect();
249        let original_query = parsed.query().map(str::to_string);
250        if !pairs.is_empty() {
251            parsed.set_query(None);
252            let mut query = parsed.query_pairs_mut();
253            for (key, value) in &pairs {
254                query.append_pair(key, value);
255            }
256        }
257        // `query_pairs_mut` always re-encodes; restore the original
258        // query string when nothing was actually redacted so we don't
259        // perturb otherwise stable URLs.
260        if !changed {
261            parsed.set_query(original_query.as_deref());
262            return parsed.to_string();
263        }
264        parsed.to_string()
265    }
266
267    /// Returns a redacted string. Cheap (`Cow::Borrowed`) when nothing
268    /// matched. Applies, in order: URL-shaped string detection (so the
269    /// userinfo or sensitive query params on `https://user:pw@…?api_key=…`
270    /// are scrubbed), then high-confidence secret pattern replacement.
271    pub fn redact_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
272        if !self.scan_strings {
273            return Cow::Borrowed(value);
274        }
275        match self.redact_url_in_string(value) {
276            Cow::Borrowed(_) => scan_secret_patterns(value, REDACTED_PLACEHOLDER),
277            Cow::Owned(url_scrubbed) => {
278                let pattern_scrubbed =
279                    scan_secret_patterns(&url_scrubbed, REDACTED_PLACEHOLDER).into_owned();
280                Cow::Owned(pattern_scrubbed)
281            }
282        }
283    }
284
285    /// Redact sensitive credentials and query parameters from HTTP(S) URLs
286    /// embedded in free-form diagnostic text. This is intentionally separate
287    /// from [`Self::redact_string`]: broad text tokenization is useful for
288    /// transport errors that include URLs inside prose, while normal string
289    /// redaction keeps its lower-perturbation standalone-URL behavior.
290    #[expect(
291        clippy::string_slice,
292        reason = "cursors are find offsets or char_indices token ends on the same text"
293    )]
294    pub fn redact_urls_in_text<'a>(&self, value: &'a str) -> Cow<'a, str> {
295        let mut scan_cursor = 0;
296        let mut emit_cursor = 0;
297        let mut output: Option<String> = None;
298
299        while let Some(relative_start) = find_http_url_start(&value[scan_cursor..]) {
300            let start = scan_cursor + relative_start;
301            let token_end = http_url_token_end(value, start);
302            let token = &value[start..token_end];
303            let Some((url, suffix)) = split_url_token(token) else {
304                scan_cursor = token_end;
305                continue;
306            };
307            let redacted = self.redact_url(url);
308            if redacted != url {
309                let output = output.get_or_insert_with(|| String::with_capacity(value.len()));
310                output.push_str(&value[emit_cursor..start]);
311                output.push_str(&redacted);
312                output.push_str(suffix);
313                emit_cursor = token_end;
314            }
315            scan_cursor = token_end;
316        }
317
318        match output {
319            Some(mut output) => {
320                output.push_str(&value[emit_cursor..]);
321                Cow::Owned(output)
322            }
323            None => Cow::Borrowed(value),
324        }
325    }
326
327    /// Conservative predicate for fields that must contain logical
328    /// secret references rather than raw credential material.
329    ///
330    /// This is intentionally broader than [`redact_string`]: short
331    /// fake-looking values such as `sk-live-secret` are useful test
332    /// sentinels and should be rejected from `required_secrets` /
333    /// context-pack manifests even though the free-form string
334    /// redactor avoids replacing such short text globally.
335    pub fn looks_like_secret_value(&self, value: &str) -> bool {
336        let trimmed = value.trim();
337        !trimmed.is_empty()
338            && (self.redact_string(trimmed).as_ref() != trimmed
339                || has_secret_prefix(trimmed)
340                || is_long_bare_secret_candidate(trimmed))
341    }
342
343    /// If `value` is a single URL with credentials or sensitive query
344    /// params, return the redacted form. Standalone URLs are common in
345    /// logged request envelopes; we don't try to walk arbitrary text
346    /// for embedded URLs because that turns into ad-hoc tokenization.
347    fn redact_url_in_string<'a>(&self, value: &'a str) -> Cow<'a, str> {
348        if !self.redact_url_userinfo
349            || !(value.starts_with("http://") || value.starts_with("https://"))
350        {
351            return Cow::Borrowed(value);
352        }
353        let trimmed = value.trim();
354        if trimmed.contains(char::is_whitespace) {
355            return Cow::Borrowed(value);
356        }
357        let redacted = self.redact_url(trimmed);
358        if redacted == trimmed {
359            Cow::Borrowed(value)
360        } else {
361            Cow::Owned(redacted)
362        }
363    }
364
365    /// Recursively walk a JSON value, redacting sensitive object fields
366    /// and string contents in place.
367    pub fn redact_json_in_place(&self, value: &mut JsonValue) {
368        match value {
369            JsonValue::Object(map) => {
370                let mut keys_to_redact: Vec<String> = Vec::new();
371                for (key, child) in map.iter_mut() {
372                    if self.field_is_sensitive(key) {
373                        keys_to_redact.push(key.clone());
374                    } else {
375                        self.redact_json_in_place(child);
376                    }
377                }
378                for key in keys_to_redact {
379                    map.insert(key, JsonValue::String(REDACTED_PLACEHOLDER.to_string()));
380                }
381            }
382            JsonValue::Array(items) => {
383                for item in items.iter_mut() {
384                    self.redact_json_in_place(item);
385                }
386            }
387            JsonValue::String(s) => {
388                let redacted = self.redact_string(s);
389                if let Cow::Owned(replacement) = redacted {
390                    *s = replacement;
391                }
392            }
393            _ => {}
394        }
395    }
396
397    /// Convenience for callers that have an immutable JSON value: clone
398    /// once and redact.
399    pub fn redact_json(&self, value: &JsonValue) -> JsonValue {
400        let mut clone = value.clone();
401        self.redact_json_in_place(&mut clone);
402        clone
403    }
404}
405
406impl harn_session_store::EventRedactor for RedactionPolicy {
407    fn redact_json_in_place(&self, value: &mut JsonValue) {
408        Self::redact_json_in_place(self, value);
409    }
410
411    fn redact_headers(&self, headers: &BTreeMap<String, String>) -> BTreeMap<String, String> {
412        Self::redact_headers(self, headers)
413    }
414}
415
416fn find_http_url_start(value: &str) -> Option<usize> {
417    match (value.find("http://"), value.find("https://")) {
418        (Some(http), Some(https)) => Some(http.min(https)),
419        (Some(http), None) => Some(http),
420        (None, Some(https)) => Some(https),
421        (None, None) => None,
422    }
423}
424
425#[expect(
426    clippy::string_slice,
427    reason = "start is a find offset of an ASCII scheme prefix"
428)]
429fn http_url_token_end(value: &str, start: usize) -> usize {
430    value[start..]
431        .char_indices()
432        .find_map(|(offset, character)| {
433            (offset > 0 && is_url_text_delimiter(character)).then_some(start + offset)
434        })
435        .unwrap_or(value.len())
436}
437
438fn is_url_text_delimiter(character: char) -> bool {
439    character.is_whitespace() || matches!(character, '"' | '\'' | '<' | '>' | '`')
440}
441
442#[expect(
443    clippy::string_slice,
444    reason = "prose_end/end retreat from token.len() by whole trailing chars"
445)]
446fn split_url_token(token: &str) -> Option<(&str, &str)> {
447    let mut prose_end = token.len();
448    while prose_end > 0 {
449        let candidate = &token[..prose_end];
450        let last = candidate.chars().last()?;
451        if !is_trailing_prose_punctuation(last) {
452            break;
453        }
454        prose_end -= last.len_utf8();
455    }
456    if prose_end > 0 {
457        let candidate = &token[..prose_end];
458        if Url::parse(candidate).is_ok() {
459            return Some((candidate, &token[prose_end..]));
460        }
461    }
462
463    let mut end = token.len();
464    while end > 0 {
465        let candidate = &token[..end];
466        if Url::parse(candidate).is_ok() {
467            return Some((candidate, &token[end..]));
468        }
469        let last = candidate.chars().last()?;
470        if !is_trailing_prose_punctuation(last) {
471            return None;
472        }
473        end -= last.len_utf8();
474    }
475    None
476}
477
478fn is_trailing_prose_punctuation(character: char) -> bool {
479    matches!(
480        character,
481        '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'
482    )
483}
484
485fn default_safe_headers() -> BTreeSet<String> {
486    BTreeSet::from([
487        "content-length".to_string(),
488        "content-type".to_string(),
489        "request-id".to_string(),
490        "user-agent".to_string(),
491        "x-a2a-delivery".to_string(),
492        "x-correlation-id".to_string(),
493        "x-github-delivery".to_string(),
494        "x-github-event".to_string(),
495        "x-github-hook-id".to_string(),
496        "x-request-id".to_string(),
497        "x-slack-request-timestamp".to_string(),
498    ])
499}
500
501fn default_deny_header_substrings() -> BTreeSet<String> {
502    BTreeSet::from([
503        "authorization".to_string(),
504        "cookie".to_string(),
505        "secret".to_string(),
506        "signature".to_string(),
507        "token".to_string(),
508        "key".to_string(),
509    ])
510}
511
512fn is_default_sensitive_url_param(lower: &str) -> bool {
513    let compact = compact_secret_name(lower);
514    matches!(
515        compact.as_str(),
516        "apikey"
517            | "accesstoken"
518            | "refreshtoken"
519            | "idtoken"
520            | "clientsecret"
521            | "password"
522            | "secret"
523            | "token"
524            | "auth"
525            | "bearer"
526            | "sig"
527            | "signature"
528    ) || compact.ends_with("token")
529        || compact.ends_with("secret")
530        || compact.ends_with("password")
531}
532
533fn is_default_sensitive_field(lower: &str) -> bool {
534    let compact = compact_secret_name(lower);
535    matches!(
536        compact.as_str(),
537        "authorization"
538            | "proxyauthorization"
539            | "cookie"
540            | "setcookie"
541            | "apikey"
542            | "xamzsecuritytoken"
543            | "xapikey"
544            | "xauthtoken"
545            | "xcsrftoken"
546            | "xxsrftoken"
547            | "accesstoken"
548            | "refreshtoken"
549            | "idtoken"
550            | "bearertoken"
551            | "clientsecret"
552            | "password"
553            | "secret"
554            | "passwd"
555            | "privatekey"
556            | "sessiontoken"
557            | "protectedvalues"
558            | "protecteddisclosure"
559    ) || compact.ends_with("token")
560        || compact.ends_with("secret")
561        || compact.ends_with("password")
562        || compact.ends_with("apikey")
563}
564
565fn compact_secret_name(lower: &str) -> String {
566    lower
567        .chars()
568        .filter(|ch| *ch != '_' && *ch != '-')
569        .collect()
570}
571
572fn has_secret_prefix(trimmed: &str) -> bool {
573    trimmed.starts_with("sk-")
574        || trimmed.starts_with("ghp_")
575        || trimmed.starts_with("ghs_")
576        || trimmed.starts_with("xoxb-")
577        || trimmed.starts_with("xoxp-")
578        || trimmed.starts_with("AKIA")
579}
580
581fn is_long_bare_secret_candidate(trimmed: &str) -> bool {
582    trimmed.len() > 48
583        && trimmed
584            .chars()
585            .all(|ch| ch.is_ascii_alphanumeric() || ch == '_' || ch == '-')
586}
587
588thread_local! {
589    static REDACTION_POLICY_STACK: RefCell<Vec<RedactionPolicy>> = const { RefCell::new(Vec::new()) };
590}
591
592/// Push a policy onto the thread-local stack. Pair every push with a
593/// [`pop_policy`] call (or use [`PolicyGuard`]).
594pub fn push_policy(policy: RedactionPolicy) {
595    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().push(policy));
596}
597
598/// Pop the most recently pushed policy. Safe to call when the stack is
599/// empty.
600pub fn pop_policy() {
601    REDACTION_POLICY_STACK.with(|stack| {
602        stack.borrow_mut().pop();
603    });
604}
605
606/// Drop all installed policies, custom token-redaction patterns, the
607/// audit sink, and the per-thread audit ring. Used by
608/// `reset_thread_local_state` so test runs that share a thread cannot
609/// leak policy overrides into each other.
610pub fn clear_policy_stack() {
611    REDACTION_POLICY_STACK.with(|stack| stack.borrow_mut().clear());
612    patterns::clear_custom_patterns();
613    let _ = patterns::install_audit_sink(None);
614    patterns::clear_audit_ring();
615}
616
617/// Return the currently installed policy, falling back to
618/// [`RedactionPolicy::default`] when the stack is empty. Always returns
619/// an owned clone so callers can drop the borrow before recursing.
620pub fn current_policy() -> RedactionPolicy {
621    REDACTION_POLICY_STACK.with(|stack| {
622        stack
623            .borrow()
624            .last()
625            .cloned()
626            .unwrap_or_else(RedactionPolicy::default)
627    })
628}
629
630/// RAII guard that pushes a policy on construction and pops it on drop.
631///
632/// ```ignore
633/// let _guard = harn_vm::redact::PolicyGuard::new(RedactionPolicy::default());
634/// // … emit receipts, transcripts, etc.
635/// ```
636pub struct PolicyGuard;
637
638impl PolicyGuard {
639    pub fn new(policy: RedactionPolicy) -> Self {
640        push_policy(policy);
641        Self
642    }
643}
644
645impl Drop for PolicyGuard {
646    fn drop(&mut self) {
647        pop_policy();
648    }
649}
650
651#[cfg(test)]
652mod tests {
653    use super::*;
654    use serde_json::json;
655
656    fn sample_headers() -> BTreeMap<String, String> {
657        BTreeMap::from([
658            ("Authorization".to_string(), "Bearer secret123".to_string()),
659            ("Cookie".to_string(), "session=abc".to_string()),
660            ("Content-Type".to_string(), "application/json".to_string()),
661            ("X-Webhook-Token".to_string(), "tok-xyz".to_string()),
662            (
663                "X-Slack-Signature".to_string(),
664                "v0=abcdef123456".to_string(),
665            ),
666            ("User-Agent".to_string(), "Harn/1.0".to_string()),
667            ("X-GitHub-Delivery".to_string(), "delivery-123".to_string()),
668        ])
669    }
670
671    #[test]
672    fn default_policy_redacts_auth_headers_and_keeps_safe_ones() {
673        let policy = RedactionPolicy::default();
674        let redacted = policy.redact_headers(&sample_headers());
675        assert_eq!(
676            redacted.get("Authorization").unwrap(),
677            REDACTED_HEADER_VALUE
678        );
679        assert_eq!(redacted.get("Cookie").unwrap(), REDACTED_HEADER_VALUE);
680        assert_eq!(
681            redacted.get("X-Webhook-Token").unwrap(),
682            REDACTED_HEADER_VALUE
683        );
684        assert_eq!(
685            redacted.get("X-Slack-Signature").unwrap(),
686            REDACTED_HEADER_VALUE
687        );
688        assert_eq!(redacted.get("User-Agent").unwrap(), "Harn/1.0");
689        assert_eq!(redacted.get("X-GitHub-Delivery").unwrap(), "delivery-123");
690        assert_eq!(redacted.get("Content-Type").unwrap(), "application/json");
691    }
692
693    #[test]
694    fn passthrough_policy_redacts_nothing() {
695        let policy = RedactionPolicy::passthrough();
696        let redacted = policy.redact_headers(&sample_headers());
697        assert_eq!(redacted.get("Authorization").unwrap(), "Bearer secret123");
698    }
699
700    #[test]
701    fn host_can_extend_safe_and_deny_headers() {
702        let policy = RedactionPolicy::default()
703            .with_safe_header("X-Webhook-Token")
704            .with_deny_header_substring("delivery");
705        let redacted = policy.redact_headers(&sample_headers());
706        assert_eq!(redacted.get("X-Webhook-Token").unwrap(), "tok-xyz");
707        assert_eq!(
708            redacted.get("X-GitHub-Delivery").unwrap(),
709            REDACTED_HEADER_VALUE,
710            "host explicitly forced delivery to be sensitive"
711        );
712    }
713
714    #[test]
715    fn redact_url_strips_userinfo_and_sensitive_query_params() {
716        let policy = RedactionPolicy::default();
717        let redacted = policy.redact_url(
718            "https://user:pw@api.example.com/v1?api_key=abcdef&clientSecret=hidden&page=2",
719        );
720        assert!(redacted.contains("api_key=%5Bredacted%5D"));
721        assert!(redacted.contains("clientSecret=%5Bredacted%5D"));
722        assert!(redacted.contains("page=2"));
723        assert!(!redacted.contains("user:pw@"));
724    }
725
726    #[test]
727    fn redact_url_leaves_clean_urls_alone() {
728        let policy = RedactionPolicy::default();
729        let url = "https://api.example.com/v1?page=2";
730        assert_eq!(policy.redact_url(url), url);
731    }
732
733    #[test]
734    fn redact_urls_in_text_strips_embedded_sensitive_urls() {
735        let policy = RedactionPolicy::default();
736        let redacted = policy.redact_urls_in_text(
737            "clean https://status.example.com/health then \
738             redirect from (https://user:pw@api.example.com/start?access_token=source-secret) \
739             to http://public.example.com/next?client_secret=target-secret.",
740        );
741        assert!(redacted.starts_with("clean https://status.example.com/health then "));
742        assert!(redacted.contains("access_token=%5Bredacted%5D"));
743        assert!(redacted.contains("client_secret=%5Bredacted%5D"));
744        assert!(!redacted.contains("source-secret"));
745        assert!(!redacted.contains("target-secret"));
746        assert!(!redacted.contains("user:pw@"));
747        assert!(redacted.ends_with('.'));
748    }
749
750    #[test]
751    fn redact_json_strips_sensitive_field_names_recursively() {
752        let policy = RedactionPolicy::default();
753        let mut value = json!({
754            "headers": {
755                "authorization": "Bearer abc",
756                "X-Amz-Security-Token": "session",
757                "x-trace-id": "trace_1",
758            },
759            "list": [
760                { "auth_token": "tok_secret", "accessToken": "camel", "name": "alice" },
761                { "name": "bob" },
762            ],
763            "clientSecret": "camel-secret",
764            "protected_values": {"legal_identity": {"given_name": "PersonalSentinel"}},
765            "free_form": "Bearer ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD",
766            "url": "https://api.example.com/v1?api_key=hideme",
767        });
768        policy.redact_json_in_place(&mut value);
769        assert_eq!(value["headers"]["authorization"], REDACTED_PLACEHOLDER);
770        assert_eq!(
771            value["headers"]["X-Amz-Security-Token"],
772            REDACTED_PLACEHOLDER
773        );
774        assert_eq!(value["headers"]["x-trace-id"], "trace_1");
775        assert_eq!(value["list"][0]["auth_token"], REDACTED_PLACEHOLDER);
776        assert_eq!(value["list"][0]["accessToken"], REDACTED_PLACEHOLDER);
777        assert_eq!(value["list"][0]["name"], "alice");
778        assert_eq!(value["clientSecret"], REDACTED_PLACEHOLDER);
779        assert_eq!(value["protected_values"], REDACTED_PLACEHOLDER);
780        let free_form = value["free_form"].as_str().unwrap();
781        // Free-form pattern matches produce the OA-06 named placeholder
782        // `<redacted:<pattern>:<len>>` so audit logs can attribute leaks to a
783        // specific provider.
784        assert!(
785            free_form.contains("<redacted:"),
786            "expected named placeholder, got: {free_form}"
787        );
788        assert!(!free_form.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
789    }
790
791    #[test]
792    fn policy_guard_pushes_and_pops_thread_local() {
793        clear_policy_stack();
794        assert_eq!(current_policy(), RedactionPolicy::default());
795        {
796            let policy = RedactionPolicy::default().with_extra_field("custom_token");
797            let _guard = PolicyGuard::new(policy.clone());
798            assert_eq!(current_policy(), policy);
799        }
800        assert_eq!(current_policy(), RedactionPolicy::default());
801    }
802
803    #[test]
804    fn redact_string_replaces_known_secret_patterns() {
805        let policy = RedactionPolicy::default();
806        let input =
807            "use sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD or AKIAABCDEFGHIJKLMNOP for now";
808        let out = policy.redact_string(input);
809        // Each provider pattern emits its own `<redacted:<name>:<len>>`
810        // placeholder so audit logs can attribute the leak.
811        assert!(out.contains("<redacted:openai_key:"));
812        assert!(out.contains("<redacted:aws_access_key:"));
813        assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
814        assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz0123456789ABCD"));
815    }
816
817    #[test]
818    fn looks_like_secret_value_accepts_logical_secret_references() {
819        let policy = RedactionPolicy::default();
820        assert!(policy.looks_like_secret_value("sk-live-secret"));
821        assert!(policy.looks_like_secret_value("AKIAABCDEFGHIJKLMNOP"));
822        assert!(!policy.looks_like_secret_value("github/webhook-secret"));
823        assert!(!policy.looks_like_secret_value("SPLUNK_READ_TOKEN"));
824    }
825}