1pub const SECRET_KEY_SUFFIXES: &[&str] = &["_KEY", "_SECRET", "_PASSWORD", "_TOKEN"];
13
14const SECRET_VALUE_PREFIXES: &[&str] = &[
19 "sk-",
20 "ghp_",
21 "ghs_",
22 "gho_",
23 "ghu_",
24 "dckr_pat_",
25 "glpat-",
26 "AIZA",
27 "xox",
28];
29
30fn is_secret(key: &str, value: &str) -> bool {
37 if value.is_empty() {
38 return false;
39 }
40 let key_upper = key.to_uppercase();
41 if SECRET_KEY_SUFFIXES.iter().any(|s| key_upper.ends_with(s)) {
42 return true;
43 }
44 SECRET_VALUE_PREFIXES.iter().any(|p| value.starts_with(p))
45}
46
47pub fn string(input: &str, env: &[(String, String)]) -> String {
53 let mut secrets: Vec<(&str, &str)> = env
54 .iter()
55 .filter(|(k, v)| is_secret(k, v))
56 .map(|(k, v)| (k.as_str(), v.as_str()))
57 .collect();
58 secrets.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
59
60 let mut result = input.to_string();
61 for (key, value) in secrets {
62 result = result.replace(value, &format!("${}", key));
63 }
64 result
65}
66
67pub fn with_env(input: &str, env: &[(String, String)]) -> String {
72 string(&redact_url_credentials(input), env)
73}
74
75pub fn redact_process_env(input: &str) -> String {
83 let env: Vec<(String, String)> = std::env::vars().collect();
84 with_env(input, &env)
85}
86
87pub fn redact_url_credentials(input: &str) -> String {
99 let mut result = String::with_capacity(input.len());
107 let mut rest = input;
108 while let Some(scheme_end) = rest.find("://") {
109 let after_scheme_start = scheme_end + 3;
110 result.push_str(&rest[..after_scheme_start]);
111 let after_scheme = &rest[after_scheme_start..];
112 let terminator = after_scheme
113 .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
114 .unwrap_or(after_scheme.len());
115 let authority = &after_scheme[..terminator];
116 if let Some(last_at) = authority.rfind('@') {
117 result.push_str("<redacted>@");
119 result.push_str(&authority[last_at + 1..]);
120 rest = &after_scheme[terminator..];
121 } else {
122 result.push_str(authority);
123 rest = &after_scheme[terminator..];
124 }
125 }
126 result.push_str(rest);
127 result
128}
129
130pub fn redact_bearer_tokens(input: &str) -> String {
156 let bytes = input.as_bytes();
157 let mut out = String::with_capacity(input.len());
158 let mut i = 0;
159 while i < bytes.len() {
160 if let Some(name_len) = match_authorization_prefix(&bytes[i..]) {
166 out.push_str("Authorization: <redacted>");
167 i += name_len;
168 while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
169 i += 1;
170 }
171 continue;
172 }
173 let preceded_by_boundary = i == 0
177 || matches!(
178 bytes[i - 1],
179 b' ' | b'\t' | b':' | b',' | b';' | b'(' | b'"' | b'\'' | b'<' | b'\n' | b'\r'
180 );
181 if preceded_by_boundary && let Some(kw_len) = match_bearer_prefix(&bytes[i..]) {
182 out.push_str("Bearer <redacted>");
183 i += kw_len;
184 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
186 i += 1;
187 }
188 continue;
189 }
190 if preceded_by_boundary && let Some(kw_len) = match_basic_prefix(&bytes[i..]) {
191 out.push_str("Basic <redacted>");
192 i += kw_len;
193 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
194 i += 1;
195 }
196 continue;
197 }
198 out.push(bytes[i] as char);
200 i += 1;
201 }
202 out
203}
204
205fn match_bearer_prefix(bytes: &[u8]) -> Option<usize> {
208 const KW: &[u8] = b"Bearer ";
209 if bytes.len() < KW.len() {
210 return None;
211 }
212 for (i, kw_byte) in KW.iter().enumerate() {
213 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
214 return None;
215 }
216 }
217 Some(KW.len())
218}
219
220fn match_basic_prefix(bytes: &[u8]) -> Option<usize> {
225 const KW: &[u8] = b"Basic ";
226 if bytes.len() < KW.len() {
227 return None;
228 }
229 for (i, kw_byte) in KW.iter().enumerate() {
230 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
231 return None;
232 }
233 }
234 Some(KW.len())
235}
236
237fn match_authorization_prefix(bytes: &[u8]) -> Option<usize> {
241 const KW: &[u8] = b"Authorization:";
242 if bytes.len() < KW.len() {
243 return None;
244 }
245 for (i, kw_byte) in KW.iter().enumerate() {
246 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
247 return None;
248 }
249 }
250 Some(KW.len())
251}
252
253#[cfg(test)]
254mod tests {
255 use super::*;
256
257 #[test]
258 fn test_redact_by_key_suffix() {
259 let env = vec![
260 (
261 "DOCKER_PASSWORD".to_string(),
262 "mysecretpassword123".to_string(),
263 ),
264 ("PLAIN_VAR".to_string(), "not-a-secret".to_string()),
265 ];
266 let result = string("Login with mysecretpassword123 succeeded", &env);
267 assert_eq!(result, "Login with $DOCKER_PASSWORD succeeded");
268 assert!(!result.contains("mysecretpassword123"));
269 }
270
271 #[test]
272 fn test_redact_by_value_prefix() {
273 let env = vec![("MY_TOKEN".to_string(), "ghp_abc123def456ghi789".to_string())];
274 let result = string("Using token ghp_abc123def456ghi789", &env);
275 assert_eq!(result, "Using token $MY_TOKEN");
276 }
277
278 #[test]
279 fn test_redact_includes_short_secret_when_key_looks_secret() {
280 let env = vec![("API_KEY".to_string(), "short".to_string())];
284 let result = string("Value is short", &env);
285 assert_eq!(result, "Value is $API_KEY");
286 }
287
288 #[test]
289 fn test_redact_skips_empty_value() {
290 let env = vec![("API_KEY".to_string(), String::new())];
294 let result = string("Value is short", &env);
295 assert_eq!(result, "Value is short");
296 }
297
298 #[test]
299 fn test_redact_longer_values_first() {
300 let env = vec![
301 ("SHORT_TOKEN".to_string(), "abcdefghij".to_string()),
302 ("LONG_TOKEN".to_string(), "abcdefghijklmnop".to_string()),
303 ];
304 let result = string("secret: abcdefghijklmnop", &env);
305 assert_eq!(result, "secret: $LONG_TOKEN");
307 }
308
309 #[test]
310 fn test_redact_no_secrets() {
311 let env = vec![("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())];
312 let result = string("PATH is set", &env);
313 assert_eq!(result, "PATH is set");
314 }
315
316 #[test]
317 fn test_redact_multiple_occurrences() {
318 let env = vec![(
319 "REGISTRY_PASSWORD".to_string(),
320 "supersecret123".to_string(),
321 )];
322 let result = string("auth supersecret123 retry supersecret123", &env);
323 assert_eq!(result, "auth $REGISTRY_PASSWORD retry $REGISTRY_PASSWORD");
324 }
325
326 #[test]
327 fn test_is_secret_key_suffixes() {
328 assert!(is_secret("DOCKER_PASSWORD", "longvalue1234"));
329 assert!(is_secret("API_TOKEN", "longvalue1234"));
330 assert!(is_secret("signing_key", "longvalue1234")); assert!(is_secret("MY_SECRET", "longvalue1234"));
332 assert!(!is_secret("MY_CONFIG", "longvalue1234"));
333 }
334
335 #[test]
336 fn test_is_secret_value_prefixes() {
337 assert!(is_secret("ANYTHING", "ghp_1234567890"));
338 assert!(is_secret("ANYTHING", "sk-1234567890"));
339 assert!(is_secret("ANYTHING", "dckr_pat_1234567890"));
340 assert!(is_secret("ANYTHING", "glpat-1234567890"));
341 assert!(!is_secret("ANYTHING", "regular_value1234"));
342 }
343
344 #[test]
345 fn test_redact_sort_stability_same_length() {
346 let env = vec![
349 ("B_SECRET".to_string(), "same_length_val".to_string()),
350 ("A_SECRET".to_string(), "same_length_val".to_string()),
351 ];
352 let result = string("found same_length_val here", &env);
355 assert_eq!(result, "found $A_SECRET here");
356 }
357
358 #[test]
359 fn test_redact_deterministic_with_different_lengths() {
360 let env = vec![
362 ("Z_TOKEN".to_string(), "short_secret_val".to_string()),
363 (
364 "A_TOKEN".to_string(),
365 "a_longer_secret_value_here".to_string(),
366 ),
367 ];
368 let result = string("prefix a_longer_secret_value_here suffix", &env);
369 assert_eq!(result, "prefix $A_TOKEN suffix");
370 }
371
372 #[test]
373 fn test_with_env_composes_url_strip_and_env_mask() {
374 let env = vec![(
379 "CARGO_REGISTRY_TOKEN".to_string(),
380 "ghp_realsecretvalue".to_string(),
381 )];
382 let input = "pushed via https://tok@host/x then logged ghp_realsecretvalue";
383 let result = with_env(input, &env);
384 assert_eq!(
385 result,
386 "pushed via https://<redacted>@host/x then logged $CARGO_REGISTRY_TOKEN"
387 );
388 assert!(!result.contains("ghp_realsecretvalue"));
389 assert!(!result.contains("tok@host"));
390 }
391
392 #[test]
393 fn test_redact_url_credentials_https_with_token() {
394 let input = "remote: https://ghp_abc123def@github.com/owner/repo.git";
395 let result = redact_url_credentials(input);
396 assert_eq!(
397 result,
398 "remote: https://<redacted>@github.com/owner/repo.git"
399 );
400 assert!(!result.contains("ghp_abc123def"));
401 }
402
403 #[test]
404 fn test_redact_url_credentials_user_pass_pair() {
405 let input = "pushing to https://user:p@ssw0rd@gitlab.example.com/foo/bar";
406 let result = redact_url_credentials(input);
407 assert_eq!(
408 result, "pushing to https://<redacted>@gitlab.example.com/foo/bar",
409 "userinfo must cover the entire user:pass segment up to the host-@"
410 );
411 }
412
413 #[test]
414 fn test_redact_url_credentials_no_userinfo_unchanged() {
415 let input = "fetching https://github.com/owner/repo.git";
416 assert_eq!(redact_url_credentials(input), input);
417 }
418
419 #[test]
420 fn test_redact_url_credentials_ssh_unchanged() {
421 let input = "fetching git@github.com:owner/repo.git";
425 assert_eq!(redact_url_credentials(input), input);
426 }
427
428 #[test]
429 fn test_redact_url_credentials_multiple_urls_in_one_line() {
430 let input = "from https://token1@a.com/x to https://token2@b.com/y";
431 let result = redact_url_credentials(input);
432 assert_eq!(
433 result, "from https://<redacted>@a.com/x to https://<redacted>@b.com/y",
434 "both URLs must be redacted, leaving the connecting prose intact"
435 );
436 }
437
438 #[test]
439 fn test_redact_url_credentials_does_not_consume_path_at_sign() {
440 let input = "GET https://api.example.com/users/foo@bar.com/profile";
443 assert_eq!(
444 redact_url_credentials(input),
445 input,
446 "an `@` after the first `/` is part of the path, not userinfo"
447 );
448 }
449
450 #[test]
451 fn test_redact_url_credentials_empty_input() {
452 assert_eq!(redact_url_credentials(""), "");
453 }
454
455 #[test]
456 fn test_redact_url_credentials_plain_text() {
457 let input = "no URLs here, just words";
458 assert_eq!(redact_url_credentials(input), input);
459 }
460
461 #[test]
462 fn test_redact_url_credentials_percent_encoded_userinfo() {
463 let input = "https://user%40name:pass@host.example.com/path";
469 let result = redact_url_credentials(input);
470 assert_eq!(result, "https://<redacted>@host.example.com/path");
471 assert!(!result.contains("user%40name"));
472 assert!(!result.contains("pass"));
473 }
474
475 #[test]
476 fn test_redact_url_credentials_trailing_query() {
477 let input = "https://user:pass@host.example.com?foo=bar";
480 let result = redact_url_credentials(input);
481 assert_eq!(result, "https://<redacted>@host.example.com?foo=bar");
482 assert!(!result.contains("user:pass"));
483 assert!(result.ends_with("?foo=bar"));
484 }
485
486 #[test]
487 fn test_redact_url_credentials_trailing_fragment() {
488 let input = "https://user:pass@host.example.com#frag";
491 let result = redact_url_credentials(input);
492 assert_eq!(result, "https://<redacted>@host.example.com#frag");
493 assert!(!result.contains("user:pass"));
494 assert!(result.ends_with("#frag"));
495 }
496
497 #[test]
498 fn test_redact_url_credentials_whitespace_boundary() {
499 let input = "https://user:pass@host.example.com then more";
502 let result = redact_url_credentials(input);
503 assert_eq!(result, "https://<redacted>@host.example.com then more");
504 assert!(!result.contains("user:pass"));
505 assert!(result.ends_with(" then more"));
506 }
507
508 #[test]
509 fn test_redact_bearer_tokens_basic() {
510 let input = "auth header: Bearer ghp_abcdef123456 expires soon";
511 let result = redact_bearer_tokens(input);
512 assert_eq!(result, "auth header: Bearer <redacted> expires soon");
513 assert!(!result.contains("ghp_abcdef123456"));
514 }
515
516 #[test]
517 fn test_redact_bearer_tokens_case_insensitive() {
518 let input = "bearer ghp_lowercase_token";
521 assert_eq!(
522 redact_bearer_tokens(input),
523 "Bearer <redacted>",
524 "lowercase 'bearer' must still redact"
525 );
526 let input = "BEARER ghp_uppercase_token";
527 assert_eq!(redact_bearer_tokens(input), "Bearer <redacted>");
528 }
529
530 #[test]
531 fn test_redact_bearer_tokens_authorization_header() {
532 let input = "request: Authorization: Bearer ghp_xyz\nresponse: 401";
536 let result = redact_bearer_tokens(input);
537 assert_eq!(
538 result, "request: Authorization: <redacted>\nresponse: 401",
539 "header value (including the inner Bearer token) must be redacted as one"
540 );
541 assert!(!result.contains("ghp_xyz"));
542 }
543
544 #[test]
545 fn test_redact_bearer_tokens_authorization_header_single_line() {
546 let input = "Authorization: Bearer ghp_xyz";
550 let result = redact_bearer_tokens(input);
551 assert_eq!(result, "Authorization: <redacted>");
552 assert!(!result.contains("ghp_xyz"));
553 }
554
555 #[test]
556 fn test_redact_bearer_tokens_no_match_unchanged() {
557 let input = "some random text with no relevant tokens here";
563 assert_eq!(redact_bearer_tokens(input), input);
564 }
565
566 #[test]
567 fn test_redact_bearer_tokens_over_redacts_prose_use() {
568 let input = "the bearer of bad news arrived";
573 let result = redact_bearer_tokens(input);
574 assert_eq!(result, "the Bearer <redacted> bad news arrived");
575 }
576
577 #[test]
578 fn test_redact_bearer_tokens_empty_input() {
579 assert_eq!(redact_bearer_tokens(""), "");
580 }
581
582 #[test]
583 fn test_redact_basic_token_redacts_b64_payload() {
584 let input = "auth: Basic ZnVyeXRva2VuOg== rest";
585 let result = redact_bearer_tokens(input);
586 assert_eq!(result, "auth: Basic <redacted> rest");
587 assert!(!result.contains("ZnVyeXRva2VuOg=="));
588 }
589
590 #[test]
591 fn test_redact_basic_token_case_insensitive() {
592 let input = "auth: basic ZnVyeXRva2VuOg==";
593 assert_eq!(redact_bearer_tokens(input), "auth: Basic <redacted>");
594 }
595
596 #[test]
597 fn test_redact_bearer_tokens_handles_multiple_occurrences() {
598 let input = "first Bearer ghp_aaa and second Bearer ghp_bbb done";
599 let result = redact_bearer_tokens(input);
600 assert_eq!(
601 result,
602 "first Bearer <redacted> and second Bearer <redacted> done"
603 );
604 assert!(!result.contains("ghp_aaa"));
605 assert!(!result.contains("ghp_bbb"));
606 }
607}