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        let text = "Found -----BEGIN RSA PRIVATE KEY----- in file";
183        let result = redact_secrets(text);
184        assert!(result.contains("[REDACTED]"));
185        assert!(!result.contains("-----BEGIN"));
186    }
187
188    #[test]
189    fn redacts_slack_tokens() {
190        let text = "Bot token xoxb-123-456 and user xoxp-789";
191        let result = redact_secrets(text);
192        assert_eq!(result, "Bot token [REDACTED] and user [REDACTED]");
193    }
194
195    #[test]
196    fn preserves_normal_text() {
197        let text = "This is a normal response with no secrets";
198        let result = redact_secrets(text);
199        assert_eq!(result, text);
200        assert_matches!(result, Cow::Borrowed(_));
201    }
202
203    #[test]
204    fn handles_empty_string() {
205        assert_eq!(redact_secrets(""), "");
206    }
207
208    #[test]
209    fn multiple_secrets_redacted() {
210        let text = "Keys: sk-abc123 AKIAIOSFODNN7 ghp_xxxxx";
211        let result = redact_secrets(text);
212        assert_eq!(result, "Keys: [REDACTED] [REDACTED] [REDACTED]");
213    }
214
215    #[test]
216    fn preserves_multiline_whitespace() {
217        let text = "Line one\n  indented line\n\ttabbed line\nsk-secret here";
218        let result = redact_secrets(text);
219        assert_eq!(
220            result,
221            "Line one\n  indented line\n\ttabbed line\n[REDACTED] here"
222        );
223    }
224
225    #[test]
226    fn preserves_code_block_formatting() {
227        let text = "```rust\nfn main() {\n    let key = \"sk-abc123\";\n    println!(\"{}\", key);\n}\n```";
228        let result = redact_secrets(text);
229        assert!(result.contains("```rust\nfn"));
230        assert!(result.contains("    let"));
231        assert!(result.contains("[REDACTED]"));
232        assert!(!result.contains("sk-abc123"));
233    }
234
235    #[test]
236    fn preserves_multiple_spaces() {
237        let text = "word1   word2     word3";
238        let result = redact_secrets(text);
239        assert_eq!(result, text);
240    }
241
242    #[test]
243    fn no_allocation_without_secrets() {
244        let text = "safe text without any secrets";
245        let result = redact_secrets(text);
246        assert_matches!(result, Cow::Borrowed(_));
247    }
248
249    #[test]
250    fn all_secret_prefixes_tested() {
251        for prefix in SECRET_PREFIXES {
252            let text = format!("token: {prefix}abc123");
253            let result = redact_secrets(&text);
254            assert!(result.contains("[REDACTED]"), "Failed for prefix: {prefix}");
255            assert!(!result.contains(*prefix), "Prefix not redacted: {prefix}");
256        }
257    }
258
259    #[test]
260    fn redacts_google_api_key() {
261        let text = "Google key: AIzaSyA1234567890abcdefghijklmnop";
262        let result = redact_secrets(text);
263        assert!(result.contains("[REDACTED]"));
264        assert!(!result.contains("AIza"));
265    }
266
267    #[test]
268    fn redacts_google_oauth_token() {
269        let text = "OAuth token ya29.a0AfH6SMBx1234567890";
270        let result = redact_secrets(text);
271        assert!(result.contains("[REDACTED]"));
272        assert!(!result.contains("ya29."));
273    }
274
275    #[test]
276    fn redacts_gitlab_pat() {
277        let text = "GitLab token: glpat-xxxxxxxxxxxxxxxxxxxx";
278        let result = redact_secrets(text);
279        assert!(result.contains("[REDACTED]"));
280        assert!(!result.contains("glpat-"));
281    }
282
283    #[test]
284    fn only_whitespace() {
285        assert_eq!(redact_secrets("   \n\t  "), "   \n\t  ");
286    }
287
288    #[test]
289    fn secret_at_end_of_line() {
290        let text = "token: sk-abc123";
291        let result = redact_secrets(text);
292        assert_eq!(result, "token: [REDACTED]");
293    }
294
295    #[test]
296    fn redacts_secret_in_url() {
297        let text = "https://api.example.com?key=sk-abc123xyz";
298        let result = redact_secrets(text);
299        assert!(result.contains("[REDACTED]"));
300        assert!(!result.contains("sk-abc123xyz"));
301    }
302
303    #[test]
304    fn redacts_secret_in_json() {
305        let text = r#"{"api_key":"sk-abc123def456"}"#;
306        let result = redact_secrets(text);
307        assert!(result.contains("[REDACTED]"));
308        assert!(!result.contains("sk-abc123def456"));
309    }
310
311    #[test]
312    fn sanitize_home_path() {
313        let text = "error at /home/user/project/src/main.rs:42";
314        let result = sanitize_paths(text);
315        assert_eq!(result, "error at [PATH]");
316    }
317
318    #[test]
319    fn sanitize_users_path() {
320        let text = "failed: /Users/dev/code/lib.rs not found";
321        let result = sanitize_paths(text);
322        assert!(result.contains("[PATH]"));
323        assert!(!result.contains("/Users/"));
324    }
325
326    #[test]
327    fn sanitize_no_paths() {
328        let text = "normal error message";
329        let result = sanitize_paths(text);
330        assert_matches!(result, Cow::Borrowed(_));
331    }
332
333    #[test]
334    fn redacts_huggingface_token() {
335        let text = "HuggingFace token: hf_abcdefghijklmnopqrstuvwxyz";
336        let result = redact_secrets(text);
337        assert!(result.contains("[REDACTED]"));
338        assert!(!result.contains("hf_"));
339    }
340
341    #[test]
342    fn redacts_npm_token() {
343        let text = "NPM token npm_abc123XYZ";
344        let result = redact_secrets(text);
345        assert!(result.contains("[REDACTED]"));
346        assert!(!result.contains("npm_abc"));
347    }
348
349    #[test]
350    fn redacts_docker_pat() {
351        let text = "Docker token: dckr_pat_xxxxxxxxxxxx";
352        let result = redact_secrets(text);
353        assert!(result.contains("[REDACTED]"));
354        assert!(!result.contains("dckr_pat_"));
355    }
356
357    use proptest::prelude::*;
358
359    #[test]
360    fn scrub_no_match_passthrough() {
361        let text = "hello world, nothing sensitive here";
362        let result = scrub_content(text);
363        assert_matches!(result, Cow::Borrowed(_));
364        assert_eq!(result.as_ref(), text);
365    }
366
367    #[test]
368    fn scrub_only_secrets() {
369        let text = "key: sk-abc123def";
370        let result = scrub_content(text);
371        assert!(result.contains("[REDACTED]"));
372        assert!(!result.contains("sk-abc123"));
373        assert!(!result.contains("/home/"));
374    }
375
376    #[test]
377    fn scrub_only_paths() {
378        let text = "error at /Users/dev/project/src/main.rs:42";
379        let result = scrub_content(text);
380        assert!(result.contains("[PATH]"));
381        assert!(!result.contains("/Users/dev/"));
382    }
383
384    #[test]
385    fn scrub_secrets_and_paths_combined() {
386        let text = "token sk-abc123 found at /home/user/config.toml";
387        let result = scrub_content(text);
388        assert!(result.contains("[REDACTED]"));
389        assert!(result.contains("[PATH]"));
390        assert!(!result.contains("sk-abc123"));
391        assert!(!result.contains("/home/user/"));
392    }
393
394    #[test]
395    fn scrub_secrets_no_paths() {
396        // Secret found but no path → function returns Cow::Owned (modified string)
397        let text = "use sk-abc123 for auth";
398        let result = scrub_content(text);
399        assert!(
400            matches!(result, Cow::Owned(_)),
401            "must return Cow::Owned when secret was found"
402        );
403        assert!(result.contains("[REDACTED]"));
404        assert!(!result.contains("[PATH]"));
405    }
406
407    #[test]
408    fn sanitize_paths_all_prefixes() {
409        let cases = [
410            ("/root/secrets.toml", "/root/"),
411            ("/tmp/tmpfile.lock", "/tmp/"),
412            ("/var/log/app.log", "/var/"),
413        ];
414        for (text, prefix) in cases {
415            let result = sanitize_paths(text);
416            assert!(result.contains("[PATH]"), "{prefix} must be sanitized");
417            assert!(
418                !result.contains(prefix),
419                "{prefix} must be removed from output"
420            );
421        }
422    }
423
424    // ── #5917: Bearer/JWT coverage added to redact_secrets/scrub_content ──────────────
425
426    #[test]
427    fn redacts_bearer_token() {
428        let result = redact_secrets("Authorization: Bearer eyJhbGciOiJSUzI1NiJ9.payload.signature");
429        assert!(
430            result.contains("[REDACTED]"),
431            "Bearer token must be redacted: {result}"
432        );
433        assert!(
434            !result.contains("eyJhbGciOiJSUzI1NiJ9"),
435            "raw JWT header must not appear: {result}"
436        );
437        assert!(
438            result.contains("Authorization:"),
439            "header name must be preserved: {result}"
440        );
441    }
442
443    #[test]
444    fn redacts_bearer_token_case_insensitive() {
445        let result = redact_secrets("authorization: bearer eyJhbGciOiJSUzI1NiJ9.payload.signature");
446        assert!(
447            result.contains("[REDACTED]"),
448            "Bearer header match must be case-insensitive: {result}"
449        );
450    }
451
452    #[test]
453    fn redacts_standalone_jwt() {
454        let jwt = "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyMTIzIn0.SflKxwRJSMeKKF2";
455        let input = format!("token value: {jwt} was found in logs");
456        let result = redact_secrets(&input);
457        assert!(
458            result.contains("[REDACTED_JWT]"),
459            "standalone JWT must be replaced with [REDACTED_JWT]: {result}"
460        );
461        assert!(
462            !result.contains("eyJhbGci"),
463            "raw JWT must not appear: {result}"
464        );
465    }
466
467    #[test]
468    fn redacts_alg_none_jwt_with_empty_signature() {
469        let input = "token: eyJhbGciOiJub25lIn0.eyJzdWIiOiJ1c2VyIn0. was submitted";
470        let result = redact_secrets(input);
471        assert!(
472            result.contains("[REDACTED_JWT]"),
473            "alg=none JWT with empty signature must be redacted: {result}"
474        );
475    }
476
477    #[test]
478    fn scrub_content_redacts_secret_path_bearer_and_jwt_together() {
479        let text =
480            "key sk-abc123 at /home/user/f with Authorization: Bearer eyJhbG.pay.sig and eyJx.b.c";
481        let result = scrub_content(text);
482        assert!(result.contains("[REDACTED]"), "API key must be redacted");
483        assert!(result.contains("[PATH]"), "path must be redacted");
484        assert!(!result.contains("sk-abc123"), "raw API key must not appear");
485        assert!(!result.contains("eyJhbG"), "raw JWT must not appear");
486    }
487
488    // ── #6315: redact_binary_blobs ──────────────────────────────────────────────────
489
490    #[test]
491    fn redact_binary_blobs_redacts_long_base64_run() {
492        let payload = "A".repeat(300);
493        let text = format!("tool output: {payload} end");
494        let result = redact_binary_blobs(&text);
495        assert!(result.contains("<redacted possible binary data:"));
496        assert!(!result.contains(&payload));
497        assert!(result.contains("bytes, blake3:"));
498    }
499
500    #[test]
501    fn redact_binary_blobs_marker_is_stable_for_same_input() {
502        let payload = "B".repeat(250);
503        let first = redact_binary_blobs(&payload).into_owned();
504        let second = redact_binary_blobs(&payload).into_owned();
505        assert_eq!(first, second);
506    }
507
508    #[test]
509    fn redact_binary_blobs_leaves_short_base64_looking_strings_alone() {
510        let text = "id=".to_owned() + &"C".repeat(199);
511        let result = redact_binary_blobs(&text);
512        assert_matches!(result, Cow::Borrowed(_));
513        assert_eq!(result.as_ref(), text);
514    }
515
516    #[test]
517    fn redact_binary_blobs_leaves_non_base64_text_alone() {
518        let text = "This is a normal sentence with no binary data in it at all.";
519        let result = redact_binary_blobs(text);
520        assert_matches!(result, Cow::Borrowed(_));
521        assert_eq!(result.as_ref(), text);
522    }
523
524    #[test]
525    fn redact_binary_blobs_does_not_over_redact_typical_short_ids() {
526        // Typical UUIDs, git hashes, and hex digests are all well under MIN_BLOB_LEN.
527        let text = "commit 77442b11d2f3, uuid 550e8400-e29b-41d4-a716-446655440000, sha256:abc123";
528        let result = redact_binary_blobs(text);
529        assert_matches!(result, Cow::Borrowed(_));
530        assert_eq!(result.as_ref(), text);
531    }
532
533    #[test]
534    fn redact_binary_blobs_undecodable_run_gets_fallback_marker() {
535        // 200 'A's decode fine as base64 in isolation, so force an invalid-length run
536        // (not a multiple of 4, no valid padding) to hit the undecodable fallback path.
537        let payload = "A".repeat(201);
538        let result = redact_binary_blobs(&payload);
539        assert!(result.contains("<redacted possible binary data: undecodable"));
540        assert!(!result.contains(&payload));
541    }
542
543    proptest! {
544        #[test]
545        fn redact_binary_blobs_never_panics(s in ".*") {
546            let _ = redact_binary_blobs(&s);
547        }
548
549        #[test]
550        fn redact_secrets_never_panics(s in ".*") {
551            let _ = redact_secrets(&s);
552        }
553
554        #[test]
555        fn sanitize_paths_never_panics(s in ".*") {
556            let _ = sanitize_paths(&s);
557        }
558
559        #[test]
560        fn redact_preserves_non_secret_text(s in "[a-zA-Z0-9 .,!?]{1,200}") {
561            // Only test strings that genuinely contain nothing redact_secrets will touch:
562            // no known secret prefix, no "eyJ" (JWT marker), no case-insensitive "bearer".
563            let has_secret_prefix = SECRET_PREFIXES.iter().any(|p| s.contains(*p));
564            let has_jwt_marker = s.contains("eyJ");
565            let has_bearer_marker = s.to_lowercase().contains("bearer");
566            if !has_secret_prefix && !has_jwt_marker && !has_bearer_marker {
567                let result = redact_secrets(&s);
568                assert_eq!(result.as_ref(), s.as_str());
569            }
570        }
571
572        #[test]
573        fn scrub_content_never_panics(s in ".*") {
574            let _ = scrub_content(&s);
575        }
576
577        #[test]
578        fn scrub_content_result_never_contains_raw_secret(s in ".*") {
579            let result = scrub_content(&s);
580            for prefix in SECRET_PREFIXES {
581                assert!(
582                    !result.contains(*prefix),
583                    "scrub_content must redact prefix: {prefix}"
584                );
585            }
586        }
587    }
588}