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 json_password_replacer = Replacer {
155 regex: Some(
156 Regex::new(r#"(?i)("(?:[^"\\]|\\.)*(?:pass(?:word)?|pswd|pwd)")(\s*:\s*)"(?:[^"\\]|\\.)*""#).unwrap(),
157 ),
158 repl: Some(b"$1$2\"********\"".to_vec()),
159 hints: None,
160 repl_func: None,
161 };
162
163 let json_token_replacer = Replacer {
167 regex: Some(Regex::new(r#"(?i)("(?:[^"\\]|\\.)*(?:token|jwt)")(\s*:\s*)"(?:[^"\\]|\\.)*""#).unwrap()),
168 repl: Some(b"$1$2\"********\"".to_vec()),
169 hints: None,
170 repl_func: None,
171 };
172
173 let plain_password_replacer = Replacer {
176 regex: Some(
177 Regex::new(r#"(?i)(^|[^"0-9A-Za-z_])([0-9A-Za-z_.-]*(?:pass(?:word)?|pswd|pwd))((?:=| = |:[ ]?)"?)([0-9A-Za-z#!$%&'()*+,\-./:;<=>?@\[\\\]^_{|}~]+)("?)"#)
178 .unwrap(),
179 ),
180 repl: Some(b"$1$2$3********$5".to_vec()),
181 hints: None,
182 repl_func: None,
183 };
184
185 let plain_token_replacer = Replacer {
189 regex: Some(
190 Regex::new(r#"(?i)(^|[^"0-9A-Za-z_])([0-9A-Za-z_.-]*(?:token|jwt))((?:=| = |:[ ]?)"?)([0-9A-Za-z#!$%&'()*+,\-./:;<=>?@\[\\\]^_{|}~]+)("?)"#)
191 .unwrap(),
192 ),
193 repl: Some(b"$1$2$3********$5".to_vec()),
194 hints: None,
195 repl_func: None,
196 };
197
198 Self {
199 replacers: vec![
200 hinted_api_key_replacer,
201 hinted_app_key_replacer,
202 api_key_replacer_yaml,
203 app_key_replacer_yaml,
204 api_key_replacer,
205 app_key_replacer,
206 rc_app_key_replacer,
207 bearer_hex_replacer_upper,
208 bearer_hex_replacer_lower,
209 bearer_catchall_replacer_upper,
210 bearer_catchall_replacer_lower,
211 uri_password_replacer,
212 json_password_replacer,
213 json_token_replacer,
214 plain_password_replacer,
215 plain_token_replacer,
216 ],
217 }
218 }
219}
220
221pub struct Scrubber {
223 replacers: Vec<Replacer>,
224}
225
226impl Scrubber {
227 pub fn new() -> Self {
229 Self { replacers: vec![] }
230 }
231
232 pub fn add_replacer(&mut self, replacer: Replacer) {
234 self.replacers.push(replacer);
235 }
236
237 pub fn scrub_bytes(&self, data: &[u8]) -> Vec<u8> {
241 let mut reader = BufReader::new(data);
242 self.scrub_reader(&mut reader)
243 }
244
245 fn scrub_reader(&self, reader: &mut BufReader<&[u8]>) -> Vec<u8> {
246 let mut scrubbed_lines = Vec::new();
247 let mut line = Vec::new();
248 let mut first = true;
249 while let Ok(bytes_read) = reader.read_until(b'\n', &mut line) {
250 if bytes_read == 0 {
251 break; }
253
254 if blank_regex().is_match(&line) {
255 scrubbed_lines.push(b"\n".to_vec());
256 } else if !comment_regex().is_match(&line) {
257 let b = self.scrub(&line, &self.replacers);
258 if !first {
259 scrubbed_lines.push(b"\n".to_vec());
260 }
261 scrubbed_lines.push(b);
262 first = false;
263 }
264 line.clear();
265 }
266 scrubbed_lines.join(&b'\n')
267 }
268
269 fn scrub(&self, data: &[u8], replacers: &[Replacer]) -> Vec<u8> {
271 let mut scrubbed_data = data.to_vec();
272 for replacer in replacers {
273 if replacer.regex.is_none() {
274 continue;
275 }
276
277 let contains_hint = if let Some(hints) = &replacer.hints {
278 hints.iter().any(|hint| {
279 let needle = hint.as_bytes();
280 data.windows(needle.len()).any(|window| window == needle)
281 })
282 } else {
283 false
284 };
285
286 if replacer.hints.as_ref().is_none_or(|h| h.is_empty() || contains_hint) {
287 if let Some(re) = &replacer.regex {
288 if let Some(repl_func) = &replacer.repl_func {
289 scrubbed_data = re
290 .replace_all(&scrubbed_data, |caps: ®ex::bytes::Captures| repl_func(&caps[0]))
291 .into_owned();
292 } else if let Some(repl) = &replacer.repl {
293 scrubbed_data = re.replace_all(&scrubbed_data, repl.as_slice()).into_owned();
294 }
295 }
296 }
297 }
298 scrubbed_data
299 }
300}
301
302#[cfg(test)]
303mod tests {
304 use super::*;
305
306 fn assert_clean(contents: &str, clean_contents: &str) {
307 let scrubber = default_scrubber();
308 let cleaned = scrubber.scrub_bytes(contents.as_bytes());
309 let cleaned_string = String::from_utf8(cleaned).unwrap();
310 assert_eq!(cleaned_string.trim(), clean_contents.trim());
311 }
312
313 #[test]
314 fn test_config_strip_api_key() {
315 assert_clean(
316 "api_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
317 "api_key: \"***************************abbbb\"",
318 );
319 assert_clean(
320 "api_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB",
321 "api_key: \"***************************ABBBB\"",
322 );
323 assert_clean(
324 "api_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
325 "api_key: \"***************************abbbb\"",
326 );
327 assert_clean(
328 "api_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb'",
329 "api_key: '***************************abbbb'",
330 );
331 assert_clean(
332 " api_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb' ",
333 " api_key: '***************************abbbb' ",
334 );
335 }
336
337 #[test]
338 fn test_config_app_key() {
339 assert_clean(
340 "app_key: aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
341 "app_key: \"***********************************abbbb\"",
342 );
343 assert_clean(
344 "app_key: AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABBBB",
345 "app_key: \"***********************************ABBBB\"",
346 );
347 assert_clean(
348 "app_key: \"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb\"",
349 "app_key: \"***********************************abbbb\"",
350 );
351 assert_clean(
352 "app_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb'",
353 "app_key: '***********************************abbbb'",
354 );
355 assert_clean(
356 " app_key: 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb' ",
357 " app_key: '***********************************abbbb' ",
358 );
359 }
360
361 #[test]
362 fn test_config_rc_app_key() {
363 assert_clean(
364 "key: \"DDRCM_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABCDE\"",
365 "key: \"***********************************ABCDE\"",
366 );
367 }
368
369 #[test]
370 fn test_text_strip_api_key() {
371 assert_clean(
372 "Error status code 500 : http://dog.tld/api?key=3290abeefc68e1bbe852a25252bad88c",
373 "Error status code 500 : http://dog.tld/api?key=***************************ad88c",
374 );
375 assert_clean(
376 "hintedAPIKeyReplacer : http://dog.tld/api_key=InvalidLength12345abbbb",
377 "hintedAPIKeyReplacer : http://dog.tld/api_key=***************************abbbb",
378 );
379 assert_clean(
380 "hintedAPIKeyReplacer : http://dog.tld/apikey=InvalidLength12345abbbb",
381 "hintedAPIKeyReplacer : http://dog.tld/apikey=***************************abbbb",
382 );
383 assert_clean(
384 "apiKeyReplacer: https://agent-http-intake.logs.datadoghq.com/v1/input/aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
385 "apiKeyReplacer: https://agent-http-intake.logs.datadoghq.com/v1/input/***************************abbbb",
386 );
387 }
388
389 #[test]
390 fn test_config_strip_url_password() {
391 assert_clean(
392 "proxy: random_url_key: http://user:password@host:port",
393 "proxy: random_url_key: http://user:********@host:port",
394 );
395 assert_clean(
396 "random_url_key http://user:password@host:port",
397 "random_url_key http://user:********@host:port",
398 );
399 assert_clean(
400 "random_url_key: http://user:password@host:port",
401 "random_url_key: http://user:********@host:port",
402 );
403 assert_clean(
404 "random_url_key: http://user:p@ssw0r)@host:port",
405 "random_url_key: http://user:********@host:port",
406 );
407 assert_clean(
408 "random_url_key: http://user:🔑🔒🔐🔓@host:port",
409 "random_url_key: http://user:********@host:port",
410 );
411 assert_clean(
412 "random_url_key: http://user:password@host",
413 "random_url_key: http://user:********@host",
414 );
415 assert_clean(
416 "random_url_key: protocol://user:p@ssw0r)@host:port",
417 "random_url_key: protocol://user:********@host:port",
418 );
419 assert_clean(
420 "random_url_key: \"http://user:password@host:port\"",
421 "random_url_key: \"http://user:********@host:port\"",
422 );
423 assert_clean(
424 "random_url_key: 'http://user:password@host:port'",
425 "random_url_key: 'http://user:********@host:port'",
426 );
427 assert_clean(
428 "random_domain_key: 'user:password@host:port'",
429 "random_domain_key: 'user:********@host:port'",
430 );
431 assert_clean(
432 " random_url_key: 'http://user:password@host:port' ",
433 " random_url_key: 'http://user:********@host:port' ",
434 );
435 assert_clean(
436 " random_url_key: 'mongodb+s.r-v://user:password@host:port' ",
437 " random_url_key: 'mongodb+s.r-v://user:********@host:port' ",
438 );
439 assert_clean(
440 " random_url_key: 'mongodb+srv://user:pass-with-hyphen@abc.example.com/database' ",
441 " random_url_key: 'mongodb+srv://user:********@abc.example.com/database' ",
442 );
443 }
444
445 #[test]
446 fn test_password_yaml_double_quoted_value() {
447 assert_clean("password: \"supersecret\"", "password: \"********\"");
448 }
449
450 #[test]
451 fn test_password_unquoted_value_still_scrubbed() {
452 assert_clean("password=supersecret", "password=********");
453 assert_clean("password: supersecret", "password: ********");
454 }
455
456 #[test]
457 fn test_json_password_like_key_scrubs_to_valid_json() {
458 let scrubber = default_scrubber();
459 let input = r#"{"mysql_password": "supersecret"}"#;
461 let cleaned = String::from_utf8(scrubber.scrub_bytes(input.as_bytes())).unwrap();
462 serde_json::from_str::<serde_json::Value>(&cleaned).expect("scrubbed JSON must parse");
463 assert!(cleaned.contains("********"));
464
465 let input_compact = r#"{"password":"secret"}"#;
467 let cleaned_compact = String::from_utf8(scrubber.scrub_bytes(input_compact.as_bytes())).unwrap();
468 serde_json::from_str::<serde_json::Value>(&cleaned_compact).expect("compact scrubbed JSON must parse");
469 assert!(
470 cleaned_compact.contains("********"),
471 "compact JSON password must be scrubbed: {cleaned_compact}"
472 );
473 }
474
475 #[test]
476 fn test_json_sensitive_keys_preserve_non_string_values() {
477 let input = serde_json::json!({
478 "password": null,
479 "database_password": true,
480 "PWD": 42,
481 "auth_token": [
482 { "nested_password": "array-secret" },
483 "ordinary array value",
484 ],
485 "refresh_token": {
486 "nested_jwt": "object-secret",
487 "ordinary": "ordinary object value",
488 },
489 "ordinary_string": "ordinary root value",
490 });
491 let expected = serde_json::json!({
492 "password": null,
493 "database_password": true,
494 "PWD": 42,
495 "auth_token": [
496 { "nested_password": "********" },
497 "ordinary array value",
498 ],
499 "refresh_token": {
500 "nested_jwt": "********",
501 "ordinary": "ordinary object value",
502 },
503 "ordinary_string": "ordinary root value",
504 });
505 let encoded = serde_json::to_vec(&input).unwrap();
506 let cleaned = default_scrubber().scrub_bytes(&encoded);
507 let parsed: serde_json::Value =
508 serde_json::from_slice(&cleaned).expect("scrubbing non-string values must preserve valid JSON");
509
510 assert_eq!(parsed, expected);
511 }
512
513 #[test]
514 fn test_json_sensitive_keys_scrub_string_values() {
515 let input =
516 r#"{"mysql_password":"supersecret","AUTH_TOKEN": "cluster-agent-token","service-jwt":"encoded.jwt"}"#;
517 let cleaned = default_scrubber().scrub_bytes(input.as_bytes());
518 let parsed: serde_json::Value =
519 serde_json::from_slice(&cleaned).expect("scrubbing string values must preserve valid JSON");
520
521 assert_eq!(parsed["mysql_password"], "********");
522 assert_eq!(parsed["AUTH_TOKEN"], "********");
523 assert_eq!(parsed["service-jwt"], "********");
524 }
525
526 #[test]
527 fn test_json_single_line_api_key_scrub() {
528 let scrubber = default_scrubber();
529 let input = r#"{"api_key":"aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb"}"#;
530 let cleaned = scrubber.scrub_bytes(input.as_bytes());
531 let cleaned_string = String::from_utf8(cleaned).unwrap();
532 serde_json::from_str::<serde_json::Value>(&cleaned_string).expect("scrubbed output must parse as JSON");
534 assert!(
535 cleaned_string.contains("***************************"),
536 "expected masked api key suffix, got: {cleaned_string}"
537 );
538 }
539
540 #[test]
541 fn test_large_single_line_json_scrubbed_still_parses() {
542 let mut map = serde_json::Map::new();
543 map.insert("api_key".into(), serde_json::json!("aaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb"));
544 map.insert("pad".into(), serde_json::json!("x".repeat(25_000)));
545 let line = serde_json::to_string(&serde_json::Value::Object(map)).unwrap();
546 assert!(line.len() > 16_384, "sanity: payload should exceed 16 KiB");
547
548 let scrubber = default_scrubber();
549 let cleaned = scrubber.scrub_bytes(line.as_bytes());
550 let cleaned_string = String::from_utf8(cleaned).unwrap();
551 serde_json::from_str::<serde_json::Value>(&cleaned_string).expect("JSON parse after scrub");
552 }
553
554 #[test]
555 fn test_text_strip_app_key() {
556 assert_clean(
557 "hintedAPPKeyReplacer : http://dog.tld/app_key=InvalidLength12345abbbb",
558 "hintedAPPKeyReplacer : http://dog.tld/app_key=***********************************abbbb",
559 );
560 assert_clean(
561 "hintedAPPKeyReplacer : http://dog.tld/appkey=InvalidLength12345abbbb",
562 "hintedAPPKeyReplacer : http://dog.tld/appkey=***********************************abbbb",
563 );
564 assert_clean(
565 "hintedAPPKeyReplacer : http://dog.tld/application_key=InvalidLength12345abbbb",
566 "hintedAPPKeyReplacer : http://dog.tld/application_key=***********************************abbbb",
567 );
568 assert_clean(
569 "appKeyReplacer: http://dog.tld/aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaabbbb",
570 "appKeyReplacer: http://dog.tld/***********************************abbbb",
571 );
572 }
573
574 #[test]
575 fn test_config_strip_auth_token() {
576 assert_clean("auth_token: secret", "auth_token: ********");
577 assert_clean("auth_token=secret", "auth_token=********");
578 assert_clean(" auth_token: cluster-agent-token", " auth_token: ********");
579 assert_clean(
581 "cluster_agent.auth_token: cluster-agent-token",
582 "cluster_agent.auth_token: ********",
583 );
584 assert_clean("refresh_token: abc123", "refresh_token: ********");
586 assert_clean("jwt: eyJhbGci.payload", "jwt: ********");
587 assert_clean("auth_token: \"secret\"", "auth_token: \"********\"");
588 }
589
590 #[test]
591 fn test_json_auth_token_scrubs_to_valid_json() {
592 let scrubber = default_scrubber();
594 let input = r#"{"cluster_agent.auth_token":"cluster-agent-token"}"#;
595 let cleaned = String::from_utf8(scrubber.scrub_bytes(input.as_bytes())).unwrap();
596 serde_json::from_str::<serde_json::Value>(&cleaned).expect("scrubbed JSON must parse");
597 assert!(cleaned.contains("********"), "auth_token must be masked: {cleaned}");
598 assert!(
599 !cleaned.contains("cluster-agent-token"),
600 "raw token must not remain: {cleaned}"
601 );
602 }
603
604 #[test]
605 fn test_token_replacer_no_false_positive() {
606 assert_clean("tokenizer: enabled", "tokenizer: enabled");
608 assert_clean("max_tokens: 100", "max_tokens: 100");
609 assert_clean("token_expiry: 3600", "token_expiry: 3600");
610 }
611
612 #[test]
613 fn test_strip_bearer_token() {
614 let token = format!("{}bcdef", "a".repeat(59));
616 let masked = format!("{}bcdef", "*".repeat(59));
617 assert_clean(
618 &format!("Authorization: Bearer {token}"),
619 &format!("Authorization: Bearer {masked}"),
620 );
621
622 assert_clean(
624 "Authorization: Bearer my-arbitrary-token-value",
625 "Authorization: Bearer ********",
626 );
627
628 assert_clean(
630 &format!("Authorization: bearer {token}"),
631 &format!("Authorization: bearer {masked}"),
632 );
633 assert_clean(
634 "Authorization: bearer my-arbitrary-token-value",
635 "Authorization: bearer ********",
636 );
637 }
638
639 #[test]
640 fn test_bearer_token_in_json_stays_valid() {
641 let scrubber = default_scrubber();
643 let input = r#"{"authorization":"Bearer my-arbitrary-token-value","keep":"value"}"#;
644 let cleaned = String::from_utf8(scrubber.scrub_bytes(input.as_bytes())).unwrap();
645 serde_json::from_str::<serde_json::Value>(&cleaned).expect("scrubbed JSON must parse");
646 assert!(
647 cleaned.contains("Bearer ********"),
648 "bearer token must be masked: {cleaned}"
649 );
650 assert!(
651 cleaned.contains(r#""keep":"value""#),
652 "adjacent field must survive: {cleaned}"
653 );
654 }
655}