1pub const SECRET_KEY_SUFFIXES: &[&str] = &["_KEY", "_SECRET", "_PASSWORD", "_TOKEN"];
13
14const SECRET_VALUE_PREFIXES: &[&str] = &[
21 "sk-",
22 "ghp_",
23 "ghs_",
24 "gho_",
25 "ghu_",
26 "github_pat_",
27 "dckr_pat_",
28 "glpat-",
29 "AIza",
30 "ya29.",
31 "xox",
32];
33
34fn is_secret(key: &str, value: &str) -> bool {
41 if value.is_empty() {
42 return false;
43 }
44 let key_upper = key.to_uppercase();
45 if !is_boolean_shape(value) && SECRET_KEY_SUFFIXES.iter().any(|s| key_upper.ends_with(s)) {
46 return true;
47 }
48 SECRET_VALUE_PREFIXES.iter().any(|p| value.starts_with(p))
49}
50
51fn is_boolean_shape(value: &str) -> bool {
70 matches!(
71 value.to_ascii_lowercase().as_str(),
72 "true" | "false" | "yes" | "no" | "on" | "off" | "0" | "1"
73 )
74}
75
76pub fn string(input: &str, env: &[(String, String)]) -> String {
82 let mut secrets: Vec<(&str, &str)> = env
83 .iter()
84 .filter(|(k, v)| is_secret(k, v))
85 .map(|(k, v)| (k.as_str(), v.as_str()))
86 .collect();
87 secrets.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
88
89 let mut result = input.to_string();
90 for (key, value) in secrets {
91 result = result.replace(value, &format!("${}", key));
92 }
93 result
94}
95
96pub fn with_env(input: &str, env: &[(String, String)]) -> String {
101 string(&redact_url_credentials(input), env)
102}
103
104pub fn redact_process_env(input: &str) -> String {
112 let env: Vec<(String, String)> = std::env::vars().collect();
113 with_env(input, &env)
114}
115
116pub fn redact_url_credentials(input: &str) -> String {
128 let mut result = String::with_capacity(input.len());
136 let mut rest = input;
137 while let Some(scheme_end) = rest.find("://") {
138 let after_scheme_start = scheme_end + 3;
139 result.push_str(&rest[..after_scheme_start]);
140 let after_scheme = &rest[after_scheme_start..];
141 let terminator = after_scheme
142 .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
143 .unwrap_or(after_scheme.len());
144 let authority = &after_scheme[..terminator];
145 if let Some(last_at) = authority.rfind('@') {
146 result.push_str("<redacted>@");
148 result.push_str(&authority[last_at + 1..]);
149 rest = &after_scheme[terminator..];
150 } else {
151 result.push_str(authority);
152 rest = &after_scheme[terminator..];
153 }
154 }
155 result.push_str(rest);
156 result
157}
158
159pub fn redact_bearer_tokens(input: &str) -> String {
185 let bytes = input.as_bytes();
186 let mut out = String::with_capacity(input.len());
187 let mut i = 0;
188 while i < bytes.len() {
189 if let Some(name_len) = match_authorization_prefix(&bytes[i..]) {
195 out.push_str("Authorization: <redacted>");
196 i += name_len;
197 while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
198 i += 1;
199 }
200 continue;
201 }
202 let preceded_by_boundary = i == 0
206 || matches!(
207 bytes[i - 1],
208 b' ' | b'\t' | b':' | b',' | b';' | b'(' | b'"' | b'\'' | b'<' | b'\n' | b'\r'
209 );
210 if preceded_by_boundary && let Some(kw_len) = match_bearer_prefix(&bytes[i..]) {
211 out.push_str("Bearer <redacted>");
212 i += kw_len;
213 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
215 i += 1;
216 }
217 continue;
218 }
219 if preceded_by_boundary && let Some(kw_len) = match_basic_prefix(&bytes[i..]) {
220 out.push_str("Basic <redacted>");
221 i += kw_len;
222 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
223 i += 1;
224 }
225 continue;
226 }
227 out.push(bytes[i] as char);
229 i += 1;
230 }
231 out
232}
233
234fn match_bearer_prefix(bytes: &[u8]) -> Option<usize> {
237 const KW: &[u8] = b"Bearer ";
238 if bytes.len() < KW.len() {
239 return None;
240 }
241 for (i, kw_byte) in KW.iter().enumerate() {
242 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
243 return None;
244 }
245 }
246 Some(KW.len())
247}
248
249fn match_basic_prefix(bytes: &[u8]) -> Option<usize> {
254 const KW: &[u8] = b"Basic ";
255 if bytes.len() < KW.len() {
256 return None;
257 }
258 for (i, kw_byte) in KW.iter().enumerate() {
259 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
260 return None;
261 }
262 }
263 Some(KW.len())
264}
265
266fn match_authorization_prefix(bytes: &[u8]) -> Option<usize> {
270 const KW: &[u8] = b"Authorization:";
271 if bytes.len() < KW.len() {
272 return None;
273 }
274 for (i, kw_byte) in KW.iter().enumerate() {
275 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
276 return None;
277 }
278 }
279 Some(KW.len())
280}
281
282#[cfg(test)]
283mod tests {
284 use super::*;
285
286 #[test]
287 fn test_redact_by_key_suffix() {
288 let env = vec![
289 (
290 "DOCKER_PASSWORD".to_string(),
291 "mysecretpassword123".to_string(),
292 ),
293 ("PLAIN_VAR".to_string(), "not-a-secret".to_string()),
294 ];
295 let result = string("Login with mysecretpassword123 succeeded", &env);
296 assert_eq!(result, "Login with $DOCKER_PASSWORD succeeded");
297 assert!(!result.contains("mysecretpassword123"));
298 }
299
300 #[test]
301 fn test_redact_by_value_prefix() {
302 let env = vec![("MY_TOKEN".to_string(), "ghp_abc123def456ghi789".to_string())];
303 let result = string("Using token ghp_abc123def456ghi789", &env);
304 assert_eq!(result, "Using token $MY_TOKEN");
305 }
306
307 #[test]
308 fn test_redact_includes_short_secret_when_key_looks_secret() {
309 let env = vec![("API_KEY".to_string(), "short".to_string())];
313 let result = string("Value is short", &env);
314 assert_eq!(result, "Value is $API_KEY");
315 }
316
317 #[test]
318 fn test_redact_skips_empty_value() {
319 let env = vec![("API_KEY".to_string(), String::new())];
323 let result = string("Value is short", &env);
324 assert_eq!(result, "Value is short");
325 }
326
327 #[test]
328 fn test_redact_longer_values_first() {
329 let env = vec![
330 ("SHORT_TOKEN".to_string(), "abcdefghij".to_string()),
331 ("LONG_TOKEN".to_string(), "abcdefghijklmnop".to_string()),
332 ];
333 let result = string("secret: abcdefghijklmnop", &env);
334 assert_eq!(result, "secret: $LONG_TOKEN");
336 }
337
338 #[test]
339 fn test_redact_no_secrets() {
340 let env = vec![("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())];
341 let result = string("PATH is set", &env);
342 assert_eq!(result, "PATH is set");
343 }
344
345 #[test]
346 fn test_redact_multiple_occurrences() {
347 let env = vec![(
348 "REGISTRY_PASSWORD".to_string(),
349 "supersecret123".to_string(),
350 )];
351 let result = string("auth supersecret123 retry supersecret123", &env);
352 assert_eq!(result, "auth $REGISTRY_PASSWORD retry $REGISTRY_PASSWORD");
353 }
354
355 #[test]
356 fn test_is_secret_key_suffixes() {
357 assert!(is_secret("DOCKER_PASSWORD", "longvalue1234"));
358 assert!(is_secret("API_TOKEN", "longvalue1234"));
359 assert!(is_secret("signing_key", "longvalue1234")); assert!(is_secret("MY_SECRET", "longvalue1234"));
361 assert!(!is_secret("MY_CONFIG", "longvalue1234"));
362 }
363
364 #[test]
365 fn test_is_secret_value_prefixes() {
366 assert!(is_secret("ANYTHING", "ghp_1234567890"));
367 assert!(is_secret("ANYTHING", "sk-1234567890"));
368 assert!(is_secret("ANYTHING", "dckr_pat_1234567890"));
369 assert!(is_secret("ANYTHING", "glpat-1234567890"));
370 assert!(is_secret("GH_PAT", "github_pat_11ABCDE0000000000"));
375 assert!(is_secret(
376 "GOOGLE_CREDS",
377 "AIzaSyA00000000000000000000000000000000"
378 ));
379 assert!(is_secret(
380 "GOOGLE_CREDS",
381 "ya29.a0Af00000000000000000000000"
382 ));
383 assert!(!is_secret("ANYTHING", "regular_value1234"));
384 }
385
386 #[test]
387 fn test_redact_sort_stability_same_length() {
388 let env = vec![
391 ("B_SECRET".to_string(), "same_length_val".to_string()),
392 ("A_SECRET".to_string(), "same_length_val".to_string()),
393 ];
394 let result = string("found same_length_val here", &env);
397 assert_eq!(result, "found $A_SECRET here");
398 }
399
400 #[test]
401 fn test_redact_deterministic_with_different_lengths() {
402 let env = vec![
404 ("Z_TOKEN".to_string(), "short_secret_val".to_string()),
405 (
406 "A_TOKEN".to_string(),
407 "a_longer_secret_value_here".to_string(),
408 ),
409 ];
410 let result = string("prefix a_longer_secret_value_here suffix", &env);
411 assert_eq!(result, "prefix $A_TOKEN suffix");
412 }
413
414 #[test]
415 fn test_with_env_composes_url_strip_and_env_mask() {
416 let env = vec![(
421 "CARGO_REGISTRY_TOKEN".to_string(),
422 "ghp_realsecretvalue".to_string(),
423 )];
424 let input = "pushed via https://tok@host/x then logged ghp_realsecretvalue";
425 let result = with_env(input, &env);
426 assert_eq!(
427 result,
428 "pushed via https://<redacted>@host/x then logged $CARGO_REGISTRY_TOKEN"
429 );
430 assert!(!result.contains("ghp_realsecretvalue"));
431 assert!(!result.contains("tok@host"));
432 }
433
434 #[test]
435 fn test_redact_url_credentials_https_with_token() {
436 let input = "remote: https://ghp_abc123def@github.com/owner/repo.git";
437 let result = redact_url_credentials(input);
438 assert_eq!(
439 result,
440 "remote: https://<redacted>@github.com/owner/repo.git"
441 );
442 assert!(!result.contains("ghp_abc123def"));
443 }
444
445 #[test]
446 fn test_redact_url_credentials_user_pass_pair() {
447 let input = "pushing to https://user:p@ssw0rd@gitlab.example.com/foo/bar";
448 let result = redact_url_credentials(input);
449 assert_eq!(
450 result, "pushing to https://<redacted>@gitlab.example.com/foo/bar",
451 "userinfo must cover the entire user:pass segment up to the host-@"
452 );
453 }
454
455 #[test]
456 fn test_redact_url_credentials_no_userinfo_unchanged() {
457 let input = "fetching https://github.com/owner/repo.git";
458 assert_eq!(redact_url_credentials(input), input);
459 }
460
461 #[test]
462 fn test_redact_url_credentials_ssh_unchanged() {
463 let input = "fetching git@github.com:owner/repo.git";
467 assert_eq!(redact_url_credentials(input), input);
468 }
469
470 #[test]
471 fn test_redact_url_credentials_multiple_urls_in_one_line() {
472 let input = "from https://token1@a.com/x to https://token2@b.com/y";
473 let result = redact_url_credentials(input);
474 assert_eq!(
475 result, "from https://<redacted>@a.com/x to https://<redacted>@b.com/y",
476 "both URLs must be redacted, leaving the connecting prose intact"
477 );
478 }
479
480 #[test]
481 fn test_redact_url_credentials_does_not_consume_path_at_sign() {
482 let input = "GET https://api.example.com/users/foo@bar.com/profile";
485 assert_eq!(
486 redact_url_credentials(input),
487 input,
488 "an `@` after the first `/` is part of the path, not userinfo"
489 );
490 }
491
492 #[test]
493 fn test_redact_url_credentials_empty_input() {
494 assert_eq!(redact_url_credentials(""), "");
495 }
496
497 #[test]
498 fn test_redact_url_credentials_plain_text() {
499 let input = "no URLs here, just words";
500 assert_eq!(redact_url_credentials(input), input);
501 }
502
503 #[test]
504 fn test_redact_url_credentials_percent_encoded_userinfo() {
505 let input = "https://user%40name:pass@host.example.com/path";
511 let result = redact_url_credentials(input);
512 assert_eq!(result, "https://<redacted>@host.example.com/path");
513 assert!(!result.contains("user%40name"));
514 assert!(!result.contains("pass"));
515 }
516
517 #[test]
518 fn test_redact_url_credentials_trailing_query() {
519 let input = "https://user:pass@host.example.com?foo=bar";
522 let result = redact_url_credentials(input);
523 assert_eq!(result, "https://<redacted>@host.example.com?foo=bar");
524 assert!(!result.contains("user:pass"));
525 assert!(result.ends_with("?foo=bar"));
526 }
527
528 #[test]
529 fn test_redact_url_credentials_trailing_fragment() {
530 let input = "https://user:pass@host.example.com#frag";
533 let result = redact_url_credentials(input);
534 assert_eq!(result, "https://<redacted>@host.example.com#frag");
535 assert!(!result.contains("user:pass"));
536 assert!(result.ends_with("#frag"));
537 }
538
539 #[test]
540 fn test_redact_url_credentials_whitespace_boundary() {
541 let input = "https://user:pass@host.example.com then more";
544 let result = redact_url_credentials(input);
545 assert_eq!(result, "https://<redacted>@host.example.com then more");
546 assert!(!result.contains("user:pass"));
547 assert!(result.ends_with(" then more"));
548 }
549
550 #[test]
551 fn test_redact_bearer_tokens_basic() {
552 let input = "auth header: Bearer ghp_abcdef123456 expires soon";
553 let result = redact_bearer_tokens(input);
554 assert_eq!(result, "auth header: Bearer <redacted> expires soon");
555 assert!(!result.contains("ghp_abcdef123456"));
556 }
557
558 #[test]
559 fn test_redact_bearer_tokens_case_insensitive() {
560 let input = "bearer ghp_lowercase_token";
563 assert_eq!(
564 redact_bearer_tokens(input),
565 "Bearer <redacted>",
566 "lowercase 'bearer' must still redact"
567 );
568 let input = "BEARER ghp_uppercase_token";
569 assert_eq!(redact_bearer_tokens(input), "Bearer <redacted>");
570 }
571
572 #[test]
573 fn test_redact_bearer_tokens_authorization_header() {
574 let input = "request: Authorization: Bearer ghp_xyz\nresponse: 401";
578 let result = redact_bearer_tokens(input);
579 assert_eq!(
580 result, "request: Authorization: <redacted>\nresponse: 401",
581 "header value (including the inner Bearer token) must be redacted as one"
582 );
583 assert!(!result.contains("ghp_xyz"));
584 }
585
586 #[test]
587 fn test_redact_bearer_tokens_authorization_header_single_line() {
588 let input = "Authorization: Bearer ghp_xyz";
592 let result = redact_bearer_tokens(input);
593 assert_eq!(result, "Authorization: <redacted>");
594 assert!(!result.contains("ghp_xyz"));
595 }
596
597 #[test]
598 fn test_redact_bearer_tokens_no_match_unchanged() {
599 let input = "some random text with no relevant tokens here";
605 assert_eq!(redact_bearer_tokens(input), input);
606 }
607
608 #[test]
609 fn test_redact_bearer_tokens_over_redacts_prose_use() {
610 let input = "the bearer of bad news arrived";
615 let result = redact_bearer_tokens(input);
616 assert_eq!(result, "the Bearer <redacted> bad news arrived");
617 }
618
619 #[test]
620 fn test_redact_bearer_tokens_empty_input() {
621 assert_eq!(redact_bearer_tokens(""), "");
622 }
623
624 #[test]
625 fn test_redact_basic_token_redacts_b64_payload() {
626 let input = "auth: Basic ZnVyeXRva2VuOg== rest";
627 let result = redact_bearer_tokens(input);
628 assert_eq!(result, "auth: Basic <redacted> rest");
629 assert!(!result.contains("ZnVyeXRva2VuOg=="));
630 }
631
632 #[test]
633 fn test_redact_basic_token_case_insensitive() {
634 let input = "auth: basic ZnVyeXRva2VuOg==";
635 assert_eq!(redact_bearer_tokens(input), "auth: Basic <redacted>");
636 }
637
638 #[test]
639 fn test_redact_bearer_tokens_handles_multiple_occurrences() {
640 let input = "first Bearer ghp_aaa and second Bearer ghp_bbb done";
641 let result = redact_bearer_tokens(input);
642 assert_eq!(
643 result,
644 "first Bearer <redacted> and second Bearer <redacted> done"
645 );
646 assert!(!result.contains("ghp_aaa"));
647 assert!(!result.contains("ghp_bbb"));
648 }
649
650 #[test]
651 fn numeric_boolean_flag_does_not_corrupt_http_status_regression() {
652 let env = vec![("HF_HUB_DISABLE_IMPLICIT_TOKEN".to_string(), "1".to_string())];
661 assert_eq!(
662 string("HTTP 401 unauthorized", &env),
663 "HTTP 401 unauthorized"
664 );
665 }
666
667 #[test]
668 fn boolean_flag_under_secret_key_name_is_not_masked() {
669 let env = vec![("FEATURE_TOKEN".to_string(), "true".to_string())];
670 assert_eq!(string("enabled=true here", &env), "enabled=true here");
671 }
672
673 #[test]
674 fn short_alphabetic_secret_under_secret_key_name_is_still_masked() {
675 let env = vec![("API_KEY".to_string(), "short".to_string())];
678 assert_eq!(string("Value is short", &env), "Value is $API_KEY");
679 }
680
681 #[test]
682 fn numeric_value_matching_prefix_rule_is_still_masked() {
683 let env = vec![("CI".to_string(), "ghp_1".to_string())];
686 assert_eq!(string("token ghp_1", &env), "token $CI");
687 }
688
689 #[test]
690 fn long_numeric_value_under_secret_key_is_masked() {
691 let value = "1".repeat(32);
696 let env = vec![("MY_TOKEN".to_string(), value.clone())];
697 let result = string(&format!("token {value}"), &env);
698 assert_eq!(result, "token $MY_TOKEN");
699 assert!(!result.contains(&value));
700 }
701
702 #[test]
703 fn six_digit_otp_under_token_key_is_masked() {
704 let env = vec![("PYPI_2FA_TOKEN".to_string(), "483920".to_string())];
707 let result = string("submitting code 483920", &env);
708 assert_eq!(result, "submitting code $PYPI_2FA_TOKEN");
709 assert!(!result.contains("483920"));
710 }
711
712 #[test]
713 fn short_numeric_pin_under_password_key_is_masked() {
714 let env = vec![("CARD_PASSWORD".to_string(), "1234".to_string())];
718 let result = string("PIN entered: 1234", &env);
719 assert_eq!(result, "PIN entered: $CARD_PASSWORD");
720 assert!(!result.contains("1234"));
721 }
722
723 #[test]
724 fn short_numeric_pin_under_key_suffix_is_masked() {
725 let env = vec![("SIGNING_KEY".to_string(), "9999".to_string())];
726 let result = string("key value 9999", &env);
727 assert_eq!(result, "key value $SIGNING_KEY");
728 assert!(!result.contains("9999"));
729 }
730
731 #[test]
732 fn boolean_literal_under_secret_key_name_stays_unmasked_regression() {
733 let env = vec![("ENABLE_TOKEN".to_string(), "true".to_string())];
737 assert_eq!(string("flag is true", &env), "flag is true");
738 }
739
740 #[test]
741 fn non_secret_suffixed_key_short_number_stays_unmasked_regression() {
742 let env = vec![("RETRY_COUNT".to_string(), "42".to_string())];
745 assert_eq!(string("retries: 42", &env), "retries: 42");
746 }
747
748 #[test]
749 fn numeric_boolean_flag_under_token_key_does_not_corrupt_output_regression() {
750 let env = vec![("HF_HUB_DISABLE_IMPLICIT_TOKEN".to_string(), "1".to_string())];
757 let line = "7 2024-06-01T00:00:00Z amd64 1.0.0 stable";
758 assert_eq!(string(line, &env), line);
759 }
760
761 #[test]
762 fn numeric_zero_flag_under_key_suffix_stays_unmasked_regression() {
763 let env = vec![("GPG_SIGN_KEY".to_string(), "0".to_string())];
766 assert_eq!(string("sign=0 rev 10 v0.9.0", &env), "sign=0 rev 10 v0.9.0");
767 }
768}