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.
12pub const 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. Matching is
18/// case-sensitive, so prefixes must reproduce the issuer's exact casing
19/// (`AIza`, not `AIZA`).
20const SECRET_VALUE_PREFIXES: &[&str] = &[
21    "sk-",
22    "ghp_",
23    "ghs_",
24    "gho_",
25    "ghu_",
26    "github_pat_",
27    "dckr_pat_",
28    "glpat-",
29    "AIza",
30    "ya29.",
31    "xox",
32];
33
34/// Returns true if this env entry looks like it contains a secret.
35///
36/// The empty string is the only excluded value — every non-empty value
37/// matching the heuristics is redacted, mirroring upstream
38/// Secret-detection heuristic after the
39/// length-floor was removed (commit `d1cdbb2`).
40fn is_secret(key: &str, value: &str) -> bool {
41    if value.is_empty() {
42        return false;
43    }
44    let key_upper = key.to_uppercase();
45    if !is_boolean_shape(value) && SECRET_KEY_SUFFIXES.iter().any(|s| key_upper.ends_with(s)) {
46        return true;
47    }
48    SECRET_VALUE_PREFIXES.iter().any(|p| value.starts_with(p))
49}
50
51/// A value that shares a `*_TOKEN`/`*_KEY`/`*_SECRET`/`*_PASSWORD` key name
52/// but cannot be a credential by its shape: a boolean literal used as an
53/// enable-flag. Covers both the word forms (`USE_TOKEN=true`,
54/// `SIGN_WITH_KEY=false`) and the canonical numeric flags `0`/`1`
55/// (`HF_HUB_DISABLE_IMPLICIT_TOKEN=1`, `GPG_SIGN_KEY=0`) — the single most
56/// common env enable-flag idiom, where the `_TOKEN`/`_KEY` suffix names the
57/// feature being toggled, not a credential.
58///
59/// The numeric exemption is deliberately restricted to the single characters
60/// `0` and `1`: a lone bit cannot be a credential, and redacting it corrupts
61/// every unrelated `0`/`1` in the same output (a version like `1.0.0`, a
62/// revision number, a timestamp). A *multi*-digit numeric value (a 4-digit
63/// HSM/smartcard PIN, a 6-digit OTP) is still NOT exempted, because it is
64/// indistinguishable from a genuine short numeric credential and a false
65/// exemption there is silent data exposure. Short *alphabetic* values are
66/// likewise never exempted: a 5-char `API_KEY=short` is still masked. A
67/// [`SECRET_VALUE_PREFIXES`] match takes precedence, so a positively
68/// secret-shaped value is never dropped by this filter.
69fn is_boolean_shape(value: &str) -> bool {
70    matches!(
71        value.to_ascii_lowercase().as_str(),
72        "true" | "false" | "yes" | "no" | "on" | "off" | "0" | "1"
73    )
74}
75
76/// Redact secret values in a string, replacing them with `$KEY_NAME`.
77///
78/// Longer values are replaced first to prevent partial matches.
79///
80/// Redact secret env-var values found in a string.
81pub fn string(input: &str, env: &[(String, String)]) -> String {
82    let mut secrets: Vec<(&str, &str)> = env
83        .iter()
84        .filter(|(k, v)| is_secret(k, v))
85        .map(|(k, v)| (k.as_str(), v.as_str()))
86        .collect();
87    secrets.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
88
89    let mut result = input.to_string();
90    for (key, value) in secrets {
91        result = result.replace(value, &format!("${}", key));
92    }
93    result
94}
95
96/// Apply the full outbound-text redaction policy: strip inline URL
97/// credentials, then mask known-secret env values. The single definition
98/// shared by log redaction ([`crate::log::StageLogger::redact`]) and
99/// announce body redaction so the two can never diverge.
100pub fn with_env(input: &str, env: &[(String, String)]) -> String {
101    string(&redact_url_credentials(input), env)
102}
103
104/// Convenience wrapper: redact secrets in `input` using the current
105/// process env (`std::env::vars()`) PLUS strip inline URL credentials.
106///
107/// Used by modules that don't have a `Context` in scope (e.g. the `git/`
108/// shell-out helpers) and still want the same redaction surface as the
109/// `StageLogger`. Equivalent to `redact_url_credentials(input)` followed
110/// by `string(..., &process_env_vec)`.
111pub fn redact_process_env(input: &str) -> String {
112    let env: Vec<(String, String)> = std::env::vars().collect();
113    with_env(input, &env)
114}
115
116/// Strip embedded userinfo (credentials) from any URLs found in `input`.
117///
118/// For each occurrence of `<scheme>://<userinfo>@<host>...`, the substring
119/// between `://` and the first `@` is replaced with `<redacted>`. Non-URL
120/// text is left untouched, and URLs without a userinfo component are
121/// unchanged. Handles `http`, `https`, and any other `<scheme>://` form.
122///
123/// Use this as a defense-in-depth complement to [`string`] when the secret
124/// is inlined in a URL but the bare token value is not necessarily exported
125/// as an env var (e.g. a `git_url` config string the user templated with a
126/// literal `https://user:pass@host`).
127pub fn redact_url_credentials(input: &str) -> String {
128    // Walk the string and rewrite each `<scheme>://<userinfo>@` segment.
129    // For each `://` we find, look up to the next path / query / fragment /
130    // whitespace boundary; if that authority segment contains an `@`, the
131    // text before the LAST `@` is the userinfo (RFC 3986 §3.2.1 allows
132    // unreserved `@` in the password subcomponent only when percent-encoded,
133    // but real-world tokens contain literal `@` often enough that we treat
134    // the last `@` as the host separator).
135    let mut result = String::with_capacity(input.len());
136    let mut rest = input;
137    while let Some(scheme_end) = rest.find("://") {
138        let after_scheme_start = scheme_end + 3;
139        result.push_str(&rest[..after_scheme_start]);
140        let after_scheme = &rest[after_scheme_start..];
141        let terminator = after_scheme
142            .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
143            .unwrap_or(after_scheme.len());
144        let authority = &after_scheme[..terminator];
145        if let Some(last_at) = authority.rfind('@') {
146            // userinfo = authority[..last_at], host-start = last_at + 1
147            result.push_str("<redacted>@");
148            result.push_str(&authority[last_at + 1..]);
149            rest = &after_scheme[terminator..];
150        } else {
151            result.push_str(authority);
152            rest = &after_scheme[terminator..];
153        }
154    }
155    result.push_str(rest);
156    result
157}
158
159/// Strip bearer / authorization tokens that may have been echoed by a
160/// remote endpoint into a response body before that body lands in an
161/// error message. Defense in depth — if a misbehaving registry mirrors
162/// the request's `Authorization` header back in an error response, this
163/// helper prevents the token from showing up in user-visible logs.
164///
165/// Replaces:
166///   - `Bearer <token>` → `Bearer <redacted>` (case-insensitive on the
167///     keyword; the canonical replacement spelling is always "Bearer").
168///     A "Bearer" match requires the keyword to appear at the start of
169///     the input OR immediately after one of `[ \t:,;("'<\n\r]` so that
170///     prose words like "bearer of bad news" do not match.
171///   - `Basic <b64>` → `Basic <redacted>` (case-insensitive on the
172///     keyword; same boundary rule as `Bearer`). Covers HTTP Basic
173///     auth headers like the GemFury push token (`Authorization:
174///     Basic <token-as-username:>` base64).
175///   - `Authorization:` followed by any value through end-of-line →
176///     `Authorization: <redacted>` (case-insensitive on the header name).
177///     The entire header value is consumed so `Authorization: Bearer X`
178///     doesn't leak `X` after the header redaction.
179///
180/// Use as a wrapper around any remote-supplied body text being interpolated
181/// into an error message or log line. The bare token (no scheme prefix)
182/// remains untouched — for that, rely on `string(..., env)` matching the
183/// env-var-based heuristics.
184pub fn redact_bearer_tokens(input: &str) -> String {
185    let bytes = input.as_bytes();
186    let mut out = String::with_capacity(input.len());
187    let mut i = 0;
188    while i < bytes.len() {
189        // Authorization: <rest-of-line>
190        // Always allowed to match at i (the header name itself is unambiguous
191        // when followed by a `:`). Consume through the next \n / \r so a
192        // multi-line body with subsequent normal text isn't redacted past
193        // the header's terminator.
194        if let Some(name_len) = match_authorization_prefix(&bytes[i..]) {
195            out.push_str("Authorization: <redacted>");
196            i += name_len;
197            while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
198                i += 1;
199            }
200            continue;
201        }
202        // Bearer <token>
203        // Require the preceding byte (if any) to be a token-boundary
204        // character so prose like "the bearer of bad news" doesn't match.
205        let preceded_by_boundary = i == 0
206            || matches!(
207                bytes[i - 1],
208                b' ' | b'\t' | b':' | b',' | b';' | b'(' | b'"' | b'\'' | b'<' | b'\n' | b'\r'
209            );
210        if preceded_by_boundary && let Some(kw_len) = match_bearer_prefix(&bytes[i..]) {
211            out.push_str("Bearer <redacted>");
212            i += kw_len;
213            // Skip the token value: a run of non-whitespace characters.
214            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
215                i += 1;
216            }
217            continue;
218        }
219        if preceded_by_boundary && let Some(kw_len) = match_basic_prefix(&bytes[i..]) {
220            out.push_str("Basic <redacted>");
221            i += kw_len;
222            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
223                i += 1;
224            }
225            continue;
226        }
227        // Emit one byte verbatim and advance.
228        out.push(bytes[i] as char);
229        i += 1;
230    }
231    out
232}
233
234/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
235/// "Bearer " (the trailing space is required so we don't match "Bearertown").
236fn match_bearer_prefix(bytes: &[u8]) -> Option<usize> {
237    const KW: &[u8] = b"Bearer ";
238    if bytes.len() < KW.len() {
239        return None;
240    }
241    for (i, kw_byte) in KW.iter().enumerate() {
242        if !bytes[i].eq_ignore_ascii_case(kw_byte) {
243            return None;
244        }
245    }
246    Some(KW.len())
247}
248
249/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
250/// "Basic " (the trailing space is required so we don't match "Basics" or
251/// "Basically"). Covers HTTP Basic auth headers used by GemFury and other
252/// publishers that pass the token as the Basic-auth username.
253fn match_basic_prefix(bytes: &[u8]) -> Option<usize> {
254    const KW: &[u8] = b"Basic ";
255    if bytes.len() < KW.len() {
256        return None;
257    }
258    for (i, kw_byte) in KW.iter().enumerate() {
259        if !bytes[i].eq_ignore_ascii_case(kw_byte) {
260            return None;
261        }
262    }
263    Some(KW.len())
264}
265
266/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
267/// "Authorization:" (the trailing colon is required to disambiguate from
268/// prose mentioning the word "authorization").
269fn match_authorization_prefix(bytes: &[u8]) -> Option<usize> {
270    const KW: &[u8] = b"Authorization:";
271    if bytes.len() < KW.len() {
272        return None;
273    }
274    for (i, kw_byte) in KW.iter().enumerate() {
275        if !bytes[i].eq_ignore_ascii_case(kw_byte) {
276            return None;
277        }
278    }
279    Some(KW.len())
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_redact_by_key_suffix() {
288        let env = vec![
289            (
290                "DOCKER_PASSWORD".to_string(),
291                "mysecretpassword123".to_string(),
292            ),
293            ("PLAIN_VAR".to_string(), "not-a-secret".to_string()),
294        ];
295        let result = string("Login with mysecretpassword123 succeeded", &env);
296        assert_eq!(result, "Login with $DOCKER_PASSWORD succeeded");
297        assert!(!result.contains("mysecretpassword123"));
298    }
299
300    #[test]
301    fn test_redact_by_value_prefix() {
302        let env = vec![("MY_TOKEN".to_string(), "ghp_abc123def456ghi789".to_string())];
303        let result = string("Using token ghp_abc123def456ghi789", &env);
304        assert_eq!(result, "Using token $MY_TOKEN");
305    }
306
307    #[test]
308    fn test_redact_includes_short_secret_when_key_looks_secret() {
309        // Reflects the secret-key rename after
310        // the length-floor was removed: a 5-char value under a `*_KEY` key
311        // must still be redacted.
312        let env = vec![("API_KEY".to_string(), "short".to_string())];
313        let result = string("Value is short", &env);
314        assert_eq!(result, "Value is $API_KEY");
315    }
316
317    #[test]
318    fn test_redact_skips_empty_value() {
319        // The empty string is the only excluded value: an unset env var
320        // would otherwise replace every empty substring in the input,
321        // turning "abc" into "$API_KEY a$API_KEY b$API_KEY c$API_KEY".
322        let env = vec![("API_KEY".to_string(), String::new())];
323        let result = string("Value is short", &env);
324        assert_eq!(result, "Value is short");
325    }
326
327    #[test]
328    fn test_redact_longer_values_first() {
329        let env = vec![
330            ("SHORT_TOKEN".to_string(), "abcdefghij".to_string()),
331            ("LONG_TOKEN".to_string(), "abcdefghijklmnop".to_string()),
332        ];
333        let result = string("secret: abcdefghijklmnop", &env);
334        // Longer match should be replaced first
335        assert_eq!(result, "secret: $LONG_TOKEN");
336    }
337
338    #[test]
339    fn test_redact_no_secrets() {
340        let env = vec![("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())];
341        let result = string("PATH is set", &env);
342        assert_eq!(result, "PATH is set");
343    }
344
345    #[test]
346    fn test_redact_multiple_occurrences() {
347        let env = vec![(
348            "REGISTRY_PASSWORD".to_string(),
349            "supersecret123".to_string(),
350        )];
351        let result = string("auth supersecret123 retry supersecret123", &env);
352        assert_eq!(result, "auth $REGISTRY_PASSWORD retry $REGISTRY_PASSWORD");
353    }
354
355    #[test]
356    fn test_is_secret_key_suffixes() {
357        assert!(is_secret("DOCKER_PASSWORD", "longvalue1234"));
358        assert!(is_secret("API_TOKEN", "longvalue1234"));
359        assert!(is_secret("signing_key", "longvalue1234")); // case insensitive
360        assert!(is_secret("MY_SECRET", "longvalue1234"));
361        assert!(!is_secret("MY_CONFIG", "longvalue1234"));
362    }
363
364    #[test]
365    fn test_is_secret_value_prefixes() {
366        assert!(is_secret("ANYTHING", "ghp_1234567890"));
367        assert!(is_secret("ANYTHING", "sk-1234567890"));
368        assert!(is_secret("ANYTHING", "dckr_pat_1234567890"));
369        assert!(is_secret("ANYTHING", "glpat-1234567890"));
370        // Fine-grained GitHub PAT and Google API/OAuth keys, exported under a
371        // name the suffix list does not catch, are matched by exact-case value
372        // prefix. `AIza`/`ya29.` casing is load-bearing: the match is
373        // case-sensitive, so an `AIZA` prefix would catch zero real keys.
374        assert!(is_secret("GH_PAT", "github_pat_11ABCDE0000000000"));
375        assert!(is_secret(
376            "GOOGLE_CREDS",
377            "AIzaSyA00000000000000000000000000000000"
378        ));
379        assert!(is_secret(
380            "GOOGLE_CREDS",
381            "ya29.a0Af00000000000000000000000"
382        ));
383        assert!(!is_secret("ANYTHING", "regular_value1234"));
384    }
385
386    #[test]
387    fn test_redact_sort_stability_same_length() {
388        // When two secrets have the same value length, sort by key name
389        // for deterministic output regardless of HashMap iteration order.
390        let env = vec![
391            ("B_SECRET".to_string(), "same_length_val".to_string()),
392            ("A_SECRET".to_string(), "same_length_val".to_string()),
393        ];
394        // Both keys map to the same value, so whichever sorts first by
395        // key name should win — A_SECRET comes before B_SECRET.
396        let result = string("found same_length_val here", &env);
397        assert_eq!(result, "found $A_SECRET here");
398    }
399
400    #[test]
401    fn test_redact_deterministic_with_different_lengths() {
402        // Longer values still replaced first, secondary sort by key is tiebreaker
403        let env = vec![
404            ("Z_TOKEN".to_string(), "short_secret_val".to_string()),
405            (
406                "A_TOKEN".to_string(),
407                "a_longer_secret_value_here".to_string(),
408            ),
409        ];
410        let result = string("prefix a_longer_secret_value_here suffix", &env);
411        assert_eq!(result, "prefix $A_TOKEN suffix");
412    }
413
414    #[test]
415    fn test_with_env_composes_url_strip_and_env_mask() {
416        // The canonical outbound policy must apply BOTH layers in one call:
417        // inline URL-credential stripping AND known-secret env masking. A body
418        // carrying both must come out clean on both axes — proving composition,
419        // not either layer alone.
420        let env = vec![(
421            "CARGO_REGISTRY_TOKEN".to_string(),
422            "ghp_realsecretvalue".to_string(),
423        )];
424        let input = "pushed via https://tok@host/x then logged ghp_realsecretvalue";
425        let result = with_env(input, &env);
426        assert_eq!(
427            result,
428            "pushed via https://<redacted>@host/x then logged $CARGO_REGISTRY_TOKEN"
429        );
430        assert!(!result.contains("ghp_realsecretvalue"));
431        assert!(!result.contains("tok@host"));
432    }
433
434    #[test]
435    fn test_redact_url_credentials_https_with_token() {
436        let input = "remote: https://ghp_abc123def@github.com/owner/repo.git";
437        let result = redact_url_credentials(input);
438        assert_eq!(
439            result,
440            "remote: https://<redacted>@github.com/owner/repo.git"
441        );
442        assert!(!result.contains("ghp_abc123def"));
443    }
444
445    #[test]
446    fn test_redact_url_credentials_user_pass_pair() {
447        let input = "pushing to https://user:p@ssw0rd@gitlab.example.com/foo/bar";
448        let result = redact_url_credentials(input);
449        assert_eq!(
450            result, "pushing to https://<redacted>@gitlab.example.com/foo/bar",
451            "userinfo must cover the entire user:pass segment up to the host-@"
452        );
453    }
454
455    #[test]
456    fn test_redact_url_credentials_no_userinfo_unchanged() {
457        let input = "fetching https://github.com/owner/repo.git";
458        assert_eq!(redact_url_credentials(input), input);
459    }
460
461    #[test]
462    fn test_redact_url_credentials_ssh_unchanged() {
463        // SSH-style `git@github.com:owner/repo.git` has no `://`, so the
464        // helper leaves it alone. The `git@` is part of the SSH user, not
465        // an embedded credential.
466        let input = "fetching git@github.com:owner/repo.git";
467        assert_eq!(redact_url_credentials(input), input);
468    }
469
470    #[test]
471    fn test_redact_url_credentials_multiple_urls_in_one_line() {
472        let input = "from https://token1@a.com/x to https://token2@b.com/y";
473        let result = redact_url_credentials(input);
474        assert_eq!(
475            result, "from https://<redacted>@a.com/x to https://<redacted>@b.com/y",
476            "both URLs must be redacted, leaving the connecting prose intact"
477        );
478    }
479
480    #[test]
481    fn test_redact_url_credentials_does_not_consume_path_at_sign() {
482        // `@` in a path segment (after the first `/`) must NOT be treated
483        // as a userinfo terminator.
484        let input = "GET https://api.example.com/users/foo@bar.com/profile";
485        assert_eq!(
486            redact_url_credentials(input),
487            input,
488            "an `@` after the first `/` is part of the path, not userinfo"
489        );
490    }
491
492    #[test]
493    fn test_redact_url_credentials_empty_input() {
494        assert_eq!(redact_url_credentials(""), "");
495    }
496
497    #[test]
498    fn test_redact_url_credentials_plain_text() {
499        let input = "no URLs here, just words";
500        assert_eq!(redact_url_credentials(input), input);
501    }
502
503    #[test]
504    fn test_redact_url_credentials_percent_encoded_userinfo() {
505        // A percent-encoded `@` in the userinfo (e.g. an account name like
506        // `user@name`) does not break the terminator scan: the function
507        // looks for the LAST `@` before the path / query / fragment /
508        // whitespace boundary, so both `@`s collapse into a single
509        // `<redacted>` replacement.
510        let input = "https://user%40name:pass@host.example.com/path";
511        let result = redact_url_credentials(input);
512        assert_eq!(result, "https://<redacted>@host.example.com/path");
513        assert!(!result.contains("user%40name"));
514        assert!(!result.contains("pass"));
515    }
516
517    #[test]
518    fn test_redact_url_credentials_trailing_query() {
519        // A `?` after the host begins the query string; userinfo must still
520        // be stripped, and the query is preserved verbatim.
521        let input = "https://user:pass@host.example.com?foo=bar";
522        let result = redact_url_credentials(input);
523        assert_eq!(result, "https://<redacted>@host.example.com?foo=bar");
524        assert!(!result.contains("user:pass"));
525        assert!(result.ends_with("?foo=bar"));
526    }
527
528    #[test]
529    fn test_redact_url_credentials_trailing_fragment() {
530        // A `#` after the host begins the fragment; userinfo must still
531        // be stripped, and the fragment is preserved verbatim.
532        let input = "https://user:pass@host.example.com#frag";
533        let result = redact_url_credentials(input);
534        assert_eq!(result, "https://<redacted>@host.example.com#frag");
535        assert!(!result.contains("user:pass"));
536        assert!(result.ends_with("#frag"));
537    }
538
539    #[test]
540    fn test_redact_url_credentials_whitespace_boundary() {
541        // Whitespace following the host terminates the authority. The
542        // userinfo is redacted and the trailing prose is preserved.
543        let input = "https://user:pass@host.example.com then more";
544        let result = redact_url_credentials(input);
545        assert_eq!(result, "https://<redacted>@host.example.com then more");
546        assert!(!result.contains("user:pass"));
547        assert!(result.ends_with(" then more"));
548    }
549
550    #[test]
551    fn test_redact_bearer_tokens_basic() {
552        let input = "auth header: Bearer ghp_abcdef123456 expires soon";
553        let result = redact_bearer_tokens(input);
554        assert_eq!(result, "auth header: Bearer <redacted> expires soon");
555        assert!(!result.contains("ghp_abcdef123456"));
556    }
557
558    #[test]
559    fn test_redact_bearer_tokens_case_insensitive() {
560        // The keyword "Bearer" is case-insensitive but the canonical
561        // output form is always "Bearer".
562        let input = "bearer ghp_lowercase_token";
563        assert_eq!(
564            redact_bearer_tokens(input),
565            "Bearer <redacted>",
566            "lowercase 'bearer' must still redact"
567        );
568        let input = "BEARER ghp_uppercase_token";
569        assert_eq!(redact_bearer_tokens(input), "Bearer <redacted>");
570    }
571
572    #[test]
573    fn test_redact_bearer_tokens_authorization_header() {
574        // "Authorization:" consumes through end-of-line, so the entire
575        // header value is redacted as one unit. Trailing content after
576        // a newline is preserved verbatim.
577        let input = "request: Authorization: Bearer ghp_xyz\nresponse: 401";
578        let result = redact_bearer_tokens(input);
579        assert_eq!(
580            result, "request: Authorization: <redacted>\nresponse: 401",
581            "header value (including the inner Bearer token) must be redacted as one"
582        );
583        assert!(!result.contains("ghp_xyz"));
584    }
585
586    #[test]
587    fn test_redact_bearer_tokens_authorization_header_single_line() {
588        // No newline → the header value runs to end-of-input; that's fine,
589        // the entire tail is redacted (defensive: better one over-redaction
590        // than one leaked token).
591        let input = "Authorization: Bearer ghp_xyz";
592        let result = redact_bearer_tokens(input);
593        assert_eq!(result, "Authorization: <redacted>");
594        assert!(!result.contains("ghp_xyz"));
595    }
596
597    #[test]
598    fn test_redact_bearer_tokens_no_match_unchanged() {
599        // No "Bearer " / "Authorization:" tokens → string unchanged.
600        // Note: we cannot distinguish prose use of "bearer" from a real
601        // header; the redactor errs on the side of over-redaction (it
602        // would treat "bearer of bad news" as "Bearer <redacted> bad
603        // news"). Both branches are still safer than leaking a token.
604        let input = "some random text with no relevant tokens here";
605        assert_eq!(redact_bearer_tokens(input), input);
606    }
607
608    #[test]
609    fn test_redact_bearer_tokens_over_redacts_prose_use() {
610        // Documents the known over-redaction behavior: "bearer of bad
611        // news" looks like a Bearer-token construct because the redactor
612        // can't tell prose from a header. The trade-off is intentional —
613        // safer to over-redact a prose word than to leak a real token.
614        let input = "the bearer of bad news arrived";
615        let result = redact_bearer_tokens(input);
616        assert_eq!(result, "the Bearer <redacted> bad news arrived");
617    }
618
619    #[test]
620    fn test_redact_bearer_tokens_empty_input() {
621        assert_eq!(redact_bearer_tokens(""), "");
622    }
623
624    #[test]
625    fn test_redact_basic_token_redacts_b64_payload() {
626        let input = "auth: Basic ZnVyeXRva2VuOg== rest";
627        let result = redact_bearer_tokens(input);
628        assert_eq!(result, "auth: Basic <redacted> rest");
629        assert!(!result.contains("ZnVyeXRva2VuOg=="));
630    }
631
632    #[test]
633    fn test_redact_basic_token_case_insensitive() {
634        let input = "auth: basic ZnVyeXRva2VuOg==";
635        assert_eq!(redact_bearer_tokens(input), "auth: Basic <redacted>");
636    }
637
638    #[test]
639    fn test_redact_bearer_tokens_handles_multiple_occurrences() {
640        let input = "first Bearer ghp_aaa and second Bearer ghp_bbb done";
641        let result = redact_bearer_tokens(input);
642        assert_eq!(
643            result,
644            "first Bearer <redacted> and second Bearer <redacted> done"
645        );
646        assert!(!result.contains("ghp_aaa"));
647        assert!(!result.contains("ghp_bbb"));
648    }
649
650    #[test]
651    fn numeric_boolean_flag_does_not_corrupt_http_status_regression() {
652        // A `*_TOKEN`-suffixed var set to the enable-flag `1` must NOT rewrite
653        // every `1` in unrelated text — `HTTP 401` must stay `HTTP 401`, not
654        // become `HTTP 40$…`. A single bit carries no cryptographic value, so
655        // it cannot be a credential worth protecting; masking it only corrupts
656        // output and breaks downstream parses. Multi-digit numeric secrets (a
657        // 4-digit PIN, a 6-digit OTP) remain masked — see
658        // `short_numeric_pin_under_password_key_is_masked` and
659        // `six_digit_otp_under_token_key_is_masked`.
660        let env = vec![("HF_HUB_DISABLE_IMPLICIT_TOKEN".to_string(), "1".to_string())];
661        assert_eq!(
662            string("HTTP 401 unauthorized", &env),
663            "HTTP 401 unauthorized"
664        );
665    }
666
667    #[test]
668    fn boolean_flag_under_secret_key_name_is_not_masked() {
669        let env = vec![("FEATURE_TOKEN".to_string(), "true".to_string())];
670        assert_eq!(string("enabled=true here", &env), "enabled=true here");
671    }
672
673    #[test]
674    fn short_alphabetic_secret_under_secret_key_name_is_still_masked() {
675        // The deliberately-kept behavior: a short *alphabetic* value under a
676        // secret key name is a credential and stays masked.
677        let env = vec![("API_KEY".to_string(), "short".to_string())];
678        assert_eq!(string("Value is short", &env), "Value is $API_KEY");
679    }
680
681    #[test]
682    fn numeric_value_matching_prefix_rule_is_still_masked() {
683        // A positively secret-shaped value is masked even if numeric-adjacent;
684        // the shape filter only guards the key-name branch.
685        let env = vec![("CI".to_string(), "ghp_1".to_string())];
686        assert_eq!(string("token ghp_1", &env), "token $CI");
687    }
688
689    #[test]
690    fn long_numeric_value_under_secret_key_is_masked() {
691        // Only the single-character boolean flags `0`/`1` are digit-exempt
692        // under a secret-suffixed key (see `is_boolean_shape`) — a 32-digit
693        // token under a `*_TOKEN` key must be redacted, same as any other
694        // multi-digit length.
695        let value = "1".repeat(32);
696        let env = vec![("MY_TOKEN".to_string(), value.clone())];
697        let result = string(&format!("token {value}"), &env);
698        assert_eq!(result, "token $MY_TOKEN");
699        assert!(!result.contains(&value));
700    }
701
702    #[test]
703    fn six_digit_otp_under_token_key_is_masked() {
704        // A 6-digit OTP/PIN (e.g. a PyPI 2FA code) under a `*_TOKEN` key is a
705        // real, sensitive value; no digit-shape exemption applies.
706        let env = vec![("PYPI_2FA_TOKEN".to_string(), "483920".to_string())];
707        let result = string("submitting code 483920", &env);
708        assert_eq!(result, "submitting code $PYPI_2FA_TOKEN");
709        assert!(!result.contains("483920"));
710    }
711
712    #[test]
713    fn short_numeric_pin_under_password_key_is_masked() {
714        // A 4-digit HSM/smartcard PIN under a `*_PASSWORD` key is a real
715        // credential — the short-digit exemption must never apply to a
716        // secret-suffixed key, no matter how short the value is.
717        let env = vec![("CARD_PASSWORD".to_string(), "1234".to_string())];
718        let result = string("PIN entered: 1234", &env);
719        assert_eq!(result, "PIN entered: $CARD_PASSWORD");
720        assert!(!result.contains("1234"));
721    }
722
723    #[test]
724    fn short_numeric_pin_under_key_suffix_is_masked() {
725        let env = vec![("SIGNING_KEY".to_string(), "9999".to_string())];
726        let result = string("key value 9999", &env);
727        assert_eq!(result, "key value $SIGNING_KEY");
728        assert!(!result.contains("9999"));
729    }
730
731    #[test]
732    fn boolean_literal_under_secret_key_name_stays_unmasked_regression() {
733        // The boolean-literal exemption survives the short-digit-exemption
734        // removal: a `*_TOKEN`-named var used as an enable-flag must not
735        // rewrite every "true" in unrelated output.
736        let env = vec![("ENABLE_TOKEN".to_string(), "true".to_string())];
737        assert_eq!(string("flag is true", &env), "flag is true");
738    }
739
740    #[test]
741    fn non_secret_suffixed_key_short_number_stays_unmasked_regression() {
742        // A key with no secret suffix never enters the key-suffix masking
743        // branch at all; a small count/id value must stay untouched.
744        let env = vec![("RETRY_COUNT".to_string(), "42".to_string())];
745        assert_eq!(string("retries: 42", &env), "retries: 42");
746    }
747
748    #[test]
749    fn numeric_boolean_flag_under_token_key_does_not_corrupt_output_regression() {
750        // The real-world trap: `HF_HUB_DISABLE_IMPLICIT_TOKEN=1` names the
751        // feature being toggled, not a credential. Its value `1` must NOT be
752        // treated as a secret — masking a lone bit rewrites every unrelated
753        // `1` in the output (a version, a revision number, a timestamp),
754        // silently corrupting downstream parses such as `snapcraft
755        // list-revisions` version matching.
756        let env = vec![("HF_HUB_DISABLE_IMPLICIT_TOKEN".to_string(), "1".to_string())];
757        let line = "7  2024-06-01T00:00:00Z  amd64  1.0.0  stable";
758        assert_eq!(string(line, &env), line);
759    }
760
761    #[test]
762    fn numeric_zero_flag_under_key_suffix_stays_unmasked_regression() {
763        // `0` is the other canonical numeric enable-flag; it must not rewrite
764        // unrelated zeros in output (`v0.9.0`, a revision `10`).
765        let env = vec![("GPG_SIGN_KEY".to_string(), "0".to_string())];
766        assert_eq!(string("sign=0 rev 10 v0.9.0", &env), "sign=0 rev 10 v0.9.0");
767    }
768}