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            // Upper bound 255: new-format `ghs_`/`gho_` tokens run well past the
39            // 40-char classic length, and an 80-char cap let the tail of a longer
40            // token leak past `[REDACTED]` into output and ContextSave memory.
41            ("github-token", r"gh[pousr]_[A-Za-z0-9]{36,255}\b"),
42            ("slack-token", r"xox[baprs]-[A-Za-z0-9-]{10,}"),
43            ("google-api-key", r"AIza[0-9A-Za-z_-]{35}"),
44            ("aws-access-key", r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b"),
45            ("jwt", r"\beyJ[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}\.[A-Za-z0-9_-]{8,}"),
46            // scheme://user:password@host  -> redacts the whole prefix up to '@'.
47            ("url-credentials", r"[a-zA-Z][a-zA-Z0-9+.\-]*://[^:@/\s]+:[^@/\s]+@"),
48            // First token char must be alnum/_/- so `bearer ./../etc` (a path) is
49            // not mistaken for a token; the rest allows the token68 alphabet.
50            ("bearer-token", r"(?i)bearer\s+[A-Za-z0-9_-][A-Za-z0-9._~+/-]{15,}={0,2}"),
51        ];
52        // `.expect`, not `.ok()`: these patterns are compile-time literals. A
53        // silently-dropped rule (the old `filter_map(... .ok())`) would let that
54        // entire credential class — private keys, JWTs — flow through unredacted
55        // with no log or error. Failing loud at first use is the safe default;
56        // `redact_rules_compile` below also catches a bad pattern in CI.
57        #[allow(clippy::expect_used)]
58        specs
59            .iter()
60            .map(|(name, pat)| {
61                let re = Regex::new(pat).expect("redaction rule pattern must compile");
62                (*name, re)
63            })
64            .collect()
65    })
66}
67
68/// Whether redaction is disabled via `WINX_NO_REDACT`.
69fn disabled() -> bool {
70    crate::config::env_flag("WINX_NO_REDACT")
71}
72
73/// Scrub credential patterns from `text`. Returns `Cow::Borrowed` (no allocation)
74/// when nothing matched, so the common case is cheap. The `regex` crate is
75/// linear-time, so this is safe on adversarial/large input.
76pub fn redact(text: &str) -> Cow<'_, str> {
77    if disabled() || text.is_empty() {
78        return Cow::Borrowed(text);
79    }
80    let mut owned: Option<String> = None;
81    for (name, re) in rules() {
82        let current: &str = owned.as_deref().unwrap_or(text);
83        // `replace_all` already returns `Cow::Borrowed` when nothing matched, so the
84        // separate `is_match` pre-check just doubled the regex passes on every MCP
85        // response. Drive off the returned Cow instead — one scan per rule.
86        if let Cow::Owned(replaced) =
87            re.replace_all(current, |_: &regex::Captures| format!("[REDACTED:{name}]"))
88        {
89            owned = Some(replaced);
90        }
91    }
92    owned.map_or(Cow::Borrowed(text), Cow::Owned)
93}
94
95/// Recursively redact every string inside a JSON value (for `structuredContent`).
96pub fn redact_json(value: &mut serde_json::Value) {
97    match value {
98        serde_json::Value::String(s) => {
99            if let Cow::Owned(r) = redact(s) {
100                *s = r;
101            }
102        }
103        serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
104        serde_json::Value::Object(map) => map.values_mut().for_each(redact_json),
105        _ => {}
106    }
107}
108
109#[cfg(test)]
110mod tests {
111    #![allow(clippy::unwrap_used)]
112    use super::*;
113    use proptest::prelude::*;
114
115    proptest! {
116        /// A known secret isolated by separators is ALWAYS redacted, wrapped in any
117        /// surrounding punctuation/whitespace — the raw value never survives into
118        /// output and a marker is emitted.
119        #[test]
120        fn isolated_secret_never_survives(
121            before in "[ \t=:;,(]{1,8}",
122            after in "[ \t=:;,)\n]{1,8}",
123            which in 0usize..2,
124        ) {
125            let secret = [
126                "AKIAIOSFODNN7EXAMPLE",
127                "ghp_0123456789012345678901234567890123456",
128            ][which];
129            let input = format!("{before}{secret}{after}");
130            let out = redact(&input);
131            prop_assert!(!out.contains(secret), "secret leaked: {out:?}");
132            prop_assert!(out.contains("[REDACTED:"), "no marker emitted: {out:?}");
133        }
134
135        /// Redaction is idempotent: scrubbing already-scrubbed text changes nothing
136        /// (the `[REDACTED:..]` markers must not themselves match any rule).
137        #[test]
138        fn redaction_is_idempotent(s in "[ -~]{0,80}") {
139            let once = redact(&s).into_owned();
140            let twice = redact(&once).into_owned();
141            prop_assert_eq!(once, twice);
142        }
143    }
144
145    #[test]
146    fn redacts_known_credentials() {
147        let cases = [
148            "AKIAIOSFODNN7EXAMPLE",
149            "ghp_0123456789012345678901234567890123456",
150            "sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
151            "AIzaSyA1234567890123456789012345678901234",
152            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcDEF123456",
153            "postgres://user:hunter2@db.local/app",
154        ];
155        for c in cases {
156            let red = redact(c);
157            assert!(red.contains("[REDACTED:"), "did not redact: {c} -> {red}");
158            assert!(!red.contains("hunter2"));
159        }
160    }
161
162    #[test]
163    fn redacts_pem_private_key_block() {
164        let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34Gkx...\n-----END RSA PRIVATE KEY-----\nafter";
165        let red = redact(pem);
166        assert!(red.contains("before") && red.contains("after"));
167        assert!(red.contains("[REDACTED:private-key]"));
168        assert!(!red.contains("MIIBOgIBAAJBAKj"));
169    }
170
171    #[test]
172    fn leaves_ordinary_code_untouched() {
173        let code = "pub fn parse_config(path: &str) -> Result<Config> { Ok(Config::default()) }";
174        assert!(matches!(redact(code), Cow::Borrowed(_)));
175    }
176
177    #[test]
178    fn bearer_does_not_eat_a_path() {
179        // `bearer ./../etc/passwd` is a path, not a token - must not be redacted.
180        let line = "run: bearer ./../etc/passwd && cat file";
181        assert!(matches!(redact(line), Cow::Borrowed(_)), "redacted a path as a bearer token");
182        // a real bearer token still redacts
183        let auth = "Authorization: Bearer abcdef0123456789ABCDEF";
184        assert!(redact(auth).contains("[REDACTED:bearer-token]"));
185    }
186
187    #[test]
188    fn redacts_inside_surrounding_text() {
189        let line = "export GITHUB_TOKEN=ghp_0123456789012345678901234567890123456 # ci";
190        let red = redact(line);
191        assert!(red.contains("export GITHUB_TOKEN="));
192        assert!(red.contains("[REDACTED:github-token]"));
193        assert!(red.contains("# ci"));
194    }
195
196    #[test]
197    fn redact_rules_compile() {
198        // Forces `rules()` to build every pattern. If any literal regresses into an
199        // invalid pattern, this fails in CI instead of panicking in production on
200        // the first redact() call (or, pre-fix, silently dropping the rule).
201        let names: Vec<&str> = rules().iter().map(|(n, _)| *n).collect();
202        assert!(names.contains(&"private-key"));
203        assert!(names.contains(&"github-token"));
204        assert_eq!(names.len(), 11, "rule count changed — update this assertion");
205    }
206
207    #[test]
208    fn redacts_long_github_token() {
209        // A 120-char ghs_ token must be redacted whole — no tail leaking past the
210        // marker (the old {36,80} bound truncated the match).
211        let tok = format!("ghs_{}", "a".repeat(120));
212        let line = format!("token={tok} done");
213        let red = redact(&line);
214        assert!(red.contains("[REDACTED:github-token]"), "not redacted: {red}");
215        assert!(!red.contains("aaaaaaaaaa"), "token tail leaked: {red}");
216    }
217
218    #[test]
219    fn redacts_json_strings() {
220        let mut v = serde_json::json!({"out": "key=AKIAIOSFODNN7EXAMPLE", "n": 5, "ok": true});
221        redact_json(&mut v);
222        assert!(v["out"].as_str().unwrap().contains("[REDACTED:"));
223        assert_eq!(v["n"], 5);
224    }
225}