winx_code_agent/utils/
redact.rs1use std::borrow::Cow;
17use std::sync::OnceLock;
18
19use regex::Regex;
20
21fn rules() -> &'static [(&'static str, Regex)] {
24 static RULES: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
25 RULES.get_or_init(|| {
26 let specs: &[(&str, &str)] = &[
27 (
29 "private-key",
30 r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
31 ),
32 ("anthropic-key", r"sk-ant-[A-Za-z0-9_-]{20,200}"),
36 ("openai-key", r"sk-(?:proj-)?[A-Za-z0-9_-]{20,200}"),
37 ("github-pat", r"github_pat_[A-Za-z0-9_]{22,90}"),
38 ("github-token", r"gh[pousr]_[A-Za-z0-9]{36,80}\b"),
39 ("slack-token", r"xox[baprs]-[A-Za-z0-9-]{10,}"),
40 ("google-api-key", r"AIza[0-9A-Za-z_-]{35}"),
41 ("aws-access-key", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
42 ("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
43 ("url-credentials", r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^:@/\s]+:[^@/\s]+@"),
45 ("bearer-token", r"(?i)bearer\s+[A-Za-z0-9_-][A-Za-z0-9._~+/-]{15,}={0,2}"),
48 ];
49 specs.iter().filter_map(|(name, pat)| Regex::new(pat).ok().map(|re| (*name, re))).collect()
50 })
51}
52
53fn disabled() -> bool {
55 std::env::var("WINX_NO_REDACT").is_ok_and(|v| v != "0" && !v.is_empty())
56}
57
58pub fn redact(text: &str) -> Cow<'_, str> {
62 if disabled() || text.is_empty() {
63 return Cow::Borrowed(text);
64 }
65 let mut owned: Option<String> = None;
66 for (name, re) in rules() {
67 let current: &str = owned.as_deref().unwrap_or(text);
68 if re.is_match(current) {
69 let replaced =
70 re.replace_all(current, |_: ®ex::Captures| format!("[REDACTED:{name}]"));
71 owned = Some(replaced.into_owned());
72 }
73 }
74 owned.map_or(Cow::Borrowed(text), Cow::Owned)
75}
76
77pub fn redact_json(value: &mut serde_json::Value) {
79 match value {
80 serde_json::Value::String(s) => {
81 if let Cow::Owned(r) = redact(s) {
82 *s = r;
83 }
84 }
85 serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
86 serde_json::Value::Object(map) => map.values_mut().for_each(redact_json),
87 _ => {}
88 }
89}
90
91#[cfg(test)]
92mod tests {
93 #![allow(clippy::unwrap_used)]
94 use super::*;
95
96 #[test]
97 fn redacts_known_credentials() {
98 let cases = [
99 "AKIAIOSFODNN7EXAMPLE",
100 "ghp_0123456789012345678901234567890123456",
101 "sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
102 "AIzaSyA1234567890123456789012345678901234",
103 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcDEF123456",
104 "postgres://user:hunter2@db.local/app",
105 ];
106 for c in cases {
107 let red = redact(c);
108 assert!(red.contains("[REDACTED:"), "did not redact: {c} -> {red}");
109 assert!(!red.contains("hunter2"));
110 }
111 }
112
113 #[test]
114 fn redacts_pem_private_key_block() {
115 let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34Gkx...\n-----END RSA PRIVATE KEY-----\nafter";
116 let red = redact(pem);
117 assert!(red.contains("before") && red.contains("after"));
118 assert!(red.contains("[REDACTED:private-key]"));
119 assert!(!red.contains("MIIBOgIBAAJBAKj"));
120 }
121
122 #[test]
123 fn leaves_ordinary_code_untouched() {
124 let code = "pub fn parse_config(path: &str) -> Result<Config> { Ok(Config::default()) }";
125 assert!(matches!(redact(code), Cow::Borrowed(_)));
126 }
127
128 #[test]
129 fn bearer_does_not_eat_a_path() {
130 let line = "run: bearer ./../etc/passwd && cat file";
132 assert!(matches!(redact(line), Cow::Borrowed(_)), "redacted a path as a bearer token");
133 let auth = "Authorization: Bearer abcdef0123456789ABCDEF";
135 assert!(redact(auth).contains("[REDACTED:bearer-token]"));
136 }
137
138 #[test]
139 fn redacts_inside_surrounding_text() {
140 let line = "export GITHUB_TOKEN=ghp_0123456789012345678901234567890123456 # ci";
141 let red = redact(line);
142 assert!(red.contains("export GITHUB_TOKEN="));
143 assert!(red.contains("[REDACTED:github-token]"));
144 assert!(red.contains("# ci"));
145 }
146
147 #[test]
148 fn redacts_json_strings() {
149 let mut v = serde_json::json!({"out": "key=AKIAIOSFODNN7EXAMPLE", "n": 5, "ok": true});
150 redact_json(&mut v);
151 assert!(v["out"].as_str().unwrap().contains("[REDACTED:"));
152 assert_eq!(v["n"], 5);
153 }
154}