Skip to main content

lean_ctx_protocol/
credential_redaction.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Shared deterministic credential redaction used by Local and Via Edge.
4//!
5//! This is the provider-independent, configuration-free subset of LeanCTX's
6//! established redaction policy. Callers that need configuration exclusions
7//! layer those separately; security boundaries use this default-deny baseline.
8
9use std::sync::OnceLock;
10
11use regex::Regex;
12
13macro_rules! static_regex {
14    ($pattern:expr) => {{
15        static RE: OnceLock<Regex> = OnceLock::new();
16        RE.get_or_init(|| Regex::new($pattern).expect("valid credential regex"))
17    }};
18}
19
20fn looks_like_number(value: &str) -> bool {
21    let value = value.trim_start_matches(['+', '-']);
22    !value.is_empty()
23        && value.parse::<f64>().is_ok()
24        && value
25            .chars()
26            .all(|character| character.is_ascii_digit() || ".eE+-".contains(character))
27}
28
29fn is_env_reference(value: &str) -> bool {
30    if value.starts_with("os.environ/")
31        || value.starts_with("os.getenv(")
32        || value.starts_with("process.env.")
33        || value.starts_with("System.getenv(")
34        || value.starts_with("ENV[")
35        || value.starts_with("env(")
36    {
37        return true;
38    }
39    if (value.starts_with("${") && value.ends_with('}'))
40        || (value.starts_with('$')
41            && value[1..]
42                .chars()
43                .all(|character| character.is_ascii_alphanumeric() || character == '_'))
44        || (value.starts_with('%') && value.ends_with('%') && value.len() > 2)
45    {
46        return true;
47    }
48    value.split('.').next().is_some_and(|prefix| {
49        matches!(
50            prefix.to_ascii_lowercase().as_str(),
51            "env" | "inputenv" | "serverenv" | "secrets" | "vars" | "environ"
52        ) && value.contains('.')
53    })
54}
55
56fn is_identifier_reference(value: &str) -> bool {
57    let value = value.trim();
58    if value.is_empty() || value.starts_with(['"', '\'', '`']) {
59        return false;
60    }
61    if is_env_reference(value) {
62        return true;
63    }
64    if value.contains(|character: char| character.is_ascii_digit()) {
65        return false;
66    }
67    value.split('.').all(|segment| {
68        let mut characters = segment.chars();
69        matches!(
70            characters.next(),
71            Some(character) if character.is_ascii_alphabetic() || character == '_' || character == '$'
72        ) && characters.all(|character| {
73            character.is_ascii_alphanumeric() || character == '_' || character == '$'
74        })
75    })
76}
77
78fn is_placeholder_value(value: &str) -> bool {
79    let value = value
80        .trim()
81        .trim_matches(|character| character == '"' || character == '\'' || character == '`')
82        .to_ascii_lowercase();
83    if value.starts_with('<') && value.ends_with('>') {
84        return true;
85    }
86    const MARKERS: &[&str] = &[
87        "change_me",
88        "change-me",
89        "changeme",
90        "example",
91        "placeholder",
92        "your_",
93        "your-",
94        "xxx",
95        "dummy",
96        "sample",
97        "todo",
98        "fixme",
99        "replace_me",
100        "replace-me",
101    ];
102    MARKERS.iter().any(|marker| value.contains(marker))
103}
104
105fn is_non_secret_literal(value: &str) -> bool {
106    let value = value
107        .trim()
108        .trim_matches(|character| character == '"' || character == '\'' || character == '`');
109    if looks_like_number(value) {
110        return true;
111    }
112    if value.contains(['<', '>', '|', '(', ')', '[', ']', '{', '}']) {
113        return true;
114    }
115    matches!(
116        value.to_ascii_lowercase().as_str(),
117        "" | "undefined"
118            | "null"
119            | "none"
120            | "nil"
121            | "true"
122            | "false"
123            | "string"
124            | "number"
125            | "boolean"
126            | "bigint"
127            | "symbol"
128            | "object"
129            | "any"
130            | "unknown"
131            | "never"
132            | "void"
133            | "nan"
134            | "date"
135    )
136}
137
138fn is_benign_secret_value(value: &str) -> bool {
139    matches!(
140        value.trim(),
141        "=" | "==" | "!=" | "<=" | ">=" | "===" | "!=="
142    ) || is_non_secret_literal(value)
143        || is_identifier_reference(value)
144        || is_placeholder_value(value)
145}
146
147struct Rule {
148    label: &'static str,
149    regex: &'static Regex,
150    guard_value: bool,
151}
152
153fn rules() -> Vec<Rule> {
154    vec![
155        Rule {
156            label: "Bearer token",
157            regex: static_regex!(r"(?i)(bearer\s+)[a-zA-Z0-9\-_\.]{8,}"),
158            guard_value: false,
159        },
160        Rule {
161            label: "Authorization header",
162            regex: static_regex!(r"(?i)(authorization:\s*(?:basic|bearer|token)\s+)[^\s\r\n]+"),
163            guard_value: false,
164        },
165        Rule {
166            label: "API key param",
167            regex: static_regex!(
168                r#"(?im)((?:^|[^a-z0-9])(?:api[_-]?key|apikey|access[_-]?key|secret[_-]?key|token|password|passwd|pwd|secret)\s*[=:]\s*)([^\s\r\n,;&"']+)"#
169            ),
170            guard_value: true,
171        },
172        Rule {
173            label: "AWS key",
174            regex: static_regex!(r"AKIA[0-9A-Z]{12,}"),
175            guard_value: false,
176        },
177        Rule {
178            label: "Provider API key",
179            regex: static_regex!(r"(?i)(?:sk-|AIza)[a-zA-Z0-9_\-]{8,}"),
180            guard_value: false,
181        },
182        Rule {
183            label: "Slack token",
184            regex: static_regex!(r"(?i)xox[bpa]-[a-zA-Z0-9_\-]{8,}"),
185            guard_value: false,
186        },
187        Rule {
188            label: "Private key block",
189            regex: static_regex!(
190                r"(?s)(-----BEGIN\s+(?:RSA\s+)?PRIVATE\s+KEY-----).+?-----END\s+(?:RSA\s+)?PRIVATE\s+KEY-----"
191            ),
192            guard_value: false,
193        },
194        Rule {
195            label: "GitHub token",
196            regex: static_regex!(r"(gh[pousr]_)[a-zA-Z0-9]{20,}"),
197            guard_value: false,
198        },
199        Rule {
200            label: "Generic long secret",
201            regex: static_regex!(
202                r#"(?im)((?:^|[^a-z0-9])(?:key|token|secret|password|credential|auth)\s*[=:]\s*)(['"]?[a-zA-Z0-9+/=\-_]{32,}['"]?)"#
203            ),
204            guard_value: true,
205        },
206    ]
207}
208
209/// Apply the established, configuration-free LeanCTX credential rules.
210#[must_use]
211pub fn redact_text(input: &str) -> String {
212    let mut output = input.to_owned();
213    for rule in rules() {
214        output = rule
215            .regex
216            .replace_all(&output, |captures: &regex::Captures| {
217                let whole = captures.get(0).map_or("", |value| value.as_str());
218                if rule.guard_value
219                    && let Some(value) = captures.get(2)
220                    && is_benign_secret_value(value.as_str())
221                {
222                    return whole.to_owned();
223                }
224                match captures.get(1) {
225                    Some(prefix) => format!("{}[REDACTED:{}]", prefix.as_str(), rule.label),
226                    None => format!("[REDACTED:{}]", rule.label),
227                }
228            })
229            .into_owned();
230    }
231    output
232}
233
234/// Return true when the established rules would redact any credential material.
235#[must_use]
236pub fn contains_credential(input: &str) -> bool {
237    redact_text(input) != input
238}
239
240#[cfg(test)]
241mod tests {
242    use super::*;
243
244    #[test]
245    fn detects_provider_credentials_and_preserves_references() {
246        for value in [
247            "Authorization: Bearer secret-token-123",
248            "api_key=sk-provider-secret-123456",
249            "AKIA0123456789ABCDEF",
250            "sk-provider-secret-123456",
251            "AIza0123456789abcdefghijklmn",
252            "xoxb-0123456789abcdefghijklmn",
253            "ghp_0123456789abcdefghijklmn",
254            "-----BEGIN PRIVATE KEY-----\nsecret\n-----END PRIVATE KEY-----",
255        ] {
256            assert!(contains_credential(value), "{value}");
257        }
258        for value in [
259            "api_key=${OPENAI_API_KEY}",
260            "token: process.env.API_TOKEN",
261            "password: undefined",
262            "safe source text",
263        ] {
264            assert!(!contains_credential(value), "{value}");
265        }
266    }
267}