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 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
51pub fn string(input: &str, env: &[(String, String)]) -> String {
57 let mut secrets: Vec<(&str, &str)> = env
58 .iter()
59 .filter(|(k, v)| is_secret(k, v))
60 .map(|(k, v)| (k.as_str(), v.as_str()))
61 .collect();
62 secrets.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
63
64 let mut result = input.to_string();
65 for (key, value) in secrets {
66 result = result.replace(value, &format!("${}", key));
67 }
68 result
69}
70
71pub fn with_env(input: &str, env: &[(String, String)]) -> String {
76 string(&redact_url_credentials(input), env)
77}
78
79pub fn redact_process_env(input: &str) -> String {
87 let env: Vec<(String, String)> = std::env::vars().collect();
88 with_env(input, &env)
89}
90
91pub fn redact_url_credentials(input: &str) -> String {
103 let mut result = String::with_capacity(input.len());
111 let mut rest = input;
112 while let Some(scheme_end) = rest.find("://") {
113 let after_scheme_start = scheme_end + 3;
114 result.push_str(&rest[..after_scheme_start]);
115 let after_scheme = &rest[after_scheme_start..];
116 let terminator = after_scheme
117 .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
118 .unwrap_or(after_scheme.len());
119 let authority = &after_scheme[..terminator];
120 if let Some(last_at) = authority.rfind('@') {
121 result.push_str("<redacted>@");
123 result.push_str(&authority[last_at + 1..]);
124 rest = &after_scheme[terminator..];
125 } else {
126 result.push_str(authority);
127 rest = &after_scheme[terminator..];
128 }
129 }
130 result.push_str(rest);
131 result
132}
133
134pub fn redact_bearer_tokens(input: &str) -> String {
160 let bytes = input.as_bytes();
161 let mut out = String::with_capacity(input.len());
162 let mut i = 0;
163 while i < bytes.len() {
164 if let Some(name_len) = match_authorization_prefix(&bytes[i..]) {
170 out.push_str("Authorization: <redacted>");
171 i += name_len;
172 while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
173 i += 1;
174 }
175 continue;
176 }
177 let preceded_by_boundary = i == 0
181 || matches!(
182 bytes[i - 1],
183 b' ' | b'\t' | b':' | b',' | b';' | b'(' | b'"' | b'\'' | b'<' | b'\n' | b'\r'
184 );
185 if preceded_by_boundary && let Some(kw_len) = match_bearer_prefix(&bytes[i..]) {
186 out.push_str("Bearer <redacted>");
187 i += kw_len;
188 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
190 i += 1;
191 }
192 continue;
193 }
194 if preceded_by_boundary && let Some(kw_len) = match_basic_prefix(&bytes[i..]) {
195 out.push_str("Basic <redacted>");
196 i += kw_len;
197 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
198 i += 1;
199 }
200 continue;
201 }
202 out.push(bytes[i] as char);
204 i += 1;
205 }
206 out
207}
208
209fn match_bearer_prefix(bytes: &[u8]) -> Option<usize> {
212 const KW: &[u8] = b"Bearer ";
213 if bytes.len() < KW.len() {
214 return None;
215 }
216 for (i, kw_byte) in KW.iter().enumerate() {
217 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
218 return None;
219 }
220 }
221 Some(KW.len())
222}
223
224fn match_basic_prefix(bytes: &[u8]) -> Option<usize> {
229 const KW: &[u8] = b"Basic ";
230 if bytes.len() < KW.len() {
231 return None;
232 }
233 for (i, kw_byte) in KW.iter().enumerate() {
234 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
235 return None;
236 }
237 }
238 Some(KW.len())
239}
240
241fn match_authorization_prefix(bytes: &[u8]) -> Option<usize> {
245 const KW: &[u8] = b"Authorization:";
246 if bytes.len() < KW.len() {
247 return None;
248 }
249 for (i, kw_byte) in KW.iter().enumerate() {
250 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
251 return None;
252 }
253 }
254 Some(KW.len())
255}
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn test_redact_by_key_suffix() {
263 let env = vec![
264 (
265 "DOCKER_PASSWORD".to_string(),
266 "mysecretpassword123".to_string(),
267 ),
268 ("PLAIN_VAR".to_string(), "not-a-secret".to_string()),
269 ];
270 let result = string("Login with mysecretpassword123 succeeded", &env);
271 assert_eq!(result, "Login with $DOCKER_PASSWORD succeeded");
272 assert!(!result.contains("mysecretpassword123"));
273 }
274
275 #[test]
276 fn test_redact_by_value_prefix() {
277 let env = vec![("MY_TOKEN".to_string(), "ghp_abc123def456ghi789".to_string())];
278 let result = string("Using token ghp_abc123def456ghi789", &env);
279 assert_eq!(result, "Using token $MY_TOKEN");
280 }
281
282 #[test]
283 fn test_redact_includes_short_secret_when_key_looks_secret() {
284 let env = vec![("API_KEY".to_string(), "short".to_string())];
288 let result = string("Value is short", &env);
289 assert_eq!(result, "Value is $API_KEY");
290 }
291
292 #[test]
293 fn test_redact_skips_empty_value() {
294 let env = vec![("API_KEY".to_string(), String::new())];
298 let result = string("Value is short", &env);
299 assert_eq!(result, "Value is short");
300 }
301
302 #[test]
303 fn test_redact_longer_values_first() {
304 let env = vec![
305 ("SHORT_TOKEN".to_string(), "abcdefghij".to_string()),
306 ("LONG_TOKEN".to_string(), "abcdefghijklmnop".to_string()),
307 ];
308 let result = string("secret: abcdefghijklmnop", &env);
309 assert_eq!(result, "secret: $LONG_TOKEN");
311 }
312
313 #[test]
314 fn test_redact_no_secrets() {
315 let env = vec![("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())];
316 let result = string("PATH is set", &env);
317 assert_eq!(result, "PATH is set");
318 }
319
320 #[test]
321 fn test_redact_multiple_occurrences() {
322 let env = vec![(
323 "REGISTRY_PASSWORD".to_string(),
324 "supersecret123".to_string(),
325 )];
326 let result = string("auth supersecret123 retry supersecret123", &env);
327 assert_eq!(result, "auth $REGISTRY_PASSWORD retry $REGISTRY_PASSWORD");
328 }
329
330 #[test]
331 fn test_is_secret_key_suffixes() {
332 assert!(is_secret("DOCKER_PASSWORD", "longvalue1234"));
333 assert!(is_secret("API_TOKEN", "longvalue1234"));
334 assert!(is_secret("signing_key", "longvalue1234")); assert!(is_secret("MY_SECRET", "longvalue1234"));
336 assert!(!is_secret("MY_CONFIG", "longvalue1234"));
337 }
338
339 #[test]
340 fn test_is_secret_value_prefixes() {
341 assert!(is_secret("ANYTHING", "ghp_1234567890"));
342 assert!(is_secret("ANYTHING", "sk-1234567890"));
343 assert!(is_secret("ANYTHING", "dckr_pat_1234567890"));
344 assert!(is_secret("ANYTHING", "glpat-1234567890"));
345 assert!(is_secret("GH_PAT", "github_pat_11ABCDE0000000000"));
350 assert!(is_secret(
351 "GOOGLE_CREDS",
352 "AIzaSyA00000000000000000000000000000000"
353 ));
354 assert!(is_secret(
355 "GOOGLE_CREDS",
356 "ya29.a0Af00000000000000000000000"
357 ));
358 assert!(!is_secret("ANYTHING", "regular_value1234"));
359 }
360
361 #[test]
362 fn test_redact_sort_stability_same_length() {
363 let env = vec![
366 ("B_SECRET".to_string(), "same_length_val".to_string()),
367 ("A_SECRET".to_string(), "same_length_val".to_string()),
368 ];
369 let result = string("found same_length_val here", &env);
372 assert_eq!(result, "found $A_SECRET here");
373 }
374
375 #[test]
376 fn test_redact_deterministic_with_different_lengths() {
377 let env = vec![
379 ("Z_TOKEN".to_string(), "short_secret_val".to_string()),
380 (
381 "A_TOKEN".to_string(),
382 "a_longer_secret_value_here".to_string(),
383 ),
384 ];
385 let result = string("prefix a_longer_secret_value_here suffix", &env);
386 assert_eq!(result, "prefix $A_TOKEN suffix");
387 }
388
389 #[test]
390 fn test_with_env_composes_url_strip_and_env_mask() {
391 let env = vec![(
396 "CARGO_REGISTRY_TOKEN".to_string(),
397 "ghp_realsecretvalue".to_string(),
398 )];
399 let input = "pushed via https://tok@host/x then logged ghp_realsecretvalue";
400 let result = with_env(input, &env);
401 assert_eq!(
402 result,
403 "pushed via https://<redacted>@host/x then logged $CARGO_REGISTRY_TOKEN"
404 );
405 assert!(!result.contains("ghp_realsecretvalue"));
406 assert!(!result.contains("tok@host"));
407 }
408
409 #[test]
410 fn test_redact_url_credentials_https_with_token() {
411 let input = "remote: https://ghp_abc123def@github.com/owner/repo.git";
412 let result = redact_url_credentials(input);
413 assert_eq!(
414 result,
415 "remote: https://<redacted>@github.com/owner/repo.git"
416 );
417 assert!(!result.contains("ghp_abc123def"));
418 }
419
420 #[test]
421 fn test_redact_url_credentials_user_pass_pair() {
422 let input = "pushing to https://user:p@ssw0rd@gitlab.example.com/foo/bar";
423 let result = redact_url_credentials(input);
424 assert_eq!(
425 result, "pushing to https://<redacted>@gitlab.example.com/foo/bar",
426 "userinfo must cover the entire user:pass segment up to the host-@"
427 );
428 }
429
430 #[test]
431 fn test_redact_url_credentials_no_userinfo_unchanged() {
432 let input = "fetching https://github.com/owner/repo.git";
433 assert_eq!(redact_url_credentials(input), input);
434 }
435
436 #[test]
437 fn test_redact_url_credentials_ssh_unchanged() {
438 let input = "fetching git@github.com:owner/repo.git";
442 assert_eq!(redact_url_credentials(input), input);
443 }
444
445 #[test]
446 fn test_redact_url_credentials_multiple_urls_in_one_line() {
447 let input = "from https://token1@a.com/x to https://token2@b.com/y";
448 let result = redact_url_credentials(input);
449 assert_eq!(
450 result, "from https://<redacted>@a.com/x to https://<redacted>@b.com/y",
451 "both URLs must be redacted, leaving the connecting prose intact"
452 );
453 }
454
455 #[test]
456 fn test_redact_url_credentials_does_not_consume_path_at_sign() {
457 let input = "GET https://api.example.com/users/foo@bar.com/profile";
460 assert_eq!(
461 redact_url_credentials(input),
462 input,
463 "an `@` after the first `/` is part of the path, not userinfo"
464 );
465 }
466
467 #[test]
468 fn test_redact_url_credentials_empty_input() {
469 assert_eq!(redact_url_credentials(""), "");
470 }
471
472 #[test]
473 fn test_redact_url_credentials_plain_text() {
474 let input = "no URLs here, just words";
475 assert_eq!(redact_url_credentials(input), input);
476 }
477
478 #[test]
479 fn test_redact_url_credentials_percent_encoded_userinfo() {
480 let input = "https://user%40name:pass@host.example.com/path";
486 let result = redact_url_credentials(input);
487 assert_eq!(result, "https://<redacted>@host.example.com/path");
488 assert!(!result.contains("user%40name"));
489 assert!(!result.contains("pass"));
490 }
491
492 #[test]
493 fn test_redact_url_credentials_trailing_query() {
494 let input = "https://user:pass@host.example.com?foo=bar";
497 let result = redact_url_credentials(input);
498 assert_eq!(result, "https://<redacted>@host.example.com?foo=bar");
499 assert!(!result.contains("user:pass"));
500 assert!(result.ends_with("?foo=bar"));
501 }
502
503 #[test]
504 fn test_redact_url_credentials_trailing_fragment() {
505 let input = "https://user:pass@host.example.com#frag";
508 let result = redact_url_credentials(input);
509 assert_eq!(result, "https://<redacted>@host.example.com#frag");
510 assert!(!result.contains("user:pass"));
511 assert!(result.ends_with("#frag"));
512 }
513
514 #[test]
515 fn test_redact_url_credentials_whitespace_boundary() {
516 let input = "https://user:pass@host.example.com then more";
519 let result = redact_url_credentials(input);
520 assert_eq!(result, "https://<redacted>@host.example.com then more");
521 assert!(!result.contains("user:pass"));
522 assert!(result.ends_with(" then more"));
523 }
524
525 #[test]
526 fn test_redact_bearer_tokens_basic() {
527 let input = "auth header: Bearer ghp_abcdef123456 expires soon";
528 let result = redact_bearer_tokens(input);
529 assert_eq!(result, "auth header: Bearer <redacted> expires soon");
530 assert!(!result.contains("ghp_abcdef123456"));
531 }
532
533 #[test]
534 fn test_redact_bearer_tokens_case_insensitive() {
535 let input = "bearer ghp_lowercase_token";
538 assert_eq!(
539 redact_bearer_tokens(input),
540 "Bearer <redacted>",
541 "lowercase 'bearer' must still redact"
542 );
543 let input = "BEARER ghp_uppercase_token";
544 assert_eq!(redact_bearer_tokens(input), "Bearer <redacted>");
545 }
546
547 #[test]
548 fn test_redact_bearer_tokens_authorization_header() {
549 let input = "request: Authorization: Bearer ghp_xyz\nresponse: 401";
553 let result = redact_bearer_tokens(input);
554 assert_eq!(
555 result, "request: Authorization: <redacted>\nresponse: 401",
556 "header value (including the inner Bearer token) must be redacted as one"
557 );
558 assert!(!result.contains("ghp_xyz"));
559 }
560
561 #[test]
562 fn test_redact_bearer_tokens_authorization_header_single_line() {
563 let input = "Authorization: Bearer ghp_xyz";
567 let result = redact_bearer_tokens(input);
568 assert_eq!(result, "Authorization: <redacted>");
569 assert!(!result.contains("ghp_xyz"));
570 }
571
572 #[test]
573 fn test_redact_bearer_tokens_no_match_unchanged() {
574 let input = "some random text with no relevant tokens here";
580 assert_eq!(redact_bearer_tokens(input), input);
581 }
582
583 #[test]
584 fn test_redact_bearer_tokens_over_redacts_prose_use() {
585 let input = "the bearer of bad news arrived";
590 let result = redact_bearer_tokens(input);
591 assert_eq!(result, "the Bearer <redacted> bad news arrived");
592 }
593
594 #[test]
595 fn test_redact_bearer_tokens_empty_input() {
596 assert_eq!(redact_bearer_tokens(""), "");
597 }
598
599 #[test]
600 fn test_redact_basic_token_redacts_b64_payload() {
601 let input = "auth: Basic ZnVyeXRva2VuOg== rest";
602 let result = redact_bearer_tokens(input);
603 assert_eq!(result, "auth: Basic <redacted> rest");
604 assert!(!result.contains("ZnVyeXRva2VuOg=="));
605 }
606
607 #[test]
608 fn test_redact_basic_token_case_insensitive() {
609 let input = "auth: basic ZnVyeXRva2VuOg==";
610 assert_eq!(redact_bearer_tokens(input), "auth: Basic <redacted>");
611 }
612
613 #[test]
614 fn test_redact_bearer_tokens_handles_multiple_occurrences() {
615 let input = "first Bearer ghp_aaa and second Bearer ghp_bbb done";
616 let result = redact_bearer_tokens(input);
617 assert_eq!(
618 result,
619 "first Bearer <redacted> and second Bearer <redacted> done"
620 );
621 assert!(!result.contains("ghp_aaa"));
622 assert!(!result.contains("ghp_bbb"));
623 }
624}