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/// Upper bound on full rule sweeps per call. One sweep is the common case; a
74/// second only runs after a match, and real input settles there.
75const MAX_SWEEPS: usize = 4;
76
77/// Scrub credential patterns from `text`. Returns `Cow::Borrowed` (no allocation)
78/// when nothing matched, so the common case is cheap. The `regex` crate is
79/// linear-time, so this is safe on adversarial/large input.
80///
81/// The result is a fixpoint: a marker can complete a match for an earlier rule
82/// (its `[` supplies the trailing `\b` an AWS key lacked when a bearer token
83/// followed it directly), so after any match the rules sweep again until the
84/// text stops changing. Callers can redact once and rely on
85/// `redact(redact(x)) == redact(x)`.
86pub fn redact(text: &str) -> Cow<'_, str> {
87    if disabled() || text.is_empty() {
88        return Cow::Borrowed(text);
89    }
90    let Some(mut owned) = sweep(text) else { return Cow::Borrowed(text) };
91    for _ in 1..MAX_SWEEPS {
92        match sweep(&owned) {
93            Some(next) => owned = next,
94            None => break,
95        }
96    }
97    Cow::Owned(owned)
98}
99
100/// One pass of every rule in order; `None` when nothing matched.
101fn sweep(text: &str) -> Option<String> {
102    let mut owned: Option<String> = None;
103    for (name, re) in rules() {
104        let current: &str = owned.as_deref().unwrap_or(text);
105        // `replace_all` already returns `Cow::Borrowed` when nothing matched, so the
106        // separate `is_match` pre-check just doubled the regex passes on every MCP
107        // response. Drive off the returned Cow instead — one scan per rule.
108        if let Cow::Owned(replaced) =
109            re.replace_all(current, |_: &regex::Captures| format!("[REDACTED:{name}]"))
110        {
111            owned = Some(replaced);
112        }
113    }
114    owned
115}
116
117/// Recursively redact every string inside a JSON value (for `structuredContent`).
118pub fn redact_json(value: &mut serde_json::Value) {
119    match value {
120        serde_json::Value::String(s) => {
121            if let Cow::Owned(r) = redact(s) {
122                *s = r;
123            }
124        }
125        serde_json::Value::Array(items) => items.iter_mut().for_each(redact_json),
126        serde_json::Value::Object(map) => map.values_mut().for_each(redact_json),
127        _ => {}
128    }
129}
130
131#[cfg(test)]
132mod tests {
133    #![allow(clippy::unwrap_used)]
134    use super::*;
135    use proptest::prelude::*;
136
137    proptest! {
138        /// A known secret isolated by separators is ALWAYS redacted, wrapped in any
139        /// surrounding punctuation/whitespace — the raw value never survives into
140        /// output and a marker is emitted.
141        #[test]
142        fn isolated_secret_never_survives(
143            before in "[ \t=:;,(]{1,8}",
144            after in "[ \t=:;,)\n]{1,8}",
145            which in 0usize..2,
146        ) {
147            let secret = [
148                "AKIAIOSFODNN7EXAMPLE",
149                "ghp_0123456789012345678901234567890123456",
150            ][which];
151            let input = format!("{before}{secret}{after}");
152            let out = redact(&input);
153            prop_assert!(!out.contains(secret), "secret leaked: {out:?}");
154            prop_assert!(out.contains("[REDACTED:"), "no marker emitted: {out:?}");
155        }
156
157        /// Redaction is idempotent: scrubbing already-scrubbed text changes nothing
158        /// (the `[REDACTED:..]` markers must not themselves match any rule).
159        #[test]
160        fn redaction_is_idempotent(s in "[ -~]{0,80}") {
161            let once = redact(&s).into_owned();
162            let twice = redact(&once).into_owned();
163            prop_assert_eq!(once, twice);
164        }
165    }
166
167    #[test]
168    fn redaction_reaches_a_fixpoint_when_a_marker_completes_a_match() {
169        // The bearer marker supplies the `\b` the AWS key lacked: one sweep
170        // leaves the key behind, so `redact` must sweep again before returning.
171        let line = " AKIAIOSFODNN7EXAMPLEbearer abcdefghijklmnopqrstuvwxyz";
172        let single = sweep(line).unwrap();
173        assert!(single.contains("AKIAIOSFODNN7EXAMPLE"), "{single}");
174        let red = redact(line).into_owned();
175        assert!(!red.contains("AKIAIOSFODNN7EXAMPLE"), "{red}");
176        assert_eq!(redact(&red), red.as_str());
177    }
178
179    #[test]
180    fn redacts_known_credentials() {
181        let cases = [
182            "AKIAIOSFODNN7EXAMPLE",
183            "ghp_0123456789012345678901234567890123456",
184            "sk-ant-api03-abcdefghijklmnopqrstuvwxyz",
185            "AIzaSyA1234567890123456789012345678901234",
186            "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxIn0.abcDEF123456",
187            "postgres://user:hunter2@db.local/app",
188        ];
189        for c in cases {
190            let red = redact(c);
191            assert!(red.contains("[REDACTED:"), "did not redact: {c} -> {red}");
192            assert!(!red.contains("hunter2"));
193        }
194    }
195
196    #[test]
197    fn redacts_pem_private_key_block() {
198        let pem = "before\n-----BEGIN RSA PRIVATE KEY-----\nMIIBOgIBAAJBAKj34Gkx...\n-----END RSA PRIVATE KEY-----\nafter";
199        let red = redact(pem);
200        assert!(red.contains("before") && red.contains("after"));
201        assert!(red.contains("[REDACTED:private-key]"));
202        assert!(!red.contains("MIIBOgIBAAJBAKj"));
203    }
204
205    #[test]
206    fn leaves_ordinary_code_untouched() {
207        let code = "pub fn parse_config(path: &str) -> Result<Config> { Ok(Config::default()) }";
208        assert!(matches!(redact(code), Cow::Borrowed(_)));
209    }
210
211    #[test]
212    fn bearer_does_not_eat_a_path() {
213        // `bearer ./../etc/passwd` is a path, not a token - must not be redacted.
214        let line = "run: bearer ./../etc/passwd && cat file";
215        assert!(matches!(redact(line), Cow::Borrowed(_)), "redacted a path as a bearer token");
216        // a real bearer token still redacts
217        let auth = "Authorization: Bearer abcdef0123456789ABCDEF";
218        assert!(redact(auth).contains("[REDACTED:bearer-token]"));
219    }
220
221    #[test]
222    fn redacts_inside_surrounding_text() {
223        let line = "export GITHUB_TOKEN=ghp_0123456789012345678901234567890123456 # ci";
224        let red = redact(line);
225        assert!(red.contains("export GITHUB_TOKEN="));
226        assert!(red.contains("[REDACTED:github-token]"));
227        assert!(red.contains("# ci"));
228    }
229
230    #[test]
231    fn redact_rules_compile() {
232        // Forces `rules()` to build every pattern. If any literal regresses into an
233        // invalid pattern, this fails in CI instead of panicking in production on
234        // the first redact() call (or, pre-fix, silently dropping the rule).
235        let names: Vec<&str> = rules().iter().map(|(n, _)| *n).collect();
236        assert!(names.contains(&"private-key"));
237        assert!(names.contains(&"github-token"));
238        assert_eq!(names.len(), 11, "rule count changed — update this assertion");
239    }
240
241    #[test]
242    fn redacts_long_github_token() {
243        // A 120-char ghs_ token must be redacted whole — no tail leaking past the
244        // marker (the old {36,80} bound truncated the match).
245        let tok = format!("ghs_{}", "a".repeat(120));
246        let line = format!("token={tok} done");
247        let red = redact(&line);
248        assert!(red.contains("[REDACTED:github-token]"), "not redacted: {red}");
249        assert!(!red.contains("aaaaaaaaaa"), "token tail leaked: {red}");
250    }
251
252    #[test]
253    fn redacts_json_strings() {
254        let mut v = serde_json::json!({"out": "key=AKIAIOSFODNN7EXAMPLE", "n": 5, "ok": true});
255        redact_json(&mut v);
256        assert!(v["out"].as_str().unwrap().contains("[REDACTED:"));
257        assert_eq!(v["n"], 5);
258    }
259}