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 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/// Redact secret values in a string, replacing them with `$KEY_NAME`.
52///
53/// Longer values are replaced first to prevent partial matches.
54///
55/// Redact secret env-var values found in a string.
56pub fn string(input: &str, env: &[(String, String)]) -> String {
57    let mut secrets: Vec<(&str, &str)> = env
58        .iter()
59        .filter(|(k, v)| is_secret(k, v))
60        .map(|(k, v)| (k.as_str(), v.as_str()))
61        .collect();
62    secrets.sort_by(|a, b| b.1.len().cmp(&a.1.len()).then_with(|| a.0.cmp(b.0)));
63
64    let mut result = input.to_string();
65    for (key, value) in secrets {
66        result = result.replace(value, &format!("${}", key));
67    }
68    result
69}
70
71/// Apply the full outbound-text redaction policy: strip inline URL
72/// credentials, then mask known-secret env values. The single definition
73/// shared by log redaction ([`crate::log::StageLogger::redact`]) and
74/// announce body redaction so the two can never diverge.
75pub fn with_env(input: &str, env: &[(String, String)]) -> String {
76    string(&redact_url_credentials(input), env)
77}
78
79/// Convenience wrapper: redact secrets in `input` using the current
80/// process env (`std::env::vars()`) PLUS strip inline URL credentials.
81///
82/// Used by modules that don't have a `Context` in scope (e.g. the `git/`
83/// shell-out helpers) and still want the same redaction surface as the
84/// `StageLogger`. Equivalent to `redact_url_credentials(input)` followed
85/// by `string(..., &process_env_vec)`.
86pub fn redact_process_env(input: &str) -> String {
87    let env: Vec<(String, String)> = std::env::vars().collect();
88    with_env(input, &env)
89}
90
91/// Strip embedded userinfo (credentials) from any URLs found in `input`.
92///
93/// For each occurrence of `<scheme>://<userinfo>@<host>...`, the substring
94/// between `://` and the first `@` is replaced with `<redacted>`. Non-URL
95/// text is left untouched, and URLs without a userinfo component are
96/// unchanged. Handles `http`, `https`, and any other `<scheme>://` form.
97///
98/// Use this as a defense-in-depth complement to [`string`] when the secret
99/// is inlined in a URL but the bare token value is not necessarily exported
100/// as an env var (e.g. a `git_url` config string the user templated with a
101/// literal `https://user:pass@host`).
102pub fn redact_url_credentials(input: &str) -> String {
103    // Walk the string and rewrite each `<scheme>://<userinfo>@` segment.
104    // For each `://` we find, look up to the next path / query / fragment /
105    // whitespace boundary; if that authority segment contains an `@`, the
106    // text before the LAST `@` is the userinfo (RFC 3986 §3.2.1 allows
107    // unreserved `@` in the password subcomponent only when percent-encoded,
108    // but real-world tokens contain literal `@` often enough that we treat
109    // the last `@` as the host separator).
110    let mut result = String::with_capacity(input.len());
111    let mut rest = input;
112    while let Some(scheme_end) = rest.find("://") {
113        let after_scheme_start = scheme_end + 3;
114        result.push_str(&rest[..after_scheme_start]);
115        let after_scheme = &rest[after_scheme_start..];
116        let terminator = after_scheme
117            .find(|c: char| matches!(c, '/' | '?' | '#') || c.is_whitespace())
118            .unwrap_or(after_scheme.len());
119        let authority = &after_scheme[..terminator];
120        if let Some(last_at) = authority.rfind('@') {
121            // userinfo = authority[..last_at], host-start = last_at + 1
122            result.push_str("<redacted>@");
123            result.push_str(&authority[last_at + 1..]);
124            rest = &after_scheme[terminator..];
125        } else {
126            result.push_str(authority);
127            rest = &after_scheme[terminator..];
128        }
129    }
130    result.push_str(rest);
131    result
132}
133
134/// Strip bearer / authorization tokens that may have been echoed by a
135/// remote endpoint into a response body before that body lands in an
136/// error message. Defense in depth — if a misbehaving registry mirrors
137/// the request's `Authorization` header back in an error response, this
138/// helper prevents the token from showing up in user-visible logs.
139///
140/// Replaces:
141///   - `Bearer <token>` → `Bearer <redacted>` (case-insensitive on the
142///     keyword; the canonical replacement spelling is always "Bearer").
143///     A "Bearer" match requires the keyword to appear at the start of
144///     the input OR immediately after one of `[ \t:,;("'<\n\r]` so that
145///     prose words like "bearer of bad news" do not match.
146///   - `Basic <b64>` → `Basic <redacted>` (case-insensitive on the
147///     keyword; same boundary rule as `Bearer`). Covers HTTP Basic
148///     auth headers like the GemFury push token (`Authorization:
149///     Basic <token-as-username:>` base64).
150///   - `Authorization:` followed by any value through end-of-line →
151///     `Authorization: <redacted>` (case-insensitive on the header name).
152///     The entire header value is consumed so `Authorization: Bearer X`
153///     doesn't leak `X` after the header redaction.
154///
155/// Use as a wrapper around any remote-supplied body text being interpolated
156/// into an error message or log line. The bare token (no scheme prefix)
157/// remains untouched — for that, rely on `string(..., env)` matching the
158/// env-var-based heuristics.
159pub fn redact_bearer_tokens(input: &str) -> String {
160    let bytes = input.as_bytes();
161    let mut out = String::with_capacity(input.len());
162    let mut i = 0;
163    while i < bytes.len() {
164        // Authorization: <rest-of-line>
165        // Always allowed to match at i (the header name itself is unambiguous
166        // when followed by a `:`). Consume through the next \n / \r so a
167        // multi-line body with subsequent normal text isn't redacted past
168        // the header's terminator.
169        if let Some(name_len) = match_authorization_prefix(&bytes[i..]) {
170            out.push_str("Authorization: <redacted>");
171            i += name_len;
172            while i < bytes.len() && bytes[i] != b'\n' && bytes[i] != b'\r' {
173                i += 1;
174            }
175            continue;
176        }
177        // Bearer <token>
178        // Require the preceding byte (if any) to be a token-boundary
179        // character so prose like "the bearer of bad news" doesn't match.
180        let preceded_by_boundary = i == 0
181            || matches!(
182                bytes[i - 1],
183                b' ' | b'\t' | b':' | b',' | b';' | b'(' | b'"' | b'\'' | b'<' | b'\n' | b'\r'
184            );
185        if preceded_by_boundary && let Some(kw_len) = match_bearer_prefix(&bytes[i..]) {
186            out.push_str("Bearer <redacted>");
187            i += kw_len;
188            // Skip the token value: a run of non-whitespace characters.
189            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
190                i += 1;
191            }
192            continue;
193        }
194        if preceded_by_boundary && let Some(kw_len) = match_basic_prefix(&bytes[i..]) {
195            out.push_str("Basic <redacted>");
196            i += kw_len;
197            while i < bytes.len() && !bytes[i].is_ascii_whitespace() {
198                i += 1;
199            }
200            continue;
201        }
202        // Emit one byte verbatim and advance.
203        out.push(bytes[i] as char);
204        i += 1;
205    }
206    out
207}
208
209/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
210/// "Bearer " (the trailing space is required so we don't match "Bearertown").
211fn match_bearer_prefix(bytes: &[u8]) -> Option<usize> {
212    const KW: &[u8] = b"Bearer ";
213    if bytes.len() < KW.len() {
214        return None;
215    }
216    for (i, kw_byte) in KW.iter().enumerate() {
217        if !bytes[i].eq_ignore_ascii_case(kw_byte) {
218            return None;
219        }
220    }
221    Some(KW.len())
222}
223
224/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
225/// "Basic " (the trailing space is required so we don't match "Basics" or
226/// "Basically"). Covers HTTP Basic auth headers used by GemFury and other
227/// publishers that pass the token as the Basic-auth username.
228fn match_basic_prefix(bytes: &[u8]) -> Option<usize> {
229    const KW: &[u8] = b"Basic ";
230    if bytes.len() < KW.len() {
231        return None;
232    }
233    for (i, kw_byte) in KW.iter().enumerate() {
234        if !bytes[i].eq_ignore_ascii_case(kw_byte) {
235            return None;
236        }
237    }
238    Some(KW.len())
239}
240
241/// Returns Some(prefix_len) if `bytes` starts with case-insensitive
242/// "Authorization:" (the trailing colon is required to disambiguate from
243/// prose mentioning the word "authorization").
244fn match_authorization_prefix(bytes: &[u8]) -> Option<usize> {
245    const KW: &[u8] = b"Authorization:";
246    if bytes.len() < KW.len() {
247        return None;
248    }
249    for (i, kw_byte) in KW.iter().enumerate() {
250        if !bytes[i].eq_ignore_ascii_case(kw_byte) {
251            return None;
252        }
253    }
254    Some(KW.len())
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260
261    #[test]
262    fn test_redact_by_key_suffix() {
263        let env = vec![
264            (
265                "DOCKER_PASSWORD".to_string(),
266                "mysecretpassword123".to_string(),
267            ),
268            ("PLAIN_VAR".to_string(), "not-a-secret".to_string()),
269        ];
270        let result = string("Login with mysecretpassword123 succeeded", &env);
271        assert_eq!(result, "Login with $DOCKER_PASSWORD succeeded");
272        assert!(!result.contains("mysecretpassword123"));
273    }
274
275    #[test]
276    fn test_redact_by_value_prefix() {
277        let env = vec![("MY_TOKEN".to_string(), "ghp_abc123def456ghi789".to_string())];
278        let result = string("Using token ghp_abc123def456ghi789", &env);
279        assert_eq!(result, "Using token $MY_TOKEN");
280    }
281
282    #[test]
283    fn test_redact_includes_short_secret_when_key_looks_secret() {
284        // Reflects the secret-key rename after
285        // the length-floor was removed: a 5-char value under a `*_KEY` key
286        // must still be redacted.
287        let env = vec![("API_KEY".to_string(), "short".to_string())];
288        let result = string("Value is short", &env);
289        assert_eq!(result, "Value is $API_KEY");
290    }
291
292    #[test]
293    fn test_redact_skips_empty_value() {
294        // The empty string is the only excluded value: an unset env var
295        // would otherwise replace every empty substring in the input,
296        // turning "abc" into "$API_KEY a$API_KEY b$API_KEY c$API_KEY".
297        let env = vec![("API_KEY".to_string(), String::new())];
298        let result = string("Value is short", &env);
299        assert_eq!(result, "Value is short");
300    }
301
302    #[test]
303    fn test_redact_longer_values_first() {
304        let env = vec![
305            ("SHORT_TOKEN".to_string(), "abcdefghij".to_string()),
306            ("LONG_TOKEN".to_string(), "abcdefghijklmnop".to_string()),
307        ];
308        let result = string("secret: abcdefghijklmnop", &env);
309        // Longer match should be replaced first
310        assert_eq!(result, "secret: $LONG_TOKEN");
311    }
312
313    #[test]
314    fn test_redact_no_secrets() {
315        let env = vec![("PATH".to_string(), "/usr/bin:/usr/local/bin".to_string())];
316        let result = string("PATH is set", &env);
317        assert_eq!(result, "PATH is set");
318    }
319
320    #[test]
321    fn test_redact_multiple_occurrences() {
322        let env = vec![(
323            "REGISTRY_PASSWORD".to_string(),
324            "supersecret123".to_string(),
325        )];
326        let result = string("auth supersecret123 retry supersecret123", &env);
327        assert_eq!(result, "auth $REGISTRY_PASSWORD retry $REGISTRY_PASSWORD");
328    }
329
330    #[test]
331    fn test_is_secret_key_suffixes() {
332        assert!(is_secret("DOCKER_PASSWORD", "longvalue1234"));
333        assert!(is_secret("API_TOKEN", "longvalue1234"));
334        assert!(is_secret("signing_key", "longvalue1234")); // case insensitive
335        assert!(is_secret("MY_SECRET", "longvalue1234"));
336        assert!(!is_secret("MY_CONFIG", "longvalue1234"));
337    }
338
339    #[test]
340    fn test_is_secret_value_prefixes() {
341        assert!(is_secret("ANYTHING", "ghp_1234567890"));
342        assert!(is_secret("ANYTHING", "sk-1234567890"));
343        assert!(is_secret("ANYTHING", "dckr_pat_1234567890"));
344        assert!(is_secret("ANYTHING", "glpat-1234567890"));
345        // Fine-grained GitHub PAT and Google API/OAuth keys, exported under a
346        // name the suffix list does not catch, are matched by exact-case value
347        // prefix. `AIza`/`ya29.` casing is load-bearing: the match is
348        // case-sensitive, so an `AIZA` prefix would catch zero real keys.
349        assert!(is_secret("GH_PAT", "github_pat_11ABCDE0000000000"));
350        assert!(is_secret(
351            "GOOGLE_CREDS",
352            "AIzaSyA00000000000000000000000000000000"
353        ));
354        assert!(is_secret(
355            "GOOGLE_CREDS",
356            "ya29.a0Af00000000000000000000000"
357        ));
358        assert!(!is_secret("ANYTHING", "regular_value1234"));
359    }
360
361    #[test]
362    fn test_redact_sort_stability_same_length() {
363        // When two secrets have the same value length, sort by key name
364        // for deterministic output regardless of HashMap iteration order.
365        let env = vec![
366            ("B_SECRET".to_string(), "same_length_val".to_string()),
367            ("A_SECRET".to_string(), "same_length_val".to_string()),
368        ];
369        // Both keys map to the same value, so whichever sorts first by
370        // key name should win — A_SECRET comes before B_SECRET.
371        let result = string("found same_length_val here", &env);
372        assert_eq!(result, "found $A_SECRET here");
373    }
374
375    #[test]
376    fn test_redact_deterministic_with_different_lengths() {
377        // Longer values still replaced first, secondary sort by key is tiebreaker
378        let env = vec![
379            ("Z_TOKEN".to_string(), "short_secret_val".to_string()),
380            (
381                "A_TOKEN".to_string(),
382                "a_longer_secret_value_here".to_string(),
383            ),
384        ];
385        let result = string("prefix a_longer_secret_value_here suffix", &env);
386        assert_eq!(result, "prefix $A_TOKEN suffix");
387    }
388
389    #[test]
390    fn test_with_env_composes_url_strip_and_env_mask() {
391        // The canonical outbound policy must apply BOTH layers in one call:
392        // inline URL-credential stripping AND known-secret env masking. A body
393        // carrying both must come out clean on both axes — proving composition,
394        // not either layer alone.
395        let env = vec![(
396            "CARGO_REGISTRY_TOKEN".to_string(),
397            "ghp_realsecretvalue".to_string(),
398        )];
399        let input = "pushed via https://tok@host/x then logged ghp_realsecretvalue";
400        let result = with_env(input, &env);
401        assert_eq!(
402            result,
403            "pushed via https://<redacted>@host/x then logged $CARGO_REGISTRY_TOKEN"
404        );
405        assert!(!result.contains("ghp_realsecretvalue"));
406        assert!(!result.contains("tok@host"));
407    }
408
409    #[test]
410    fn test_redact_url_credentials_https_with_token() {
411        let input = "remote: https://ghp_abc123def@github.com/owner/repo.git";
412        let result = redact_url_credentials(input);
413        assert_eq!(
414            result,
415            "remote: https://<redacted>@github.com/owner/repo.git"
416        );
417        assert!(!result.contains("ghp_abc123def"));
418    }
419
420    #[test]
421    fn test_redact_url_credentials_user_pass_pair() {
422        let input = "pushing to https://user:p@ssw0rd@gitlab.example.com/foo/bar";
423        let result = redact_url_credentials(input);
424        assert_eq!(
425            result, "pushing to https://<redacted>@gitlab.example.com/foo/bar",
426            "userinfo must cover the entire user:pass segment up to the host-@"
427        );
428    }
429
430    #[test]
431    fn test_redact_url_credentials_no_userinfo_unchanged() {
432        let input = "fetching https://github.com/owner/repo.git";
433        assert_eq!(redact_url_credentials(input), input);
434    }
435
436    #[test]
437    fn test_redact_url_credentials_ssh_unchanged() {
438        // SSH-style `git@github.com:owner/repo.git` has no `://`, so the
439        // helper leaves it alone. The `git@` is part of the SSH user, not
440        // an embedded credential.
441        let input = "fetching git@github.com:owner/repo.git";
442        assert_eq!(redact_url_credentials(input), input);
443    }
444
445    #[test]
446    fn test_redact_url_credentials_multiple_urls_in_one_line() {
447        let input = "from https://token1@a.com/x to https://token2@b.com/y";
448        let result = redact_url_credentials(input);
449        assert_eq!(
450            result, "from https://<redacted>@a.com/x to https://<redacted>@b.com/y",
451            "both URLs must be redacted, leaving the connecting prose intact"
452        );
453    }
454
455    #[test]
456    fn test_redact_url_credentials_does_not_consume_path_at_sign() {
457        // `@` in a path segment (after the first `/`) must NOT be treated
458        // as a userinfo terminator.
459        let input = "GET https://api.example.com/users/foo@bar.com/profile";
460        assert_eq!(
461            redact_url_credentials(input),
462            input,
463            "an `@` after the first `/` is part of the path, not userinfo"
464        );
465    }
466
467    #[test]
468    fn test_redact_url_credentials_empty_input() {
469        assert_eq!(redact_url_credentials(""), "");
470    }
471
472    #[test]
473    fn test_redact_url_credentials_plain_text() {
474        let input = "no URLs here, just words";
475        assert_eq!(redact_url_credentials(input), input);
476    }
477
478    #[test]
479    fn test_redact_url_credentials_percent_encoded_userinfo() {
480        // A percent-encoded `@` in the userinfo (e.g. an account name like
481        // `user@name`) does not break the terminator scan: the function
482        // looks for the LAST `@` before the path / query / fragment /
483        // whitespace boundary, so both `@`s collapse into a single
484        // `<redacted>` replacement.
485        let input = "https://user%40name:pass@host.example.com/path";
486        let result = redact_url_credentials(input);
487        assert_eq!(result, "https://<redacted>@host.example.com/path");
488        assert!(!result.contains("user%40name"));
489        assert!(!result.contains("pass"));
490    }
491
492    #[test]
493    fn test_redact_url_credentials_trailing_query() {
494        // A `?` after the host begins the query string; userinfo must still
495        // be stripped, and the query is preserved verbatim.
496        let input = "https://user:pass@host.example.com?foo=bar";
497        let result = redact_url_credentials(input);
498        assert_eq!(result, "https://<redacted>@host.example.com?foo=bar");
499        assert!(!result.contains("user:pass"));
500        assert!(result.ends_with("?foo=bar"));
501    }
502
503    #[test]
504    fn test_redact_url_credentials_trailing_fragment() {
505        // A `#` after the host begins the fragment; userinfo must still
506        // be stripped, and the fragment is preserved verbatim.
507        let input = "https://user:pass@host.example.com#frag";
508        let result = redact_url_credentials(input);
509        assert_eq!(result, "https://<redacted>@host.example.com#frag");
510        assert!(!result.contains("user:pass"));
511        assert!(result.ends_with("#frag"));
512    }
513
514    #[test]
515    fn test_redact_url_credentials_whitespace_boundary() {
516        // Whitespace following the host terminates the authority. The
517        // userinfo is redacted and the trailing prose is preserved.
518        let input = "https://user:pass@host.example.com then more";
519        let result = redact_url_credentials(input);
520        assert_eq!(result, "https://<redacted>@host.example.com then more");
521        assert!(!result.contains("user:pass"));
522        assert!(result.ends_with(" then more"));
523    }
524
525    #[test]
526    fn test_redact_bearer_tokens_basic() {
527        let input = "auth header: Bearer ghp_abcdef123456 expires soon";
528        let result = redact_bearer_tokens(input);
529        assert_eq!(result, "auth header: Bearer <redacted> expires soon");
530        assert!(!result.contains("ghp_abcdef123456"));
531    }
532
533    #[test]
534    fn test_redact_bearer_tokens_case_insensitive() {
535        // The keyword "Bearer" is case-insensitive but the canonical
536        // output form is always "Bearer".
537        let input = "bearer ghp_lowercase_token";
538        assert_eq!(
539            redact_bearer_tokens(input),
540            "Bearer <redacted>",
541            "lowercase 'bearer' must still redact"
542        );
543        let input = "BEARER ghp_uppercase_token";
544        assert_eq!(redact_bearer_tokens(input), "Bearer <redacted>");
545    }
546
547    #[test]
548    fn test_redact_bearer_tokens_authorization_header() {
549        // "Authorization:" consumes through end-of-line, so the entire
550        // header value is redacted as one unit. Trailing content after
551        // a newline is preserved verbatim.
552        let input = "request: Authorization: Bearer ghp_xyz\nresponse: 401";
553        let result = redact_bearer_tokens(input);
554        assert_eq!(
555            result, "request: Authorization: <redacted>\nresponse: 401",
556            "header value (including the inner Bearer token) must be redacted as one"
557        );
558        assert!(!result.contains("ghp_xyz"));
559    }
560
561    #[test]
562    fn test_redact_bearer_tokens_authorization_header_single_line() {
563        // No newline → the header value runs to end-of-input; that's fine,
564        // the entire tail is redacted (defensive: better one over-redaction
565        // than one leaked token).
566        let input = "Authorization: Bearer ghp_xyz";
567        let result = redact_bearer_tokens(input);
568        assert_eq!(result, "Authorization: <redacted>");
569        assert!(!result.contains("ghp_xyz"));
570    }
571
572    #[test]
573    fn test_redact_bearer_tokens_no_match_unchanged() {
574        // No "Bearer " / "Authorization:" tokens → string unchanged.
575        // Note: we cannot distinguish prose use of "bearer" from a real
576        // header; the redactor errs on the side of over-redaction (it
577        // would treat "bearer of bad news" as "Bearer <redacted> bad
578        // news"). Both branches are still safer than leaking a token.
579        let input = "some random text with no relevant tokens here";
580        assert_eq!(redact_bearer_tokens(input), input);
581    }
582
583    #[test]
584    fn test_redact_bearer_tokens_over_redacts_prose_use() {
585        // Documents the known over-redaction behavior: "bearer of bad
586        // news" looks like a Bearer-token construct because the redactor
587        // can't tell prose from a header. The trade-off is intentional —
588        // safer to over-redact a prose word than to leak a real token.
589        let input = "the bearer of bad news arrived";
590        let result = redact_bearer_tokens(input);
591        assert_eq!(result, "the Bearer <redacted> bad news arrived");
592    }
593
594    #[test]
595    fn test_redact_bearer_tokens_empty_input() {
596        assert_eq!(redact_bearer_tokens(""), "");
597    }
598
599    #[test]
600    fn test_redact_basic_token_redacts_b64_payload() {
601        let input = "auth: Basic ZnVyeXRva2VuOg== rest";
602        let result = redact_bearer_tokens(input);
603        assert_eq!(result, "auth: Basic <redacted> rest");
604        assert!(!result.contains("ZnVyeXRva2VuOg=="));
605    }
606
607    #[test]
608    fn test_redact_basic_token_case_insensitive() {
609        let input = "auth: basic ZnVyeXRva2VuOg==";
610        assert_eq!(redact_bearer_tokens(input), "auth: Basic <redacted>");
611    }
612
613    #[test]
614    fn test_redact_bearer_tokens_handles_multiple_occurrences() {
615        let input = "first Bearer ghp_aaa and second Bearer ghp_bbb done";
616        let result = redact_bearer_tokens(input);
617        assert_eq!(
618            result,
619            "first Bearer <redacted> and second Bearer <redacted> done"
620        );
621        assert!(!result.contains("ghp_aaa"));
622        assert!(!result.contains("ghp_bbb"));
623    }
624}