1pub const MARKER: &str = "[redacted]";
30
31#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Redaction {
34 pub text: String,
36 pub count: usize,
38}
39
40impl Redaction {
41 pub fn any(&self) -> bool {
43 self.count > 0
44 }
45}
46
47pub fn redact_command(raw: &str) -> Redaction {
50 let mut count = 0usize;
51 let segments = split_keep_ws(raw);
52 let token_count = segments.iter().filter(|s| !s.is_ws).count();
53
54 let progs = || {
60 segments
61 .iter()
62 .filter(|s| !s.is_ws)
63 .map(|s| s.text.as_str())
64 .filter(|t| env_assignment(t).is_none())
65 .map(program_name)
66 };
67 let program = progs()
68 .find(|p| is_credential_client(p))
69 .or_else(|| progs().find(|p| !is_wrapper(p) && !p.starts_with('-')))
70 .unwrap_or_default();
71 let ctx = Ctx {
72 program: &program,
73 docker_login: program == "docker" && segments.iter().any(|s| !s.is_ws && s.text == "login"),
76 };
77
78 let mut redact_next = vec![false; token_count];
81
82 let mut out = String::with_capacity(raw.len() + MARKER.len());
83 let mut tok_i = 0usize;
84 for seg in &segments {
85 if seg.is_ws {
86 out.push_str(&seg.text);
87 continue;
88 }
89 let this = tok_i;
90 tok_i += 1;
91
92 if redact_next[this] {
93 out.push_str(MARKER);
94 count += 1;
95 continue;
96 }
97
98 let (redacted, n, takes_next) = redact_token(&seg.text, &ctx);
99 count += n;
100 if takes_next && this + 1 < redact_next.len() {
101 redact_next[this + 1] = true;
102 }
103 out.push_str(&redacted);
104 }
105
106 Redaction { text: out, count }
107}
108
109struct Ctx<'a> {
110 program: &'a str,
111 docker_login: bool,
112}
113
114fn redact_token(tok: &str, ctx: &Ctx) -> (String, usize, bool) {
118 if let Some((key, _)) = env_assignment(tok) {
120 if sensitive_env_key(key) {
121 return (format!("{key}={MARKER}"), 1, false);
122 }
123 }
124
125 if let Some(red) = redact_header(tok) {
128 return (red, 1, false);
129 }
130
131 let uri_red = if tok.contains("://") {
136 redact_uri(tok)
137 } else {
138 None
139 };
140 let base = uri_red.as_deref().unwrap_or(tok);
141 if base.contains('?') || base.contains('&') || base.contains(":_") {
142 if let Some(red) = redact_query_params(base) {
143 return (red, 1, false);
144 }
145 }
146 if let Some(red) = uri_red {
147 return (red, 1, false);
148 }
149
150 if let Some(eq) = tok.find('=') {
153 let flag = &tok[..eq];
154 if credential_flag(flag) {
155 return (format!("{flag}={MARKER}"), 1, false);
156 }
157 }
158 if credential_flag(tok) {
159 return (tok.to_string(), 0, true);
160 }
161
162 if let Some(rest) = tok.strip_prefix("pass:") {
164 if !rest.is_empty() {
165 return (format!("pass:{MARKER}"), 1, false);
166 }
167 }
168
169 if oracle_tool(ctx.program) {
171 if let Some(red) = redact_oracle_login(tok) {
172 return (red, 1, false);
173 }
174 }
175
176 if let Some(res) = redact_short_flag(tok, ctx) {
178 return res;
179 }
180
181 (tok.to_string(), 0, false)
182}
183
184fn redact_header(tok: &str) -> Option<String> {
188 let lead = tok.len() - tok.trim_start_matches(['"', '\'']).len();
189 let inner = &tok[lead..];
190 let colon = inner.find(':')?;
191 let name = inner[..colon].to_ascii_lowercase();
192 let sensitive = matches!(
193 name.as_str(),
194 "authorization"
195 | "proxy-authorization"
196 | "x-api-key"
197 | "api-key"
198 | "apikey"
199 | "x-auth-token"
200 | "x-auth"
201 | "auth-token"
202 | "private-token"
203 | "x-amz-security-token"
204 | "x-csrf-token"
205 | "cookie"
206 | "set-cookie"
207 );
208 if !sensitive {
209 return None;
210 }
211 let after = &inner[colon + 1..];
212 let trimmed = after.trim_start();
213 let lead_ws = after.len() - trimmed.len();
214 if let Some((w, _)) = trimmed.split_once(char::is_whitespace) {
216 if is_auth_scheme(w) {
217 let kept = &after[..lead_ws + w.len() + 1];
218 return Some(format!(
219 "{}{}:{kept}{MARKER}",
220 &tok[..lead],
221 &inner[..colon]
222 ));
223 }
224 }
225 let keep_ws = if lead_ws > 0 { " " } else { "" };
226 Some(format!(
227 "{}{}:{keep_ws}{MARKER}",
228 &tok[..lead],
229 &inner[..colon]
230 ))
231}
232
233fn is_auth_scheme(w: &str) -> bool {
234 matches!(
235 w.to_ascii_lowercase().as_str(),
236 "bearer" | "basic" | "token" | "apikey" | "digest" | "negotiate" | "ntlm"
237 )
238}
239
240fn redact_uri(tok: &str) -> Option<String> {
243 let scheme_end = tok.find("://")? + 3;
244 let rest = &tok[scheme_end..];
245 let authority = &rest[..rest.find(['?', '#']).unwrap_or(rest.len())];
253 let at = authority.rfind('@')?;
254 let userinfo = &rest[..at];
255 let tail = &rest[at..];
256 match userinfo.find(':') {
257 Some(c) => Some(format!(
259 "{}{}:{MARKER}{tail}",
260 &tok[..scheme_end],
261 &userinfo[..c]
262 )),
263 None if !userinfo.contains(['/', '?', '#']) => {
266 Some(format!("{}{MARKER}{tail}", &tok[..scheme_end]))
267 }
268 None => None,
269 }
270}
271
272fn redact_query_params(tok: &str) -> Option<String> {
275 let bytes = tok.as_bytes();
276 let mut out = String::with_capacity(tok.len());
277 let mut redacted = false;
278 let mut start = 0usize;
279 let mut i = 0usize;
280 while i <= bytes.len() {
281 let sep = i == bytes.len() || matches!(bytes[i], b'&' | b';' | b'?');
282 if sep {
283 let chunk = &tok[start..i];
284 if let Some(eq) = chunk.find('=') {
285 let key = chunk[..eq]
286 .rsplit([':', '/'])
287 .next()
288 .unwrap_or(&chunk[..eq]);
289 if sensitive_param_key(key) && eq + 1 < chunk.len() {
290 out.push_str(&chunk[..eq + 1]);
291 out.push_str(MARKER);
292 redacted = true;
293 } else {
294 out.push_str(chunk);
295 }
296 } else {
297 out.push_str(chunk);
298 }
299 if i < bytes.len() {
300 out.push(bytes[i] as char);
301 }
302 start = i + 1;
303 }
304 i += 1;
305 }
306 redacted.then_some(out)
307}
308
309fn redact_oracle_login(tok: &str) -> Option<String> {
311 let (prefix, body) = match tok.split_once('=') {
312 Some((k, v)) if matches!(k.to_ascii_lowercase().as_str(), "userid" | "connect") => {
313 (&tok[..k.len() + 1], v)
314 }
315 _ => ("", tok),
316 };
317 let slash = body.find('/')?;
318 let user = &body[..slash];
319 if user.is_empty() || user.contains(' ') {
320 return None;
321 }
322 let after = &body[slash + 1..];
323 let pw_end = after.find('@').unwrap_or(after.len());
324 let pw = &after[..pw_end];
325 if pw.is_empty() || pw.contains('/') {
327 return None;
328 }
329 Some(format!("{prefix}{user}/{MARKER}{}", &after[pw_end..]))
330}
331
332fn redact_short_flag(tok: &str, ctx: &Ctx) -> Option<(String, usize, bool)> {
334 let p_flag_program = matches!(
335 ctx.program,
336 "mysql"
337 | "mysqldump"
338 | "mysqladmin"
339 | "mariadb"
340 | "mariadb-dump"
341 | "cqlsh"
342 | "mongosh"
343 | "mongo"
344 | "mongodump"
345 | "mongorestore"
346 ) || (ctx.program == "docker" && ctx.docker_login);
347 let big_p_program = ctx.program == "sqlcmd"; let flag = if big_p_program { "-P" } else { "-p" };
350 if (p_flag_program || big_p_program) && tok == flag {
351 return Some((tok.to_string(), 0, true)); }
353 if (p_flag_program || big_p_program) && tok.starts_with(flag) && tok.len() > flag.len() {
354 return Some((format!("{flag}{MARKER}"), 1, false)); }
356
357 match ctx.program {
358 "redis-cli" => {
359 if tok == "-a" || tok == "--pass" || tok == "--user" {
360 return Some((tok.to_string(), 0, true));
361 }
362 if let Some(rest) = tok.strip_prefix("-a") {
363 if !rest.is_empty() {
364 return Some((format!("-a{MARKER}"), 1, false));
365 }
366 }
367 }
368 "curl" | "wget" => {
369 if tok == "-u" || tok == "--user" {
370 return Some((tok.to_string(), 0, true));
371 }
372 if let Some(rest) = tok.strip_prefix("-u") {
373 if !rest.is_empty() {
374 if let Some(c) = rest.find(':') {
375 return Some((format!("-u{}:{MARKER}", &rest[..c]), 1, false));
376 }
377 return Some((format!("-u{MARKER}"), 1, false));
378 }
379 }
380 }
381 "sshpass" => {
382 if tok == "-p" {
383 return Some((tok.to_string(), 0, true));
384 }
385 if let Some(rest) = tok.strip_prefix("-p") {
386 if !rest.is_empty() {
387 return Some((format!("-p{MARKER}"), 1, false));
388 }
389 }
390 }
391 _ => {}
392 }
393 None
394}
395
396fn env_assignment(tok: &str) -> Option<(&str, &str)> {
398 let eq = tok.find('=')?;
399 if eq == 0 {
400 return None;
401 }
402 let key = &tok[..eq];
403 let ok = key
404 .chars()
405 .enumerate()
406 .all(|(i, c)| c == '_' || c.is_ascii_alphabetic() || (i > 0 && c.is_ascii_digit()));
407 ok.then_some((key, &tok[eq + 1..]))
408}
409
410fn sensitive_env_key(key: &str) -> bool {
411 let k = key.to_ascii_uppercase();
412 k.contains("PASSWORD")
413 || k.contains("PASSWD")
414 || k.contains("SECRET")
415 || k.contains("TOKEN")
416 || k.contains("APIKEY")
417 || k.contains("API_KEY")
418 || k == "MYSQL_PWD"
419 || k == "PGPASSWORD"
420 || k == "REDISCLI_AUTH"
421}
422
423fn sensitive_param_key(key: &str) -> bool {
424 let k = key.trim_start_matches('_').to_ascii_lowercase();
425 matches!(
426 k.as_str(),
427 "password"
428 | "passwd"
429 | "pwd"
430 | "pass"
431 | "token"
432 | "authtoken"
433 | "access_token"
434 | "accesstoken"
435 | "api_key"
436 | "apikey"
437 | "secret"
438 | "client_secret"
439 | "sig"
440 | "signature"
441 | "auth"
442 | "key"
443 )
444}
445
446fn credential_flag(flag: &str) -> bool {
447 if !flag.starts_with('-') {
450 return false;
451 }
452 let f = flag.trim_start_matches('-').to_ascii_lowercase();
453 matches!(
454 f.as_str(),
455 "password"
456 | "passwd"
457 | "token"
458 | "secret"
459 | "api-key"
460 | "apikey"
461 | "access-key"
462 | "secret-key"
463 | "secret-access-key"
464 | "auth"
465 | "auth-token"
466 | "access-token"
467 | "client-secret"
468 | "passin"
469 | "passout"
470 | "pass"
471 )
472}
473
474fn oracle_tool(program: &str) -> bool {
475 matches!(
476 program,
477 "sqlplus" | "sqlldr" | "rman" | "exp" | "imp" | "expdp" | "impdp" | "sqlcl" | "sql"
478 )
479}
480
481struct Segment {
482 text: String,
483 is_ws: bool,
484}
485
486fn split_keep_ws(s: &str) -> Vec<Segment> {
490 let mut out = Vec::new();
491 let mut cur = String::new();
492 let mut cur_ws: Option<bool> = None;
493 let mut in_single = false;
494 let mut in_double = false;
495 for c in s.chars() {
496 if c == '\'' && !in_double {
497 in_single = !in_single;
498 } else if c == '"' && !in_single {
499 in_double = !in_double;
500 }
501 let ws = c.is_whitespace() && !in_single && !in_double;
502 match cur_ws {
503 Some(prev) if prev == ws => cur.push(c),
504 Some(prev) => {
505 out.push(Segment {
506 text: std::mem::take(&mut cur),
507 is_ws: prev,
508 });
509 cur.push(c);
510 cur_ws = Some(ws);
511 }
512 None => {
513 cur.push(c);
514 cur_ws = Some(ws);
515 }
516 }
517 }
518 if let Some(ws) = cur_ws {
519 out.push(Segment {
520 text: cur,
521 is_ws: ws,
522 });
523 }
524 out
525}
526
527fn is_wrapper(p: &str) -> bool {
530 matches!(
531 p,
532 "sudo"
533 | "doas"
534 | "env"
535 | "nice"
536 | "ionice"
537 | "nohup"
538 | "time"
539 | "timeout"
540 | "stdbuf"
541 | "setsid"
542 | "xargs"
543 | "command"
544 | "builtin"
545 | "exec"
546 )
547}
548
549fn is_credential_client(p: &str) -> bool {
553 matches!(
554 p,
555 "mysql"
556 | "mysqldump"
557 | "mysqladmin"
558 | "mariadb"
559 | "mariadb-dump"
560 | "cqlsh"
561 | "mongosh"
562 | "mongo"
563 | "mongodump"
564 | "mongorestore"
565 | "docker"
566 | "sqlcmd"
567 | "redis-cli"
568 | "curl"
569 | "wget"
570 | "sshpass"
571 )
572}
573
574fn program_name(arg0: &str) -> String {
575 let base = arg0
576 .trim_matches(['"', '\''])
577 .rsplit(['/', '\\'])
578 .next()
579 .unwrap_or(arg0);
580 base.strip_suffix(".exe").unwrap_or(base).to_string()
581}
582
583#[cfg(test)]
584mod tests {
585 use super::*;
586
587 fn red(s: &str) -> String {
588 redact_command(s).text
589 }
590 fn leaks(s: &str, secret: &str) -> bool {
591 red(s).contains(secret)
592 }
593
594 #[test]
595 fn wrappers_do_not_smuggle_a_secret_past_redaction() {
596 assert!(!leaks("sudo mysql -ps3cr3t", "s3cr3t"));
598 assert!(!leaks("env mysql -ps3cr3t -u root", "s3cr3t"));
599 assert!(!leaks("nice -n10 mysql -ps3cr3t", "s3cr3t"));
600 assert!(!leaks("time mysql -ps3cr3t", "s3cr3t"));
601 assert!(!leaks("timeout 5 mysql -ps3cr3t", "s3cr3t"));
602 assert!(!leaks("sudo -u postgres mysql -ps3cr3t", "s3cr3t"));
604 assert!(!leaks("sudo redis-cli -ap@ss", "p@ss"));
606 assert!(!leaks("sudo curl -u alice:hunter2 https://x", "hunter2"));
607 assert_eq!(red("cat mysql.log"), "cat mysql.log");
609 }
610
611 #[test]
612 fn db_connection_strings() {
613 assert_eq!(
614 red("psql \"postgresql://dba:s3cr3t@prod-db/orders\""),
615 "psql \"postgresql://dba:[redacted]@prod-db/orders\""
616 );
617 assert_eq!(
619 red("psql 'postgresql://u:p@ss@host/db'"),
620 "psql 'postgresql://u:[redacted]@host/db'"
621 );
622 assert_eq!(
624 red("git remote add o https://ghp_abc123@github.com/o/r.git"),
625 "git remote add o https://[redacted]@github.com/o/r.git"
626 );
627 assert!(!leaks(
628 "svc --url=jdbc:postgresql://h/db?user=u&password=p4ss",
629 "p4ss"
630 ));
631 assert!(!leaks("psql postgres://app:aB/cD/ef@host/db", "aB/cD/ef"));
634 let both = red("psql \"postgres://u:userpw@db/prod?password=QUERYSECRET\"");
637 assert!(!both.contains("userpw"), "userinfo leaked: {both}");
638 assert!(!both.contains("QUERYSECRET"), "query secret leaked: {both}");
639 assert!(!leaks("psql postgresql://user:pa@ss@/dbname", "pa@ss"));
642 assert_eq!(red("curl http://host/p@q"), "curl http://host/p@q");
644 }
645
646 #[test]
647 fn mysql_and_db_short_flags() {
648 assert_eq!(
649 red("mysql -ptopsecret -u root"),
650 "mysql -p[redacted] -u root"
651 );
652 assert_eq!(
653 red("mysql --password=topsecret"),
654 "mysql --password=[redacted]"
655 );
656 assert_eq!(
657 red("mysql --password topsecret"),
658 "mysql --password [redacted]"
659 );
660 assert!(!leaks("sqlcmd -S srv -U sa -P MyPa55", "MyPa55"));
661 assert_eq!(
662 red("cqlsh host 9042 -u cassandra -p cassandra"),
663 "cqlsh host 9042 -u cassandra -p [redacted]"
664 );
665 assert!(!leaks(
666 "docker login -u user -p Sup3rPass reg.io",
667 "Sup3rPass"
668 ));
669 assert_eq!(
671 red("docker run -p 8080:80 img"),
672 "docker run -p 8080:80 img"
673 );
674 assert_eq!(red("mkdir -p /tmp/a/b"), "mkdir -p /tmp/a/b");
675 assert_eq!(red("ps -p 1234"), "ps -p 1234");
676 }
677
678 #[test]
679 fn quoted_multiword_secrets_are_fully_redacted() {
680 assert!(!leaks("tool --password \"pa ss word\"", "ss word"));
682 assert!(!leaks("redis-cli -a 'my pass word'", "pass word"));
683 assert!(!leaks("curl -u 'alice:my pass' https://x", "my pass"));
684 assert!(!leaks("mysql -p'top secret'", "secret"));
685 }
686
687 #[test]
688 fn env_assignments() {
689 assert_eq!(
690 red("PGPASSWORD=hunter2 pg_dump db"),
691 "PGPASSWORD=[redacted] pg_dump db"
692 );
693 assert_eq!(red("MYSQL_PWD=abc mysql"), "MYSQL_PWD=[redacted] mysql");
694 assert_eq!(
695 red("AWS_SECRET_ACCESS_KEY=zzz aws s3 ls"),
696 "AWS_SECRET_ACCESS_KEY=[redacted] aws s3 ls"
697 );
698 assert_eq!(red("PWD=/tmp ls"), "PWD=/tmp ls");
699 assert_eq!(
700 red("RUST_LOG=debug cargo test"),
701 "RUST_LOG=debug cargo test"
702 );
703 }
704
705 #[test]
706 fn headers_any_scheme_and_name() {
707 assert!(!leaks(
708 "curl -H \"X-Api-Key: sk-live-abc123\" https://api",
709 "sk-live-abc123"
710 ));
711 assert!(!leaks(
712 "curl --header \"X-Auth-Token: abc123\" https://api",
713 "abc123"
714 ));
715 assert!(!leaks(
716 "curl -H \"Authorization: Bearer tok123\" https://api",
717 "tok123"
718 ));
719 assert!(!leaks(
720 "curl -H \"Authorization: Token abc123def\" https://api",
721 "abc123def"
722 ));
723 assert!(!leaks(
724 "curl -H \"Authorization:Bearer tok123\" https://api",
725 "tok123"
726 ));
727 assert!(red("curl -H \"Authorization: Bearer tok123\" https://api").contains("https://api"));
729 }
730
731 #[test]
732 fn does_not_redact_on_incidental_authorization_word() {
733 assert_eq!(
735 red("echo 'see the authorization docs' && curl basic https://x"),
736 "echo 'see the authorization docs' && curl basic https://x"
737 );
738 assert_eq!(
739 red("git commit -m 'add authorization check' ; deploy basic stuff"),
740 "git commit -m 'add authorization check' ; deploy basic stuff"
741 );
742 assert_eq!(
743 red("mytool --mode basic value"),
744 "mytool --mode basic value"
745 );
746 }
747
748 #[test]
749 fn query_string_and_post_secrets() {
750 assert!(!leaks(
751 "curl 'https://api/v1?access_token=secret123&q=1'",
752 "secret123"
753 ));
754 assert!(!leaks(
755 "curl 'https://api/v1?api_key=secret123'",
756 "secret123"
757 ));
758 assert!(!leaks("wget https://api/data?token=abc123", "abc123"));
759 assert!(!leaks(
760 "npm config set //registry.npmjs.org/:_authToken=npm_xxx",
761 "npm_xxx"
762 ));
763 }
764
765 #[test]
766 fn oracle_easy_connect() {
767 assert_eq!(
768 red("sqlplus system/oracle@orcl"),
769 "sqlplus system/[redacted]@orcl"
770 );
771 assert_eq!(red("sqlplus scott/tiger"), "sqlplus scott/[redacted]");
772 assert!(!leaks("sqlplus scott/tiger@//host:1521/svc", "tiger"));
773 assert!(!leaks("sqlldr userid=scott/tiger@orcl", "tiger"));
774 assert_eq!(red("cat dir/file"), "cat dir/file");
776 }
777
778 #[test]
779 fn sshpass_openssl_redis() {
780 assert!(!leaks("sshpass -p 'MyP4ss' ssh user@host", "MyP4ss"));
781 assert!(!leaks("sshpass -pMyP4ss ssh user@host", "MyP4ss"));
782 assert!(!leaks(
783 "openssl rsa -in k.pem -passin pass:s3cr3t",
784 "s3cr3t"
785 ));
786 assert!(!leaks("redis-cli -aSECRET ping", "SECRET"));
787 }
788
789 #[test]
790 fn verbatim_when_no_secret() {
791 for s in [
792 "ls -la /tmp",
793 "git push --force",
794 "psql -h localhost -U readonly -d analytics",
795 "rm -rf build",
796 "echo \"hello world\"",
797 "echo $TOKEN",
798 "docker login --password-stdin",
799 "gcloud auth activate-service-account --key-file=/tmp/key.json",
800 "ssh-add ~/.ssh/id_rsa",
801 "cp -p a b",
802 "tar -p -xf x.tar",
803 ] {
804 assert_eq!(red(s), s, "must be verbatim: {s}");
805 }
806 assert_eq!(
809 red("curl http://user@host/p"),
810 "curl http://[redacted]@host/p"
811 );
812 }
813
814 #[test]
815 fn multibyte_boundaries_never_panic() {
816 assert_eq!(
817 red("psql postgres://café:naïve@h/d"),
818 "psql postgres://café:[redacted]@h/d"
819 );
820 assert_eq!(red("curl -ucafé:secret x"), "curl -ucafé:[redacted] x");
821 assert_eq!(red("mysql -pcafé"), "mysql -p[redacted]");
822 assert_eq!(
823 red("psql postgres://u🔥x:p🔥y@h/d"),
824 "psql postgres://u🔥x:[redacted]@h/d"
825 );
826 let _ = redact_command("psql postgres://u:p\u{0}w@h/d"); }
828
829 #[test]
830 fn counts_marker_invariants_and_pathological_sizes() {
831 for s in [
832 "PGPASSWORD=hunter2 psql postgres://u:p@h/d --token=c",
833 "mysql --password=x --password=y",
834 "ls -la",
835 ] {
836 let r = redact_command(s);
837 assert_eq!(r.count, r.text.matches(MARKER).count());
838 assert_eq!(r.any(), r.count > 0);
839 }
840 assert_eq!(redact_command(&"x ".repeat(500_000)).count, 0);
841 let big = format!("psql postgres://u:{}@h/d", "p".repeat(200_000));
842 assert_eq!(redact_command(&big).count, 1);
843 for s in ["", " ", "=:@//$( `", "\"unbalanced", "://@", "://:@"] {
844 let _ = redact_command(s);
845 }
846 }
847}