Skip to main content

zeph_core/
redact.rs

1// SPDX-FileCopyrightText: 2026 Andrei G <bug-ops>
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use std::borrow::Cow;
5use std::sync::LazyLock;
6
7use base64::Engine as _;
8use regex::Regex;
9use zeph_common::secrets::PATH_PREFIXES;
10use zeph_sanitizer::secret_shape::scrub_secret_shapes;
11
12/// Apply URL-credential stripping, secret redaction (including Bearer headers and JWTs),
13/// and path sanitization in a single pass.
14///
15/// Returns `Cow::Borrowed` when no changes are needed (zero-allocation fast path).
16#[must_use]
17pub fn scrub_content(text: &str) -> Cow<'_, str> {
18    // Strip URL-embedded credentials first (https://user:pass@host → https://[REDACTED]@host).
19    let after_url: Cow<'_, str> = URL_CREDS_REGEX.replace_all(text, "${scheme}[REDACTED]@");
20    let after_secrets: Cow<'_, str> = match redact_secrets(after_url.as_ref()) {
21        Cow::Borrowed(_) => after_url,
22        Cow::Owned(s) => Cow::Owned(s),
23    };
24    match sanitize_paths(after_secrets.as_ref()) {
25        Cow::Borrowed(_) => after_secrets,
26        Cow::Owned(s) => Cow::Owned(s),
27    }
28}
29
30static PATH_REGEX: LazyLock<Regex> = LazyLock::new(|| {
31    let alt = PATH_PREFIXES.join("|");
32    let full = format!(r#"(?:{alt})[^\s"'`,;{{}}\[\]]*"#);
33    Regex::new(&full).expect("path redaction regex is valid")
34});
35
36// Matches basic-auth credentials embedded in URLs: https://user:pass@host
37static URL_CREDS_REGEX: LazyLock<Regex> = LazyLock::new(|| {
38    Regex::new(r"(?i)(?P<scheme>[a-z][a-z0-9+\-.]*://)(?P<creds>[^@/\s]+:[^@/\s]+@)")
39        .expect("url credential redaction regex is valid")
40});
41
42/// Replace tokens containing known secret patterns with `[REDACTED]`.
43///
44/// Detects secrets embedded in URLs, JSON values, and quoted strings (via the
45/// [`zeph_common::secrets::SECRET_PREFIXES`] prefix list), `Authorization: Bearer` headers,
46/// and standalone JWTs. Delegates the actual shape matching to
47/// [`zeph_sanitizer::secret_shape::scrub_secret_shapes`] — the same shape-based detection used
48/// by the subagent transcript-forward sanitize pipeline (issue #6571) — so both consumers stay
49/// in sync against a single implementation. Returns `Cow::Borrowed` when nothing was redacted
50/// (zero-allocation fast path).
51#[must_use]
52pub fn redact_secrets(text: &str) -> Cow<'_, str> {
53    scrub_secret_shapes(text)
54}
55
56/// Replace absolute filesystem paths with `[PATH]` to prevent information disclosure.
57#[must_use]
58pub fn sanitize_paths(text: &str) -> Cow<'_, str> {
59    if !PATH_PREFIXES.iter().any(|p| text.contains(*p)) {
60        return Cow::Borrowed(text);
61    }
62
63    let result = PATH_REGEX.replace_all(text, "[PATH]");
64    match result {
65        Cow::Borrowed(_) => Cow::Borrowed(text),
66        Cow::Owned(s) => Cow::Owned(s),
67    }
68}
69
70/// Minimum length of a contiguous base64-alphabet run to treat as probable binary data.
71///
72/// Set well above a typical hash/ID length: natural language and code essentially never
73/// produce 200+ unbroken base64-alphabet characters by accident, so this threshold favors
74/// avoiding false positives on legitimate short tokens over catching chunked/wrapped base64
75/// (e.g. MIME-encoded with embedded newlines), which will not match a single contiguous run.
76const MIN_BLOB_LEN: usize = 200;
77
78// Matches contiguous runs of base64-alphabet characters, optionally with trailing padding.
79static BASE64_BLOB_REGEX: LazyLock<Regex> = LazyLock::new(|| {
80    Regex::new(&format!(r"[A-Za-z0-9+/]{{{MIN_BLOB_LEN},}}={{0,2}}"))
81        .expect("base64 blob redaction regex is valid")
82});
83
84/// Replace long contiguous base64-alphabet runs with a length/hash marker.
85///
86/// Guards against tool output that embeds raw binary data (e.g. a vision tool returning
87/// image bytes as plain text instead of a typed image part) from being written unredacted
88/// to debug dumps. Returns `Cow::Borrowed` when no run is found (zero-allocation fast path).
89///
90/// Known limitations (accepted for this MVP heuristic, not solved): base64 wrapped with
91/// embedded newlines (e.g. 76-char MIME line length) does not form one contiguous run and
92/// slips through undetected. Similarly, two adjacent blobs concatenated with no separator can
93/// either merge into one run that still clears the threshold, or — if an internal `=` from an
94/// unaligned blob boundary sits mid-string — get split into two independently-scored fragments
95/// that can each fall under the 200-character threshold and escape redaction even though the
96/// combined data would have tripped the heuristic as a single run.
97#[must_use]
98pub fn redact_binary_blobs(text: &str) -> Cow<'_, str> {
99    if !BASE64_BLOB_REGEX.is_match(text) {
100        return Cow::Borrowed(text);
101    }
102    Cow::Owned(
103        BASE64_BLOB_REGEX
104            .replace_all(text, |caps: &regex::Captures<'_>| {
105                let encoded = &caps[0];
106                base64::engine::general_purpose::STANDARD
107                    .decode(encoded)
108                    .map_or_else(
109                        |_| {
110                            format!(
111                                "<redacted possible binary data: undecodable, {} chars>",
112                                encoded.len()
113                            )
114                        },
115                        |bytes| {
116                            let hash = blake3::hash(&bytes).to_hex();
117                            format!(
118                                "<redacted possible binary data: {} bytes, blake3:{}>",
119                                bytes.len(),
120                                &hash[..16]
121                            )
122                        },
123                    )
124            })
125            .into_owned(),
126    )
127}
128
129#[cfg(test)]
130mod tests {
131    use super::*;
132    use std::assert_matches;
133    use zeph_common::secrets::SECRET_PREFIXES;
134
135    #[test]
136    fn redacts_openai_key() {
137        let text = "Use key sk-abc123def456 for API calls";
138        let result = redact_secrets(text);
139        assert_eq!(result, "Use key [REDACTED] for API calls");
140    }
141
142    #[test]
143    fn redacts_stripe_live_key() {
144        let text = "Stripe key: sk_live_abcdef123456";
145        let result = redact_secrets(text);
146        assert!(result.contains("[REDACTED]"));
147        assert!(!result.contains("sk_live_"));
148    }
149
150    #[test]
151    fn redacts_stripe_test_key() {
152        let text = "Test key sk_test_abc123";
153        let result = redact_secrets(text);
154        assert!(result.contains("[REDACTED]"));
155    }
156
157    #[test]
158    fn redacts_aws_key() {
159        let text = "AWS access key: AKIAIOSFODNN7EXAMPLE";
160        let result = redact_secrets(text);
161        assert!(result.contains("[REDACTED]"));
162        assert!(!result.contains("AKIA"));
163    }
164
165    #[test]
166    fn redacts_github_pat() {
167        let text = "Token: ghp_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx";
168        let result = redact_secrets(text);
169        assert!(result.contains("[REDACTED]"));
170        assert!(!result.contains("ghp_"));
171    }
172
173    #[test]
174    fn redacts_github_oauth() {
175        let text = "OAuth: gho_xxxxxxxxxxxx";
176        let result = redact_secrets(text);
177        assert!(result.contains("[REDACTED]"));
178    }
179
180    #[test]
181    fn redacts_private_key_header() {
182        // A header with no closing footer is now caught by the PEM footerless fallback
183        // (`[REDACTED_PEM_KEY]`), not the generic prefix pass (`[REDACTED]`) — the fallback
184        // matches first in the pipeline and consumes the header before the prefix pass runs.
185        // S3 (#6592 follow-up): the fallback body is constrained to PEM-plausible characters,
186        // so " in file" (plain prose, no punctuation the class would reject) still gets
187        // swallowed here — that's the known accepted tradeoff for short trailing runs of
188        // letters/spaces; `redacts_unterminated_header_does_not_swallow_unrelated_prose` below
189        // is the regression guard proving longer prose containing punctuation survives.
190        let text = "Found -----BEGIN RSA PRIVATE KEY----- in file";
191        let result = redact_secrets(text);
192        assert!(result.contains("[REDACTED_PEM_KEY]"));
193        assert!(!result.contains("-----BEGIN"));
194    }
195
196    #[test]
197    fn redacts_full_pem_private_key_body() {
198        // S1 (#6592 follow-up): `redact_secrets` delegates to
199        // `zeph_sanitizer::secret_shape::scrub_secret_shapes`, which now spans the full
200        // multi-line PEM body, not just the header token — debug dumps must not retain the
201        // base64 key material.
202        let text = "Found -----BEGIN RSA PRIVATE KEY-----\nMIIBVQIBADANBgkqhkiG9w0B\n-----END RSA PRIVATE KEY----- in file";
203        let result = redact_secrets(text);
204        assert!(result.contains("[REDACTED_PEM_KEY]"));
205        assert!(!result.contains("MIIBVQIBADANBgkqhkiG9w0B"));
206    }
207
208    #[test]
209    fn redacts_unterminated_header_does_not_swallow_unrelated_prose() {
210        // S3 regression guard (#6592 follow-up): the footerless fallback previously used
211        // `.{0,8192}` (any character), so an unterminated header mention swallowed up to 8 KB
212        // of unrelated legitimate content — e.g. a debug dump line merely *describing* where a
213        // key file lives lost everything after "PRIVATE KEY-----". The fallback body is now
214        // constrained to PEM-plausible characters, so the match stops at the first character
215        // that cannot occur in base64 (here, the `.` in the file extension).
216        let text =
217            "Found -----BEGIN RSA PRIVATE KEY----- in file /etc/ssl/key.pem and the deploy failed";
218        let result = redact_secrets(text);
219        assert!(result.contains("[REDACTED_PEM_KEY]"));
220        assert!(
221            result.contains("and the deploy failed"),
222            "unrelated trailing prose must survive redaction, not be swallowed: {result}"
223        );
224    }
225
226    #[test]
227    fn redacts_slack_tokens() {
228        let text = "Bot token xoxb-123-456 and user xoxp-789";
229        let result = redact_secrets(text);
230        assert_eq!(result, "Bot token [REDACTED] and user [REDACTED]");
231    }
232
233    #[test]
234    fn preserves_normal_text() {
235        let text = "This is a normal response with no secrets";
236        let result = redact_secrets(text);
237        assert_eq!(result, text);
238        assert_matches!(result, Cow::Borrowed(_));
239    }
240
241    #[test]
242    fn handles_empty_string() {
243        assert_eq!(redact_secrets(""), "");
244    }
245
246    #[test]
247    fn multiple_secrets_redacted() {
248        let text = "Keys: sk-abc123 AKIAIOSFODNN7 ghp_xxxxx";
249        let result = redact_secrets(text);
250        assert_eq!(result, "Keys: [REDACTED] [REDACTED] [REDACTED]");
251    }
252
253    #[test]
254    fn preserves_multiline_whitespace() {
255        let text = "Line one\n  indented line\n\ttabbed line\nsk-secret here";
256        let result = redact_secrets(text);
257        assert_eq!(
258            result,
259            "Line one\n  indented line\n\ttabbed line\n[REDACTED] here"
260        );
261    }
262
263    #[test]
264    fn preserves_code_block_formatting() {
265        let text = "```rust\nfn main() {\n    let key = \"sk-abc123\";\n    println!(\"{}\", key);\n}\n```";
266        let result = redact_secrets(text);
267        assert!(result.contains("```rust\nfn"));
268        assert!(result.contains("    let"));
269        assert!(result.contains("[REDACTED]"));
270        assert!(!result.contains("sk-abc123"));
271    }
272
273    #[test]
274    fn preserves_multiple_spaces() {
275        let text = "word1   word2     word3";
276        let result = redact_secrets(text);
277        assert_eq!(result, text);
278    }
279
280    #[test]
281    fn no_allocation_without_secrets() {
282        let text = "safe text without any secrets";
283        let result = redact_secrets(text);
284        assert_matches!(result, Cow::Borrowed(_));
285    }
286
287    #[test]
288    fn all_secret_prefixes_tested() {
289        for prefix in SECRET_PREFIXES {
290            let text = format!("token: {prefix}abc123");
291            let result = redact_secrets(&text);
292            assert!(result.contains("[REDACTED]"), "Failed for prefix: {prefix}");
293            assert!(!result.contains(*prefix), "Prefix not redacted: {prefix}");
294        }
295    }
296
297    #[test]
298    fn redacts_google_api_key() {
299        let text = "Google key: AIzaSyA1234567890abcdefghijklmnop";
300        let result = redact_secrets(text);
301        assert!(result.contains("[REDACTED]"));
302        assert!(!result.contains("AIza"));
303    }
304
305    #[test]
306    fn redacts_google_oauth_token() {
307        let text = "OAuth token ya29.a0AfH6SMBx1234567890";
308        let result = redact_secrets(text);
309        assert!(result.contains("[REDACTED]"));
310        assert!(!result.contains("ya29."));
311    }
312
313    #[test]
314    fn redacts_gitlab_pat() {
315        let text = "GitLab token: glpat-xxxxxxxxxxxxxxxxxxxx";
316        let result = redact_secrets(text);
317        assert!(result.contains("[REDACTED]"));
318        assert!(!result.contains("glpat-"));
319    }
320
321    #[test]
322    fn only_whitespace() {
323        assert_eq!(redact_secrets("   \n\t  "), "   \n\t  ");
324    }
325
326    #[test]
327    fn secret_at_end_of_line() {
328        let text = "token: sk-abc123";
329        let result = redact_secrets(text);
330        assert_eq!(result, "token: [REDACTED]");
331    }
332
333    #[test]
334    fn redacts_secret_in_url() {
335        let text = "https://api.example.com?key=sk-abc123xyz";
336        let result = redact_secrets(text);
337        assert!(result.contains("[REDACTED]"));
338        assert!(!result.contains("sk-abc123xyz"));
339    }
340
341    #[test]
342    fn redacts_secret_in_json() {
343        let text = r#"{"api_key":"sk-abc123def456"}"#;
344        let result = redact_secrets(text);
345        assert!(result.contains("[REDACTED]"));
346        assert!(!result.contains("sk-abc123def456"));
347    }
348
349    #[test]
350    fn sanitize_home_path() {
351        let text = "error at /home/user/project/src/main.rs:42";
352        let result = sanitize_paths(text);
353        assert_eq!(result, "error at [PATH]");
354    }
355
356    #[test]
357    fn sanitize_users_path() {
358        let text = "failed: /Users/dev/code/lib.rs not found";
359        let result = sanitize_paths(text);
360        assert!(result.contains("[PATH]"));
361        assert!(!result.contains("/Users/"));
362    }
363
364    #[test]
365    fn sanitize_no_paths() {
366        let text = "normal error message";
367        let result = sanitize_paths(text);
368        assert_matches!(result, Cow::Borrowed(_));
369    }
370
371    #[test]
372    fn redacts_huggingface_token() {
373        let text = "HuggingFace token: hf_abcdefghijklmnopqrstuvwxyz";
374        let result = redact_secrets(text);
375        assert!(result.contains("[REDACTED]"));
376        assert!(!result.contains("hf_"));
377    }
378
379    #[test]
380    fn redacts_npm_token() {
381        let text = "NPM token npm_abc123XYZ";
382        let result = redact_secrets(text);
383        assert!(result.contains("[REDACTED]"));
384        assert!(!result.contains("npm_abc"));
385    }
386
387    #[test]
388    fn redacts_docker_pat() {
389        let text = "Docker token: dckr_pat_xxxxxxxxxxxx";
390        let result = redact_secrets(text);
391        assert!(result.contains("[REDACTED]"));
392        assert!(!result.contains("dckr_pat_"));
393    }
394
395    use proptest::prelude::*;
396
397    #[test]
398    fn scrub_no_match_passthrough() {
399        let text = "hello world, nothing sensitive here";
400        let result = scrub_content(text);
401        assert_matches!(result, Cow::Borrowed(_));
402        assert_eq!(result.as_ref(), text);
403    }
404
405    #[test]
406    fn scrub_only_secrets() {
407        let text = "key: sk-abc123def";
408        let result = scrub_content(text);
409        assert!(result.contains("[REDACTED]"));
410        assert!(!result.contains("sk-abc123"));
411        assert!(!result.contains("/home/"));
412    }
413
414    #[test]
415    fn scrub_only_paths() {
416        let text = "error at /Users/dev/project/src/main.rs:42";
417        let result = scrub_content(text);
418        assert!(result.contains("[PATH]"));
419        assert!(!result.contains("/Users/dev/"));
420    }
421
422    #[test]
423    fn scrub_secrets_and_paths_combined() {
424        let text = "token sk-abc123 found at /home/user/config.toml";
425        let result = scrub_content(text);
426        assert!(result.contains("[REDACTED]"));
427        assert!(result.contains("[PATH]"));
428        assert!(!result.contains("sk-abc123"));
429        assert!(!result.contains("/home/user/"));
430    }
431
432    #[test]
433    fn scrub_secrets_no_paths() {
434        // Secret found but no path → function returns Cow::Owned (modified string)
435        let text = "use sk-abc123 for auth";
436        let result = scrub_content(text);
437        assert!(
438            matches!(result, Cow::Owned(_)),
439            "must return Cow::Owned when secret was found"
440        );
441        assert!(result.contains("[REDACTED]"));
442        assert!(!result.contains("[PATH]"));
443    }
444
445    #[test]
446    fn sanitize_paths_all_prefixes() {
447        let cases = [
448            ("/root/secrets.toml", "/root/"),
449            ("/tmp/tmpfile.lock", "/tmp/"),
450            ("/var/log/app.log", "/var/"),
451        ];
452        for (text, prefix) in cases {
453            let result = sanitize_paths(text);
454            assert!(result.contains("[PATH]"), "{prefix} must be sanitized");
455            assert!(
456                !result.contains(prefix),
457                "{prefix} must be removed from output"
458            );
459        }
460    }
461
462    // ── #5917: Bearer/JWT coverage added to redact_secrets/scrub_content ──────────────
463
464    #[test]
465    fn redacts_bearer_token() {
466        let result = redact_secrets("Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.signature");
467        assert!(
468            result.contains("[REDACTED]"),
469            "Bearer token must be redacted: {result}"
470        );
471        assert!(
472            !result.contains("eyJhbGciOiJSUzI1NiJ9"),
473            "raw JWT header must not appear: {result}"
474        );
475        assert!(
476            result.contains("Authorization:"),
477            "header name must be preserved: {result}"
478        );
479    }
480
481    #[test]
482    fn redacts_bearer_token_case_insensitive() {
483        let result = redact_secrets("authorization: bearer eyJhbGciOiJSUzI1NiJ9.payload.signature");
484        assert!(
485            result.contains("[REDACTED]"),
486            "Bearer header match must be case-insensitive: {result}"
487        );
488    }
489
490    #[test]
491    fn redacts_standalone_jwt() {
492        let jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.SflKxwRJSMeKKF2";
493        let input = format!("token value: {jwt} was found in logs");
494        let result = redact_secrets(&input);
495        assert!(
496            result.contains("[REDACTED_JWT]"),
497            "standalone JWT must be replaced with [REDACTED_JWT]: {result}"
498        );
499        assert!(
500            !result.contains("eyJhbGci"),
501            "raw JWT must not appear: {result}"
502        );
503    }
504
505    #[test]
506    fn redacts_alg_none_jwt_with_empty_signature() {
507        let input = "token: eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0. was submitted";
508        let result = redact_secrets(input);
509        assert!(
510            result.contains("[REDACTED_JWT]"),
511            "alg=none JWT with empty signature must be redacted: {result}"
512        );
513    }
514
515    #[test]
516    fn scrub_content_redacts_secret_path_bearer_and_jwt_together() {
517        let text =
518            "key sk-abc123 at /home/user/f with Authorization: Bearer eyJhbG.pay.sig and eyJx.b.c";
519        let result = scrub_content(text);
520        assert!(result.contains("[REDACTED]"), "API key must be redacted");
521        assert!(result.contains("[PATH]"), "path must be redacted");
522        assert!(!result.contains("sk-abc123"), "raw API key must not appear");
523        assert!(!result.contains("eyJhbG"), "raw JWT must not appear");
524    }
525
526    // ── #6315: redact_binary_blobs ──────────────────────────────────────────────────
527
528    #[test]
529    fn redact_binary_blobs_redacts_long_base64_run() {
530        let payload = "A".repeat(300);
531        let text = format!("tool output: {payload} end");
532        let result = redact_binary_blobs(&text);
533        assert!(result.contains("<redacted possible binary data:"));
534        assert!(!result.contains(&payload));
535        assert!(result.contains("bytes, blake3:"));
536    }
537
538    #[test]
539    fn redact_binary_blobs_marker_is_stable_for_same_input() {
540        let payload = "B".repeat(250);
541        let first = redact_binary_blobs(&payload).into_owned();
542        let second = redact_binary_blobs(&payload).into_owned();
543        assert_eq!(first, second);
544    }
545
546    #[test]
547    fn redact_binary_blobs_leaves_short_base64_looking_strings_alone() {
548        let text = "id=".to_owned() + &"C".repeat(199);
549        let result = redact_binary_blobs(&text);
550        assert_matches!(result, Cow::Borrowed(_));
551        assert_eq!(result.as_ref(), text);
552    }
553
554    #[test]
555    fn redact_binary_blobs_leaves_non_base64_text_alone() {
556        let text = "This is a normal sentence with no binary data in it at all.";
557        let result = redact_binary_blobs(text);
558        assert_matches!(result, Cow::Borrowed(_));
559        assert_eq!(result.as_ref(), text);
560    }
561
562    #[test]
563    fn redact_binary_blobs_does_not_over_redact_typical_short_ids() {
564        // Typical UUIDs, git hashes, and hex digests are all well under MIN_BLOB_LEN.
565        let text = "commit 77442b11d2f3, uuid 550e8400-e29b-41d4-a716-446655440000, sha256:abc123";
566        let result = redact_binary_blobs(text);
567        assert_matches!(result, Cow::Borrowed(_));
568        assert_eq!(result.as_ref(), text);
569    }
570
571    #[test]
572    fn redact_binary_blobs_undecodable_run_gets_fallback_marker() {
573        // 200 'A's decode fine as base64 in isolation, so force an invalid-length run
574        // (not a multiple of 4, no valid padding) to hit the undecodable fallback path.
575        let payload = "A".repeat(201);
576        let result = redact_binary_blobs(&payload);
577        assert!(result.contains("<redacted possible binary data: undecodable"));
578        assert!(!result.contains(&payload));
579    }
580
581    proptest! {
582        #[test]
583        fn redact_binary_blobs_never_panics(s in ".*") {
584            let _ = redact_binary_blobs(&s);
585        }
586
587        #[test]
588        fn redact_secrets_never_panics(s in ".*") {
589            let _ = redact_secrets(&s);
590        }
591
592        #[test]
593        fn sanitize_paths_never_panics(s in ".*") {
594            let _ = sanitize_paths(&s);
595        }
596
597        #[test]
598        fn redact_preserves_non_secret_text(s in "[a-zA-Z0-9 .,!?]{1,200}") {
599            // Only test strings that genuinely contain nothing redact_secrets will touch:
600            // no known secret prefix, no "eyJ" (JWT marker), no case-insensitive "bearer".
601            let has_secret_prefix = SECRET_PREFIXES.iter().any(|p| s.contains(*p));
602            let has_jwt_marker = s.contains("eyJ");
603            let has_bearer_marker = s.to_lowercase().contains("bearer");
604            if !has_secret_prefix && !has_jwt_marker && !has_bearer_marker {
605                let result = redact_secrets(&s);
606                assert_eq!(result.as_ref(), s.as_str());
607            }
608        }
609
610        #[test]
611        fn scrub_content_never_panics(s in ".*") {
612            let _ = scrub_content(&s);
613        }
614
615        #[test]
616        fn scrub_content_result_never_contains_raw_secret(s in ".*") {
617            let result = scrub_content(&s);
618            for prefix in SECRET_PREFIXES {
619                assert!(
620                    !result.contains(*prefix),
621                    "scrub_content must redact prefix: {prefix}"
622                );
623            }
624        }
625    }
626}