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
73pub fn redact(text: &str) -> Cow<'_, str> {
77 if disabled() || text.is_empty() {
78 return Cow::Borrowed(text);
79 }
80 let mut owned: Option<String> = None;
81 for (name, re) in rules() {
82 let current: &str = owned.as_deref().unwrap_or(text);
83 if let Cow::Owned(replaced) =
87 re.replace_all(current, |_: ®ex::Captures| format!("[REDACTED:{name}]"))
88 {
89 owned = Some(replaced);
90 }
91 }
92 owned.map_or(Cow::Borrowed(text), Cow::Owned)
93}
94
95pub fn redact_json(value: &mut serde_json::Value) {
97 match value {
98 serde_json::Value::String(s) => {
99 if let Cow::Owned(r) = redact(s) {
100 *s = r;
101 }
102 }
103 serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
104 serde_json::Value::Object(map) => map.values_mut().for_each(redact_json),
105 _ => {}
106 }
107}
108
109#[cfg(test)]
110mod tests {
111 #![allow(clippy::unwrap_used)]
112 use super::*;
113 use proptest::prelude::*;
114
115 proptest! {
116 #[test]
120 fn isolated_secret_never_survives(
121 before in "[ \t=:;,(]{1,8}",
122 after in "[ \t=:;,)\n]{1,8}",
123 which in 0usize..2,
124 ) {
125 let secret = [
126 "AKIAIOSFODNN7EXAMPLE",
127 "ghp_0123456789012345678901234567890123456",
128 ][which];
129 let input = format!("{before}{secret}{after}");
130 let out = redact(&input);
131 prop_assert!(!out.contains(secret), "secret leaked: {out:?}");
132 prop_assert!(out.contains("[REDACTED:"), "no marker emitted: {out:?}");
133 }
134
135 #[test]
138 fn redaction_is_idempotent(s in "[ -~]{0,80}") {
139 let once = redact(&s).into_owned();
140 let twice = redact(&once).into_owned();
141 prop_assert_eq!(once, twice);
142 }
143 }
144
145 #[test]
146 fn redacts_known_credentials() {
147 let cases = [
148 "AKIAIOSFODNN7EXAMPLE",
149 "ghp_0123456789012345678901234567890123456",
150 "sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
151 "AIzaSyA1234567890123456789012345678901234",
152 "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcDEF123456",
153 "postgres://user:hunter2@db.local/app",
154 ];
155 for c in cases {
156 let red = redact(c);
157 assert!(red.contains("[REDACTED:"), "did not redact: {c} -> {red}");
158 assert!(!red.contains("hunter2"));
159 }
160 }
161
162 #[test]
163 fn redacts_pem_private_key_block() {
164 let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34Gkx...\n-----END RSA PRIVATE KEY-----\nafter";
165 let red = redact(pem);
166 assert!(red.contains("before") && red.contains("after"));
167 assert!(red.contains("[REDACTED:private-key]"));
168 assert!(!red.contains("MIIBOgIBAAJBAKj"));
169 }
170
171 #[test]
172 fn leaves_ordinary_code_untouched() {
173 let code = "pub fn parse_config(path: &str) -> Result<Config> { Ok(Config::default()) }";
174 assert!(matches!(redact(code), Cow::Borrowed(_)));
175 }
176
177 #[test]
178 fn bearer_does_not_eat_a_path() {
179 let line = "run: bearer ./../etc/passwd && cat file";
181 assert!(matches!(redact(line), Cow::Borrowed(_)), "redacted a path as a bearer token");
182 let auth = "Authorization: Bearer abcdef0123456789ABCDEF";
184 assert!(redact(auth).contains("[REDACTED:bearer-token]"));
185 }
186
187 #[test]
188 fn redacts_inside_surrounding_text() {
189 let line = "export GITHUB_TOKEN=ghp_0123456789012345678901234567890123456 # ci";
190 let red = redact(line);
191 assert!(red.contains("export GITHUB_TOKEN="));
192 assert!(red.contains("[REDACTED:github-token]"));
193 assert!(red.contains("# ci"));
194 }
195
196 #[test]
197 fn redact_rules_compile() {
198 let names: Vec<&str> = rules().iter().map(|(n, _)| *n).collect();
202 assert!(names.contains(&"private-key"));
203 assert!(names.contains(&"github-token"));
204 assert_eq!(names.len(), 11, "rule count changed — update this assertion");
205 }
206
207 #[test]
208 fn redacts_long_github_token() {
209 let tok = format!("ghs_{}", "a".repeat(120));
212 let line = format!("token={tok} done");
213 let red = redact(&line);
214 assert!(red.contains("[REDACTED:github-token]"), "not redacted: {red}");
215 assert!(!red.contains("aaaaaaaaaa"), "token tail leaked: {red}");
216 }
217
218 #[test]
219 fn redacts_json_strings() {
220 let mut v = serde_json::json!({"out": "key=AKIAIOSFODNN7EXAMPLE", "n": 5, "ok": true});
221 redact_json(&mut v);
222 assert!(v["out"].as_str().unwrap().contains("[REDACTED:"));
223 assert_eq!(v["n"], 5);
224 }
225}