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