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