agent_code_lib/services/
secret_masker.rs1use regex::Regex;
14use std::sync::LazyLock;
15
16struct Pattern {
19 kind: &'static str,
20 re: Regex,
21}
22
23static PATTERNS: LazyLock<Vec<Pattern>> = LazyLock::new(|| {
24 let specs: &[(&str, &str)] = &[
25 (
27 "private_key",
28 r"(?s)-----BEGIN (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----.*?-----END (?:RSA |EC |DSA |OPENSSH |PGP )?PRIVATE KEY-----",
29 ),
30 ("aws_access_key", r"AKIA[0-9A-Z]{16}"),
32 ("github_pat", r"ghp_[a-zA-Z0-9]{36}"),
34 ("github_token", r"github_pat_[a-zA-Z0-9_]{50,}"),
36 ("provider_api_key", r"sk-[a-zA-Z0-9_\-]{20,}"),
38 (
43 "url_credential",
44 r#"(?i)\b(postgres(?:ql)?|mysql|mariadb|redis|rediss|mongodb(?:\+srv)?|amqp|amqps|mqtt|mqtts|smtp|smtps|sftp|ssh|ldap|ldaps|https?)://([a-zA-Z0-9._-]*):([^@\s"'\\]{3,})@"#,
45 ),
46 (
53 "credential",
54 r#"(?i)\b((?:[a-z][a-z0-9_-]*[_-])?(?:api[_-]?key|secret|password|passwd|token|auth)[a-z0-9_-]*)\s*[:=]\s*(?:\\?"[A-Za-z0-9_\-./+=]{8,}\\?"|\\?'[A-Za-z0-9_\-./+=]{8,}\\?')"#,
55 ),
56 (
61 "credential",
62 r#"(?i)\b((?:[a-z][a-z0-9_-]*[_-])?(?:api[_-]?key|secret|password|passwd|token|auth)[a-z0-9_-]*)\s*[:=]\s*[A-Za-z0-9_\-./+=]{8,}"#,
63 ),
64 ];
65
66 specs
67 .iter()
68 .map(|(kind, pat)| Pattern {
69 kind,
70 re: Regex::new(pat).expect("secret_masker pattern must compile"),
71 })
72 .collect()
73});
74
75pub fn mask(input: &str) -> String {
80 let mut out = input.to_string();
81 for p in PATTERNS.iter() {
82 match p.kind {
83 "credential" => {
85 out =
86 p.re.replace_all(&out, "${1}=[REDACTED:credential]")
87 .into_owned();
88 }
89 "url_credential" => {
92 out =
93 p.re.replace_all(&out, "${1}://${2}:[REDACTED:url_credential]@")
94 .into_owned();
95 }
96 _ => {
97 let replacement = format!("[REDACTED:{}]", p.kind);
98 out = p.re.replace_all(&out, replacement.as_str()).into_owned();
99 }
100 }
101 }
102 out
103}
104
105#[cfg(test)]
106mod tests {
107 use super::*;
108
109 #[test]
110 fn masks_aws_access_key() {
111 let input = "aws key is AKIAIOSFODNN7EXAMPLE in the config";
112 let out = mask(input);
113 assert!(out.contains("[REDACTED:aws_access_key]"));
114 assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"));
115 }
116
117 #[test]
118 fn masks_github_pat() {
119 let input = "token=ghp_abcdefghijklmnopqrstuvwxyz0123456789";
120 let out = mask(input);
121 assert!(!out.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789"));
122 assert!(out.contains("REDACTED"));
125 }
126
127 #[test]
128 fn masks_openai_style_key() {
129 let input = "OPENAI_API_KEY=sk-proj-abcdefghijklmnopqrstuvwxyz1234567890";
130 let out = mask(input);
131 assert!(!out.contains("sk-proj-abcdefghijklmnopqrstuvwxyz1234567890"));
132 assert!(out.contains("REDACTED"));
133 }
134
135 #[test]
136 fn masks_pem_private_key() {
137 let input = "config:\n-----BEGIN RSA PRIVATE KEY-----\nMIIEpAIBAAKCAQEA\ngarbage\n-----END RSA PRIVATE KEY-----\nend";
138 let out = mask(input);
139 assert!(out.contains("[REDACTED:private_key]"));
140 assert!(!out.contains("MIIEpAIBAAKCAQEA"));
141 assert!(out.starts_with("config:"));
142 assert!(out.trim_end().ends_with("end"));
143 }
144
145 #[test]
146 fn masks_generic_credential_assignment() {
147 let cases = [
148 "api_key = \"abcd1234efgh5678\"",
149 "API-KEY: s3cret_v@lue_12345",
150 "password=hunter2hunter2",
151 "auth_token = longlonglonglonglonglonglongvalue",
152 ];
153 for c in cases {
154 let out = mask(c);
155 assert!(
156 out.contains("[REDACTED:credential]"),
157 "failed to mask: {c} -> {out}",
158 );
159 }
160 }
161
162 #[test]
163 fn preserves_non_secret_text() {
164 let input = "the quick brown fox jumps over the lazy dog 42 times";
165 let out = mask(input);
166 assert_eq!(out, input);
167 }
168
169 #[test]
170 fn does_not_mask_short_values() {
171 let input = "id = abcd";
174 let out = mask(input);
175 assert_eq!(out, input);
176 }
177
178 #[test]
179 fn idempotent() {
180 let input = "key=sk-abcdefghijklmnopqrstuvwxyz and AKIAIOSFODNN7EXAMPLE";
181 let once = mask(input);
182 let twice = mask(&once);
183 assert_eq!(once, twice);
184 }
185
186 #[test]
187 fn masks_multiple_secrets_in_one_string() {
188 let input = "aws=AKIAIOSFODNN7EXAMPLE gh=ghp_abcdefghijklmnopqrstuvwxyz0123456789";
189 let out = mask(input);
190 assert!(!out.contains("AKIAIOSFODNN7EXAMPLE"));
191 assert!(!out.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789"));
192 }
193
194 #[test]
195 fn masks_single_quoted_credential() {
196 let input = "api_key = 'hunter2hunter2'";
197 let out = mask(input);
198 assert!(!out.contains("hunter2hunter2"));
199 assert!(out.contains("[REDACTED:credential]"));
200 }
201
202 #[test]
203 fn masks_mixed_quoted_and_unquoted_in_one_input() {
204 let input = r#"password = "firstsecretvalue1" and token=secondsecretvalue2"#;
206 let out = mask(input);
207 assert!(!out.contains("firstsecretvalue1"));
208 assert!(!out.contains("secondsecretvalue2"));
209 assert_eq!(out.matches("[REDACTED:credential]").count(), 2);
211 }
212
213 #[test]
214 fn does_not_consume_surrounding_json_quote() {
215 let input = r#"{"text": "api_key=hunter2hunter2hunter2"}"#;
219 let out = mask(input);
220 assert!(out.contains("[REDACTED:credential]"));
221 assert!(out.ends_with(r#""}"#));
223 serde_json::from_str::<serde_json::Value>(&out)
225 .expect("masked JSON fragment must still parse");
226 }
227
228 #[test]
229 fn does_not_mask_json_key_form() {
230 let input = r#"{"api_key": "hunter2hunter2"}"#;
233 let out = mask(input);
234 serde_json::from_str::<serde_json::Value>(&out)
240 .expect("masked JSON key form must still parse");
241 }
242
243 #[test]
244 fn empty_input_does_not_panic() {
245 assert_eq!(mask(""), "");
246 }
247
248 #[test]
249 fn strengthened_idempotency_across_split_pattern() {
250 let input = concat!(
253 "AKIAIOSFODNN7EXAMPLE ",
254 "ghp_abcdefghijklmnopqrstuvwxyz0123456789 ",
255 "sk-proj-abcdefghijklmnopqrstuvwxyz12345 ",
256 r#"password = "firstsecretvalue1" "#,
257 "token=secondsecretvalue2 ",
258 "auth_token: thirdsecretvalue3",
259 );
260 let once = mask(input);
261 let twice = mask(&once);
262 let thrice = mask(&twice);
263 assert_eq!(once, twice, "mask is not idempotent");
264 assert_eq!(twice, thrice);
265 assert!(!once.contains("AKIAIOSFODNN7EXAMPLE"));
267 assert!(!once.contains("ghp_abcdefghijklmnopqrstuvwxyz0123456789"));
268 assert!(!once.contains("firstsecretvalue1"));
269 assert!(!once.contains("secondsecretvalue2"));
270 assert!(!once.contains("thirdsecretvalue3"));
271 }
272
273 #[test]
274 fn masks_url_embedded_password_postgres() {
275 let input = "postgres://admin:hunter2hunter2@db.internal:5432/prod";
276 let out = mask(input);
277 assert!(!out.contains("hunter2hunter2"));
278 assert!(out.contains("[REDACTED:url_credential]"));
279 assert!(out.starts_with("postgres://admin:"));
281 assert!(out.contains("@db.internal:5432/prod"));
282 }
283
284 #[test]
285 fn masks_url_embedded_password_redis_without_user() {
286 let input = "redis://:supersecretvalue@cache.local:6379";
287 let out = mask(input);
288 assert!(!out.contains("supersecretvalue"));
289 assert!(out.contains("[REDACTED:url_credential]"));
290 }
291
292 #[test]
293 fn masks_url_embedded_password_inside_json_escape() {
294 let input = r#"{"text": "DATABASE_URL=postgres://user:hunter2hunter2@host/db"}"#;
298 let out = mask(input);
299 assert!(!out.contains("hunter2hunter2"));
300 serde_json::from_str::<serde_json::Value>(&out)
302 .expect("masked URL-credential JSON must still parse");
303 }
304
305 #[test]
306 fn does_not_mask_url_without_password() {
307 let input = "visit https://example.com/path for docs";
308 let out = mask(input);
309 assert_eq!(out, input);
310 }
311
312 #[test]
313 fn masks_uppercase_env_var_style() {
314 let input = "API_KEY=verylongprovidersecretvalue123";
315 let out = mask(input);
316 assert!(!out.contains("verylongprovidersecretvalue123"));
317 assert!(out.contains("REDACTED"));
318 }
319
320 #[test]
321 fn allows_legitimate_code_through() {
322 let code = r#"
323 fn add(a: i32, b: i32) -> i32 { a + b }
324 let x = vec![1, 2, 3];
325 println!("{}", x.len());
326 "#;
327 let out = mask(code);
328 assert_eq!(out, code);
329 }
330}