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