1use regex::RegexSet;
2
3const BUILTIN_PATTERNS: &[&str] = &[
4 r"(?i)\b(sk|pk|key)[-_][a-zA-Z0-9]{20,}\b",
6 r"(?i)bearer\s+[a-zA-Z0-9\-_.~+/]{20,}",
8 r"\bAKIA[0-9A-Z]{16}\b",
10 r"\beyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\b",
12 r"\b[0-9a-fA-F]{40,}\b",
14 r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b",
16 r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b",
18 r"\bsk-[a-zA-Z0-9]{32,}\b",
20 r"\bsk-ant-[a-zA-Z0-9\-]{20,}\b",
22 r"\b(ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}\b",
24 r"\b(sk|pk|rk)_(test|live)_[a-zA-Z0-9]{20,}\b",
26];
27
28const SENSITIVE_JSON_KEYS: &[&str] = &[
29 "api_key",
30 "apiKey",
31 "api-key",
32 "secret",
33 "password",
34 "passwd",
35 "token",
36 "access_token",
37 "refresh_token",
38 "private_key",
39 "privateKey",
40 "secret_key",
41 "secretKey",
42 "authorization",
43 "auth_token",
44 "session_token",
45 "cookie",
46 "credentials",
47 "ssn",
48 "credit_card",
49 "card_number",
50];
51
52const SECRET_URL_PARAMS: &[&str] = &[
55 "access_token",
56 "access-token",
57 "refresh_token",
58 "id_token",
59 "auth_token",
60 "token",
61 "api_key",
62 "api-key",
63 "apikey",
64 "key",
65 "client_secret",
66 "secret",
67 "password",
68 "passwd",
69 "pwd",
70 "auth",
71 "sig",
72 "signature",
73 "x-amz-signature",
74 "x-amz-credential",
75 "x-amz-security-token",
76 "x-goog-signature",
77 "x-goog-credential",
78];
79
80static URL_SECRET_PARAM_RE: std::sync::LazyLock<Option<regex::Regex>> =
83 std::sync::LazyLock::new(|| {
84 let names: Vec<String> = SECRET_URL_PARAMS.iter().map(|n| regex::escape(n)).collect();
85 compile_builtin(&format!(
86 r#"(?i)([?&#](?:{})=)[^&#\s"'<>\\]+"#,
87 names.join("|")
88 ))
89 });
90
91static TEXT_KEY_VALUE_RE: std::sync::LazyLock<Option<regex::Regex>> = std::sync::LazyLock::new(
95 || {
96 let mut words: Vec<String> = SENSITIVE_JSON_KEYS
97 .iter()
98 .map(|k| regex::escape(&k.to_lowercase()))
99 .collect();
100 words.sort();
101 words.dedup();
102 compile_builtin(&format!(
103 r#"(?i)("[^"\\\n]{{0,64}}?(?:{})[^"\\\n]{{0,64}}?"\s*:\s*)("(?:[^"\\]|\\.)*(?:"|\z)|-?\d[\d.eE+-]*)"#,
104 words.join("|")
105 ))
106 },
107);
108
109fn compile_builtin(pattern: &str) -> Option<regex::Regex> {
111 regex::Regex::new(pattern)
112 .inspect_err(|e| tracing::error!("BUG: built-in redaction pattern failed to compile: {e}"))
113 .ok()
114}
115
116fn redact_text_key_values(text: &str) -> std::borrow::Cow<'_, str> {
118 match TEXT_KEY_VALUE_RE.as_ref() {
119 Some(re) => re.replace_all(text, r#"${1}"[REDACTED]""#),
120 None => std::borrow::Cow::Borrowed(text),
121 }
122}
123
124pub struct Redactor {
127 builtin_set: RegexSet,
128 builtin_compiled: Vec<regex::Regex>,
129 custom_set: Option<RegexSet>,
130 custom_compiled: Vec<regex::Regex>,
131}
132
133impl Redactor {
134 pub fn try_new(custom_patterns: &[String]) -> Result<Self, regex::Error> {
140 let builtin_set = RegexSet::new(BUILTIN_PATTERNS)?;
141 let builtin_compiled: Vec<regex::Regex> = BUILTIN_PATTERNS
142 .iter()
143 .filter_map(|p| regex::Regex::new(p).ok())
144 .collect();
145
146 let (custom_set, custom_compiled) = if custom_patterns.is_empty() {
147 (None, Vec::new())
148 } else {
149 let set = RegexSet::new(custom_patterns)?;
150 let compiled: Vec<regex::Regex> = custom_patterns
151 .iter()
152 .map(|p| regex::Regex::new(p))
153 .collect::<Result<Vec<_>, _>>()?;
154 (Some(set), compiled)
155 };
156
157 Ok(Self {
158 builtin_set,
159 builtin_compiled,
160 custom_set,
161 custom_compiled,
162 })
163 }
164
165 pub fn new(custom_patterns: &[String]) -> Self {
170 let (builtin_set, builtin_compiled) = match RegexSet::new(BUILTIN_PATTERNS) {
171 Ok(set) => {
172 let compiled: Vec<regex::Regex> = BUILTIN_PATTERNS
173 .iter()
174 .filter_map(|p| regex::Regex::new(p).ok())
175 .collect();
176 (set, compiled)
177 }
178 Err(e) => {
179 tracing::error!(
180 "BUG: built-in redaction patterns failed to compile: {e}. \
181 Redaction will be disabled."
182 );
183 let empty: Vec<String> = Vec::new();
186 let empty_set = RegexSet::new(&empty).unwrap_or_else(|_| unreachable!());
187 (empty_set, Vec::new())
188 }
189 };
190
191 let (custom_set, custom_compiled) = if custom_patterns.is_empty() {
192 (None, Vec::new())
193 } else {
194 match RegexSet::new(custom_patterns) {
195 Ok(set) => {
196 let compiled: Vec<regex::Regex> = custom_patterns
197 .iter()
198 .filter_map(|p| regex::Regex::new(p).ok())
199 .collect();
200 (Some(set), compiled)
201 }
202 Err(e) => {
203 tracing::warn!("Failed to compile custom redaction patterns: {e}");
204 (None, Vec::new())
205 }
206 }
207 };
208
209 Self {
210 builtin_set,
211 builtin_compiled,
212 custom_set,
213 custom_compiled,
214 }
215 }
216
217 #[must_use]
225 pub fn redact(&self, input: &str) -> String {
226 let mut output = self.redact_regex(input);
227 if let Some(re) = URL_SECRET_PARAM_RE.as_ref()
228 && re.is_match(&output)
229 {
230 output = re.replace_all(&output, "${1}[REDACTED]").into_owned();
231 }
232 output = self.redact_json_keys(&output);
233 output
234 }
235
236 fn redact_regex(&self, input: &str) -> String {
237 let has_builtin = self.builtin_set.is_match(input);
238 let has_custom = self.custom_set.as_ref().is_some_and(|c| c.is_match(input));
239
240 if !has_builtin && !has_custom {
241 return input.to_string();
242 }
243
244 let mut output = input.to_string();
245
246 if has_builtin {
247 for re in &self.builtin_compiled {
248 output = re.replace_all(&output, "[REDACTED]").to_string();
249 }
250 }
251
252 if has_custom {
253 for re in &self.custom_compiled {
254 output = re.replace_all(&output, "[REDACTED]").to_string();
255 }
256 }
257
258 output
259 }
260
261 fn redact_json_keys(&self, input: &str) -> String {
262 match serde_json::from_str::<serde_json::Value>(input) {
263 Ok(value) => match redact_json_value(&value, 0) {
264 Some(redacted) => {
265 serde_json::to_string(&redacted).unwrap_or_else(|_| input.to_string())
266 }
267 None => input.to_string(),
268 },
269 Err(_) => redact_text_key_values(input).into_owned(),
271 }
272 }
273}
274
275const MAX_NESTED_JSON_DEPTH: usize = 4;
277
278fn is_sensitive_key(key: &str) -> bool {
279 let lower_key = key.to_lowercase();
280 SENSITIVE_JSON_KEYS
281 .iter()
282 .any(|k| lower_key.contains(&k.to_lowercase()))
283}
284
285fn redact_json_value(value: &serde_json::Value, depth: usize) -> Option<serde_json::Value> {
289 match value {
290 serde_json::Value::Object(map) => {
291 let mut changed = false;
292 let mut new_map = serde_json::Map::new();
293 for (key, val) in map {
294 let new_val = if is_sensitive_key(key) && !val.is_boolean() {
295 Some(serde_json::Value::String("[REDACTED]".into()))
297 } else {
298 redact_json_value(val, depth)
299 };
300 changed |= new_val.is_some();
301 new_map.insert(key.clone(), new_val.unwrap_or_else(|| val.clone()));
302 }
303 changed.then_some(serde_json::Value::Object(new_map))
304 }
305 serde_json::Value::Array(arr) => {
306 let redacted: Vec<Option<serde_json::Value>> =
307 arr.iter().map(|v| redact_json_value(v, depth)).collect();
308 redacted.iter().any(Option::is_some).then(|| {
309 serde_json::Value::Array(
310 redacted
311 .into_iter()
312 .zip(arr)
313 .map(|(new, old)| new.unwrap_or_else(|| old.clone()))
314 .collect(),
315 )
316 })
317 }
318 serde_json::Value::String(text) => redact_json_string(text, depth),
319 _ => None,
320 }
321}
322
323fn redact_json_string(text: &str, depth: usize) -> Option<serde_json::Value> {
324 let trimmed = text.trim_start();
325 if depth < MAX_NESTED_JSON_DEPTH
326 && (trimmed.starts_with('{') || trimmed.starts_with('['))
327 && let Ok(inner) = serde_json::from_str::<serde_json::Value>(text)
328 {
329 let redacted = redact_json_value(&inner, depth + 1)?;
330 return serde_json::to_string(&redacted)
331 .ok()
332 .map(serde_json::Value::String);
333 }
334 match redact_text_key_values(text) {
335 std::borrow::Cow::Owned(redacted) => Some(serde_json::Value::String(redacted)),
336 std::borrow::Cow::Borrowed(_) => None,
337 }
338}
339
340impl Default for Redactor {
341 fn default() -> Self {
342 Self::new(&[])
343 }
344}
345
346#[cfg(test)]
347mod tests {
348 use super::*;
349
350 #[test]
351 fn redacts_api_keys() {
352 let r = Redactor::default();
353 assert!(
354 r.redact("key is sk-abc123def456ghi789jkl012mno")
355 .contains("[REDACTED]")
356 );
357 }
358
359 #[test]
360 fn redacts_bearer_tokens() {
361 let r = Redactor::default();
362 let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
363 let output = r.redact(input);
364 assert!(output.contains("[REDACTED]"));
365 assert!(!output.contains("eyJhbGci"));
366 }
367
368 #[test]
369 fn redacts_emails() {
370 let r = Redactor::default();
371 assert!(
372 r.redact("contact user@example.com for help")
373 .contains("[REDACTED]")
374 );
375 }
376
377 #[test]
378 fn passes_through_clean_text() {
379 let r = Redactor::default();
380 let input = r#"{"ok": true, "title": "My App"}"#;
381 assert_eq!(r.redact(input), input);
382 }
383
384 #[test]
385 fn custom_patterns_work() {
386 let r = Redactor::new(&["secret_\\w+".to_string()]);
387 assert!(
388 r.redact("found secret_project_alpha here")
389 .contains("[REDACTED]")
390 );
391 }
392
393 #[test]
394 fn redacts_json_sensitive_keys() {
395 let r = Redactor::default();
396 let input = r#"{"api_key":"sk-test-12345","name":"John","token":"abc123"}"#;
397 let output = r.redact(input);
398 assert!(output.contains("[REDACTED]"));
399 assert!(output.contains("John"));
400 assert!(!output.contains("sk-test-12345"));
401 }
402
403 #[test]
404 fn preserves_boolean_sensitive_keys() {
405 let r = Redactor::default();
406 let input = r#"{"has_api_key":true,"api_key":"secret-value-here"}"#;
407 let output = r.redact(input);
408 let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
409 assert_eq!(parsed["has_api_key"], serde_json::Value::Bool(true));
410 assert_eq!(
411 parsed["api_key"],
412 serde_json::Value::String("[REDACTED]".into())
413 );
414 }
415
416 #[test]
417 fn redacts_nested_json_keys() {
418 let r = Redactor::default();
419 let input = r#"{"config":{"llm":{"api_key":"sk-live-xxx","model":"gpt-4"}}}"#;
420 let output = r.redact(input);
421 assert!(output.contains("[REDACTED]"));
422 assert!(output.contains("gpt-4"));
423 assert!(!output.contains("sk-live-xxx"));
424 }
425
426 #[test]
429 fn redacts_secret_query_parameters() {
430 let r = Redactor::default();
431 let out = r.redact(
432 "GET https://api.example.com/v1/items?user=7&access_token=abc123def&page=2 https://x.test/cb#id_token=eyJraWQ&state=s https://h.test/?API_KEY=k9k9k9 https://blob.test/f?sv=2024&sig=Zx%2Fq&se=1",
433 );
434 for secret in ["abc123def", "eyJraWQ", "k9k9k9", "Zx%2Fq"] {
435 assert!(!out.contains(secret), "{secret} survived: {out}");
436 }
437 for kept in ["user=7", "page=2", "state=s", "sv=2024", "se=1"] {
438 assert!(out.contains(kept), "{kept} was redacted: {out}");
439 }
440 let clean = "https://x.test/?keyboard=us&monkey=1&tokens_used=5";
442 assert_eq!(r.redact(clean), clean);
443 let json = r#"{"url":"https://x.test/a?token=zzz111&b=1"}"#;
445 let v: serde_json::Value = serde_json::from_str(&r.redact(json)).unwrap();
446 assert_eq!(v["url"], "https://x.test/a?token=[REDACTED]&b=1");
447 }
448
449 #[test]
450 fn redacts_sensitive_keys_in_json_embedded_in_text() {
451 let r = Redactor::default();
452 let out =
453 r.redact(r#"invoke login -> {"ok":true,"session_token":"s3cr3t-value","n":1} done"#);
454 assert!(!out.contains("s3cr3t-value"), "{out}");
455 assert!(out.contains(r#""ok":true"#), "{out}");
456 let out = r.redact(r#"body: {"password": "hunter2", "data": "xxxxxxxx…(truncated"#);
458 assert!(!out.contains("hunter2"), "{out}");
459 let out = r.redact(r#"body: {"id": 1, "secret": "abcdefgh"#);
461 assert!(!out.contains("abcdefgh"), "{out}");
462 assert!(out.contains(r#""id": 1"#), "{out}");
463 }
464
465 #[test]
466 fn redacts_sensitive_keys_in_json_nested_inside_json_strings() {
467 let r = Redactor::default();
468 let input = serde_json::json!({
470 "command": "get_settings",
471 "response": "{\"llm\":{\"api_key\":\"sk-live-abc\",\"model\":\"m1\"}}",
472 "truncated_body": "{\"refresh_token\":\"rt-999\",\"x\":\"…"
473 })
474 .to_string();
475 let out = r.redact(&input);
476 let v: serde_json::Value = serde_json::from_str(&out).expect("still valid JSON");
477 let response = v["response"].as_str().unwrap();
478 assert!(!response.contains("sk-live-abc"), "{out}");
479 assert!(response.contains("m1"), "{out}");
480 assert!(
481 !v["truncated_body"].as_str().unwrap().contains("rt-999"),
482 "{out}"
483 );
484 assert_eq!(v["command"], "get_settings");
485 }
486
487 #[test]
488 fn redacts_github_tokens() {
489 let r = Redactor::default();
490 assert!(
491 r.redact("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno")
492 .contains("[REDACTED]")
493 );
494 }
495
496 #[test]
497 fn redacts_stripe_keys() {
498 let r = Redactor::default();
499 assert!(
500 r.redact("sk_test_ABCDEFGHIJKLMNOPQRSTUVWXYZab")
501 .contains("[REDACTED]")
502 );
503 }
504
505 #[test]
506 fn try_new_valid_patterns() {
507 let r = Redactor::try_new(&["secret_\\w+".to_string()]);
508 assert!(r.is_ok());
509 let r = r.unwrap();
510 assert!(r.redact("found secret_alpha here").contains("[REDACTED]"));
511 }
512
513 #[test]
514 fn try_new_invalid_pattern_returns_error() {
515 let r = Redactor::try_new(&["[invalid".to_string()]);
516 assert!(r.is_err());
517 }
518
519 #[test]
520 fn try_new_empty_patterns() {
521 let r = Redactor::try_new(&[]);
522 assert!(r.is_ok());
523 }
524}