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