1use regex::Regex;
2use serde_json::Value;
3use std::sync::OnceLock;
4
5fn patterns() -> &'static [Regex] {
6 static PATTERNS: OnceLock<Vec<Regex>> = OnceLock::new();
7 PATTERNS.get_or_init(|| {
8 [
9 r"(?i)\b(?:sk|sk-ant|ghp_|gho_|github_pat_|xoxb-|xapp-)[A-Za-z0-9_\-]{16,}\b",
10 r"(?i)(?:authorization|x-api-key|api[_-]?key|access[_-]?token|refresh[_-]?token|password|secret)\s*[:=]\s*(?:bearer\s+)?[^\s,;]+",
11 r"(?i)https?://[^\s/@:]+:[^\s/@]+@[^\s]+",
12 r"(?im)\b[A-Z][A-Z0-9_]*(?:KEY|TOKEN|SECRET|PASSWORD|CREDENTIALS?)[A-Z0-9_]*\s*=\s*[^\s]+",
13 r"-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----[\s\S]*?-----END (?:RSA |EC |OPENSSH )?PRIVATE KEY-----",
14 ]
15 .into_iter()
16 .map(|pattern| Regex::new(pattern).expect("valid redaction regex"))
17 .collect()
18 })
19}
20
21pub fn redact_text(input: &str) -> String {
22 let mut output = input.to_string();
23 for pattern in patterns() {
24 output = pattern.replace_all(&output, "[REDACTED]").into_owned();
25 }
26 for name in [
27 "ANTHROPIC_API_KEY",
28 "OPENAI_API_KEY",
29 "GEMINI_API_KEY",
30 "DISCORD_TOKEN",
31 "TELEGRAM_BOT_TOKEN",
32 "SLACK_BOT_TOKEN",
33 "SLACK_APP_TOKEN",
34 "GITHUB_TOKEN",
35 ] {
36 if let Ok(secret) = std::env::var(name) {
37 if secret.len() >= 8 {
38 output = output.replace(&secret, "[REDACTED]");
39 }
40 }
41 }
42 output
43}
44
45pub fn redact_tool_payload(tool: &str, input: &str) -> String {
46 if tool != "praefectus" {
47 return redact_text(input);
48 }
49 let Ok(mut value) = serde_json::from_str::<Value>(input) else {
50 return "[REDACTED]".to_string();
51 };
52 redact_praefectus_value(&mut value);
53 serde_json::to_string(&value).unwrap_or_else(|_| "[REDACTED]".to_string())
54}
55
56fn redact_praefectus_value(value: &mut Value) {
57 match value {
58 Value::Array(values) => values.iter_mut().for_each(redact_praefectus_value),
59 Value::Object(values) => {
60 for key in [
61 "after",
62 "authority",
63 "before",
64 "evidence",
65 "fallback_chain",
66 "name",
67 "signature",
68 "target",
69 "text",
70 "value",
71 "warnings",
72 ] {
73 if values.contains_key(key) {
74 values.insert(key.to_string(), Value::String("[REDACTED]".to_string()));
75 }
76 }
77 values.values_mut().for_each(redact_praefectus_value);
78 }
79 _ => {}
80 }
81}
82
83#[cfg(test)]
84mod tests {
85 use super::*;
86
87 #[test]
88 fn redacts_common_tokens() {
89 let output = redact_text(
90 "token sk-ant-abcdefghijklmnopqrstuvwxyz and ghp_abcdefghijklmnopqrstuvwxyz1234567890",
91 );
92 assert!(!output.contains("abcdefghijklmnopqrstuvwxyz"));
93 assert!(output.contains("[REDACTED]"));
94 }
95
96 #[test]
97 fn redacts_environment_assignments() {
98 let output = redact_text("AWS_SECRET_ACCESS_KEY=top-secret\nCUSTOM_TOKEN=value");
99 assert!(!output.contains("top-secret"));
100 assert!(!output.contains("CUSTOM_TOKEN=value"));
101 }
102
103 #[test]
104 fn redacts_headers_and_private_keys() {
105 let output = redact_text("Authorization: Bearer secret-token\n-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----");
106 assert!(!output.contains("secret-token"));
107 assert!(!output.contains("abc"));
108 }
109
110 #[test]
111 fn redacts_praefectus_targets_and_values_from_persistent_payloads() {
112 let output = redact_tool_payload(
113 "praefectus",
114 r#"{"desktop_action":{"kind":"set_value","value":"private"},"target":{"element_id":"selector"}}"#,
115 );
116 assert!(!output.contains("private"));
117 assert!(!output.contains("selector"));
118 assert!(output.contains("[REDACTED]"));
119 }
120}