1use 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#[must_use]
16pub fn scrub_content(text: &str) -> Cow<'_, str> {
17 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
29static 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
49static 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
55static BEARER_REGEX: LazyLock<Regex> =
57 LazyLock::new(|| Regex::new(BEARER_TOKEN_PATTERN).expect("bearer redaction regex is valid"));
58
59static JWT_REGEX: LazyLock<Regex> =
61 LazyLock::new(|| Regex::new(JWT_PATTERN).expect("jwt redaction regex is valid"));
62
63#[must_use]
70pub fn redact_secrets(text: &str) -> Cow<'_, str> {
71 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#[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
107const MIN_BLOB_LEN: usize = 200;
114
115static 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#[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: ®ex::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 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 #[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 #[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 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 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 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}