Skip to main content

anodizer_core/
redact.rs

1//! Secret redaction for command output.
2//!
3//! Scans environment
4//! variables for secret-looking entries and replaces their values in
5//! output strings with `$KEY_NAME`.
6
7/// Key suffixes that indicate a secret value.
8///
9/// `_KEY` covers AI provider API keys (`ANTHROPIC_API_KEY`,
10/// `OPENAI_API_KEY`) alongside signing-key and other historical
11/// secret-bearing variable names.
12const SECRET_KEY_SUFFIXES: &[&str] = &["_KEY", "_SECRET", "_PASSWORD", "_TOKEN"];
13
14/// Value prefixes that indicate a secret regardless of key name.
15///
16/// Catches provider API keys (`sk-...`, `sk-ant-...`) regardless of the
17/// variable name they happen to be exported under.
18const 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
30/// Returns true if this env entry looks like it contains a secret.
31///
32/// The empty string is the only excluded value — every non-empty value
33/// matching the heuristics is redacted, mirroring upstream
34/// Secret-detection heuristic after the
35/// length-floor was removed (commit `d1cdbb2`).
36fn 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
47/// Redact secret values in a string, replacing them with `$KEY_NAME`.
48///
49/// Longer values are replaced first to prevent partial matches.
50///
51/// Redact secret env-var values found in a string.
52pub 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
67/// Convenience wrapper: redact secrets in `input` using the current
68/// process env (`std::env::vars()`) PLUS strip inline URL credentials.
69///
70/// Used by modules that don't have a `Context` in scope (e.g. the `git/`
71/// shell-out helpers) and still want the same redaction surface as the
72/// `StageLogger`. Equivalent to `redact_url_credentials(input)` followed
73/// by `string(..., &process_env_vec)`.
74pub 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
80/// Strip embedded userinfo (credentials) from any URLs found in `input`.
81///
82/// For each occurrence of `<scheme>://<userinfo>@<host>...`, the substring
83/// between `://` and the first `@` is replaced with `<redacted>`. Non-URL
84/// text is left untouched, and URLs without a userinfo component are
85/// unchanged. Handles `http`, `https`, and any other `<scheme>://` form.
86///
87/// Use this as a defense-in-depth complement to [`string`] when the secret
88/// is inlined in a URL but the bare token value is not necessarily exported
89/// as an env var (e.g. a `git_url` config string the user templated with a
90/// literal `https://user:pass@host`).
91pub fn redact_url_credentials(input: &str) -> String {
92    // Walk the string and rewrite each `<scheme>://<userinfo>@` segment.
93    // For each `://` we find, look up to the next path / query / fragment /
94    // whitespace boundary; if that authority segment contains an `@`, the
95    // text before the LAST `@` is the userinfo (RFC 3986 §3.2.1 allows
96    // unreserved `@` in the password subcomponent only when percent-encoded,
97    // but real-world tokens contain literal `@` often enough that we treat
98    // the last `@` as the host separator).
99    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            // userinfo = authority[..last_at], host-start = last_at + 1
111            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
123/// Strip bearer / authorization tokens that may have been echoed by a
124/// remote endpoint into a response body before that body lands in an
125/// error message. Defense in depth — if a misbehaving registry mirrors
126/// the request's `Authorization` header back in an error response, this
127/// helper prevents the token from showing up in user-visible logs.
128///
129/// Replaces:
130///   - `Bearer <token>` → `Bearer <redacted>` (case-insensitive on the
131///     keyword; the canonical replacement spelling is always "Bearer").
132///     A "Bearer" match requires the keyword to appear at the start of
133///     the input OR immediately after one of `[ \t:,;("'<\n\r]` so that
134///     prose words like "bearer of bad news" do not match.
135///   - `Basic <b64>` → `Basic <redacted>` (case-insensitive on the
136///     keyword; same boundary rule as `Bearer`). Covers HTTP Basic
137///     auth headers like the GemFury push token (`Authorization:
138///     Basic <token-as-username:>` base64).
139///   - `Authorization:` followed by any value through end-of-line →
140///     `Authorization: <redacted>` (case-insensitive on the header name).
141///     The entire header value is consumed so `Authorization: Bearer X`
142///     doesn't leak `X` after the header redaction.
143///
144/// Use as a wrapper around any remote-supplied body text being interpolated
145/// into an error message or log line. The bare token (no scheme prefix)
146/// remains untouched — for that, rely on `string(..., env)` matching the
147/// env-var-based heuristics.
148pub 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        // Authorization: <rest-of-line>
154        // Always allowed to match at i (the header name itself is unambiguous
155        // when followed by a `:`). Consume through the next \n / \r so a
156        // multi-line body with subsequent normal text isn't redacted past
157        // the header's terminator.
158        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        // Bearer <token>
167        // Require the preceding byte (if any) to be a token-boundary
168        // character so prose like "the bearer of bad news" doesn't match.
169        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            // Skip the token value: a run of non-whitespace characters.
178            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        // Emit one byte verbatim and advance.
192        out.push(bytes[i] as char);
193        i += 1;
194    }
195    out
196}
197
198/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
199/// "Bearer " (the trailing space is required so we don't match "Bearertown").
200fn 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
213/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
214/// "Basic " (the trailing space is required so we don't match "Basics" or
215/// "Basically"). Covers HTTP Basic auth headers used by GemFury and other
216/// publishers that pass the token as the Basic-auth username.
217fn 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
230/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
231/// "Authorization:" (the trailing colon is required to disambiguate from
232/// prose mentioning the word "authorization").
233fn 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        // Reflects the secret-key rename after
274        // the length-floor was removed: a 5-char value under a `*_KEY` key
275        // must still be redacted.
276        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        // The empty string is the only excluded value: an unset env var
284        // would otherwise replace every empty substring in the input,
285        // turning "abc" into "$API_KEY a$API_KEY b$API_KEY c$API_KEY".
286        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        // Longer match should be replaced first
299        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")); // case insensitive
324        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        // When two secrets have the same value length, sort by key name
340        // for deterministic output regardless of HashMap iteration order.
341        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        // Both keys map to the same value, so whichever sorts first by
346        // key name should win — A_SECRET comes before B_SECRET.
347        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        // Longer values still replaced first, secondary sort by key is tiebreaker
354        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        // SSH-style `git@github.com:owner/repo.git` has no `://`, so the
395        // helper leaves it alone. The `git@` is part of the SSH user, not
396        // an embedded credential.
397        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        // `@` in a path segment (after the first `/`) must NOT be treated
414        // as a userinfo terminator.
415        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        // A percent-encoded `@` in the userinfo (e.g. an account name like
437        // `user@name`) does not break the terminator scan: the function
438        // looks for the LAST `@` before the path / query / fragment /
439        // whitespace boundary, so both `@`s collapse into a single
440        // `<redacted>` replacement.
441        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        // A `?` after the host begins the query string; userinfo must still
451        // be stripped, and the query is preserved verbatim.
452        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        // A `#` after the host begins the fragment; userinfo must still
462        // be stripped, and the fragment is preserved verbatim.
463        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        // Whitespace following the host terminates the authority. The
473        // userinfo is redacted and the trailing prose is preserved.
474        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        // The keyword "Bearer" is case-insensitive but the canonical
492        // output form is always "Bearer".
493        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        // "Authorization:" consumes through end-of-line, so the entire
506        // header value is redacted as one unit. Trailing content after
507        // a newline is preserved verbatim.
508        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        // No newline → the header value runs to end-of-input; that's fine,
520        // the entire tail is redacted (defensive: better one over-redaction
521        // than one leaked token).
522        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        // No "Bearer " / "Authorization:" tokens → string unchanged.
531        // Note: we cannot distinguish prose use of "bearer" from a real
532        // header; the redactor errs on the side of over-redaction (it
533        // would treat "bearer of bad news" as "Bearer <redacted> bad
534        // news"). Both branches are still safer than leaking a token.
535        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        // Documents the known over-redaction behavior: "bearer of bad
542        // news" looks like a Bearer-token construct because the redactor
543        // can't tell prose from a header. The trade-off is intentional —
544        // safer to over-redact a prose word than to leak a real token.
545        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}