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";
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 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 #[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 #[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 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 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 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}