Skip to main content

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        // Capture the optional closing `"` as $4 so the replacement preserves it for JSON values without breaking
153        // unquoted values (plain text / YAML). Without $4, `"password":"secret"` → `"password":"********` (invalid JSON).
154        // `:[ ]?` matches both compact JSON (`"password":"secret"`) and spaced YAML (`password: secret`).
155        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        // Redacts the value of any key ending in `token` or `jwt` (for example, `auth_token`,
163        // `cluster_agent.auth_token`, `refresh_token`). Mirrors `password_replacer`: the trailing `"` in the
164        // key group plus the `$4` closing-quote capture keep compact JSON (`"auth_token":"x"`) valid, and the
165        // optional leading `"` lets the match start mid-key (so dotted keys like `cluster_agent.auth_token`
166        // match after the `.`). `hints` is intentionally `None`: the hint check is case-sensitive, so a
167        // `"token"` hint would skip an uppercase `AUTH_TOKEN` key that `(?i)` would otherwise match.
168        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
196/// A YAML scrubber that can be configured with different replacers.
197pub struct Scrubber {
198    replacers: Vec<Replacer>,
199}
200
201impl Scrubber {
202    /// Creates a new `Scrubber` with no replacers.
203    pub fn new() -> Self {
204        Self { replacers: vec![] }
205    }
206
207    /// Adds a replacer to the scrubber.
208    pub fn add_replacer(&mut self, replacer: Replacer) {
209        self.replacers.push(replacer);
210    }
211
212    /// Scrubs sensitive data from a byte slice.
213    ///
214    /// This method will scrub the data, returning a new byte vector.
215    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; // EOF
227            }
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    /// Applies the replacers to the data.
245    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: &regex::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        // spaced (pretty-printed JSON / YAML)
435        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        // compact JSON (no space after colon)
441        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        // Must remain valid JSON after scrubbing (regex YAML-style replacers must not corrupt JSON).
457        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        // Flat dotted key: the prefix before the final `.` is preserved, only the value is masked.
504        assert_clean(
505            "cluster_agent.auth_token: cluster-agent-token",
506            "cluster_agent.auth_token: ********",
507        );
508        // Any key ending in `token`/`jwt`, quoted or not.
509        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        // The reported scenario: `cluster_agent.auth_token` in the JSON the `config` CLI re-parses.
517        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        // Keys that merely contain `token` but do not end in `token`/`jwt` are left untouched.
531        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        // Canonical 64-hex bearer token: first 59 characters masked, last 5 kept for correlation.
539        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        // Arbitrary (non-hex) bearer token: fully masked.
547        assert_clean(
548            "Authorization: Bearer my-arbitrary-token-value",
549            "Authorization: Bearer ********",
550        );
551
552        // Now in lowercase for case insensitivity.
553        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        // A `Bearer <token>` embedded in compact JSON must mask only the token, not span into adjacent fields.
566        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}