1const 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 redact_process_env(input: &str) -> String {
75 let env: Vec<(String, String)> = std::env::vars().collect();
76 let stripped = redact_url_credentials(input);
77 string(&stripped, &env)
78}
79
80pub fn redact_url_credentials(input: &str) -> String {
92 let mut result = String::with_capacity(input.len());
100 let mut rest = input;
101 while let Some(scheme_end) = rest.find("://") {
102 let after_scheme_start = scheme_end + 3;
103 result.push_str(&rest[..after_scheme_start]);
104 let after_scheme = &rest[after_scheme_start..];
105 let terminator = after_scheme
106 .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
107 .unwrap_or(after_scheme.len());
108 let authority = &after_scheme[..terminator];
109 if let Some(last_at) = authority.rfind('@') {
110 result.push_str("<redacted>@");
112 result.push_str(&authority[last_at + 1..]);
113 rest = &after_scheme[terminator..];
114 } else {
115 result.push_str(authority);
116 rest = &after_scheme[terminator..];
117 }
118 }
119 result.push_str(rest);
120 result
121}
122
123pub fn redact_bearer_tokens(input: &str) -> String {
149 let bytes = input.as_bytes();
150 let mut out = String::with_capacity(input.len());
151 let mut i = 0;
152 while i < bytes.len() {
153 if let Some(name_len) = match_authorization_prefix(&bytes[i..]) {
159 out.push_str("Authorization: <redacted>");
160 i += name_len;
161 while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
162 i += 1;
163 }
164 continue;
165 }
166 let preceded_by_boundary = i == 0
170 || matches!(
171 bytes[i - 1],
172 b' ' | b'\t' | b':' | b',' | b';' | b'(' | b'"' | b'\'' | b'<' | b'\n' | b'\r'
173 );
174 if preceded_by_boundary && let Some(kw_len) = match_bearer_prefix(&bytes[i..]) {
175 out.push_str("Bearer <redacted>");
176 i += kw_len;
177 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
179 i += 1;
180 }
181 continue;
182 }
183 if preceded_by_boundary && let Some(kw_len) = match_basic_prefix(&bytes[i..]) {
184 out.push_str("Basic <redacted>");
185 i += kw_len;
186 while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
187 i += 1;
188 }
189 continue;
190 }
191 out.push(bytes[i] as char);
193 i += 1;
194 }
195 out
196}
197
198fn match_bearer_prefix(bytes: &[u8]) -> Option<usize> {
201 const KW: &[u8] = b"Bearer ";
202 if bytes.len() < KW.len() {
203 return None;
204 }
205 for (i, kw_byte) in KW.iter().enumerate() {
206 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
207 return None;
208 }
209 }
210 Some(KW.len())
211}
212
213fn match_basic_prefix(bytes: &[u8]) -> Option<usize> {
218 const KW: &[u8] = b"Basic ";
219 if bytes.len() < KW.len() {
220 return None;
221 }
222 for (i, kw_byte) in KW.iter().enumerate() {
223 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
224 return None;
225 }
226 }
227 Some(KW.len())
228}
229
230fn match_authorization_prefix(bytes: &[u8]) -> Option<usize> {
234 const KW: &[u8] = b"Authorization:";
235 if bytes.len() < KW.len() {
236 return None;
237 }
238 for (i, kw_byte) in KW.iter().enumerate() {
239 if !bytes[i].eq_ignore_ascii_case(kw_byte) {
240 return None;
241 }
242 }
243 Some(KW.len())
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249
250 #[test]
251 fn test_redact_by_key_suffix() {
252 let env = vec![
253 (
254 "DOCKER_PASSWORD".to_string(),
255 "mysecretpassword123".to_string(),
256 ),
257 ("PLAIN_VAR".to_string(), "not-a-secret".to_string()),
258 ];
259 let result = string("Login with mysecretpassword123 succeeded", &env);
260 assert_eq!(result, "Login with $DOCKER_PASSWORD succeeded");
261 assert!(!result.contains("mysecretpassword123"));
262 }
263
264 #[test]
265 fn test_redact_by_value_prefix() {
266 let env = vec![("MY_TOKEN".to_string(), "ghp_abc123def456ghi789".to_string())];
267 let result = string("Using token ghp_abc123def456ghi789", &env);
268 assert_eq!(result, "Using token $MY_TOKEN");
269 }
270
271 #[test]
272 fn test_redact_includes_short_secret_when_key_looks_secret() {
273 let env = vec![("API_KEY".to_string(), "short".to_string())];
277 let result = string("Value is short", &env);
278 assert_eq!(result, "Value is $API_KEY");
279 }
280
281 #[test]
282 fn test_redact_skips_empty_value() {
283 let env = vec![("API_KEY".to_string(), String::new())];
287 let result = string("Value is short", &env);
288 assert_eq!(result, "Value is short");
289 }
290
291 #[test]
292 fn test_redact_longer_values_first() {
293 let env = vec![
294 ("SHORT_TOKEN".to_string(), "abcdefghij".to_string()),
295 ("LONG_TOKEN".to_string(), "abcdefghijklmnop".to_string()),
296 ];
297 let result = string("secret: abcdefghijklmnop", &env);
298 assert_eq!(result, "secret: $LONG_TOKEN");
300 }
301
302 #[test]
303 fn test_redact_no_secrets() {
304 let env = vec![("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())];
305 let result = string("PATH is set", &env);
306 assert_eq!(result, "PATH is set");
307 }
308
309 #[test]
310 fn test_redact_multiple_occurrences() {
311 let env = vec![(
312 "REGISTRY_PASSWORD".to_string(),
313 "supersecret123".to_string(),
314 )];
315 let result = string("auth supersecret123 retry supersecret123", &env);
316 assert_eq!(result, "auth $REGISTRY_PASSWORD retry $REGISTRY_PASSWORD");
317 }
318
319 #[test]
320 fn test_is_secret_key_suffixes() {
321 assert!(is_secret("DOCKER_PASSWORD", "longvalue1234"));
322 assert!(is_secret("API_TOKEN", "longvalue1234"));
323 assert!(is_secret("signing_key", "longvalue1234")); assert!(is_secret("MY_SECRET", "longvalue1234"));
325 assert!(!is_secret("MY_CONFIG", "longvalue1234"));
326 }
327
328 #[test]
329 fn test_is_secret_value_prefixes() {
330 assert!(is_secret("ANYTHING", "ghp_1234567890"));
331 assert!(is_secret("ANYTHING", "sk-1234567890"));
332 assert!(is_secret("ANYTHING", "dckr_pat_1234567890"));
333 assert!(is_secret("ANYTHING", "glpat-1234567890"));
334 assert!(!is_secret("ANYTHING", "regular_value1234"));
335 }
336
337 #[test]
338 fn test_redact_sort_stability_same_length() {
339 let env = vec![
342 ("B_SECRET".to_string(), "same_length_val".to_string()),
343 ("A_SECRET".to_string(), "same_length_val".to_string()),
344 ];
345 let result = string("found same_length_val here", &env);
348 assert_eq!(result, "found $A_SECRET here");
349 }
350
351 #[test]
352 fn test_redact_deterministic_with_different_lengths() {
353 let env = vec![
355 ("Z_TOKEN".to_string(), "short_secret_val".to_string()),
356 (
357 "A_TOKEN".to_string(),
358 "a_longer_secret_value_here".to_string(),
359 ),
360 ];
361 let result = string("prefix a_longer_secret_value_here suffix", &env);
362 assert_eq!(result, "prefix $A_TOKEN suffix");
363 }
364
365 #[test]
366 fn test_redact_url_credentials_https_with_token() {
367 let input = "remote: https://ghp_abc123def@github.com/owner/repo.git";
368 let result = redact_url_credentials(input);
369 assert_eq!(
370 result,
371 "remote: https://<redacted>@github.com/owner/repo.git"
372 );
373 assert!(!result.contains("ghp_abc123def"));
374 }
375
376 #[test]
377 fn test_redact_url_credentials_user_pass_pair() {
378 let input = "pushing to https://user:p@ssw0rd@gitlab.example.com/foo/bar";
379 let result = redact_url_credentials(input);
380 assert_eq!(
381 result, "pushing to https://<redacted>@gitlab.example.com/foo/bar",
382 "userinfo must cover the entire user:pass segment up to the host-@"
383 );
384 }
385
386 #[test]
387 fn test_redact_url_credentials_no_userinfo_unchanged() {
388 let input = "fetching https://github.com/owner/repo.git";
389 assert_eq!(redact_url_credentials(input), input);
390 }
391
392 #[test]
393 fn test_redact_url_credentials_ssh_unchanged() {
394 let input = "fetching git@github.com:owner/repo.git";
398 assert_eq!(redact_url_credentials(input), input);
399 }
400
401 #[test]
402 fn test_redact_url_credentials_multiple_urls_in_one_line() {
403 let input = "from https://token1@a.com/x to https://token2@b.com/y";
404 let result = redact_url_credentials(input);
405 assert_eq!(
406 result, "from https://<redacted>@a.com/x to https://<redacted>@b.com/y",
407 "both URLs must be redacted, leaving the connecting prose intact"
408 );
409 }
410
411 #[test]
412 fn test_redact_url_credentials_does_not_consume_path_at_sign() {
413 let input = "GET https://api.example.com/users/foo@bar.com/profile";
416 assert_eq!(
417 redact_url_credentials(input),
418 input,
419 "an `@` after the first `/` is part of the path, not userinfo"
420 );
421 }
422
423 #[test]
424 fn test_redact_url_credentials_empty_input() {
425 assert_eq!(redact_url_credentials(""), "");
426 }
427
428 #[test]
429 fn test_redact_url_credentials_plain_text() {
430 let input = "no URLs here, just words";
431 assert_eq!(redact_url_credentials(input), input);
432 }
433
434 #[test]
435 fn test_redact_url_credentials_percent_encoded_userinfo() {
436 let input = "https://user%40name:pass@host.example.com/path";
442 let result = redact_url_credentials(input);
443 assert_eq!(result, "https://<redacted>@host.example.com/path");
444 assert!(!result.contains("user%40name"));
445 assert!(!result.contains("pass"));
446 }
447
448 #[test]
449 fn test_redact_url_credentials_trailing_query() {
450 let input = "https://user:pass@host.example.com?foo=bar";
453 let result = redact_url_credentials(input);
454 assert_eq!(result, "https://<redacted>@host.example.com?foo=bar");
455 assert!(!result.contains("user:pass"));
456 assert!(result.ends_with("?foo=bar"));
457 }
458
459 #[test]
460 fn test_redact_url_credentials_trailing_fragment() {
461 let input = "https://user:pass@host.example.com#frag";
464 let result = redact_url_credentials(input);
465 assert_eq!(result, "https://<redacted>@host.example.com#frag");
466 assert!(!result.contains("user:pass"));
467 assert!(result.ends_with("#frag"));
468 }
469
470 #[test]
471 fn test_redact_url_credentials_whitespace_boundary() {
472 let input = "https://user:pass@host.example.com then more";
475 let result = redact_url_credentials(input);
476 assert_eq!(result, "https://<redacted>@host.example.com then more");
477 assert!(!result.contains("user:pass"));
478 assert!(result.ends_with(" then more"));
479 }
480
481 #[test]
482 fn test_redact_bearer_tokens_basic() {
483 let input = "auth header: Bearer ghp_abcdef123456 expires soon";
484 let result = redact_bearer_tokens(input);
485 assert_eq!(result, "auth header: Bearer <redacted> expires soon");
486 assert!(!result.contains("ghp_abcdef123456"));
487 }
488
489 #[test]
490 fn test_redact_bearer_tokens_case_insensitive() {
491 let input = "bearer ghp_lowercase_token";
494 assert_eq!(
495 redact_bearer_tokens(input),
496 "Bearer <redacted>",
497 "lowercase 'bearer' must still redact"
498 );
499 let input = "BEARER ghp_uppercase_token";
500 assert_eq!(redact_bearer_tokens(input), "Bearer <redacted>");
501 }
502
503 #[test]
504 fn test_redact_bearer_tokens_authorization_header() {
505 let input = "request: Authorization: Bearer ghp_xyz\nresponse: 401";
509 let result = redact_bearer_tokens(input);
510 assert_eq!(
511 result, "request: Authorization: <redacted>\nresponse: 401",
512 "header value (including the inner Bearer token) must be redacted as one"
513 );
514 assert!(!result.contains("ghp_xyz"));
515 }
516
517 #[test]
518 fn test_redact_bearer_tokens_authorization_header_single_line() {
519 let input = "Authorization: Bearer ghp_xyz";
523 let result = redact_bearer_tokens(input);
524 assert_eq!(result, "Authorization: <redacted>");
525 assert!(!result.contains("ghp_xyz"));
526 }
527
528 #[test]
529 fn test_redact_bearer_tokens_no_match_unchanged() {
530 let input = "some random text with no relevant tokens here";
536 assert_eq!(redact_bearer_tokens(input), input);
537 }
538
539 #[test]
540 fn test_redact_bearer_tokens_over_redacts_prose_use() {
541 let input = "the bearer of bad news arrived";
546 let result = redact_bearer_tokens(input);
547 assert_eq!(result, "the Bearer <redacted> bad news arrived");
548 }
549
550 #[test]
551 fn test_redact_bearer_tokens_empty_input() {
552 assert_eq!(redact_bearer_tokens(""), "");
553 }
554
555 #[test]
556 fn test_redact_basic_token_redacts_b64_payload() {
557 let input = "auth: Basic ZnVyeXRva2VuOg== rest";
558 let result = redact_bearer_tokens(input);
559 assert_eq!(result, "auth: Basic <redacted> rest");
560 assert!(!result.contains("ZnVyeXRva2VuOg=="));
561 }
562
563 #[test]
564 fn test_redact_basic_token_case_insensitive() {
565 let input = "auth: basic ZnVyeXRva2VuOg==";
566 assert_eq!(redact_bearer_tokens(input), "auth: Basic <redacted>");
567 }
568
569 #[test]
570 fn test_redact_bearer_tokens_handles_multiple_occurrences() {
571 let input = "first Bearer ghp_aaa and second Bearer ghp_bbb done";
572 let result = redact_bearer_tokens(input);
573 assert_eq!(
574 result,
575 "first Bearer <redacted> and second Bearer <redacted> done"
576 );
577 assert!(!result.contains("ghp_aaa"));
578 assert!(!result.contains("ghp_bbb"));
579 }
580}