@@ -247,20 +247,29 @@ pub fn redact_secrets(text: &str) -> String {
247247 // has the scheme prefix consumed first; otherwise section 2b would
248248 // eat just the `bearer` word and leave the token behind.
249249 // -------------------------------------------------------------------------
250- for scheme in & [ "bearer " , "basic " ] {
251- // R1-A: scan ALL occurrences, not just the first - a blob with a second
252- // `Authorization: Bearer <token>` previously leaked the later tokens.
250+ for scheme in & [ "bearer" , "basic" ] {
251+ // R1-A + PURPLE follow-up: match the scheme WORD then ANY whitespace
252+ // (space/tab/newline), at EVERY occurrence. The prior `"bearer "`
253+ // literal-space match missed `Bearer\t...` / `Bearer\n...` and is a
254+ // no-code-exec leak into logs/audit (Codex PURPLE NO-SHIP).
253255 let mut from = 0usize ;
254256 loop {
255257 let lower_search = redacted. to_lowercase ( ) ;
256- let Some ( rel) = lower_search[ from..] . find ( scheme) else {
258+ let Some ( rel) = lower_search. get ( from..) . and_then ( |s| s . find ( scheme) ) else {
257259 break ;
258260 } ;
259- let scheme_start = from + rel;
260- let token_start = scheme_start + scheme. len ( ) ;
261- if redacted. len ( ) <= token_start + 6 {
262- break ;
261+ let after_kw = from + rel + scheme. len ( ) ;
262+ // Require >=1 whitespace after the scheme word so `bearertoken`
263+ // is not treated as a scheme prefix.
264+ let mut cursor = after_kw;
265+ while cursor < redacted. len ( ) && redacted. as_bytes ( ) [ cursor] . is_ascii_whitespace ( ) {
266+ cursor += 1 ;
267+ }
268+ if cursor == after_kw {
269+ from = after_kw;
270+ continue ;
263271 }
272+ let token_start = cursor;
264273 let token_end = redacted[ token_start..]
265274 . find ( |c : char | c. is_whitespace ( ) || c == '"' || c == '\'' )
266275 . map_or ( redacted. len ( ) , |i| token_start + i) ;
@@ -270,7 +279,7 @@ pub fn redact_secrets(text: &str) -> String {
270279 redacted. replace_range ( token_start..token_end, "***REDACTED***" ) ;
271280 from = token_start + "***REDACTED***" . len ( ) ;
272281 } else {
273- from = token_end ;
282+ from = after_kw ;
274283 }
275284 }
276285 }
@@ -592,6 +601,36 @@ mod tests {
592601 assert ! ( !redacted. contains( "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9" ) ) ;
593602 }
594603
604+ // Codex PURPLE NO-SHIP regression: scheme followed by tab/newline (not just
605+ // a literal space) must still redact the token, at EVERY occurrence.
606+ #[ test]
607+ fn test_redact_secrets_bearer_tab_and_newline_after_scheme ( ) {
608+ let r = redact_secrets ( "Authorization:\n Bearer\t SECRETVALUE123456" ) ;
609+ assert ! (
610+ !r. contains( "SECRETVALUE123456" ) ,
611+ "tab-after-Bearer must redact: {r}"
612+ ) ;
613+ let r2 = redact_secrets ( "Bearer\n ANOTHERSECRET987654" ) ;
614+ assert ! (
615+ !r2. contains( "ANOTHERSECRET987654" ) ,
616+ "newline-after-Bearer must redact: {r2}"
617+ ) ;
618+ let r3 = redact_secrets ( "Bearer firsttoken111111 and Bearer secondtoken222222" ) ;
619+ assert ! (
620+ !r3. contains( "firsttoken111111" ) ,
621+ "first token must redact: {r3}"
622+ ) ;
623+ assert ! (
624+ !r3. contains( "secondtoken222222" ) ,
625+ "second token must redact: {r3}"
626+ ) ;
627+ let r4 = redact_secrets ( "Basic\t dXNlcjpwYXNzd29yZA==" ) ;
628+ assert ! (
629+ !r4. contains( "dXNlcjpwYXNzd29yZA==" ) ,
630+ "tab-after-Basic must redact: {r4}"
631+ ) ;
632+ }
633+
595634 // =========================================================================
596635 // No secrets — passthrough
597636 // =========================================================================
0 commit comments