1use std::io::{BufRead, BufReader};
4use std::sync::OnceLock;
5
6use regex::bytes::Regex;
7
8static COMMENT_REGEX: OnceLock<Regex> = OnceLock::new();
9static BLANK_REGEX: OnceLock<Regex> = OnceLock::new();
10
11fn comment_regex() -> &'static Regex {
12 COMMENT_REGEX.get_or_init(|| Regex::new(r"^\s*#.*$").unwrap())
13}
14
15fn blank_regex() -> &'static Regex {
16 BLANK_REGEX.get_or_init(|| Regex::new(r"^\s*$").unwrap())
17}
18
19type ReplFunc = Box<dyn Fn(&[u8]) -> Vec<u8> + Send + Sync>;
20
21pub struct Replacer {
23 pub regex: Option<Regex>,
25
26 pub hints: Option<Vec<String>>,
30
31 pub repl: Option<Vec<u8>>,
35
36 pub repl_func: Option<ReplFunc>,
39}
40
41static DEFAULT_SCRUBBER: OnceLock<Scrubber> = OnceLock::new();
42
43pub fn default_scrubber() -> &'static Scrubber {
48 DEFAULT_SCRUBBER.get_or_init(Scrubber::default)
49}
50
51impl Default for Scrubber {
52 fn default() -> Self {
53 let hinted_api_key_replacer = Replacer {
54 regex: Some(Regex::new(r"(api_?key=)[a-zA-Z0-9]+([a-zA-Z0-9]{5})\b").unwrap()),
55 repl: Some(b"$1***************************$2".to_vec()),
56 hints: Some(vec!["api_key".to_string(), "apikey".to_string()]),
57 repl_func: None,
58 };
59
60 let hinted_app_key_replacer = Replacer {
61 regex: Some(Regex::new(r"(ap(?:p|plication)_?key=)[a-zA-Z0-9]+([a-zA-Z0-9]{5})\b").unwrap()),
62 repl: Some(b"$1***********************************$2".to_vec()),
63 hints: Some(vec![
64 "appkey".to_string(),
65 "app_key".to_string(),
66 "application_key".to_string(),
67 ]),
68 repl_func: None,
69 };
70
71 let api_key_replacer = Replacer {
73 regex: Some(Regex::new(r"\b[a-fA-F0-9]{27}([a-fA-F0-9]{5})\b").unwrap()),
74 repl: Some(b"***************************$1".to_vec()),
75 hints: None,
76 repl_func: None,
77 };
78
79 let api_key_replacer_yaml = Replacer {
81 regex: Some(Regex::new(r#"(\-|\:|,|\[|\{)(\s+)?\b[a-fA-F0-9]{27}([a-fA-F0-9]{5})\b"#).unwrap()),
82 repl: Some(b"$1$2\"***************************$3\"".to_vec()),
83 hints: None,
84 repl_func: None,
85 };
86
87 let app_key_replacer_yaml = Replacer {
88 regex: Some(Regex::new(r#"(\-|\:|,|\[|\{)(\s+)?\b[a-fA-F0-9]{35}([a-fA-F0-9]{5})\b"#).unwrap()),
89 repl: Some(b"$1$2\"***********************************$3\"".to_vec()),
90 hints: None,
91 repl_func: None,
92 };
93
94 let app_key_replacer = Replacer {
95 regex: Some(Regex::new(r"\b[a-fA-F0-9]{35}([a-fA-F0-9]{5})\b").unwrap()),
96 repl: Some(b"***********************************$1".to_vec()),
97 hints: None,
98 repl_func: None,
99 };
100
101 let rc_app_key_replacer = Replacer {
103 regex: Some(Regex::new(r"\bDDRCM_[A-Z0-9]+([A-Z0-9]{5})\b").unwrap()),
104 repl: Some(b"***********************************$1".to_vec()),
105 hints: None,
106 repl_func: None,
107 };
108
109 let bearer_hex_replacer_upper = Replacer {
113 regex: Some(Regex::new(r"\bBearer [a-fA-F0-9]{59}([a-fA-F0-9]{5})\b").unwrap()),
114 repl: Some(b"Bearer ***********************************************************$1".to_vec()),
115 hints: Some(vec!["Bearer".to_string()]),
116 repl_func: None,
117 };
118
119 let bearer_hex_replacer_lower = Replacer {
120 regex: Some(Regex::new(r"\bbearer [a-fA-F0-9]{59}([a-fA-F0-9]{5})\b").unwrap()),
121 repl: Some(b"bearer ***********************************************************$1".to_vec()),
122 hints: Some(vec!["bearer".to_string()]),
123 repl_func: None,
124 };
125
126 let bearer_catchall_replacer_upper = Replacer {
131 regex: Some(Regex::new(r#"\bBearer\s+[^*\s"]+"#).unwrap()),
132 repl: Some(b"Bearer ********".to_vec()),
133 hints: Some(vec!["Bearer".to_string()]),
134 repl_func: None,
135 };
136
137 let bearer_catchall_replacer_lower = Replacer {
138 regex: Some(Regex::new(r#"\bbearer\s+[^*\s"]+"#).unwrap()),
139 repl: Some(b"bearer ********".to_vec()),
140 hints: Some(vec!["bearer".to_string()]),
141 repl_func: None,
142 };
143
144 let uri_password_replacer = Replacer {
146 regex: Some(Regex::new(r#"(?i)([a-z][a-z0-9+-.]+://|\b)([^:\s]+):([^\s|"]+)@"#).unwrap()),
147 repl: Some(b"$1$2:********@".to_vec()),
148 hints: None,
149 repl_func: None,
150 };
151
152 let password_replacer = Replacer {
156 regex: Some(Regex::new(r#"(?i)(\"?(?:pass(?:word)?|pswd|pwd)\"?)((?:=| = |:[ ]?)\"?)([0-9A-Za-z#!$%&'()*+,\-./:;<=>?@\[\\\]^_{|}~]+)(\"?)"#).unwrap()),
157 repl: Some(b"$1$2********$4".to_vec()),
158 hints: None,
159 repl_func: None,
160 };
161
162 let token_replacer = Replacer {
169 regex: Some(Regex::new(r#"(?i)(\"?(?:[\w-]*(?:token|jwt))\"?)((?:=| = |:[ ]?)\"?)([0-9A-Za-z#!$%&'()*+,\-./:;<=>?@\[\\\]^_{|}~]+)(\"?)"#).unwrap()),
170 repl: Some(b"$1$2********$4".to_vec()),
171 hints: None,
172 repl_func: None,
173 };
174
175 Self {
176 replacers: vec![
177 hinted_api_key_replacer,
178 hinted_app_key_replacer,
179 api_key_replacer_yaml,
180 app_key_replacer_yaml,
181 api_key_replacer,
182 app_key_replacer,
183 rc_app_key_replacer,
184 bearer_hex_replacer_upper,
185 bearer_hex_replacer_lower,
186 bearer_catchall_replacer_upper,
187 bearer_catchall_replacer_lower,
188 uri_password_replacer,
189 password_replacer,
190 token_replacer,
191 ],
192 }
193 }
194}
195
196pub struct Scrubber {
198 replacers: Vec<Replacer>,
199}
200
201impl Scrubber {
202 pub fn new() -> Self {
204 Self { replacers: vec![] }
205 }
206
207 pub fn add_replacer(&mut self, replacer: Replacer) {
209 self.replacers.push(replacer);
210 }
211
212 pub fn scrub_bytes(&self, data: &[u8]) -> Vec<u8> {
216 let mut reader = BufReader::new(data);
217 self.scrub_reader(&mut reader)
218 }
219
220 fn scrub_reader(&self, reader: &mut BufReader<&[u8]>) -> Vec<u8> {
221 let mut scrubbed_lines = Vec::new();
222 let mut line = Vec::new();
223 let mut first = true;
224 while let Ok(bytes_read) = reader.read_until(b'\n', &mut line) {
225 if bytes_read == 0 {
226 break; }
228
229 if blank_regex().is_match(&line) {
230 scrubbed_lines.push(b"\n".to_vec());
231 } else if !comment_regex().is_match(&line) {
232 let b = self.scrub(&line, &self.replacers);
233 if !first {
234 scrubbed_lines.push(b"\n".to_vec());
235 }
236 scrubbed_lines.push(b);
237 first = false;
238 }
239 line.clear();
240 }
241 scrubbed_lines.join(&b'\n')
242 }
243
244 fn scrub(&self, data: &[u8], replacers: &[Replacer]) -> Vec<u8> {
246 let mut scrubbed_data = data.to_vec();
247 for replacer in replacers {
248 if replacer.regex.is_none() {
249 continue;
250 }
251
252 let contains_hint = if let Some(hints) = &replacer.hints {
253 hints.iter().any(|hint| {
254 let needle = hint.as_bytes();
255 data.windows(needle.len()).any(|window| window == needle)
256 })
257 } else {
258 false
259 };
260
261 if replacer.hints.as_ref().is_none_or(|h| h.is_empty() || contains_hint) {
262 if let Some(re) = &replacer.regex {
263 if let Some(repl_func) = &replacer.repl_func {
264 scrubbed_data = re
265 .replace_all(&scrubbed_data, |caps: ®ex::bytes::Captures| repl_func(&caps[0]))
266 .into_owned();
267 } else if let Some(repl) = &replacer.repl {
268 scrubbed_data = re.replace_all(&scrubbed_data, repl.as_slice()).into_owned();
269 }
270 }
271 }
272 }
273 scrubbed_data
274 }
275}
276
277#[cfg(test)]
278mod tests {
279 use super::*;
280
281 fn assert_clean(contents: &str, clean_contents: &str) {
282 let scrubber = default_scrubber();
283 let cleaned = scrubber.scrub_bytes(contents.as_bytes());
284 let cleaned_string = String::from_utf8(cleaned).unwrap();
285 assert_eq!(cleaned_string.trim(), clean_contents.trim());
286 }
287
288 #[test]
289 fn test_config_strip_api_key() {
290 assert_clean(
291 "api_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
292 "api_key: \"***************************abbbb\"",
293 );
294 assert_clean(
295 "api_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB",
296 "api_key: \"***************************ABBBB\"",
297 );
298 assert_clean(
299 "api_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
300 "api_key: \"***************************abbbb\"",
301 );
302 assert_clean(
303 "api_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb'",
304 "api_key: '***************************abbbb'",
305 );
306 assert_clean(
307 " api_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb' ",
308 " api_key: '***************************abbbb' ",
309 );
310 }
311
312 #[test]
313 fn test_config_app_key() {
314 assert_clean(
315 "app_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
316 "app_key: \"***********************************abbbb\"",
317 );
318 assert_clean(
319 "app_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB",
320 "app_key: \"***********************************ABBBB\"",
321 );
322 assert_clean(
323 "app_key: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb\"",
324 "app_key: \"***********************************abbbb\"",
325 );
326 assert_clean(
327 "app_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb'",
328 "app_key: '***********************************abbbb'",
329 );
330 assert_clean(
331 " app_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb' ",
332 " app_key: '***********************************abbbb' ",
333 );
334 }
335
336 #[test]
337 fn test_config_rc_app_key() {
338 assert_clean(
339 "key: \"DDRCM_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABCDE\"",
340 "key: \"***********************************ABCDE\"",
341 );
342 }
343
344 #[test]
345 fn test_text_strip_api_key() {
346 assert_clean(
347 "Error status code 500 : http://dog.tld/api?key=3290abeefc68e1bbe852a25252bad88c",
348 "Error status code 500 : http://dog.tld/api?key=***************************ad88c",
349 );
350 assert_clean(
351 "hintedAPIKeyReplacer : http://dog.tld/api_key=InvalidLength12345abbbb",
352 "hintedAPIKeyReplacer : http://dog.tld/api_key=***************************abbbb",
353 );
354 assert_clean(
355 "hintedAPIKeyReplacer : http://dog.tld/apikey=InvalidLength12345abbbb",
356 "hintedAPIKeyReplacer : http://dog.tld/apikey=***************************abbbb",
357 );
358 assert_clean(
359 "apiKeyReplacer: https://agent-http-intake.logs.datadoghq.com/v1/input/aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
360 "apiKeyReplacer: https://agent-http-intake.logs.datadoghq.com/v1/input/***************************abbbb",
361 );
362 }
363
364 #[test]
365 fn test_config_strip_url_password() {
366 assert_clean(
367 "proxy: random_url_key: http://user:password@host:port",
368 "proxy: random_url_key: http://user:********@host:port",
369 );
370 assert_clean(
371 "random_url_key http://user:password@host:port",
372 "random_url_key http://user:********@host:port",
373 );
374 assert_clean(
375 "random_url_key: http://user:password@host:port",
376 "random_url_key: http://user:********@host:port",
377 );
378 assert_clean(
379 "random_url_key: http://user:p@ssw0r)@host:port",
380 "random_url_key: http://user:********@host:port",
381 );
382 assert_clean(
383 "random_url_key: http://user:🔑🔒🔐🔓@host:port",
384 "random_url_key: http://user:********@host:port",
385 );
386 assert_clean(
387 "random_url_key: http://user:password@host",
388 "random_url_key: http://user:********@host",
389 );
390 assert_clean(
391 "random_url_key: protocol://user:p@ssw0r)@host:port",
392 "random_url_key: protocol://user:********@host:port",
393 );
394 assert_clean(
395 "random_url_key: \"http://user:password@host:port\"",
396 "random_url_key: \"http://user:********@host:port\"",
397 );
398 assert_clean(
399 "random_url_key: 'http://user:password@host:port'",
400 "random_url_key: 'http://user:********@host:port'",
401 );
402 assert_clean(
403 "random_domain_key: 'user:password@host:port'",
404 "random_domain_key: 'user:********@host:port'",
405 );
406 assert_clean(
407 " random_url_key: 'http://user:password@host:port' ",
408 " random_url_key: 'http://user:********@host:port' ",
409 );
410 assert_clean(
411 " random_url_key: 'mongodb+s.r-v://user:password@host:port' ",
412 " random_url_key: 'mongodb+s.r-v://user:********@host:port' ",
413 );
414 assert_clean(
415 " random_url_key: 'mongodb+srv://user:pass-with-hyphen@abc.example.com/database' ",
416 " random_url_key: 'mongodb+srv://user:********@abc.example.com/database' ",
417 );
418 }
419
420 #[test]
421 fn test_password_yaml_double_quoted_value() {
422 assert_clean("password: \"supersecret\"", "password: \"********\"");
423 }
424
425 #[test]
426 fn test_password_unquoted_value_still_scrubbed() {
427 assert_clean("password=supersecret", "password=********");
428 assert_clean("password: supersecret", "password: ********");
429 }
430
431 #[test]
432 fn test_json_password_like_key_scrubs_to_valid_json() {
433 let scrubber = default_scrubber();
434 let input = r#"{"mysql_password": "supersecret"}"#;
436 let cleaned = String::from_utf8(scrubber.scrub_bytes(input.as_bytes())).unwrap();
437 serde_json::from_str::<serde_json::Value>(&cleaned).expect("scrubbed JSON must parse");
438 assert!(cleaned.contains("********"));
439
440 let input_compact = r#"{"password":"secret"}"#;
442 let cleaned_compact = String::from_utf8(scrubber.scrub_bytes(input_compact.as_bytes())).unwrap();
443 serde_json::from_str::<serde_json::Value>(&cleaned_compact).expect("compact scrubbed JSON must parse");
444 assert!(
445 cleaned_compact.contains("********"),
446 "compact JSON password must be scrubbed: {cleaned_compact}"
447 );
448 }
449
450 #[test]
451 fn test_json_single_line_api_key_scrub() {
452 let scrubber = default_scrubber();
453 let input = r#"{"api_key":"aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb"}"#;
454 let cleaned = scrubber.scrub_bytes(input.as_bytes());
455 let cleaned_string = String::from_utf8(cleaned).unwrap();
456 serde_json::from_str::<serde_json::Value>(&cleaned_string).expect("scrubbed output must parse as JSON");
458 assert!(
459 cleaned_string.contains("***************************"),
460 "expected masked api key suffix, got: {cleaned_string}"
461 );
462 }
463
464 #[test]
465 fn test_large_single_line_json_scrubbed_still_parses() {
466 let mut map = serde_json::Map::new();
467 map.insert("api_key".into(), serde_json::json!("aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb"));
468 map.insert("pad".into(), serde_json::json!("x".repeat(25_000)));
469 let line = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
470 assert!(line.len() > 16_384, "sanity: payload should exceed 16 KiB");
471
472 let scrubber = default_scrubber();
473 let cleaned = scrubber.scrub_bytes(line.as_bytes());
474 let cleaned_string = String::from_utf8(cleaned).unwrap();
475 serde_json::from_str::<serde_json::Value>(&cleaned_string).expect("JSON parse after scrub");
476 }
477
478 #[test]
479 fn test_text_strip_app_key() {
480 assert_clean(
481 "hintedAPPKeyReplacer : http://dog.tld/app_key=InvalidLength12345abbbb",
482 "hintedAPPKeyReplacer : http://dog.tld/app_key=***********************************abbbb",
483 );
484 assert_clean(
485 "hintedAPPKeyReplacer : http://dog.tld/appkey=InvalidLength12345abbbb",
486 "hintedAPPKeyReplacer : http://dog.tld/appkey=***********************************abbbb",
487 );
488 assert_clean(
489 "hintedAPPKeyReplacer : http://dog.tld/application_key=InvalidLength12345abbbb",
490 "hintedAPPKeyReplacer : http://dog.tld/application_key=***********************************abbbb",
491 );
492 assert_clean(
493 "appKeyReplacer: http://dog.tld/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
494 "appKeyReplacer: http://dog.tld/***********************************abbbb",
495 );
496 }
497
498 #[test]
499 fn test_config_strip_auth_token() {
500 assert_clean("auth_token: secret", "auth_token: ********");
501 assert_clean("auth_token=secret", "auth_token=********");
502 assert_clean(" auth_token: cluster-agent-token", " auth_token: ********");
503 assert_clean(
505 "cluster_agent.auth_token: cluster-agent-token",
506 "cluster_agent.auth_token: ********",
507 );
508 assert_clean("refresh_token: abc123", "refresh_token: ********");
510 assert_clean("jwt: eyJhbGci.payload", "jwt: ********");
511 assert_clean("auth_token: \"secret\"", "auth_token: \"********\"");
512 }
513
514 #[test]
515 fn test_json_auth_token_scrubs_to_valid_json() {
516 let scrubber = default_scrubber();
518 let input = r#"{"cluster_agent.auth_token":"cluster-agent-token"}"#;
519 let cleaned = String::from_utf8(scrubber.scrub_bytes(input.as_bytes())).unwrap();
520 serde_json::from_str::<serde_json::Value>(&cleaned).expect("scrubbed JSON must parse");
521 assert!(cleaned.contains("********"), "auth_token must be masked: {cleaned}");
522 assert!(
523 !cleaned.contains("cluster-agent-token"),
524 "raw token must not remain: {cleaned}"
525 );
526 }
527
528 #[test]
529 fn test_token_replacer_no_false_positive() {
530 assert_clean("tokenizer: enabled", "tokenizer: enabled");
532 assert_clean("max_tokens: 100", "max_tokens: 100");
533 assert_clean("token_expiry: 3600", "token_expiry: 3600");
534 }
535
536 #[test]
537 fn test_strip_bearer_token() {
538 let token = format!("{}bcdef", "a".repeat(59));
540 let masked = format!("{}bcdef", "*".repeat(59));
541 assert_clean(
542 &format!("Authorization: Bearer {token}"),
543 &format!("Authorization: Bearer {masked}"),
544 );
545
546 assert_clean(
548 "Authorization: Bearer my-arbitrary-token-value",
549 "Authorization: Bearer ********",
550 );
551
552 assert_clean(
554 &format!("Authorization: bearer {token}"),
555 &format!("Authorization: bearer {masked}"),
556 );
557 assert_clean(
558 "Authorization: bearer my-arbitrary-token-value",
559 "Authorization: bearer ********",
560 );
561 }
562
563 #[test]
564 fn test_bearer_token_in_json_stays_valid() {
565 let scrubber = default_scrubber();
567 let input = r#"{"authorization":"Bearer my-arbitrary-token-value","keep":"value"}"#;
568 let cleaned = String::from_utf8(scrubber.scrub_bytes(input.as_bytes())).unwrap();
569 serde_json::from_str::<serde_json::Value>(&cleaned).expect("scrubbed JSON must parse");
570 assert!(
571 cleaned.contains("Bearer ********"),
572 "bearer token must be masked: {cleaned}"
573 );
574 assert!(
575 cleaned.contains(r#""keep":"value""#),
576 "adjacent field must survive: {cleaned}"
577 );
578 }
579}