Skip to main content

lean_ctx/core/
redaction.rs

1macro_rules! static_regex {
2    ($pattern:expr) => {{
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
27pub fn redact_text(input: &str) -> String {
28    let patterns: Vec<(&str, &regex::Regex)> = vec![
29        (
30            "Bearer token",
31            static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
32        ),
33        (
34            "Authorization header",
35            static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
36        ),
37        (
38            "API key param",
39            static_regex!(
40                r#"(?i)((?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)[^\s\r\n,;&"']+"#
41            ),
42        ),
43        ("AWS key", static_regex!(r"(AKIA[0-9A-Z]{12,})")),
44        (
45            "Private key block",
46            static_regex!(
47                r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?(-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----)"
48            ),
49        ),
50        (
51            "GitHub token",
52            static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
53        ),
54        (
55            "Generic long secret",
56            static_regex!(
57                r#"(?i)(?:key|token|secret|password|credential|auth)\s*[=:]\s*['"]?([a-zA-Z0-9+/=\-_]{32,})['"]?"#
58            ),
59        ),
60    ];
61
62    let mut out = input.to_string();
63    for (label, re) in &patterns {
64        out = re
65            .replace_all(&out, |caps: &regex::Captures| {
66                if let Some(prefix) = caps.get(1) {
67                    format!("{}[REDACTED:{}]", prefix.as_str(), label)
68                } else {
69                    format!("[REDACTED:{label}]")
70                }
71            })
72            .to_string();
73    }
74    out
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn redacts_bearer_token() {
83        let s = "Authorization: Bearer abcdefghijklmnopqrstuvwxyz012345";
84        let out = redact_text(s);
85        assert!(out.contains("[REDACTED"));
86        assert!(!out.contains("abcdefghijklmnopqrstuvwxyz"));
87    }
88
89    #[test]
90    fn redacts_private_key_block() {
91        let s = "-----BEGIN PRIVATE KEY-----\nabc\n-----END PRIVATE KEY-----";
92        let out = redact_text(s);
93        assert!(out.contains("[REDACTED"));
94        assert!(!out.contains("\nabc\n"));
95    }
96}