Skip to main content

victauri_plugin/
redaction.rs

1use regex::RegexSet;
2
3const BUILTIN_PATTERNS: &[&str] = &[
4    // API keys: sk-..., pk-..., key-... (20+ chars)
5    r"(?i)\b(sk|pk|key)[-_][a-zA-Z0-9]{20,}\b",
6    // Bearer tokens in output
7    r"(?i)bearer\s+[a-zA-Z0-9\-_.~+/]{20,}",
8    // AWS keys
9    r"\bAKIA[0-9A-Z]{16}\b",
10    // JWT tokens (3 base64 sections separated by dots)
11    r"\beyJ[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\.[a-zA-Z0-9_-]{10,}\b",
12    // Generic long hex secrets (40+ hex chars — SHA1 hashes, API keys)
13    r"\b[0-9a-fA-F]{40,}\b",
14    // Email addresses
15    r"\b[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}\b",
16    // Credit card numbers (basic patterns)
17    r"\b\d{4}[- ]?\d{4}[- ]?\d{4}[- ]?\d{4}\b",
18    // OpenAI-style keys
19    r"\bsk-[a-zA-Z0-9]{32,}\b",
20    // Anthropic keys
21    r"\bsk-ant-[a-zA-Z0-9\-]{20,}\b",
22    // GitHub tokens
23    r"\b(ghp|gho|ghu|ghs|ghr)_[a-zA-Z0-9]{36,}\b",
24    // Stripe keys
25    r"\b(sk|pk|rk)_(test|live)_[a-zA-Z0-9]{20,}\b",
26];
27
28const SENSITIVE_JSON_KEYS: &[&str] = &[
29    "api_key",
30    "apiKey",
31    "api-key",
32    "secret",
33    "password",
34    "passwd",
35    "token",
36    "access_token",
37    "refresh_token",
38    "private_key",
39    "privateKey",
40    "secret_key",
41    "secretKey",
42    "authorization",
43    "auth_token",
44    "session_token",
45    "cookie",
46    "credentials",
47    "ssn",
48    "credit_card",
49    "card_number",
50];
51
52/// URL query/fragment parameters whose value is a credential. Matched only as a whole parameter
53/// name right after `?`, `&` or `#`, so `?keyboard=` or `&tokens_used=` are left alone.
54const SECRET_URL_PARAMS: &[&str] = &[
55    "access_token",
56    "access-token",
57    "refresh_token",
58    "id_token",
59    "auth_token",
60    "token",
61    "api_key",
62    "api-key",
63    "apikey",
64    "key",
65    "client_secret",
66    "secret",
67    "password",
68    "passwd",
69    "pwd",
70    "auth",
71    "sig",
72    "signature",
73    "x-amz-signature",
74    "x-amz-credential",
75    "x-amz-security-token",
76    "x-goog-signature",
77    "x-goog-credential",
78];
79
80/// `?name=value` / `&name=value` / `#name=value` for a [`SECRET_URL_PARAMS`] name; the value runs
81/// to the next separator, whitespace, quote or backslash (the end of a JSON string).
82static URL_SECRET_PARAM_RE: std::sync::LazyLock<Option<regex::Regex>> =
83    std::sync::LazyLock::new(|| {
84        let names: Vec<String> = SECRET_URL_PARAMS.iter().map(|n| regex::escape(n)).collect();
85        compile_builtin(&format!(
86            r#"(?i)([?&#](?:{})=)[^&#\s"'<>\\]+"#,
87            names.join("|")
88        ))
89    });
90
91/// `"<key containing a sensitive word>": "<string>"` or `: <number>` in free text — JSON
92/// embedded in a non-JSON output, a truncated JSON fragment, or the content of a JSON string. A
93/// string value cut off by the end of the text (a truncated body) is redacted to the end.
94static TEXT_KEY_VALUE_RE: std::sync::LazyLock<Option<regex::Regex>> = std::sync::LazyLock::new(
95    || {
96        let mut words: Vec<String> = SENSITIVE_JSON_KEYS
97            .iter()
98            .map(|k| regex::escape(&k.to_lowercase()))
99            .collect();
100        words.sort();
101        words.dedup();
102        compile_builtin(&format!(
103            r#"(?i)("[^"\\\n]{{0,64}}?(?:{})[^"\\\n]{{0,64}}?"\s*:\s*)("(?:[^"\\]|\\.)*(?:"|\z)|-?\d[\d.eE+-]*)"#,
104            words.join("|")
105        ))
106    },
107);
108
109/// Compile one of the built-in patterns; a failure is a bug, logged instead of panicking.
110fn compile_builtin(pattern: &str) -> Option<regex::Regex> {
111    regex::Regex::new(pattern)
112        .inspect_err(|e| tracing::error!("BUG: built-in redaction pattern failed to compile: {e}"))
113        .ok()
114}
115
116/// Replace the value of every sensitive `"key": value` pair found in free text.
117fn redact_text_key_values(text: &str) -> std::borrow::Cow<'_, str> {
118    match TEXT_KEY_VALUE_RE.as_ref() {
119        Some(re) => re.replace_all(text, r#"${1}"[REDACTED]""#),
120        None => std::borrow::Cow::Borrowed(text),
121    }
122}
123
124/// Output redactor that scrubs API keys, tokens, emails, and sensitive JSON keys
125/// from MCP tool output. Applies built-in patterns plus optional custom regexes.
126pub struct Redactor {
127    builtin_set: RegexSet,
128    builtin_compiled: Vec<regex::Regex>,
129    custom_set: Option<RegexSet>,
130    custom_compiled: Vec<regex::Regex>,
131}
132
133impl Redactor {
134    /// Build a redactor with custom patterns.
135    ///
136    /// # Errors
137    ///
138    /// Returns [`regex::Error`] if any custom pattern (or a built-in pattern) fails to compile.
139    pub fn try_new(custom_patterns: &[String]) -> Result<Self, regex::Error> {
140        let builtin_set = RegexSet::new(BUILTIN_PATTERNS)?;
141        let builtin_compiled: Vec<regex::Regex> = BUILTIN_PATTERNS
142            .iter()
143            .filter_map(|p| regex::Regex::new(p).ok())
144            .collect();
145
146        let (custom_set, custom_compiled) = if custom_patterns.is_empty() {
147            (None, Vec::new())
148        } else {
149            let set = RegexSet::new(custom_patterns)?;
150            let compiled: Vec<regex::Regex> = custom_patterns
151                .iter()
152                .map(|p| regex::Regex::new(p))
153                .collect::<Result<Vec<_>, _>>()?;
154            (Some(set), compiled)
155        };
156
157        Ok(Self {
158            builtin_set,
159            builtin_compiled,
160            custom_set,
161            custom_compiled,
162        })
163    }
164
165    /// Build a redactor with custom patterns, logging a warning and skipping any invalid patterns.
166    ///
167    /// If the built-in redaction patterns fail to compile (a bug), falls back to
168    /// an empty set and logs an error rather than panicking.
169    pub fn new(custom_patterns: &[String]) -> Self {
170        let (builtin_set, builtin_compiled) = match RegexSet::new(BUILTIN_PATTERNS) {
171            Ok(set) => {
172                let compiled: Vec<regex::Regex> = BUILTIN_PATTERNS
173                    .iter()
174                    .filter_map(|p| regex::Regex::new(p).ok())
175                    .collect();
176                (set, compiled)
177            }
178            Err(e) => {
179                tracing::error!(
180                    "BUG: built-in redaction patterns failed to compile: {e}. \
181                     Redaction will be disabled."
182                );
183                // Fall back to an empty set so the process survives.
184                // An empty RegexSet always compiles successfully.
185                let empty: Vec<String> = Vec::new();
186                let empty_set = RegexSet::new(&empty).unwrap_or_else(|_| unreachable!());
187                (empty_set, Vec::new())
188            }
189        };
190
191        let (custom_set, custom_compiled) = if custom_patterns.is_empty() {
192            (None, Vec::new())
193        } else {
194            match RegexSet::new(custom_patterns) {
195                Ok(set) => {
196                    let compiled: Vec<regex::Regex> = custom_patterns
197                        .iter()
198                        .filter_map(|p| regex::Regex::new(p).ok())
199                        .collect();
200                    (Some(set), compiled)
201                }
202                Err(e) => {
203                    tracing::warn!("Failed to compile custom redaction patterns: {e}");
204                    (None, Vec::new())
205                }
206            }
207        };
208
209        Self {
210            builtin_set,
211            builtin_compiled,
212            custom_set,
213            custom_compiled,
214        }
215    }
216
217    /// Scrub sensitive data from `input` using regex patterns and JSON-key matching.
218    ///
219    /// Passes, in order: the value patterns (API keys, bearer tokens, JWTs, emails, … plus any
220    /// custom patterns); credential parameters in URLs (`?access_token=…`, `&sig=…`); then keys.
221    /// Key-based redaction applies to the whole output when it is JSON — including JSON carried
222    /// inside its string values (IPC/network bodies are JSON-encoded strings) — and otherwise to
223    /// `"key": value` pairs found in the text. Best effort: see the limits in `docs/src/security.md`.
224    #[must_use]
225    pub fn redact(&self, input: &str) -> String {
226        let mut output = self.redact_regex(input);
227        if let Some(re) = URL_SECRET_PARAM_RE.as_ref()
228            && re.is_match(&output)
229        {
230            output = re.replace_all(&output, "${1}[REDACTED]").into_owned();
231        }
232        output = self.redact_json_keys(&output);
233        output
234    }
235
236    fn redact_regex(&self, input: &str) -> String {
237        let has_builtin = self.builtin_set.is_match(input);
238        let has_custom = self.custom_set.as_ref().is_some_and(|c| c.is_match(input));
239
240        if !has_builtin && !has_custom {
241            return input.to_string();
242        }
243
244        let mut output = input.to_string();
245
246        if has_builtin {
247            for re in &self.builtin_compiled {
248                output = re.replace_all(&output, "[REDACTED]").to_string();
249            }
250        }
251
252        if has_custom {
253            for re in &self.custom_compiled {
254                output = re.replace_all(&output, "[REDACTED]").to_string();
255            }
256        }
257
258        output
259    }
260
261    fn redact_json_keys(&self, input: &str) -> String {
262        match serde_json::from_str::<serde_json::Value>(input) {
263            Ok(value) => match redact_json_value(&value, 0) {
264                Some(redacted) => {
265                    serde_json::to_string(&redacted).unwrap_or_else(|_| input.to_string())
266                }
267                None => input.to_string(),
268            },
269            // Not JSON as a whole: redact the `"key": value` pairs of any JSON inside the text.
270            Err(_) => redact_text_key_values(input).into_owned(),
271        }
272    }
273}
274
275/// How deep JSON nested inside JSON strings is followed.
276const MAX_NESTED_JSON_DEPTH: usize = 4;
277
278fn is_sensitive_key(key: &str) -> bool {
279    let lower_key = key.to_lowercase();
280    SENSITIVE_JSON_KEYS
281        .iter()
282        .any(|k| lower_key.contains(&k.to_lowercase()))
283}
284
285/// `value` with every sensitive key's value redacted, or `None` if nothing needed redacting.
286/// A string value that holds JSON is redacted inside (and re-encoded); any other string has the
287/// `"key": value` pairs in its text redacted (a truncated JSON body, say).
288fn redact_json_value(value: &serde_json::Value, depth: usize) -> Option<serde_json::Value> {
289    match value {
290        serde_json::Value::Object(map) => {
291            let mut changed = false;
292            let mut new_map = serde_json::Map::new();
293            for (key, val) in map {
294                let new_val = if is_sensitive_key(key) && !val.is_boolean() {
295                    // Booleans like `has_api_key: true` only indicate presence — kept.
296                    Some(serde_json::Value::String("[REDACTED]".into()))
297                } else {
298                    redact_json_value(val, depth)
299                };
300                changed |= new_val.is_some();
301                new_map.insert(key.clone(), new_val.unwrap_or_else(|| val.clone()));
302            }
303            changed.then_some(serde_json::Value::Object(new_map))
304        }
305        serde_json::Value::Array(arr) => {
306            let redacted: Vec<Option<serde_json::Value>> =
307                arr.iter().map(|v| redact_json_value(v, depth)).collect();
308            redacted.iter().any(Option::is_some).then(|| {
309                serde_json::Value::Array(
310                    redacted
311                        .into_iter()
312                        .zip(arr)
313                        .map(|(new, old)| new.unwrap_or_else(|| old.clone()))
314                        .collect(),
315                )
316            })
317        }
318        serde_json::Value::String(text) => redact_json_string(text, depth),
319        _ => None,
320    }
321}
322
323fn redact_json_string(text: &str, depth: usize) -> Option<serde_json::Value> {
324    let trimmed = text.trim_start();
325    if depth < MAX_NESTED_JSON_DEPTH
326        && (trimmed.starts_with('{') || trimmed.starts_with('['))
327        && let Ok(inner) = serde_json::from_str::<serde_json::Value>(text)
328    {
329        let redacted = redact_json_value(&inner, depth + 1)?;
330        return serde_json::to_string(&redacted)
331            .ok()
332            .map(serde_json::Value::String);
333    }
334    match redact_text_key_values(text) {
335        std::borrow::Cow::Owned(redacted) => Some(serde_json::Value::String(redacted)),
336        std::borrow::Cow::Borrowed(_) => None,
337    }
338}
339
340impl Default for Redactor {
341    fn default() -> Self {
342        Self::new(&[])
343    }
344}
345
346#[cfg(test)]
347mod tests {
348    use super::*;
349
350    #[test]
351    fn redacts_api_keys() {
352        let r = Redactor::default();
353        assert!(
354            r.redact("key is sk-abc123def456ghi789jkl012mno")
355                .contains("[REDACTED]")
356        );
357    }
358
359    #[test]
360    fn redacts_bearer_tokens() {
361        let r = Redactor::default();
362        let input = "Authorization: Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dozjgNryP4J3jVmNHl0w5N_XgL0n3I9PlFUP0THsR8U";
363        let output = r.redact(input);
364        assert!(output.contains("[REDACTED]"));
365        assert!(!output.contains("eyJhbGci"));
366    }
367
368    #[test]
369    fn redacts_emails() {
370        let r = Redactor::default();
371        assert!(
372            r.redact("contact user@example.com for help")
373                .contains("[REDACTED]")
374        );
375    }
376
377    #[test]
378    fn passes_through_clean_text() {
379        let r = Redactor::default();
380        let input = r#"{"ok": true, "title": "My App"}"#;
381        assert_eq!(r.redact(input), input);
382    }
383
384    #[test]
385    fn custom_patterns_work() {
386        let r = Redactor::new(&["secret_\\w+".to_string()]);
387        assert!(
388            r.redact("found secret_project_alpha here")
389                .contains("[REDACTED]")
390        );
391    }
392
393    #[test]
394    fn redacts_json_sensitive_keys() {
395        let r = Redactor::default();
396        let input = r#"{"api_key":"sk-test-12345","name":"John","token":"abc123"}"#;
397        let output = r.redact(input);
398        assert!(output.contains("[REDACTED]"));
399        assert!(output.contains("John"));
400        assert!(!output.contains("sk-test-12345"));
401    }
402
403    #[test]
404    fn preserves_boolean_sensitive_keys() {
405        let r = Redactor::default();
406        let input = r#"{"has_api_key":true,"api_key":"secret-value-here"}"#;
407        let output = r.redact(input);
408        let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
409        assert_eq!(parsed["has_api_key"], serde_json::Value::Bool(true));
410        assert_eq!(
411            parsed["api_key"],
412            serde_json::Value::String("[REDACTED]".into())
413        );
414    }
415
416    #[test]
417    fn redacts_nested_json_keys() {
418        let r = Redactor::default();
419        let input = r#"{"config":{"llm":{"api_key":"sk-live-xxx","model":"gpt-4"}}}"#;
420        let output = r.redact(input);
421        assert!(output.contains("[REDACTED]"));
422        assert!(output.contains("gpt-4"));
423        assert!(!output.contains("sk-live-xxx"));
424    }
425
426    /// R5B-REDACT1: secrets in URL query strings were not matched (tokens ride in IPC/network
427    /// URLs), and key-based redaction only applied when the WHOLE output parsed as JSON.
428    #[test]
429    fn redacts_secret_query_parameters() {
430        let r = Redactor::default();
431        let out = r.redact(
432            "GET https://api.example.com/v1/items?user=7&access_token=abc123def&page=2              https://x.test/cb#id_token=eyJraWQ&state=s https://h.test/?API_KEY=k9k9k9              https://blob.test/f?sv=2024&sig=Zx%2Fq&se=1",
433        );
434        for secret in ["abc123def", "eyJraWQ", "k9k9k9", "Zx%2Fq"] {
435            assert!(!out.contains(secret), "{secret} survived: {out}");
436        }
437        for kept in ["user=7", "page=2", "state=s", "sv=2024", "se=1"] {
438            assert!(out.contains(kept), "{kept} was redacted: {out}");
439        }
440        // Not a secret parameter: only whole parameter names match.
441        let clean = "https://x.test/?keyboard=us&monkey=1&tokens_used=5";
442        assert_eq!(r.redact(clean), clean);
443        // Inside a JSON string, the URL is redacted and the JSON stays valid.
444        let json = r#"{"url":"https://x.test/a?token=zzz111&b=1"}"#;
445        let v: serde_json::Value = serde_json::from_str(&r.redact(json)).unwrap();
446        assert_eq!(v["url"], "https://x.test/a?token=[REDACTED]&b=1");
447    }
448
449    #[test]
450    fn redacts_sensitive_keys_in_json_embedded_in_text() {
451        let r = Redactor::default();
452        let out =
453            r.redact(r#"invoke login -> {"ok":true,"session_token":"s3cr3t-value","n":1} done"#);
454        assert!(!out.contains("s3cr3t-value"), "{out}");
455        assert!(out.contains(r#""ok":true"#), "{out}");
456        // A truncated JSON fragment (log fields are cut at 4 KB) still has its keys redacted.
457        let out = r.redact(r#"body: {"password": "hunter2", "data": "xxxxxxxx…(truncated"#);
458        assert!(!out.contains("hunter2"), "{out}");
459        // …including when the cut falls inside the secret itself.
460        let out = r.redact(r#"body: {"id": 1, "secret": "abcdefgh"#);
461        assert!(!out.contains("abcdefgh"), "{out}");
462        assert!(out.contains(r#""id": 1"#), "{out}");
463    }
464
465    #[test]
466    fn redacts_sensitive_keys_in_json_nested_inside_json_strings() {
467        let r = Redactor::default();
468        // IPC/network logs carry bodies as JSON-encoded strings inside the JSON result.
469        let input = serde_json::json!({
470            "command": "get_settings",
471            "response": "{\"llm\":{\"api_key\":\"sk-live-abc\",\"model\":\"m1\"}}",
472            "truncated_body": "{\"refresh_token\":\"rt-999\",\"x\":\"…"
473        })
474        .to_string();
475        let out = r.redact(&input);
476        let v: serde_json::Value = serde_json::from_str(&out).expect("still valid JSON");
477        let response = v["response"].as_str().unwrap();
478        assert!(!response.contains("sk-live-abc"), "{out}");
479        assert!(response.contains("m1"), "{out}");
480        assert!(
481            !v["truncated_body"].as_str().unwrap().contains("rt-999"),
482            "{out}"
483        );
484        assert_eq!(v["command"], "get_settings");
485    }
486
487    #[test]
488    fn redacts_github_tokens() {
489        let r = Redactor::default();
490        assert!(
491            r.redact("ghp_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmno")
492                .contains("[REDACTED]")
493        );
494    }
495
496    #[test]
497    fn redacts_stripe_keys() {
498        let r = Redactor::default();
499        assert!(
500            r.redact("sk_test_ABCDEFGHIJKLMNOPQRSTUVWXYZab")
501                .contains("[REDACTED]")
502        );
503    }
504
505    #[test]
506    fn try_new_valid_patterns() {
507        let r = Redactor::try_new(&["secret_\\w+".to_string()]);
508        assert!(r.is_ok());
509        let r = r.unwrap();
510        assert!(r.redact("found secret_alpha here").contains("[REDACTED]"));
511    }
512
513    #[test]
514    fn try_new_invalid_pattern_returns_error() {
515        let r = Redactor::try_new(&["[invalid".to_string()]);
516        assert!(r.is_err());
517    }
518
519    #[test]
520    fn try_new_empty_patterns() {
521        let r = Redactor::try_new(&[]);
522        assert!(r.is_ok());
523    }
524}