agentmux_common/
redact.rs1const 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
33pub 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
47fn 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
77pub 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
111fn 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
142fn 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 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 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
216fn 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
252fn 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
297fn 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 #[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}