Skip to main content

harn_vm/redact/
patterns.rs

1//! Free-form string secret patterns reused for redaction.
2//!
3//! Each pattern is named so the replacement placeholder is
4//! `<redacted:<pattern_name>:<len>>` and audit events can attribute the
5//! redaction to a specific provider. The shared
6//! [`crate::secret_patterns`] catalog is also used by the
7//! `secret_scan` builtin, so a string that scanning reports is also a
8//! string that redaction scrubs.
9//!
10//! # Custom patterns
11//!
12//! Hosts and scripts can register additional named patterns through
13//! [`register_custom_pattern`]. Custom patterns live on a thread-local
14//! stack so test pollution stays contained and so a per-orchestrator
15//! override can be installed alongside the existing
16//! [`crate::redact::PolicyGuard`].
17//!
18//! # Audit
19//!
20//! Every redaction synchronously records a [`RedactionEvent`] in a
21//! per-thread ring drainable via [`drain_audit_ring`], and also fires
22//! an optional [`AuditSink`] callback. The default sink installed by
23//! the [`crate::stdlib::token_redaction`] stdlib forwards events to
24//! the live events pipeline and, on a multi-threaded Tokio runtime,
25//! to the `audit.token_redaction` event-log topic. Audit entries
26//! carry the diagnostic identifier `HARN-OAU-001` from the OA-06
27//! epic — they never include the raw token.
28
29use std::borrow::Cow;
30use std::cell::RefCell;
31use std::collections::BTreeMap;
32use std::sync::LazyLock;
33
34use regex::Regex;
35
36use crate::secret_patterns::DEFAULT_SECRET_PATTERN_SPECS;
37
38/// Stable identifier emitted in audit logs for every token-redaction
39/// event. Part of the OA-06 epic's compliance contract.
40pub const TOKEN_REDACTION_DIAGNOSTIC: &str = "HARN-OAU-001";
41
42/// Event-log topic used for token-redaction audit events.
43pub const TOKEN_REDACTION_AUDIT_TOPIC: &str = "audit.token_redaction";
44
45/// Size of a single regex scan window. Inputs at or below this size are
46/// scanned in one pass; larger inputs are scanned in overlapping windows of
47/// this size (see [`SCAN_WINDOW_OVERLAP_BYTES`]) so no single regex call ever
48/// runs over more than this many bytes — keeping a pathological (custom)
49/// pattern from triggering catastrophic behavior on the persistence hot path
50/// — while a secret embedded anywhere in an oversized value is still redacted.
51const MAX_SCAN_INPUT_BYTES: usize = 256 * 1024;
52
53/// Overlap between consecutive scan windows for oversized inputs. Every window
54/// re-scans this many bytes of its predecessor's tail so a secret straddling a
55/// window boundary is fully contained in — and detected by — at least one
56/// window. It must exceed the longest possible secret match; 8 KiB is far above
57/// any real API key / token / PEM header while staying negligible relative to
58/// the 256 KiB window, so the windowed scan stays linear in the input length.
59const SCAN_WINDOW_OVERLAP_BYTES: usize = 8 * 1024;
60
61/// One redaction pattern with a stable display name.
62#[derive(Clone)]
63pub struct NamedPattern {
64    /// Short, kebab-case identifier (e.g. `"github_pat_classic"`).
65    /// Stable across versions — emitted in audit events and in the
66    /// `<redacted:name:len>` placeholder.
67    pub name: &'static str,
68    /// Compiled regex. Always anchored on `\b` or non-word boundaries
69    /// so it does not chew unrelated identifiers.
70    pub regex: Regex,
71}
72
73impl std::fmt::Debug for NamedPattern {
74    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
75        f.debug_struct("NamedPattern")
76            .field("name", &self.name)
77            .field("regex", &self.regex.as_str())
78            .finish()
79    }
80}
81
82/// Default token patterns shipped with Harn. Order matters only for
83/// audit attribution when multiple patterns would match the same
84/// substring — earlier patterns win.
85pub static DEFAULT_PATTERNS: LazyLock<Vec<NamedPattern>> = LazyLock::new(|| {
86    DEFAULT_SECRET_PATTERN_SPECS
87        .iter()
88        .map(|spec| NamedPattern {
89            name: spec.redaction_name,
90            regex: Regex::new(spec.regex).unwrap_or_else(|error| {
91                panic!("invalid {} secret regex: {error}", spec.redaction_name)
92            }),
93        })
94        .collect()
95});
96
97thread_local! {
98    /// Custom token patterns installed by stdlib callers. Stored on a
99    /// per-thread stack the same way [`crate::redact::PolicyGuard`]
100    /// stores active policies; `reset_thread_local_state` clears them.
101    static CUSTOM_PATTERNS: RefCell<Vec<NamedPattern>> = const { RefCell::new(Vec::new()) };
102
103    /// Callback that receives one entry per pattern that matched.
104    /// Set by callers that want to audit redactions
105    /// (`stdlib::token_redaction` installs a default sink that
106    /// forwards to the event log when a runtime is available).
107    /// `None` means "no extra audit collection on this thread".
108    /// Every redaction also lands in [`AUDIT_RING`] regardless of
109    /// whether a sink is installed.
110    static AUDIT_SINK: RefCell<Option<AuditSink>> = const { RefCell::new(None) };
111
112    /// Authoritative per-thread audit ring. Always populated on
113    /// every redaction so the synchronous compliance contract holds
114    /// in every execution context (sync host calls, single-threaded
115    /// LocalSet, multi-thread runtime). Drained by stdlib via
116    /// [`drain_audit_ring`].
117    static AUDIT_RING: RefCell<Vec<RedactionEvent>> = const { RefCell::new(Vec::new()) };
118}
119
120/// Per-redaction event passed to an installed [`AuditSink`].
121#[derive(Clone, Debug, PartialEq, Eq)]
122pub struct RedactionEvent {
123    pub pattern_name: String,
124    pub match_count: usize,
125    /// Total bytes redacted across all matches of this pattern.
126    pub bytes_redacted: usize,
127}
128
129/// Thread-local callback invoked once per pattern that matched during a
130/// single `scan_secret_patterns` call.
131pub type AuditSink = std::rc::Rc<dyn Fn(&RedactionEvent)>;
132
133/// Register a custom named pattern on the calling thread. Returns an
134/// error if the regex fails to compile. The pattern is appended after
135/// the default catalog, so default patterns still win when multiple
136/// would match the same substring.
137pub fn register_custom_pattern(name: impl Into<String>, regex_source: &str) -> Result<(), String> {
138    let regex = Regex::new(regex_source).map_err(|error| format!("invalid regex: {error}"))?;
139    // Leak the name to `'static` so the pattern's name field stays
140    // borrow-free and serialization can carry the same lifetime as
141    // the default catalog. Custom patterns are rare and never freed
142    // — the leak is bounded by the number of distinct user-supplied
143    // names per process.
144    let name_static: &'static str = Box::leak(name.into().into_boxed_str());
145    CUSTOM_PATTERNS.with(|cell| {
146        cell.borrow_mut().push(NamedPattern {
147            name: name_static,
148            regex,
149        });
150    });
151    Ok(())
152}
153
154/// Drop all custom patterns installed via [`register_custom_pattern`]
155/// on the calling thread. Idempotent.
156pub fn clear_custom_patterns() {
157    CUSTOM_PATTERNS.with(|cell| cell.borrow_mut().clear());
158}
159
160/// Return the names of every default pattern, in catalog order.
161pub fn default_pattern_names() -> Vec<&'static str> {
162    DEFAULT_PATTERNS.iter().map(|p| p.name).collect()
163}
164
165/// Return the names of every custom pattern currently installed on the
166/// calling thread.
167pub fn custom_pattern_names() -> Vec<String> {
168    CUSTOM_PATTERNS.with(|cell| cell.borrow().iter().map(|p| p.name.to_string()).collect())
169}
170
171/// Install a per-thread audit sink. The previous sink (if any) is
172/// returned so callers can chain or restore.
173pub fn install_audit_sink(sink: Option<AuditSink>) -> Option<AuditSink> {
174    AUDIT_SINK.with(|cell| std::mem::replace(&mut *cell.borrow_mut(), sink))
175}
176
177fn emit_audit(events: &[RedactionEvent]) {
178    if events.is_empty() {
179        return;
180    }
181    // Always push to the per-thread ring so a synchronous
182    // `drain_audit_ring` call returns every event recorded since
183    // the last drain, regardless of whether an extra sink is
184    // installed on this thread.
185    AUDIT_RING.with(|ring| {
186        let mut ring = ring.borrow_mut();
187        for event in events {
188            // Bounded cap: 1024 entries is well above any realistic
189            // per-step audit pressure but small enough to be a
190            // no-op for normal workloads and to keep a runaway
191            // sink from OOMing the process.
192            if ring.len() >= 1024 {
193                ring.remove(0);
194            }
195            ring.push(event.clone());
196        }
197    });
198    let sink = AUDIT_SINK.with(|cell| cell.borrow().clone());
199    if let Some(sink) = sink {
200        for event in events {
201            sink(event);
202        }
203    }
204}
205
206/// Drain every audit event recorded on the calling thread since the
207/// last drain. The returned vec is in the order events fired.
208pub fn drain_audit_ring() -> Vec<RedactionEvent> {
209    AUDIT_RING.with(|ring| std::mem::take(&mut *ring.borrow_mut()))
210}
211
212/// Clear the per-thread audit ring without returning its contents.
213/// Used by `clear_policy_stack` so tests sharing a thread cannot
214/// leak audit events into each other.
215pub fn clear_audit_ring() {
216    AUDIT_RING.with(|ring| ring.borrow_mut().clear());
217}
218
219/// Build the per-match replacement string in the canonical
220/// `<redacted:<name>:<len>>` form. Length reflects the redacted match
221/// in UTF-8 bytes.
222fn replacement_for(name: &str, matched: &str) -> String {
223    format!("<redacted:{name}:{}>", matched.len())
224}
225
226/// Replace any high-confidence secret matches in `input` with the
227/// canonical `<redacted:<pattern_name>:<len>>` placeholder. Returns
228/// `Cow::Borrowed` when nothing matched, so callers paying for a clone
229/// only pay when there was real work.
230///
231/// The legacy `placeholder` argument is kept for callers that want a
232/// flat `[redacted]` form (e.g. headers and URL params). When the
233/// placeholder is the canonical `[redacted]` constant the named form
234/// is used; any other placeholder is substituted verbatim so callers
235/// that need a specific marker (URL-param escaping, etc.) still get
236/// it byte-for-byte.
237pub fn scan_secret_patterns<'a>(input: &'a str, placeholder: &str) -> Cow<'a, str> {
238    if input.is_empty() {
239        return Cow::Borrowed(input);
240    }
241    let use_named_placeholder = placeholder == crate::redact::REDACTED_PLACEHOLDER;
242
243    // Oversized inputs are scanned in overlapping windows instead of being
244    // passed through unredacted: a secret embedded in a large tool result,
245    // transcript, or base64 blob under a non-sensitive field name must not leak
246    // just because the whole value exceeds the single-pass window. No individual
247    // regex call ever sees more than one window, so a pathological custom
248    // pattern still cannot run over the entire giant string at once.
249    if input.len() > MAX_SCAN_INPUT_BYTES {
250        return scan_secret_patterns_windowed(input, use_named_placeholder, placeholder);
251    }
252
253    let mut owned: Option<String> = None;
254    let mut audit_events: BTreeMap<&'static str, RedactionEvent> = BTreeMap::new();
255
256    // Drive defaults then custom patterns. We collect custom
257    // patterns into a Vec so the closure does not borrow the
258    // thread-local across the regex calls.
259    let custom: Vec<NamedPattern> = CUSTOM_PATTERNS.with(|cell| cell.borrow().clone());
260    let all_patterns = DEFAULT_PATTERNS.iter().chain(custom.iter());
261
262    for pattern in all_patterns {
263        let target: &str = owned.as_deref().unwrap_or(input);
264        let matches: Vec<(usize, usize)> = pattern
265            .regex
266            .find_iter(target)
267            .map(|m| (m.start(), m.end()))
268            .collect();
269        if matches.is_empty() {
270            continue;
271        }
272        let total_bytes: usize = matches.iter().map(|(s, e)| e - s).sum();
273        audit_events.insert(
274            pattern.name,
275            RedactionEvent {
276                pattern_name: pattern.name.to_string(),
277                match_count: matches.len(),
278                bytes_redacted: total_bytes,
279            },
280        );
281
282        // Walk matches in reverse so we can splice without
283        // recomputing offsets after each cut.
284        let mut buffer = target.to_string();
285        for (start, end) in matches.into_iter().rev() {
286            let matched_slice = &buffer[start..end];
287            let replacement = if use_named_placeholder {
288                replacement_for(pattern.name, matched_slice)
289            } else {
290                placeholder.to_string()
291            };
292            buffer.replace_range(start..end, &replacement);
293        }
294        owned = Some(buffer);
295    }
296
297    let result = match owned {
298        Some(value) if value == input => Cow::Borrowed(input),
299        Some(value) => Cow::Owned(value),
300        None => Cow::Borrowed(input),
301    };
302
303    if matches!(result, Cow::Owned(_)) {
304        let events: Vec<RedactionEvent> = audit_events.into_values().collect();
305        emit_audit(&events);
306    }
307
308    result
309}
310
311/// Round `offset` down to the nearest UTF-8 char boundary (or `0`).
312fn floor_char_boundary(s: &str, mut offset: usize) -> usize {
313    if offset >= s.len() {
314        return s.len();
315    }
316    while offset > 0 && !s.is_char_boundary(offset) {
317        offset -= 1;
318    }
319    offset
320}
321
322/// Round `offset` up to the nearest UTF-8 char boundary (or `s.len()`).
323fn ceil_char_boundary(s: &str, mut offset: usize) -> usize {
324    if offset >= s.len() {
325        return s.len();
326    }
327    while offset < s.len() && !s.is_char_boundary(offset) {
328        offset += 1;
329    }
330    offset
331}
332
333/// Overlapping-window variant of [`scan_secret_patterns`] for inputs larger
334/// than [`MAX_SCAN_INPUT_BYTES`].
335///
336/// Each pattern is scanned over consecutive windows of `MAX_SCAN_INPUT_BYTES`
337/// bytes that overlap their predecessor by [`SCAN_WINDOW_OVERLAP_BYTES`]. Because
338/// the overlap exceeds the longest possible secret, every match is *strictly
339/// interior* to at least one window; a match that touches an artificial
340/// (non-terminal) window edge is dropped there — which also discards the false
341/// `\b` word boundaries that slicing would otherwise create — and picked up
342/// whole in the neighbouring window. Global match ranges are collected in
343/// pattern-priority order (earlier patterns win on overlap, matching the
344/// single-pass path) and spliced once from the end so offsets stay valid.
345fn scan_secret_patterns_windowed<'a>(
346    input: &'a str,
347    use_named_placeholder: bool,
348    placeholder: &str,
349) -> Cow<'a, str> {
350    let step = MAX_SCAN_INPUT_BYTES - SCAN_WINDOW_OVERLAP_BYTES;
351    let custom: Vec<NamedPattern> = CUSTOM_PATTERNS.with(|cell| cell.borrow().clone());
352
353    // Claimed global byte ranges, kept sorted by start. Each range also carries
354    // its replacement text and the pattern that produced it (for audit).
355    struct Claim {
356        start: usize,
357        end: usize,
358        replacement: String,
359        pattern: &'static str,
360    }
361    let mut claims: Vec<Claim> = Vec::new();
362
363    for pattern in DEFAULT_PATTERNS.iter().chain(custom.iter()) {
364        let mut window_start = 0usize;
365        loop {
366            let ws = floor_char_boundary(input, window_start);
367            let we = ceil_char_boundary(
368                input,
369                (window_start + MAX_SCAN_INPUT_BYTES).min(input.len()),
370            );
371            for m in pattern.regex.find_iter(&input[ws..we]) {
372                let gs = ws + m.start();
373                let ge = ws + m.end();
374                // Drop matches touching an artificial window edge; the same
375                // secret is strictly interior to the neighbouring window.
376                if (gs == ws && ws != 0) || (ge == we && we != input.len()) {
377                    continue;
378                }
379                // First (highest-priority) pattern wins on overlap; this also
380                // deduplicates a match seen in two overlapping windows.
381                if claims.iter().any(|c| gs < c.end && c.start < ge) {
382                    continue;
383                }
384                let replacement = if use_named_placeholder {
385                    replacement_for(pattern.name, &input[gs..ge])
386                } else {
387                    placeholder.to_string()
388                };
389                claims.push(Claim {
390                    start: gs,
391                    end: ge,
392                    replacement,
393                    pattern: pattern.name,
394                });
395            }
396            if we >= input.len() {
397                break;
398            }
399            window_start += step;
400        }
401    }
402
403    if claims.is_empty() {
404        return Cow::Borrowed(input);
405    }
406
407    claims.sort_by_key(|c| c.start);
408
409    // Audit tallies, grouped by pattern in catalog order.
410    let mut audit_events: BTreeMap<&'static str, RedactionEvent> = BTreeMap::new();
411    for claim in &claims {
412        let event = audit_events
413            .entry(claim.pattern)
414            .or_insert_with(|| RedactionEvent {
415                pattern_name: claim.pattern.to_string(),
416                match_count: 0,
417                bytes_redacted: 0,
418            });
419        event.match_count += 1;
420        event.bytes_redacted += claim.end - claim.start;
421    }
422
423    let mut out = input.to_string();
424    for claim in claims.iter().rev() {
425        out.replace_range(claim.start..claim.end, &claim.replacement);
426    }
427
428    emit_audit(&audit_events.into_values().collect::<Vec<_>>());
429    Cow::Owned(out)
430}
431
432#[cfg(test)]
433mod tests {
434    use super::*;
435
436    fn run_clean() {
437        clear_custom_patterns();
438        install_audit_sink(None);
439        clear_audit_ring();
440    }
441
442    #[test]
443    fn returns_borrowed_when_clean() {
444        run_clean();
445        let out = scan_secret_patterns("just plain text", crate::redact::REDACTED_PLACEHOLDER);
446        assert!(matches!(out, Cow::Borrowed(_)));
447    }
448
449    #[test]
450    fn replaces_aws_and_github_tokens_with_named_placeholder() {
451        run_clean();
452        let input = "AKIAABCDEFGHIJKLMNOP and ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
453        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
454        let rendered = out.into_owned();
455        assert!(rendered.contains("<redacted:aws_access_key:20>"));
456        assert!(rendered.contains("<redacted:github_token:40>"));
457        assert!(!rendered.contains("AKIAABCDEFGHIJKLMNOP"));
458    }
459
460    #[test]
461    fn legacy_placeholder_path_still_works_for_url_param_values() {
462        run_clean();
463        let input = "AKIAABCDEFGHIJKLMNOP";
464        // A non-`[redacted]` placeholder is used verbatim — this is
465        // the URL-param escaping path.
466        let out = scan_secret_patterns(input, "%5Bredacted%5D");
467        assert!(out.contains("%5Bredacted%5D"));
468        assert!(!out.contains("AKIAABCDEFGHIJKLMNOP"));
469    }
470
471    #[test]
472    fn replaces_bearer_token_inside_text() {
473        run_clean();
474        let input = "header: Authorization: Bearer abcDEFghi123_-+/=xyz tail";
475        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
476        assert!(out.contains("<redacted:bearer_token:"));
477        assert!(!out.contains("abcDEFghi123_-+/=xyz"));
478        assert!(out.contains("tail"));
479    }
480
481    #[test]
482    fn replaces_sensitive_assignments_inside_text() {
483        run_clean();
484        let input = "retry with token=abc123 and max_tokens=200";
485        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
486        assert!(out.contains("<redacted:sensitive_assignment:"));
487        assert!(!out.contains("token=abc123"));
488        assert!(out.contains("max_tokens=200"));
489    }
490
491    #[test]
492    fn sensitive_assignment_preserves_source_declarations() {
493        run_clean();
494        let input = "pub const Token = struct { kind: u8 };\nconst Secret = enum { a, b };";
495        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
496        assert!(matches!(out, Cow::Borrowed(_)));
497    }
498
499    #[test]
500    fn sensitive_assignment_redacts_placeholder_secret_words() {
501        run_clean();
502        let input = "Checkout incident needed the same query token=secret";
503        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
504        assert!(out.contains("<redacted:sensitive_assignment:"));
505        assert!(!out.contains("token=secret"));
506    }
507
508    #[test]
509    fn replaces_jwt_tokens() {
510        run_clean();
511        let input = "token=eyJabcd.eyJefgh.signature_pad here";
512        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
513        assert!(out.contains("<redacted:jwt:"));
514        assert!(!out.contains("eyJabcd.eyJefgh.signature_pad"));
515    }
516
517    #[test]
518    fn replaces_private_key_blocks() {
519        run_clean();
520        let input =
521            "-----BEGIN OPENSSH PRIVATE KEY-----\nsecret-material\n-----END OPENSSH PRIVATE KEY-----";
522        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
523        assert!(out.contains("<redacted:private_key_block:"));
524        assert!(!out.contains("secret-material"));
525    }
526
527    #[test]
528    fn replaces_ai_provider_tokens() {
529        run_clean();
530        let huggingface = format!("hf_{}", "a".repeat(24));
531        let cerebras = format!("csk-{}", "b".repeat(48));
532        let together = format!("tgp_v1_{}", "c".repeat(32));
533        let google = format!("AIza{}", "D".repeat(35));
534        let input = format!("{huggingface} {cerebras} {together} {google}");
535
536        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
537        let rendered = out.into_owned();
538
539        assert!(rendered.contains("<redacted:huggingface_token:"));
540        assert!(rendered.contains("<redacted:cerebras_key:"));
541        assert!(rendered.contains("<redacted:together_key:"));
542        assert!(rendered.contains("<redacted:google_api_key:"));
543        assert!(!rendered.contains(&huggingface));
544        assert!(!rendered.contains(&cerebras));
545        assert!(!rendered.contains(&together));
546        assert!(!rendered.contains(&google));
547    }
548
549    #[test]
550    fn custom_pattern_redacts_and_is_introspectable() {
551        run_clean();
552        register_custom_pattern("acme_token", r"\bACME-[A-Z0-9]{8}\b").unwrap();
553        assert_eq!(custom_pattern_names(), vec!["acme_token".to_string()]);
554        let out = scan_secret_patterns(
555            "header ACME-12345678 trailer",
556            crate::redact::REDACTED_PLACEHOLDER,
557        );
558        assert!(
559            out.contains("<redacted:acme_token:13>"),
560            "expected acme_token redaction, got: {out}"
561        );
562        clear_custom_patterns();
563        assert!(custom_pattern_names().is_empty());
564    }
565
566    #[test]
567    fn audit_sink_receives_one_event_per_matching_pattern() {
568        use std::cell::RefCell;
569        use std::rc::Rc;
570        run_clean();
571        let captured: Rc<RefCell<Vec<RedactionEvent>>> = Rc::new(RefCell::new(Vec::new()));
572        let sink_captured = captured.clone();
573        install_audit_sink(Some(Rc::new(move |event| {
574            sink_captured.borrow_mut().push(event.clone());
575        })));
576        let input =
577            "AKIAABCDEFGHIJKLMNOP AKIA0000000000000000 ghp_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
578        let out = scan_secret_patterns(input, crate::redact::REDACTED_PLACEHOLDER);
579        assert!(matches!(out, Cow::Owned(_)));
580        let events = captured.borrow();
581        assert_eq!(events.len(), 2);
582        let by_name: BTreeMap<&str, &RedactionEvent> = events
583            .iter()
584            .map(|event| (event.pattern_name.as_str(), event))
585            .collect();
586        assert_eq!(by_name.get("aws_access_key").unwrap().match_count, 2);
587        assert_eq!(by_name.get("github_token").unwrap().match_count, 1);
588        // The synchronous ring captures the same events so a
589        // compliance drain returns them regardless of which sink
590        // (if any) is installed.
591        drop(events);
592        install_audit_sink(None);
593        let ring = drain_audit_ring();
594        assert_eq!(ring.len(), 2);
595    }
596
597    #[test]
598    fn audit_ring_records_events_even_without_a_sink() {
599        run_clean();
600        let _ = scan_secret_patterns("AKIAABCDEFGHIJKLMNOP", crate::redact::REDACTED_PLACEHOLDER);
601        let ring = drain_audit_ring();
602        assert_eq!(ring.len(), 1);
603        assert_eq!(ring[0].pattern_name, "aws_access_key");
604        // Drain is destructive.
605        assert!(drain_audit_ring().is_empty());
606    }
607
608    const AWS_KEY: &str = "AKIAABCDEFGHIJKLMNOP";
609
610    #[test]
611    fn secret_past_the_scan_cap_is_redacted() {
612        // A secret placed well beyond MAX_SCAN_INPUT_BYTES must still be scrubbed
613        // — the old behavior passed the whole value through unredacted.
614        run_clean();
615        let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 4096);
616        input.push_str(AWS_KEY);
617        input.push(' ');
618        assert!(input.len() > MAX_SCAN_INPUT_BYTES);
619        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
620        assert!(matches!(out, Cow::Owned(_)), "oversized secret must redact");
621        assert!(
622            !out.contains(AWS_KEY),
623            "secret leaked: {}",
624            &out[out.len().saturating_sub(64)..]
625        );
626        assert!(out.contains("<redacted:aws_access_key:20>"));
627    }
628
629    #[test]
630    fn secret_straddling_a_window_boundary_is_redacted() {
631        // Place the 20-byte key so it spans the first window's end
632        // (MAX_SCAN_INPUT_BYTES): half inside window 0, half beyond. The overlap
633        // guarantees it is fully interior to window 1 and thus detected.
634        run_clean();
635        let prefix_len = MAX_SCAN_INPUT_BYTES - (AWS_KEY.len() / 2);
636        let mut input = " ".repeat(prefix_len);
637        input.push_str(AWS_KEY);
638        input.push_str(&" ".repeat(SCAN_WINDOW_OVERLAP_BYTES)); // ensure a 2nd window exists
639        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
640        assert!(!out.contains(AWS_KEY), "straddling secret leaked");
641        assert!(out.contains("<redacted:aws_access_key:20>"));
642        // Exactly one redaction — the overlap must not double-count it.
643        assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
644    }
645
646    #[test]
647    fn oversized_non_secret_blob_is_not_over_redacted() {
648        // A large innocuous value (no secret) must pass through untouched, not be
649        // blanket-redacted.
650        run_clean();
651        let blob = "lorem ipsum dolor sit amet ".repeat(MAX_SCAN_INPUT_BYTES / 20);
652        assert!(blob.len() > MAX_SCAN_INPUT_BYTES);
653        let out = scan_secret_patterns(&blob, crate::redact::REDACTED_PLACEHOLDER);
654        assert!(
655            matches!(out, Cow::Borrowed(_)),
656            "clean blob must not be rewritten"
657        );
658        assert_eq!(out.as_ref(), blob);
659    }
660
661    #[test]
662    fn oversized_scan_records_audit_event() {
663        run_clean();
664        let mut input = " ".repeat(MAX_SCAN_INPUT_BYTES + 100);
665        input.push_str(AWS_KEY);
666        input.push(' ');
667        let _ = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
668        let ring = drain_audit_ring();
669        assert_eq!(ring.len(), 1);
670        assert_eq!(ring[0].pattern_name, "aws_access_key");
671        assert_eq!(ring[0].match_count, 1);
672        assert_eq!(ring[0].bytes_redacted, 20);
673    }
674
675    #[test]
676    fn multi_megabyte_scan_stays_linear_and_redacts() {
677        // ~5 MiB (≈20 windows) with a single embedded secret. The windowed scan
678        // is linear in the input, so this returns near-instantly; a catastrophic
679        // (e.g. O(n²)) regression would instead blow the test-runner timeout. We
680        // assert on the result rather than the wall clock to stay deterministic.
681        run_clean();
682        let mut input = "x ".repeat(5 * 1024 * 1024 / 2);
683        input.push_str(AWS_KEY);
684        input.push(' ');
685        let out = scan_secret_patterns(&input, crate::redact::REDACTED_PLACEHOLDER);
686        assert!(!out.contains(AWS_KEY));
687        assert_eq!(out.matches("<redacted:aws_access_key:20>").count(), 1);
688    }
689
690    #[test]
691    fn default_pattern_names_are_stable() {
692        let names = default_pattern_names();
693        assert!(names.contains(&"jwt"));
694        assert!(names.contains(&"github_token"));
695        assert!(names.contains(&"github_pat_fine"));
696        assert!(names.contains(&"slack_token"));
697        assert!(names.contains(&"aws_access_key"));
698        assert!(names.contains(&"huggingface_token"));
699        assert!(names.contains(&"cerebras_key"));
700        assert!(names.contains(&"together_key"));
701        assert!(names.contains(&"google_api_key"));
702        assert!(names.contains(&"private_key_block"));
703        assert!(names.contains(&"bearer_token"));
704        assert!(names.contains(&"sensitive_assignment"));
705    }
706}