saluki_common/
scrubber.rs

1//! A YAML scrubber for redacting sensitive information.
2
3use 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
21/// Defines a rule for scrubbing sensitive information.
22pub struct Replacer {
23    /// `regex` must match the sensitive information within a value.
24    pub regex: Option<Regex>,
25
26    /// `hints`, if given, are strings which must also be present in the text for the
27    /// `regex` to match. This can be used to limit the contexts where an otherwise
28    /// very broad `regex` is actually applied.
29    pub hints: Option<Vec<String>>,
30
31    /// `repl` is the byte slice to replace the substring matching `regex`. It can use
32    /// the `regex` crate's replacement-string syntax (for example, `$1` to refer to the
33    /// first capture group).
34    pub repl: Option<Vec<u8>>,
35
36    /// `repl_func`, if set, is called with the matched byte slice. The return value
37    /// is used as the replacement. Only one of `repl` and `repl_func` should be set.
38    pub repl_func: Option<ReplFunc>,
39}
40
41static DEFAULT_SCRUBBER: OnceLock<Scrubber> = OnceLock::new();
42
43/// Returns a reference to the default, lazily initialized global scrubber.
44///
45/// This function ensures that the default scrubber, with its associated regex compilation,
46/// is only initialized once for the lifetime of the application.
47pub 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        // Non-hinted API key replacer: matches 32 hex chars, keeps last 5
72        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        // YAML-specific replacers that are aware of quotes and other syntax
80        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        // Replacer for DDRCM App Key
102        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        // Bearer token in the canonical 64-hex form (for example, an IPC/Cluster Agent auth token in an
110        // `Authorization: Bearer <token>` header): mask the first 59 hex characters and keep the last 5 for
111        // correlation. Runs before `bearer_catchall_replacer` so the masked output is left untouched by it.
112        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        // Any other `Bearer <token>` value (arbitrary, non-hex). The token character class excludes `*`,
127        // whitespace, and `"` so the match stops at the JSON string boundary and cannot span into adjacent
128        // fields, keeping scrubbed JSON valid (and the `*` exclusion avoids re-matching the output of
129        // `bearer_hex_replacer`). This is the JSON-safe equivalent of the upstream `\bBearer\s+[^*]+\b`.
130        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        // Replacer for URI passwords (for example, protocol://user:password@host)
145        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        // JSON string values need both quotes to be part of the match. Keeping the key and separator captures while
153        // replacing the complete value string prevents non-string JSON values from being mistaken for secrets.
154        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        // Redacts JSON string values whose key ends in `token` or `jwt` (for example, `auth_token`,
164        // `cluster_agent.auth_token`, or `refresh_token`). Requiring a quoted JSON value keeps non-string values such
165        // as `null` unchanged and preserves valid JSON.
166        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        // Plain text and YAML keys cannot begin immediately after a double quote. This retains legacy unquoted and
174        // dotted-key matching without allowing the search to begin partway through a quoted JSON key.
175        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        // Redacts plain text and YAML values for unquoted keys ending in `token` or `jwt`, including dotted keys such
186        // as `cluster_agent.auth_token`. `hints` is intentionally `None`: hint checks are case-sensitive, while token
187        // key matching is not, so a `token` hint would skip uppercase keys such as `AUTH_TOKEN`.
188        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
221/// A YAML scrubber that can be configured with different replacers.
222pub struct Scrubber {
223    replacers: Vec<Replacer>,
224}
225
226impl Scrubber {
227    /// Creates a new `Scrubber` with no replacers.
228    pub fn new() -> Self {
229        Self { replacers: vec![] }
230    }
231
232    /// Adds a replacer to the scrubber.
233    pub fn add_replacer(&mut self, replacer: Replacer) {
234        self.replacers.push(replacer);
235    }
236
237    /// Scrubs sensitive data from a byte slice.
238    ///
239    /// This method will scrub the data, returning a new byte vector.
240    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; // EOF
252            }
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    /// Applies the replacers to the data.
270    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: &regex::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        // spaced (pretty-printed JSON / YAML)
460        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        // compact JSON (no space after colon)
466        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        // Must remain valid JSON after scrubbing (regex YAML-style replacers must not corrupt JSON).
533        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        // Flat dotted key: the prefix before the final `.` is preserved, only the value is masked.
580        assert_clean(
581            "cluster_agent.auth_token: cluster-agent-token",
582            "cluster_agent.auth_token: ********",
583        );
584        // Any key ending in `token`/`jwt`, quoted or not.
585        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        // The reported scenario: `cluster_agent.auth_token` in the JSON the `config` CLI re-parses.
593        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        // Keys that merely contain `token` but do not end in `token`/`jwt` are left untouched.
607        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        // Canonical 64-hex bearer token: first 59 characters masked, last 5 kept for correlation.
615        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        // Arbitrary (non-hex) bearer token: fully masked.
623        assert_clean(
624            "Authorization: Bearer my-arbitrary-token-value",
625            "Authorization: Bearer ********",
626        );
627
628        // Now in lowercase for case insensitivity.
629        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        // A `Bearer <token>` embedded in compact JSON must mask only the token, not span into adjacent fields.
642        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}