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