Skip to main content

lean_ctx/core/
redaction.rs

1macro_rules! static_regex {
2    ($pattern:expr_2021) => {{
3        static RE: std::sync::OnceLock<regex::Regex> = std::sync::OnceLock::new();
4        RE.get_or_init(|| {
5            regex::Regex::new($pattern).expect(concat!("BUG: invalid static regex: ", $pattern))
6        })
7    }};
8}
9
10pub fn redaction_enabled_for_active_role() -> bool {
11    let role = crate::core::roles::active_role();
12    if role.role.name == "admin" {
13        role.io.redact_outputs
14    } else {
15        // Contract: redaction never disabled for non-admin roles.
16        true
17    }
18}
19
20pub fn redact_text_if_enabled(input: &str) -> String {
21    if !redaction_enabled_for_active_role() {
22        return input.to_string();
23    }
24    redact_text(input)
25}
26
27/// Right-hand sides that look like `key: value` but are obviously not secrets:
28/// TypeScript type annotations and language literals. Redacting these corrupts
29/// source files read through `ctx_read` (GH #430), so the key/value rules skip
30/// them. Compared case-insensitively after trimming surrounding quotes.
31fn is_non_secret_literal(value: &str) -> bool {
32    let v = value
33        .trim()
34        .trim_matches(|c| c == '"' || c == '\'' || c == '`');
35    // Type expressions are never flat secret tokens: real keys/tokens are drawn
36    // from `[A-Za-z0-9+/=_-]`, whereas type annotations carry angle brackets,
37    // unions, arrays or call/object syntax. `password: Promise<string>` and
38    // `apiKey: Record<string, unknown>` must survive ctx_read verbatim (GH #430).
39    if v.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
40        return true;
41    }
42    matches!(
43        v.to_ascii_lowercase().as_str(),
44        "" | "undefined"
45            | "null"
46            | "none"
47            | "nil"
48            | "true"
49            | "false"
50            | "string"
51            | "number"
52            | "boolean"
53            | "bigint"
54            | "symbol"
55            | "object"
56            | "any"
57            | "unknown"
58            | "never"
59            | "void"
60            | "nan"
61            | "date"
62    )
63}
64
65/// One redaction rule: a labelled regex plus how the match is rebuilt.
66struct Rule {
67    label: &'static str,
68    re: &'static regex::Regex,
69    /// When set, group 1 is a prefix to keep and group 2 is the secret value;
70    /// the match is left untouched if that value is a non-secret literal
71    /// (`password: undefined`, `secret: string`, …) — see `is_non_secret_literal`.
72    guard_value: bool,
73}
74
75/// The single source of truth for secret patterns. `shell::redact` delegates
76/// here so the two layers can never drift apart again.
77fn redaction_rules() -> Vec<Rule> {
78    vec![
79        Rule {
80            label: "Bearer token",
81            re: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
82            guard_value: false,
83        },
84        Rule {
85            label: "Authorization header",
86            re: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
87            guard_value: false,
88        },
89        // Key/value secrets: group 1 = `name=`/`name: ` prefix (kept), group 2 =
90        // the value (redacted unless it is a non-secret literal — GH #430).
91        Rule {
92            label: "API key param",
93            re: static_regex!(
94                r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
95            ),
96            guard_value: true,
97        },
98        // Whole token is the secret — no prefix group, so the entire match is
99        // replaced. (Previously group 1 captured the key itself and leaked it.)
100        Rule {
101            label: "AWS key",
102            re: static_regex!(r"AKIA[0-9A-Z]{12,}"),
103            guard_value: false,
104        },
105        Rule {
106            label: "Private key block",
107            re: static_regex!(
108                r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
109            ),
110            guard_value: false,
111        },
112        Rule {
113            label: "GitHub token",
114            re: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
115            guard_value: false,
116        },
117        // Group 1 = prefix (kept); the 32+ char value after it is redacted.
118        // (Previously the value was captured into group 1 and kept verbatim.)
119        Rule {
120            label: "Generic long secret",
121            re: static_regex!(
122                r#"(?i)((?:key|token|secret|password|credential|auth)\s*[=:]\s*)['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?"#
123            ),
124            guard_value: false,
125        },
126    ]
127}
128
129pub fn redact_text(input: &str) -> String {
130    let mut out = input.to_string();
131    for rule in redaction_rules() {
132        out = rule
133            .re
134            .replace_all(&out, |caps: &regex::Captures| {
135                if rule.guard_value
136                    && let Some(value) = caps.get(2)
137                    && is_non_secret_literal(value.as_str())
138                {
139                    // Not a secret (e.g. `password: undefined`) — keep verbatim.
140                    return caps
141                        .get(0)
142                        .map_or(String::new(), |m| m.as_str().to_string());
143                }
144                match caps.get(1) {
145                    Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
146                    None => format!("[REDACTED:{}]", rule.label),
147                }
148            })
149            .to_string();
150    }
151    out
152}
153
154/// Apply caller-supplied policy redaction patterns on top of the built-in
155/// secret rules: each regex match becomes `[REDACTED:<label>]`. Returns the
156/// transformed text and the number of redactions applied (for audit counts).
157///
158/// Used by context policy packs (GL #673) so a pack's `[redaction]` block
159/// actually removes matching content from what the model sees. The patterns are
160/// the pack's `[redaction]` entries, precompiled by
161/// [`crate::core::policy::runtime`].
162#[must_use]
163pub fn redact_with_patterns(input: &str, patterns: &[(String, regex::Regex)]) -> (String, usize) {
164    let mut out = input.to_string();
165    let mut hits = 0usize;
166    for (label, re) in patterns {
167        let mut local = 0usize;
168        out = re
169            .replace_all(&out, |_caps: &regex::Captures| {
170                local += 1;
171                format!("[REDACTED:{label}]")
172            })
173            .to_string();
174        hits += local;
175    }
176    (out, hits)
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182
183    #[test]
184    fn redacts_bearer_token() {
185        let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
186        let out = redact_text(s);
187        assert!(out.contains("[REDACTED"));
188        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
189    }
190
191    #[test]
192    fn redacts_private_key_block() {
193        let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
194        let out = redact_text(s);
195        assert!(out.contains("[REDACTED"));
196        assert!(!out.contains("\nabc\n"));
197    }
198
199    #[test]
200    fn redacts_api_key_param_value() {
201        let out = redact_text("password=hunter2-super-secret-value");
202        assert!(
203            out.contains("password=[REDACTED:API key param]"),
204            "got: {out}"
205        );
206        assert!(!out.contains("hunter2"));
207    }
208
209    /// GH #430: TypeScript type annotations and language literals must NOT be
210    /// redacted — over-eager masking corrupted source files read via ctx_read.
211    #[test]
212    fn keeps_non_secret_literals() {
213        for s in [
214            "password: undefined",
215            "secret: string",
216            "token: null",
217            "apiKey: boolean",
218            "password = false",
219            "secret: any",
220            "let pwd: number = 1",
221        ] {
222            assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
223        }
224    }
225
226    /// GH #430: TS type annotations (generics, unions, arrays, function/object
227    /// types) carry angle brackets / brackets that real secret tokens never do,
228    /// so they must survive verbatim even when the key looks sensitive.
229    #[test]
230    fn keeps_type_annotations() {
231        for s in [
232            "password: Promise<string>",
233            "apiKey: Record<string, unknown>",
234            "token: string[]",
235            "secret: () => void",
236            "password: string | undefined",
237            "credential: { value: string }",
238        ] {
239            assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
240        }
241    }
242
243    /// Whole-token secrets must be removed, not annotated in place — previously
244    /// the closure kept group 1 (the key itself) and only appended `[REDACTED]`.
245    #[test]
246    fn fully_redacts_aws_key() {
247        let out = redact_text("AKIAIOSFODNN7EXAMPLE");
248        assert!(
249            !out.contains("AKIAIOSFODNN7EXAMPLE"),
250            "AWS key leaked: {out}"
251        );
252        assert!(out.contains("[REDACTED:AWS key]"));
253    }
254
255    #[test]
256    fn fully_redacts_generic_long_secret() {
257        // `credential=` is not covered by the API-key-param rule, so this
258        // exercises the generic fallback (the previously leaky path).
259        let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; // 32 chars
260        let out = redact_text(&format!("credential={secret}"));
261        assert!(!out.contains(secret), "long secret leaked: {out}");
262        assert!(
263            out.contains("credential=[REDACTED:Generic long secret]"),
264            "got: {out}"
265        );
266    }
267
268    #[test]
269    fn redacts_github_token_keeping_prefix() {
270        let out = redact_text("ghp_abcdefghijklmnopqrstuvwxyz0123");
271        assert!(out.starts_with("ghp_[REDACTED:GitHub token]"), "got: {out}");
272        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
273    }
274
275    #[test]
276    fn policy_patterns_redact_with_label_and_count() {
277        let patterns = vec![(
278            "employee_id".to_string(),
279            regex::Regex::new(r"EMP-\d{4}").unwrap(),
280        )];
281        let (out, hits) = redact_with_patterns("user EMP-1234 and EMP-5678", &patterns);
282        assert_eq!(hits, 2);
283        assert!(!out.contains("EMP-1234"));
284        assert!(out.contains("[REDACTED:employee_id]"));
285    }
286
287    #[test]
288    fn policy_patterns_noop_when_no_match() {
289        let patterns = vec![("iban".to_string(), regex::Regex::new(r"CH\d{2}").unwrap())];
290        let (out, hits) = redact_with_patterns("nothing sensitive here", &patterns);
291        assert_eq!(hits, 0);
292        assert_eq!(out, "nothing sensitive here");
293    }
294}