Skip to main content

winx_code_agent/utils/
redact.rs

1//! Secret redaction for outgoing tool output.
2//!
3//! winx pipes shell output, file contents, and saved memory straight into the
4//! model's context - `env`, `cat .env`, CI logs, etc. This scrubs high-confidence
5//! credential patterns from any text leaving the server (and from persisted
6//! `ContextSave` memory) before it reaches the model or disk.
7//!
8//! Only **named, low-false-positive** patterns are matched (provider key
9//! prefixes, JWTs, PEM private-key blocks, `user:pass@` URLs). There is no
10//! entropy heuristic, so ordinary code is never mangled. Set `WINX_NO_REDACT=1`
11//! to disable entirely (e.g. when you knowingly need the raw value).
12//!
13//! A redacted span becomes `[REDACTED:<rule>]` - the rule name only, never any
14//! part of the secret.
15
16use std::borrow::Cow;
17use std::sync::OnceLock;
18
19use regex::Regex;
20
21/// Named credential patterns, applied in order. Each match is replaced wholesale
22/// with `[REDACTED:<name>]`. Kept deliberately specific to avoid false positives.
23fn rules() -> &'static [(&'static str, Regex)] {
24    static RULES: OnceLock<Vec<(&'static str, Regex)>> = OnceLock::new();
25    RULES.get_or_init(|| {
26        let specs: &[(&str, &str)] = &[
27            // PEM private key blocks (any kind). `(?s)` so `.` spans newlines.
28            (
29                "private-key",
30                r"(?s)-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----.*?-----END [A-Z0-9 ]*PRIVATE KEY-----",
31            ),
32            // Upper bounds keep a stray `sk-`/`ghp_` prefix on a long base64 blob
33            // from swallowing the whole blob, while staying generous enough for
34            // real (incl. long `sk-proj-`) keys.
35            ("anthropic-key", r"sk-ant-[A-Za-z0-9_-]{20,200}"),
36            ("openai-key", r"sk-(?:proj-)?[A-Za-z0-9_-]{20,200}"),
37            ("github-pat", r"github_pat_[A-Za-z0-9_]{22,90}"),
38            ("github-token", r"gh[pousr]_[A-Za-z0-9]{36,80}\b"),
39            ("slack-token", r"xox[baprs]-[A-Za-z0-9-]{10,}"),
40            ("google-api-key", r"AIza[0-9A-Za-z_-]{35}"),
41            ("aws-access-key", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
42            ("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
43            // scheme://user:password@host  -> redacts the whole prefix up to '@'.
44            ("url-credentials", r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^:@/\s]+:[^@/\s]+@"),
45            // First token char must be alnum/_/- so `bearer ./../etc` (a path) is
46            // not mistaken for a token; the rest allows the token68 alphabet.
47            ("bearer-token", r"(?i)bearer\s+[A-Za-z0-9_-][A-Za-z0-9._~+/-]{15,}={0,2}"),
48        ];
49        specs.iter().filter_map(|(name, pat)| Regex::new(pat).ok().map(|re| (*name, re))).collect()
50    })
51}
52
53/// Whether redaction is disabled via `WINX_NO_REDACT`.
54fn disabled() -> bool {
55    std::env::var("WINX_NO_REDACT").is_ok_and(|v| v != "0" && !v.is_empty())
56}
57
58/// Scrub credential patterns from `text`. Returns `Cow::Borrowed` (no allocation)
59/// when nothing matched, so the common case is cheap. The `regex` crate is
60/// linear-time, so this is safe on adversarial/large input.
61pub fn redact(text: &str) -> Cow<'_, str> {
62    if disabled() || text.is_empty() {
63        return Cow::Borrowed(text);
64    }
65    let mut owned: Option<String> = None;
66    for (name, re) in rules() {
67        let current: &str = owned.as_deref().unwrap_or(text);
68        if re.is_match(current) {
69            let replaced =
70                re.replace_all(current, |_: &regex::Captures| format!("[REDACTED:{name}]"));
71            owned = Some(replaced.into_owned());
72        }
73    }
74    owned.map_or(Cow::Borrowed(text), Cow::Owned)
75}
76
77/// Recursively redact every string inside a JSON value (for `structuredContent`).
78pub fn redact_json(value: &mut serde_json::Value) {
79    match value {
80        serde_json::Value::String(s) => {
81            if let Cow::Owned(r) = redact(s) {
82                *s = r;
83            }
84        }
85        serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
86        serde_json::Value::Object(map) => map.values_mut().for_each(redact_json),
87        _ => {}
88    }
89}
90
91#[cfg(test)]
92mod tests {
93    #![allow(clippy::unwrap_used)]
94    use super::*;
95
96    #[test]
97    fn redacts_known_credentials() {
98        let cases = [
99            "AKIAIOSFODNN7EXAMPLE",
100            "ghp_0123456789012345678901234567890123456",
101            "sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
102            "AIzaSyA1234567890123456789012345678901234",
103            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcDEF123456",
104            "postgres://user:hunter2@db.local/app",
105        ];
106        for c in cases {
107            let red = redact(c);
108            assert!(red.contains("[REDACTED:"), "did not redact: {c} -> {red}");
109            assert!(!red.contains("hunter2"));
110        }
111    }
112
113    #[test]
114    fn redacts_pem_private_key_block() {
115        let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34Gkx...\n-----END RSA PRIVATE KEY-----\nafter";
116        let red = redact(pem);
117        assert!(red.contains("before") && red.contains("after"));
118        assert!(red.contains("[REDACTED:private-key]"));
119        assert!(!red.contains("MIIBOgIBAAJBAKj"));
120    }
121
122    #[test]
123    fn leaves_ordinary_code_untouched() {
124        let code = "pub fn parse_config(path: &str) -> Result<Config> { Ok(Config::default()) }";
125        assert!(matches!(redact(code), Cow::Borrowed(_)));
126    }
127
128    #[test]
129    fn bearer_does_not_eat_a_path() {
130        // `bearer ./../etc/passwd` is a path, not a token - must not be redacted.
131        let line = "run: bearer ./../etc/passwd && cat file";
132        assert!(matches!(redact(line), Cow::Borrowed(_)), "redacted a path as a bearer token");
133        // a real bearer token still redacts
134        let auth = "Authorization: Bearer abcdef0123456789ABCDEF";
135        assert!(redact(auth).contains("[REDACTED:bearer-token]"));
136    }
137
138    #[test]
139    fn redacts_inside_surrounding_text() {
140        let line = "export GITHUB_TOKEN=ghp_0123456789012345678901234567890123456 # ci";
141        let red = redact(line);
142        assert!(red.contains("export GITHUB_TOKEN="));
143        assert!(red.contains("[REDACTED:github-token]"));
144        assert!(red.contains("# ci"));
145    }
146
147    #[test]
148    fn redacts_json_strings() {
149        let mut v = serde_json::json!({"out": "key=AKIAIOSFODNN7EXAMPLE", "n": 5, "ok": true});
150        redact_json(&mut v);
151        assert!(v["out"].as_str().unwrap().contains("[REDACTED:"));
152        assert_eq!(v["n"], 5);
153    }
154}