From 73baa1911250e300ce84107e10cd813c0a73abe5 Mon Sep 17 00:00:00 2001 From: M3gA-Mind Date: Fri, 23 Oct 2026 04:23:55 +0530 Subject: [PATCH] fix(sanitize): redact secrets inside escaped JSON --- crates/tinyinference-core/src/sanitize.rs | 25 +++++++++++-------- .../tinyinference-core/src/sanitize_tests.rs | 8 ++++++ 2 files changed, 22 insertions(+), 11 deletions(-) diff --git a/crates/tinyinference-core/src/sanitize.rs b/crates/tinyinference-core/src/sanitize.rs index d52c273e..2e3273ca 100644 --- a/crates/tinyinference-core/src/sanitize.rs +++ b/crates/tinyinference-core/src/sanitize.rs @@ -105,12 +105,14 @@ pub fn scrub_secret_patterns(input: &str) -> String { /// [`scrub_credentials`] drops `==` comparisons and asks [`looks_like_secret`] /// whether the value is a credential or ordinary code. /// -/// Groups: `key` = sensitive key, 2 = operator, 3 = double-quoted value, -/// 4 = single-quoted value, 5 = bare value. A bare value may start with `$` -/// and continue through `@ $ % # ~ ^`; `!`, `?`, `:`, `&` and `*` still end it -/// (GraphQL `String!`, `Token::Bearer`, query strings, the redaction marker). +/// Groups: `key` = sensitive key, `op` = operator, `dq`/`sq` = double/single- +/// quoted value, `edq` = value quoted with `\"` inside stringified JSON +/// (a value holding an escaped quote is only redacted up to it), `bare` = +/// bare value. A bare value may start with `$` and continue through +/// `@ $ % # ~ ^`; `!`, `?`, `:`, `&` and `*` still end it (GraphQL `String!`, +/// `Token::Bearer`, query strings, the redaction marker). static SENSITIVE_KV_REGEX: LazyLock = LazyLock::new(|| { - Regex::new(r#"(?i)\b(?P[a-z0-9_\-]*(?:token|api[_-]?key|password|secret|user[_-]?key|bearer|credential))["']?[ \t]*(:=|==|[:=])[ \t]*(?:"((?:\\.|[^"\\])*)"|'((?:\\.|[^'\\])*)'|([a-zA-Z0-9_+./=\-$][a-zA-Z0-9_+./=\-@$%#~^]*))"#).unwrap() + Regex::new(r#"(?i)\b(?P[a-z0-9_\-]*(?:token|api[_-]?key|password|secret|user[_-]?key|bearer|credential))(?:\\?["'])?[ \t]*(?P:=|==|[:=])[ \t]*(?:"(?P(?:\\.|[^"\\])*)"|'(?P(?:\\.|[^'\\])*)'|\\"(?P(?:\\\\|[^"\\])*)\\"|(?P[a-zA-Z0-9_+./=\-$][a-zA-Z0-9_+./=\-@$%#~^]*))"#).unwrap() }); /// Value prefixes issued by credential providers. A labelled value starting @@ -298,14 +300,15 @@ pub fn scrub_credentials(input: &str) -> String { let stage_kv = SENSITIVE_KV_REGEX.replace_all(input, |caps: ®ex::Captures<'_>| { let full_match = &caps[0]; // `token == other` compares; it does not assign. - if &caps[2] == "==" { + if &caps["op"] == "==" { return full_match.to_string(); } - let quoted = caps.get(5).is_none(); + let quoted = caps.name("bare").is_none(); let value = caps - .get(3) - .or(caps.get(4)) - .or(caps.get(5)) + .name("dq") + .or(caps.name("sq")) + .or(caps.name("edq")) + .or(caps.name("bare")) .expect("sensitive key-value match has a value"); let rest = &input[caps.get(0).expect("full match").end()..]; let key = caps.name("key").expect("sensitive key capture"); @@ -319,7 +322,7 @@ pub fn scrub_credentials(input: &str) -> String { let annotation_follows = rest .trim_start_matches([' ', '\t']) .starts_with([',', ';', ')', '<', '>', '|', '=', '[', '{', '?']); - if &caps[2] == ":" + if &caps["op"] == ":" && !quoted && ((value .as_str() diff --git a/crates/tinyinference-core/src/sanitize_tests.rs b/crates/tinyinference-core/src/sanitize_tests.rs index d70530ca..2dd8e55b 100644 --- a/crates/tinyinference-core/src/sanitize_tests.rs +++ b/crates/tinyinference-core/src/sanitize_tests.rs @@ -360,3 +360,11 @@ fn bare_values_with_punctuation_are_fully_redacted() { .collect(); assert!(leaked.is_empty(), "leaked: {leaked:?}"); } + +// A labelled secret inside stringified (escaped) JSON. +#[test] +fn secret_in_escaped_json_is_redacted() { + let input = r#"{"body":"{\"password\":\"hunter2secret\"}"}"#; + let out = scrub_credentials(input); + assert!(!out.contains("hunter2secret"), "leaked: {out}"); +}