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,255}\b"),
42 ("slack-token", r"xox[baprs]-[A-Za-z0-9-]{10,}"),
43 ("google-api-key", r"AIza[0-9A-Za-z_-]{35}"),
44 ("aws-access-key", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
45 ("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
46 ("url-credentials", r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^:@/\s]+:[^@/\s]+@"),
48 ("bearer-token", r"(?i)bearer\s+[A-Za-z0-9_-][A-Za-z0-9._~+/-]{15,}={0,2}"),
51 ];
52 #[allow(clippy::expect_used)]
58 specs
59 .iter()
60 .map(|(name, pat)| {
61 let re = Regex::new(pat).expect("redaction rule pattern must compile");
62 (*name, re)
63 })
64 .collect()
65 })
66}
67
68fn disabled() -> bool {
70 crate::config::env_flag("WINX_NO_REDACT")
71}
72
73const MAX_SWEEPS: usize = 4;
76
77pub fn redact(text: &str) -> Cow<'_, str> {
87 if disabled() || text.is_empty() {
88 return Cow::Borrowed(text);
89 }
90 let Some(mut owned) = sweep(text) else { return Cow::Borrowed(text) };
91 for _ in 1..MAX_SWEEPS {
92 match sweep(&owned) {
93 Some(next) => owned = next,
94 None => break,
95 }
96 }
97 Cow::Owned(owned)
98}
99
100fn sweep(text: &str) -> Option<String> {
102 let mut owned: Option<String> = None;
103 for (name, re) in rules() {
104 let current: &str = owned.as_deref().unwrap_or(text);
105 if let Cow::Owned(replaced) =
109 re.replace_all(current, |_: ®ex::Captures| format!("[REDACTED:{name}]"))
110 {
111 owned = Some(replaced);
112 }
113 }
114 owned
115}
116
117pub fn redact_json(value: &mut serde_json::Value) {
119 match value {
120 serde_json::Value::String(s) => {
121 if let Cow::Owned(r) = redact(s) {
122 *s = r;
123 }
124 }
125 serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
126 serde_json::Value::Object(map) => map.values_mut().for_each(redact_json),
127 _ => {}
128 }
129}
130
131#[cfg(test)]
132mod tests {
133 #![allow(clippy::unwrap_used)]
134 use super::*;
135 use proptest::prelude::*;
136
137 proptest! {
138 #[test]
142 fn isolated_secret_never_survives(
143 before in "[ \t=:;,(]{1,8}",
144 after in "[ \t=:;,)\n]{1,8}",
145 which in 0usize..2,
146 ) {
147 let secret = [
148 "AKIAIOSFODNN7EXAMPLE",
149 "ghp_0123456789012345678901234567890123456",
150 ][which];
151 let input = format!("{before}{secret}{after}");
152 let out = redact(&input);
153 prop_assert!(!out.contains(secret), "secret leaked: {out:?}");
154 prop_assert!(out.contains("[REDACTED:"), "no marker emitted: {out:?}");
155 }
156
157 #[test]
160 fn redaction_is_idempotent(s in "[ -~]{0,80}") {
161 let once = redact(&s).into_owned();
162 let twice = redact(&once).into_owned();
163 prop_assert_eq!(once, twice);
164 }
165 }
166
167 #[test]
168 fn redaction_reaches_a_fixpoint_when_a_marker_completes_a_match() {
169 let line = " AKIAIOSFODNN7EXAMPLEbearer abcdefghijklmnopqrstuvwxyz";
172 let single = sweep(line).unwrap();
173 assert!(single.contains("AKIAIOSFODNN7EXAMPLE"), "{single}");
174 let red = redact(line).into_owned();
175 assert!(!red.contains("AKIAIOSFODNN7EXAMPLE"), "{red}");
176 assert_eq!(redact(&red), red.as_str());
177 }
178
179 #[test]
180 fn redacts_known_credentials() {
181 let cases = [
182 "AKIAIOSFODNN7EXAMPLE",
183 "ghp_0123456789012345678901234567890123456",
184 "sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
185 "AIzaSyA1234567890123456789012345678901234",
186 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcDEF123456",
187 "postgres://user:hunter2@db.local/app",
188 ];
189 for c in cases {
190 let red = redact(c);
191 assert!(red.contains("[REDACTED:"), "did not redact: {c} -> {red}");
192 assert!(!red.contains("hunter2"));
193 }
194 }
195
196 #[test]
197 fn redacts_pem_private_key_block() {
198 let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34Gkx...\n-----END RSA PRIVATE KEY-----\nafter";
199 let red = redact(pem);
200 assert!(red.contains("before") && red.contains("after"));
201 assert!(red.contains("[REDACTED:private-key]"));
202 assert!(!red.contains("MIIBOgIBAAJBAKj"));
203 }
204
205 #[test]
206 fn leaves_ordinary_code_untouched() {
207 let code = "pub fn parse_config(path: &str) -> Result<Config> { Ok(Config::default()) }";
208 assert!(matches!(redact(code), Cow::Borrowed(_)));
209 }
210
211 #[test]
212 fn bearer_does_not_eat_a_path() {
213 let line = "run: bearer ./../etc/passwd && cat file";
215 assert!(matches!(redact(line), Cow::Borrowed(_)), "redacted a path as a bearer token");
216 let auth = "Authorization: Bearer abcdef0123456789ABCDEF";
218 assert!(redact(auth).contains("[REDACTED:bearer-token]"));
219 }
220
221 #[test]
222 fn redacts_inside_surrounding_text() {
223 let line = "export GITHUB_TOKEN=ghp_0123456789012345678901234567890123456 # ci";
224 let red = redact(line);
225 assert!(red.contains("export GITHUB_TOKEN="));
226 assert!(red.contains("[REDACTED:github-token]"));
227 assert!(red.contains("# ci"));
228 }
229
230 #[test]
231 fn redact_rules_compile() {
232 let names: Vec<&str> = rules().iter().map(|(n, _)| *n).collect();
236 assert!(names.contains(&"private-key"));
237 assert!(names.contains(&"github-token"));
238 assert_eq!(names.len(), 11, "rule count changed — update this assertion");
239 }
240
241 #[test]
242 fn redacts_long_github_token() {
243 let tok = format!("ghs_{}", "a".repeat(120));
246 let line = format!("token={tok} done");
247 let red = redact(&line);
248 assert!(red.contains("[REDACTED:github-token]"), "not redacted: {red}");
249 assert!(!red.contains("aaaaaaaaaa"), "token tail leaked: {red}");
250 }
251
252 #[test]
253 fn redacts_json_strings() {
254 let mut v = serde_json::json!({"out": "key=AKIAIOSFODNN7EXAMPLE", "n": 5, "ok": true});
255 redact_json(&mut v);
256 assert!(v["out"].as_str().unwrap().contains("[REDACTED:"));
257 assert_eq!(v["n"], 5);
258 }
259}