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