Skip to main content

agentmux_common/
redact.rs

1// Copyright 2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4//! Shape-based, best-effort secret redaction — the one Rust implementation
5//! `SPEC_ERROR_COPY_EVERYWHERE_2026_09_24.md` §4.5 asks every formatter that
6//! puts error/log text on the clipboard to share, instead of each inventing
7//! its own. Moved here from `crates/srv/src/backend/continuity.rs`, which
8//! only recognized credential-prefixed tokens and PEM private keys — enough
9//! for a continuation packet, not enough for raw stderr and request details
10//! (Codex P1 on #3689).
11//!
12//! Deliberately manual string scanning, not the `regex` crate: nothing in
13//! this workspace depends on `regex` today, and `continuity.rs`'s own
14//! pre-existing redaction (kept here unchanged) already established that as
15//! the house style for this kind of text scan.
16//!
17//! Mirrored on the TypeScript side by `frontend/app/errors/redact.ts`, and
18//! both are tested against the same `docs/specs/fixtures/redaction-vectors.json`
19//! so the two can't drift apart (§4.5).
20
21const SECRET_PREFIXES: &[&str] = &[
22    "github_pat_", "ghp_", "gho_", "ghu_", "ghs_", "ghr_", "sk-", "xoxa-", "xoxb-", "xoxo-", "xoxp-", "xoxr-", "xoxs-",
23    "AKIA",
24];
25
26const REDACTED_HEADERS: &[&str] = &["authorization", "proxy-authorization", "x-api-key", "api-key", "cookie", "set-cookie"];
27
28const SENSITIVE_KEY_SUBSTRINGS: &[&str] = &[
29    "password", "passwd", "pwd", "secret", "token", "api_key", "apikey", "access_key", "private_key", "client_secret",
30    "credential",
31];
32
33/// The full redaction pipeline. Order matters: the original credential-prefix
34/// and private-key passes run first (unchanged, so their existing callers'
35/// exact output is preserved byte-for-byte on inputs that only they match),
36/// then the newer, broader shape-based passes.
37pub fn redact_secrets(text: &str) -> String {
38    let out = redact_credential_prefixes(text);
39    let out = redact_private_keys(&out);
40    let out = redact_headers(&out);
41    let out = redact_key_value_pairs(&out);
42    let out = redact_url_userinfo(&out);
43    let out = redact_jwts(&out);
44    redact_aws_secret_key(&out)
45}
46
47/// Blanks credential-prefixed tokens (`ghp_...`, `sk-...`, AWS access-key
48/// ids, …). Deliberately shape-based: it errs toward redacting a long token
49/// that merely looks like a secret. Was `continuity.rs::redact_secrets`.
50fn redact_credential_prefixes(text: &str) -> String {
51    let mut out = String::with_capacity(text.len());
52    let mut rest = text;
53    loop {
54        let hit = SECRET_PREFIXES
55            .iter()
56            .filter_map(|p| rest.find(p).map(|i| (i, *p)))
57            .min_by_key(|(i, p)| (*i, std::cmp::Reverse(p.len())));
58        let Some((at, prefix)) = hit else {
59            out.push_str(rest);
60            break;
61        };
62        let after = &rest[at + prefix.len()..];
63        let body = after
64            .find(|c: char| !(c.is_ascii_alphanumeric() || c == '_' || c == '-'))
65            .unwrap_or(after.len());
66        out.push_str(&rest[..at]);
67        if body >= 16 {
68            out.push_str("[redacted secret]");
69        } else {
70            out.push_str(&rest[at..at + prefix.len() + body]);
71        }
72        rest = &after[body..];
73    }
74    out
75}
76
77/// Blanks PEM private keys (`-----BEGIN ... PRIVATE KEY-----` blocks), never
78/// a certificate. Public — `continuity.rs`'s own tests exercise this pass in
79/// isolation (ReAgent P0 on #3643: a certificate ahead of a key must not end
80/// the scan and let the key through).
81pub fn redact_private_keys(text: &str) -> String {
82    let mut out = text.to_string();
83    let mut from = 0;
84    while let Some(found) = out[from..].find("-----BEGIN") {
85        let start = from + found;
86        let header_end = out[start..].find('\n').map_or(out.len(), |i| start + i);
87        if !out[start..header_end].contains("PRIVATE KEY") {
88            from = header_end;
89            continue;
90        }
91        let end = out[start..]
92            .find("-----END")
93            .and_then(|i| out[start + i..].find("KEY-----").map(|j| start + i + j + "KEY-----".len()))
94            .unwrap_or(out.len());
95        out.replace_range(start..end, "[redacted private key]");
96        from = start + "[redacted private key]".len();
97    }
98    out
99}
100
101fn ends_with_newline_preserved(original: &str, out: String) -> String {
102    if original.ends_with('\n') && !out.ends_with('\n') {
103        let mut out = out;
104        out.push('\n');
105        out
106    } else {
107        out
108    }
109}
110
111/// Blanks `Authorization:` / `Proxy-Authorization:` / `x-api-key:` /
112/// `api-key:` / `cookie:` / `set-cookie:` header *values* (any scheme —
113/// `Bearer`, `Basic`, `Token`, a raw key, a cookie jar), line by line.
114fn redact_headers(text: &str) -> String {
115    let redacted = text
116        .lines()
117        .map(|line| {
118            let trimmed_start = line.trim_start();
119            let indent_len = line.len() - trimmed_start.len();
120            if let Some(colon) = trimmed_start.find(':') {
121                let name = trimmed_start[..colon].trim();
122                if REDACTED_HEADERS.iter().any(|h| h.eq_ignore_ascii_case(name)) {
123                    return format!("{}{}: [redacted]", &line[..indent_len], name);
124                }
125            }
126            line.to_string()
127        })
128        .collect::<Vec<_>>()
129        .join("\n");
130    ends_with_newline_preserved(text, redacted)
131}
132
133fn is_key_char(c: char) -> bool {
134    c.is_ascii_alphanumeric() || c == '_' || c == '-'
135}
136
137fn is_sensitive_key(key: &str) -> bool {
138    let lower = key.trim_matches(|c| c == '"' || c == '\'').to_ascii_lowercase();
139    SENSITIVE_KEY_SUBSTRINGS.iter().any(|s| lower.contains(s))
140}
141
142/// Blanks `key=value`, `key: value` and JSON `"key": "value"` pairs whose
143/// key *contains* a sensitive substring, case-insensitive — deliberately
144/// "contains", not "equals" (§4.5's literal wording), matching this module's
145/// existing err-toward-redacting philosophy. A key like `tokenizer` is
146/// redacted by this rule even though the word alone is harmless prose
147/// (`ordinary_words_that_share_a_prefix_survive_redaction` below covers the
148/// prose case, which never has the `key=`/`key:` shape this rule requires).
149fn redact_key_value_pairs(text: &str) -> String {
150    let chars: Vec<(usize, char)> = text.char_indices().collect();
151    let mut out = String::with_capacity(text.len());
152    let mut last_end = 0usize;
153    let mut idx = 0usize;
154    while idx < chars.len() {
155        let (_, ch) = chars[idx];
156        if ch != '=' && ch != ':' {
157            idx += 1;
158            continue;
159        }
160        // A JSON key's closing quote sits right before ':' — skip past it so
161        // the key-char scan below reaches the actual key text, not nothing.
162        let mut key_end_idx = idx;
163        if key_end_idx > 0 && matches!(chars[key_end_idx - 1].1, '"' | '\'') {
164            key_end_idx -= 1;
165        }
166        let mut key_start_idx = key_end_idx;
167        while key_start_idx > 0 && is_key_char(chars[key_start_idx - 1].1) {
168            key_start_idx -= 1;
169        }
170        if key_start_idx == key_end_idx {
171            idx += 1;
172            continue;
173        }
174        let key_start_byte = chars[key_start_idx].0;
175        let key_end_byte = chars[key_end_idx].0;
176        let key = &text[key_start_byte..key_end_byte];
177        if !is_sensitive_key(key) {
178            idx += 1;
179            continue;
180        }
181        // Forward: separator, optional whitespace, optional quote, the value.
182        let mut v = idx + 1;
183        while v < chars.len() && (chars[v].1 == ' ' || chars[v].1 == '\t') {
184            v += 1;
185        }
186        let val_quote = if v < chars.len() && (chars[v].1 == '"' || chars[v].1 == '\'') { Some(chars[v].1) } else { None };
187        let val_start = if val_quote.is_some() { v + 1 } else { v };
188        let mut w = val_start;
189        if let Some(vq) = val_quote {
190            while w < chars.len() && chars[w].1 != vq {
191                w += 1;
192            }
193        } else {
194            while w < chars.len() && !matches!(chars[w].1, ' ' | '\t' | '\n' | '\r' | ',' | '}' | ')' | ';') {
195                w += 1;
196            }
197        }
198        let value_prefix_end = if chars.get(val_start).is_some() || val_start == chars.len() {
199            if val_start < chars.len() { chars[val_start].0 } else { text.len() }
200        } else {
201            text.len()
202        };
203        out.push_str(&text[last_end..value_prefix_end]);
204        out.push_str("[redacted]");
205        let skip_to = if val_quote.is_some() { w + 1 } else { w };
206        last_end = if skip_to < chars.len() { chars[skip_to].0 } else { text.len() };
207        if val_quote.is_some() && w < chars.len() {
208            out.push(chars[w].1);
209        }
210        idx = skip_to;
211    }
212    out.push_str(&text[last_end..]);
213    out
214}
215
216/// Blanks URL userinfo passwords: `scheme://user:password@host` keeps
217/// `scheme://user:[redacted]@host`.
218fn redact_url_userinfo(text: &str) -> String {
219    let mut out = String::with_capacity(text.len());
220    let mut last_end = 0usize;
221    let mut search_from = 0usize;
222    while let Some(rel) = text[search_from..].find("://") {
223        let scheme_end = search_from + rel + 3;
224        let authority_end = text[scheme_end..]
225            .find(|c: char| c == '/' || c == '?' || c == '#' || c.is_whitespace())
226            .map(|i| scheme_end + i)
227            .unwrap_or(text.len());
228        let authority = &text[scheme_end..authority_end];
229        if let Some(at_rel) = authority.find('@') {
230            let at_pos = scheme_end + at_rel;
231            let userinfo = &authority[..at_rel];
232            if let Some(colon_rel) = userinfo.find(':') {
233                let colon_pos = scheme_end + colon_rel;
234                out.push_str(&text[last_end..=colon_pos]);
235                out.push_str("[redacted]");
236                last_end = at_pos;
237            }
238        }
239        search_from = authority_end.max(scheme_end + 1);
240        if search_from > text.len() {
241            break;
242        }
243    }
244    out.push_str(&text[last_end..]);
245    out
246}
247
248fn is_base64url_char(c: char) -> bool {
249    c.is_ascii_alphanumeric() || c == '-' || c == '_'
250}
251
252/// Blanks JWTs: three base64url segments, the first starting `eyJ` (a
253/// base64url-encoded `{"` — every JWT header starts with a JSON object).
254fn redact_jwts(text: &str) -> String {
255    let mut out = String::with_capacity(text.len());
256    let mut last_end = 0usize;
257    let mut search_from = 0usize;
258    while let Some(rel) = text[search_from..].find("eyJ") {
259        let start = search_from + rel;
260        let prev_is_word = start > 0 && is_base64url_char(text[..start].chars().next_back().unwrap());
261        if prev_is_word {
262            search_from = start + 3;
263            continue;
264        }
265        let seg1_end = start + text[start..].find(|c: char| !is_base64url_char(c)).unwrap_or(text.len() - start);
266        let dot1_ok = seg1_end < text.len() && text.as_bytes()[seg1_end] == b'.';
267        if !dot1_ok {
268            search_from = start + 3;
269            continue;
270        }
271        let seg2_start = seg1_end + 1;
272        let seg2_end = seg2_start + text[seg2_start..].find(|c: char| !is_base64url_char(c)).unwrap_or(text.len() - seg2_start);
273        let dot2_ok = seg2_end > seg2_start && seg2_end < text.len() && text.as_bytes()[seg2_end] == b'.';
274        if !dot2_ok {
275            search_from = start + 3;
276            continue;
277        }
278        let seg3_start = seg2_end + 1;
279        let seg3_end = seg3_start + text[seg3_start..].find(|c: char| !is_base64url_char(c)).unwrap_or(text.len() - seg3_start);
280        if seg3_end == seg3_start {
281            search_from = start + 3;
282            continue;
283        }
284        out.push_str(&text[last_end..start]);
285        out.push_str("[redacted]");
286        last_end = seg3_end;
287        search_from = seg3_end;
288    }
289    out.push_str(&text[last_end..]);
290    out
291}
292
293fn is_base64_char(c: char) -> bool {
294    c.is_ascii_alphanumeric() || c == '+' || c == '/' || c == '='
295}
296
297/// Blanks a 40-character base64 value on the same line as
298/// `aws_secret_access_key` (any case).
299fn redact_aws_secret_key(text: &str) -> String {
300    let redacted = text
301        .lines()
302        .map(|line| {
303            if !line.to_ascii_lowercase().contains("aws_secret_access_key") {
304                return line.to_string();
305            }
306            let chars: Vec<(usize, char)> = line.char_indices().collect();
307            let mut i = 0usize;
308            while i < chars.len() {
309                if !is_base64_char(chars[i].1) {
310                    i += 1;
311                    continue;
312                }
313                let run_start = i;
314                let mut j = i;
315                while j < chars.len() && is_base64_char(chars[j].1) {
316                    j += 1;
317                }
318                if j - run_start == 40 {
319                    let start_byte = chars[run_start].0;
320                    let end_byte = if j < chars.len() { chars[j].0 } else { line.len() };
321                    return format!("{}[redacted]{}", &line[..start_byte], &line[end_byte..]);
322                }
323                i = j;
324            }
325            line.to_string()
326        })
327        .collect::<Vec<_>>()
328        .join("\n");
329    ends_with_newline_preserved(text, redacted)
330}
331
332#[cfg(test)]
333mod tests {
334    use super::*;
335    use std::path::PathBuf;
336
337    #[test]
338    fn a_private_key_after_a_certificate_is_still_redacted() {
339        let text = "-----BEGIN CERTIFICATE-----\nMIIBcert\n-----END CERTIFICATE-----\n\
340                    -----BEGIN RSA PRIVATE KEY-----\nMIIEsecret\n-----END RSA PRIVATE KEY-----\ndone";
341        let out = redact_private_keys(text);
342        assert!(out.contains("MIIBcert"), "a certificate is public: {out}");
343        assert!(!out.contains("MIIEsecret"), "{out}");
344        assert!(out.contains("[redacted private key]") && out.ends_with("done"));
345    }
346
347    #[test]
348    fn every_slack_token_family_is_redacted() {
349        for prefix in ["xoxa-", "xoxb-", "xoxo-", "xoxp-", "xoxr-", "xoxs-"] {
350            let token = format!("{prefix}1234567890-abcdefghijklmnop");
351            assert_eq!(redact_secrets(&format!("t {token} t")), "t [redacted secret] t", "{prefix}");
352        }
353    }
354
355    #[test]
356    fn ordinary_words_that_share_a_prefix_survive_redaction() {
357        assert_eq!(redact_secrets("a task-list and a desk-lamp"), "a task-list and a desk-lamp");
358    }
359
360    #[test]
361    fn authorization_header_any_scheme_is_redacted() {
362        assert_eq!(redact_secrets("Authorization: Bearer abc.def.ghi"), "Authorization: [redacted]");
363        assert_eq!(redact_secrets("Authorization: Basic dXNlcjpwYXNz"), "Authorization: [redacted]");
364        assert_eq!(redact_secrets("proxy-authorization: Token xyz"), "proxy-authorization: [redacted]");
365    }
366
367    #[test]
368    fn cookie_and_api_key_headers_are_redacted() {
369        assert_eq!(redact_secrets("x-api-key: abc123"), "x-api-key: [redacted]");
370        assert_eq!(redact_secrets("Cookie: session=abc123"), "Cookie: [redacted]");
371        assert_eq!(redact_secrets("Set-Cookie: session=abc123; Path=/"), "Set-Cookie: [redacted]");
372    }
373
374    #[test]
375    fn key_value_forms_are_redacted() {
376        assert_eq!(redact_secrets("password=hunter2"), "password=[redacted]");
377        assert_eq!(redact_secrets("password: hunter2"), "password: [redacted]");
378        assert_eq!(redact_secrets(r#"{"password": "hunter2"}"#), r#"{"password": "[redacted]"}"#);
379        assert_eq!(redact_secrets("client_secret=abc123"), "client_secret=[redacted]");
380        assert_eq!(redact_secrets("credential=abc123"), "credential=[redacted]");
381    }
382
383    #[test]
384    fn url_userinfo_password_is_redacted_not_the_username() {
385        assert_eq!(
386            redact_secrets("fetch https://alice:hunter2@example.com/path?q=1"),
387            "fetch https://alice:[redacted]@example.com/path?q=1"
388        );
389    }
390
391    #[test]
392    fn a_jwt_is_redacted_whole() {
393        let jwt = "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
394        assert_eq!(redact_secrets(&format!("token is {jwt} end")), "token is [redacted] end");
395    }
396
397    #[test]
398    fn an_aws_secret_access_key_is_redacted() {
399        assert_eq!(
400            redact_secrets("aws_secret_access_key=ABCDEFGHIJabcdefghij1234567890ABCDEFGHIJ"),
401            "aws_secret_access_key=[redacted]"
402        );
403    }
404
405    #[test]
406    fn a_secret_straddling_a_cut_boundary_is_still_redacted() {
407        let long = format!("{}ghp_abcdefghijklmnopqrstuvwxyz0123{}", "a".repeat(1_994), "b".repeat(5_000));
408        let out = redact_secrets(&long);
409        assert!(!out.contains("ghp_ab"), "a fragment of the token leaked");
410        assert!(out.contains("[redacted secret]"));
411    }
412
413    /// §4.5 / §8: one shared set of vectors, run by both this crate and
414    /// `frontend/app/errors/redact.test.ts`, so the two implementations
415    /// can't silently drift apart.
416    #[test]
417    fn shared_redaction_vectors() {
418        let root = PathBuf::from(env!("CARGO_MANIFEST_DIR"))
419            .parent()
420            .and_then(std::path::Path::parent)
421            .expect("crates/common is two levels below the repo root")
422            .join("docs/specs/fixtures/redaction-vectors.json");
423        let raw = std::fs::read_to_string(&root).unwrap_or_else(|e| panic!("reading {root:?}: {e}"));
424        let vectors: Vec<serde_json::Value> = serde_json::from_str(&raw).expect("valid JSON");
425        assert!(!vectors.is_empty());
426        for v in &vectors {
427            let name = v["name"].as_str().unwrap_or("<unnamed>");
428            let input = v["input"].as_str().expect("vector.input is a string");
429            let expected = v["expected"].as_str().expect("vector.expected is a string");
430            assert_eq!(redact_secrets(input), expected, "vector {name:?}");
431        }
432    }
433}