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#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn redacts_bearer_token() {
160        let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
161        let out = redact_text(s);
162        assert!(out.contains("[REDACTED"));
163        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
164    }
165
166    #[test]
167    fn redacts_private_key_block() {
168        let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
169        let out = redact_text(s);
170        assert!(out.contains("[REDACTED"));
171        assert!(!out.contains("\nabc\n"));
172    }
173
174    #[test]
175    fn redacts_api_key_param_value() {
176        let out = redact_text("password=hunter2-super-secret-value");
177        assert!(
178            out.contains("password=[REDACTED:API key param]"),
179            "got: {out}"
180        );
181        assert!(!out.contains("hunter2"));
182    }
183
184    /// GH #430: TypeScript type annotations and language literals must NOT be
185    /// redacted — over-eager masking corrupted source files read via ctx_read.
186    #[test]
187    fn keeps_non_secret_literals() {
188        for s in [
189            "password: undefined",
190            "secret: string",
191            "token: null",
192            "apiKey: boolean",
193            "password = false",
194            "secret: any",
195            "let pwd: number = 1",
196        ] {
197            assert_eq!(redact_text(s), s, "must not redact non-secret literal: {s}");
198        }
199    }
200
201    /// GH #430: TS type annotations (generics, unions, arrays, function/object
202    /// types) carry angle brackets / brackets that real secret tokens never do,
203    /// so they must survive verbatim even when the key looks sensitive.
204    #[test]
205    fn keeps_type_annotations() {
206        for s in [
207            "password: Promise<string>",
208            "apiKey: Record<string, unknown>",
209            "token: string[]",
210            "secret: () => void",
211            "password: string | undefined",
212            "credential: { value: string }",
213        ] {
214            assert_eq!(redact_text(s), s, "must not redact type annotation: {s}");
215        }
216    }
217
218    /// Whole-token secrets must be removed, not annotated in place — previously
219    /// the closure kept group 1 (the key itself) and only appended `[REDACTED]`.
220    #[test]
221    fn fully_redacts_aws_key() {
222        let out = redact_text("AKIAIOSFODNN7EXAMPLE");
223        assert!(
224            !out.contains("AKIAIOSFODNN7EXAMPLE"),
225            "AWS key leaked: {out}"
226        );
227        assert!(out.contains("[REDACTED:AWS key]"));
228    }
229
230    #[test]
231    fn fully_redacts_generic_long_secret() {
232        // `credential=` is not covered by the API-key-param rule, so this
233        // exercises the generic fallback (the previously leaky path).
234        let secret = "A1b2C3d4E5f6G7h8I9j0K1l2M3n4O5p6"; // 32 chars
235        let out = redact_text(&format!("credential={secret}"));
236        assert!(!out.contains(secret), "long secret leaked: {out}");
237        assert!(
238            out.contains("credential=[REDACTED:Generic long secret]"),
239            "got: {out}"
240        );
241    }
242
243    #[test]
244    fn redacts_github_token_keeping_prefix() {
245        let out = redact_text("ghp_abcdefghijklmnopqrstuvwxyz0123");
246        assert!(out.starts_with("ghp_[REDACTED:GitHub token]"), "got: {out}");
247        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
248    }
249}