1use 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#[must_use]
17pub fn scrub_content(text: &str) -> Cow<'_, str> {
18 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
36static 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#[must_use]
52pub fn redact_secrets(text: &str) -> Cow<'_, str> {
53 scrub_secret_shapes(text)
54}
55
56#[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
70const MIN_BLOB_LEN: usize = 200;
77
78static 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#[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: ®ex::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";
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 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 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 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 #[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 #[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 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 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 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}