Skip to main content

kintsugi_core/
redact.rs

1//! Command-line secret redaction.
2//!
3//! Audit recorders that capture commands verbatim will faithfully store the
4//! credentials that appear on a command line — DB connection strings, `mysql
5//! -pSECRET`, `PGPASSWORD=…`, bearer tokens. `auditd` does exactly this; `tlog`
6//! disables input logging *because* of it. Kintsugi must not: the event log is
7//! append-only and hash-chained (you can't scrub it later), and the security
8//! spine forbids secret *values* in the log (rule #6) while still preserving the
9//! raw command (rule #3).
10//!
11//! This module redacts only the **value span** of a detected credential, leaving
12//! the rest of the command verbatim and replacing the secret with a fixed marker.
13//! It is intentionally **conservative** — when in doubt it over-redacts — because a
14//! leaked secret in an immutable log is far worse than an over-redacted one. It
15//! does **no I/O** and is allocation-light so it can run on the capture hot path.
16//!
17//! Tokenization is **quote-aware**: a quoted value (`--password "pa ss word"`) is
18//! a single token, so a multi-word secret is redacted whole — a per-whitespace
19//! approach leaks the tail of every quoted credential.
20//!
21//! It is best-effort pattern matching, not a guarantee: a novel flag can slip
22//! through, and secrets typed at a sub-prompt (`psql`→`\password`) or inside a
23//! here-doc body are out of scope. Pair it with operational guidance (use
24//! `.pgpass` / secret stores) and a periodic log scan for stragglers.
25
26/// The placeholder a redacted value is replaced with. ASCII and unambiguous (not
27/// `<…>`, which reads like a shell redirect). Frozen — it enters the canonical
28/// hash, so changing it would change every event hash.
29pub const MARKER: &str = "[redacted]";
30
31/// The result of redacting a command line.
32#[derive(Debug, Clone, PartialEq, Eq)]
33pub struct Redaction {
34    /// The command with every detected secret value replaced by [`MARKER`].
35    pub text: String,
36    /// How many secret values were redacted (0 = nothing matched).
37    pub count: usize,
38}
39
40impl Redaction {
41    /// Whether any secret was redacted.
42    pub fn any(&self) -> bool {
43        self.count > 0
44    }
45}
46
47/// Redact credentials that appear inline in a command line. Preserves the
48/// command's structure and whitespace; only secret *values* are replaced.
49pub 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    // Effective program for flag-gating. A credential client is honored *anywhere*
55    // on the line so command wrappers (`sudo mysql -p…`, `env mysql -p…`,
56    // `sudo -u postgres mysql -p…`, `timeout 5 mysql -p…`) can't smuggle a secret
57    // past the program-gated redaction. Otherwise it's the first non-wrapper,
58    // non-assignment, non-flag token. Over-redaction here is the safe direction.
59    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 -p` is a password; `docker run -p` is a port. Detect the
74        // `login` subcommand as a bare token (no full-line lowercase allocation).
75        docker_login: program == "docker" && segments.iter().any(|s| !s.is_ws && s.text == "login"),
76    };
77
78    // `redact_next[k]` = the next token's whole value is the secret of a separated
79    // flag (`--password SECRET`).
80    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
114/// Redact secrets *within* a single (quote-aware) token. Returns (redacted, count,
115/// `takes_next`) where `takes_next` means the following token is the secret value
116/// of a separated flag.
117fn redact_token(tok: &str, ctx: &Ctx) -> (String, usize, bool) {
118    // 1. `KEY=value` env assignment with a sensitive key (PGPASSWORD=…).
119    if let Some((key, _)) = env_assignment(tok) {
120        if sensitive_env_key(key) {
121            return (format!("{key}={MARKER}"), 1, false);
122        }
123    }
124
125    // 2. A sensitive HTTP header carried as one (often quoted) token:
126    //    `X-Api-Key: v`, `Authorization: Bearer v`, `Authorization:Token v`.
127    if let Some(red) = redact_header(tok) {
128        return (red, 1, false);
129    }
130
131    // 3. A URI: redact the userinfo password (or a colonless PAT), AND any
132    //    sensitive query-string parameter in the SAME token — a connection string
133    //    can carry a secret in both (`scheme://u:p@host/db?password=…`), so we must
134    //    not stop after the userinfo: compose both passes on the one token.
135    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    // 4. Long credential flags: --password=…, --token=…, openssl -passin=… (and
151    //    the separated `--password SECRET` form via `takes_next`).
152    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    // 5. OpenSSL inline `pass:SECRET`.
163    if let Some(rest) = tok.strip_prefix("pass:") {
164        if !rest.is_empty() {
165            return (format!("pass:{MARKER}"), 1, false);
166        }
167    }
168
169    // 6. Oracle `user/pass`, `user/pass@tns`, `userid=user/pass@…` (program-gated).
170    if oracle_tool(ctx.program) {
171        if let Some(red) = redact_oracle_login(tok) {
172            return (red, 1, false);
173        }
174    }
175
176    // 7. Program-gated short password flags (avoid `mkdir -p`, `docker run -p`).
177    if let Some(res) = redact_short_flag(tok, ctx) {
178        return res;
179    }
180
181    (tok.to_string(), 0, false)
182}
183
184/// `Header-Name: value` (possibly quoted, with an optional auth scheme word).
185/// Per-token detection — no line-global gate — so an incidental "authorization"
186/// in a commit message can't arm a spurious redaction.
187fn 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    // Keep an auth scheme word (Bearer/Basic/Token/…) but redact the credential.
215    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
240/// Redact a URI userinfo password (or colonless token-as-user), preserving the
241/// surrounding token (scheme, quotes, host, path).
242fn redact_uri(tok: &str) -> Option<String> {
243    let scheme_end = tok.find("://")? + 3;
244    let rest = &tok[scheme_end..];
245    // The userinfo lives before the query/fragment. `?` and `#` reliably END the
246    // authority (they're not valid unencoded in userinfo), and a secret in the
247    // query is redacted separately by the compose pass — so confine the search to
248    // the pre-query region. Within it, the userinfo `@` is the RIGHTMOST `@`: a
249    // password may contain `@` and `/`, and the host may be empty
250    // (`scheme://user:pass@/db`). The colon/path heuristic below then decides
251    // whether this is real userinfo or just an `@` inside a path.
252    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        // `user:password` — redact the password (which may contain `/` or `@`).
258        Some(c) => Some(format!(
259            "{}{}:{MARKER}{tail}",
260            &tok[..scheme_end],
261            &userinfo[..c]
262        )),
263        // Colonless userinfo is a PAT-as-username (redact) ONLY when it has no path
264        // chars; otherwise the `@` is just inside the path — leave the URL alone.
265        None if !userinfo.contains(['/', '?', '#']) => {
266            Some(format!("{}{MARKER}{tail}", &tok[..scheme_end]))
267        }
268        None => None,
269    }
270}
271
272/// Redact sensitive `key=value` query/connection-string parameters in a token
273/// (`?access_token=…`, `&password=…`, `;password=…`, `:_authToken=…`).
274fn 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
309/// Oracle `user/password`, `user/password@connect`, `userid=user/password@…`.
310fn 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    // Non-empty, and not a filesystem path (`a/b/c`).
326    if pw.is_empty() || pw.contains('/') {
327        return None;
328    }
329    Some(format!("{prefix}{user}/{MARKER}{}", &after[pw_end..]))
330}
331
332/// Program-gated short password flags. Returns (redacted, count, takes_next).
333fn 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"; // sqlcmd uses uppercase -P
348
349    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)); // separated value
352    }
353    if (p_flag_program || big_p_program) && tok.starts_with(flag) && tok.len() > flag.len() {
354        return Some((format!("{flag}{MARKER}"), 1, false)); // attached value
355    }
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
396/// Split `KEY=value` (only when KEY looks like a shell variable name).
397fn 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    // Must be an actual flag — a bare word like `auth`/`token`/`secret` is a
448    // subcommand (`gcloud auth …`, `vault token …`), not a credential flag.
449    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
486/// Split `s` into alternating whitespace / token segments, **honoring quotes** so
487/// whitespace inside `'…'` / `"…"` does not break a token (a quoted multi-word
488/// secret stays one token). Preserves every byte so the pieces rejoin to `s`.
489fn 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
527/// Command wrappers that prefix the real program (so the program-gated redaction
528/// must look past them, not treat the wrapper as the program).
529fn 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
549/// Programs whose short flags carry a credential (so `-p…`/`-a…`/`-u…` redaction
550/// must fire even when the program appears after a wrapper). Mirrors the set
551/// `redact_short_flag` switches on.
552fn 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        // sudo/env/nice/time/timeout prefixes must not defeat the -p redaction.
597        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        // sudo WITH its own options before the real client.
603        assert!(!leaks("sudo -u postgres mysql -ps3cr3t", "s3cr3t"));
604        // redis-cli and curl behind a wrapper, too.
605        assert!(!leaks("sudo redis-cli -ap@ss", "p@ss"));
606        assert!(!leaks("sudo curl -u alice:hunter2 https://x", "hunter2"));
607        // a non-client token named like a client must NOT trigger a port redaction.
608        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        // password containing '@' — split at the LAST '@' before the path.
618        assert_eq!(
619            red("psql 'postgresql://u:p@ss@host/db'"),
620            "psql 'postgresql://u:[redacted]@host/db'"
621        );
622        // colonless PAT-as-username.
623        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        // Password containing '/' (e.g. a base64 secret) — must not slip through
632        // because the matcher mistook the '/' for the path boundary (regression).
633        assert!(!leaks("psql postgres://app:aB/cD/ef@host/db", "aB/cD/ef"));
634        // Secret in the query string ALONGSIDE userinfo: BOTH must be redacted —
635        // the userinfo pass must not short-circuit the query pass (regression).
636        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        // Empty-host DSN (default host / unix socket): the real '@' precedes '/',
640        // so an '@' inside the password must not be mistaken for the delimiter.
641        assert!(!leaks("psql postgresql://user:pa@ss@/dbname", "pa@ss"));
642        // An '@' that is purely in the path (no userinfo) must be left untouched.
643        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        // `docker run -p` is a PORT, must NOT be redacted.
670        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        // The headline leak: the whole quoted value must go, not just the first word.
681        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        // the scheme word is preserved and the URL is NOT eaten.
728        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        // "authorization" in prose must NOT arm a later bare `basic`/`bearer`.
734        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        // a non-oracle program with a path-like a/b arg is untouched.
775        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        // Colonless `scheme://word@host` is conservatively redacted (could be a
807        // PAT-as-username), accepting over-redaction of a plain username.
808        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"); // NUL, no panic
827    }
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}