Skip to main content

rightkit_agent_engine_protocol/
secret_scan.rs

1//! Shared secret detection (A195).
2//!
3//! Pure scanner used by the engine's reversible redactor (model boundary) and
4//! by the storage layer's irreversible persistence redaction. It reports byte
5//! spans of secrets; callers decide the replacement. Raw values are never
6//! logged or retained here.
7
8use std::borrow::Cow;
9
10/// Prefix shared by every redaction placeholder.
11pub const SECRET_PLACEHOLDER_OPEN: &str = "[[CR_SECRET_";
12/// Suffix shared by every redaction placeholder.
13pub const SECRET_PLACEHOLDER_CLOSE: &str = "]]";
14
15const MIN_ASSIGNED_VALUE_LEN: usize = 8;
16const NAME_KEYWORDS: &[&str] = &[
17    "SECRET",
18    "TOKEN",
19    "PASSWORD",
20    "PASSWD",
21    "API_KEY",
22    "APIKEY",
23    "PRIVATE_KEY",
24    "ACCESS_KEY",
25    "CREDENTIAL",
26];
27
28/// One detected secret: byte range `start..end` of the scanned text.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct SecretSpan {
31    pub start: usize,
32    pub end: usize,
33    pub kind: &'static str,
34}
35
36fn is_tok(b: u8) -> bool {
37    b.is_ascii_alphanumeric() || b == b'_' || b == b'-' || b == b'.'
38}
39
40fn classify(run: &str, after_bearer: bool) -> Option<&'static str> {
41    let len = run.len();
42    if run.starts_with("sk-ant-") && len >= 20 {
43        return Some("ANTHROPIC");
44    }
45    if run.starts_with("sk-") && len >= 20 {
46        return Some("APIKEY");
47    }
48    if ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"]
49        .iter()
50        .any(|p| run.starts_with(p))
51        && len >= 20
52    {
53        return Some("GITHUB");
54    }
55    if run.starts_with("github_pat_") && len >= 30 {
56        return Some("GITHUB");
57    }
58    if (run.starts_with("AKIA") || run.starts_with("ASIA"))
59        && len == 20
60        && run
61            .bytes()
62            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
63    {
64        return Some("AWS");
65    }
66    if run.starts_with("AIza") && len == 39 {
67        return Some("GOOGLE");
68    }
69    if run.starts_with("xox")
70        && len >= 15
71        && matches!(
72            run.as_bytes().get(3),
73            Some(b'b' | b'a' | b'p' | b'r' | b's')
74        )
75        && run.as_bytes().get(4) == Some(&b'-')
76    {
77        return Some("SLACK");
78    }
79    if run.starts_with("eyJ") && len >= 30 {
80        let parts: Vec<&str> = run.split('.').collect();
81        if parts.len() == 3 && parts.iter().all(|p| !p.is_empty()) {
82            return Some("JWT");
83        }
84    }
85    if after_bearer && len >= 16 {
86        return Some("BEARER");
87    }
88    None
89}
90
91fn name_is_secretish(name: &str) -> bool {
92    let norm = name.to_ascii_uppercase().replace('-', "_");
93    NAME_KEYWORDS.iter().any(|k| norm.contains(k))
94}
95
96fn ends_with_bearer(prefix: &str) -> bool {
97    let b = prefix.as_bytes();
98    b.len() >= 7 && b[b.len() - 7..].eq_ignore_ascii_case(b"bearer ")
99}
100
101/// Byte span of the value in `NAME=value`, `NAME: value`, `"name": "value"`
102/// style assignments, where `name_end` is the end of the name token.
103fn assigned_value_span(s: &str, name_end: usize) -> Option<(usize, usize)> {
104    let b = s.as_bytes();
105    let n = b.len();
106    let mut i = name_end;
107    if i < n && (b[i] == b'"' || b[i] == b'\'') {
108        i += 1;
109    }
110    while i < n && b[i] == b' ' {
111        i += 1;
112    }
113    if i >= n || (b[i] != b'=' && b[i] != b':') {
114        return None;
115    }
116    i += 1;
117    while i < n && b[i] == b' ' {
118        i += 1;
119    }
120    if i < n && (b[i] == b'"' || b[i] == b'\'') {
121        i += 1;
122    }
123    let vs = i;
124    while i < n
125        && !b[i].is_ascii_whitespace()
126        && !matches!(b[i], b'"' | b'\'' | b',' | b';' | b'}' | b']')
127    {
128        i += 1;
129    }
130    if i > vs {
131        Some((vs, i))
132    } else {
133        None
134    }
135}
136
137fn pem_spans(text: &str) -> Vec<SecretSpan> {
138    let mut spans = Vec::new();
139    let mut base = 0usize;
140    while let Some(rel) = text[base..].find("-----BEGIN ") {
141        let begin = base + rel;
142        let after = &text[begin..];
143        let header_end = match after[11..].find("-----") {
144            Some(e) => e + 11 + 5,
145            None => break,
146        };
147        if !after[..header_end].contains("PRIVATE KEY") {
148            base = begin + header_end;
149            continue;
150        }
151        // Unterminated block: redact through end of text (fail closed).
152        let block_end = match after.find("-----END ") {
153            Some(e) => after[e + 9..]
154                .find("-----")
155                .map(|x| e + 9 + x + 5)
156                .unwrap_or(after.len()),
157            None => after.len(),
158        };
159        spans.push(SecretSpan {
160            start: begin,
161            end: begin + block_end,
162            kind: "PRIVATEKEY",
163        });
164        base = begin + block_end;
165    }
166    spans
167}
168
169/// Find every secret in `text`, as non-overlapping spans sorted by start.
170pub fn find_secrets(text: &str) -> Vec<SecretSpan> {
171    let pem = pem_spans(text);
172    let in_pem = |i: usize| pem.iter().any(|s| i >= s.start && i < s.end);
173    let bytes = text.as_bytes();
174    let n = bytes.len();
175    let mut found: Vec<SecretSpan> = Vec::new();
176    let mut i = 0usize;
177    while i < n {
178        if !is_tok(bytes[i]) {
179            i += 1;
180            continue;
181        }
182        let start = i;
183        while i < n && is_tok(bytes[i]) {
184            i += 1;
185        }
186        let mut end = i;
187        while end > start && bytes[end - 1] == b'.' {
188            end -= 1;
189        }
190        if end == start || in_pem(start) {
191            continue;
192        }
193        let run = &text[start..end];
194        if let Some(kind) = classify(run, ends_with_bearer(&text[..start])) {
195            found.push(SecretSpan { start, end, kind });
196            continue;
197        }
198        if name_is_secretish(run) {
199            if let Some((vs, ve)) = assigned_value_span(text, end) {
200                let value = &text[vs..ve];
201                if value.len() >= MIN_ASSIGNED_VALUE_LEN
202                    && !value.starts_with(SECRET_PLACEHOLDER_OPEN)
203                {
204                    found.push(SecretSpan {
205                        start: vs,
206                        end: ve,
207                        kind: "ASSIGNED",
208                    });
209                    i = ve;
210                }
211            }
212        }
213    }
214    found.extend(pem);
215    found.sort_by_key(|s| s.start);
216    found
217}
218
219/// Irreversibly replace every secret with `[[CR_SECRET_<KIND>_REDACTED]]`.
220/// Returns the input unchanged (borrowed) when nothing matched.
221pub fn redact_irreversible(text: &str) -> Cow<'_, str> {
222    let spans = find_secrets(text);
223    if spans.is_empty() {
224        return Cow::Borrowed(text);
225    }
226    let mut out = String::with_capacity(text.len());
227    let mut copied = 0usize;
228    for s in spans {
229        out.push_str(&text[copied..s.start]);
230        out.push_str(SECRET_PLACEHOLDER_OPEN);
231        out.push_str(s.kind);
232        out.push_str("_REDACTED");
233        out.push_str(SECRET_PLACEHOLDER_CLOSE);
234        copied = s.end;
235    }
236    out.push_str(&text[copied..]);
237    Cow::Owned(out)
238}
239
240fn walk(v: &mut serde_json::Value, changed: &mut bool) {
241    match v {
242        serde_json::Value::String(s) => {
243            if let Cow::Owned(r) = redact_irreversible(s) {
244                *s = r;
245                *changed = true;
246            }
247        }
248        serde_json::Value::Array(a) => a.iter_mut().for_each(|x| walk(x, changed)),
249        serde_json::Value::Object(o) => o.iter_mut().for_each(|(_, x)| walk(x, changed)),
250        _ => {}
251    }
252}
253
254/// Redact every string inside a serialized JSON payload (so escaped quotes
255/// and newline escapes cannot hide a secret), re-serializing only when
256/// something changed. Non-JSON input is redacted as plain text.
257pub fn redact_json_payload(payload: &str) -> Cow<'_, str> {
258    match serde_json::from_str::<serde_json::Value>(payload) {
259        Ok(mut v) => {
260            let mut changed = false;
261            walk(&mut v, &mut changed);
262            if !changed {
263                return Cow::Borrowed(payload);
264            }
265            match serde_json::to_string(&v) {
266                Ok(s) => Cow::Owned(s),
267                Err(_) => redact_irreversible(payload),
268            }
269        }
270        Err(_) => redact_irreversible(payload),
271    }
272}
273
274#[cfg(test)]
275mod tests {
276    use super::*;
277
278    fn fake_sk() -> String {
279        format!("sk-{}", "FAKE".repeat(8))
280    }
281
282    #[test]
283    fn irreversible_redaction_removes_value_and_is_idempotent() {
284        let t = format!("a {} b", fake_sk());
285        let r = redact_irreversible(&t).into_owned();
286        assert!(!r.contains("FAKE"));
287        assert!(r.contains("[[CR_SECRET_APIKEY_REDACTED]]"));
288        assert_eq!(redact_irreversible(&r), r);
289    }
290
291    #[test]
292    fn clean_text_is_borrowed() {
293        assert!(matches!(
294            redact_irreversible("nothing here"),
295            Cow::Borrowed(_)
296        ));
297    }
298
299    #[test]
300    fn json_payload_secrets_in_nested_escaped_strings_are_redacted() {
301        let inner = "{\"api_key\": \"fake-value-12345\"}";
302        let payload = serde_json::json!({"content": inner, "n": 1}).to_string();
303        let out = redact_json_payload(&payload);
304        assert!(!out.contains("fake-value-12345"));
305        assert!(serde_json::from_str::<serde_json::Value>(&out).is_ok());
306    }
307
308    #[test]
309    fn json_payload_without_secrets_is_byte_identical() {
310        let p = r#"{"a":"hello","b":[1,2]}"#;
311        assert!(matches!(redact_json_payload(p), Cow::Borrowed(_)));
312    }
313
314    #[test]
315    fn pem_block_is_one_span() {
316        // Built at run time so the source holds no PEM header (secret scans read sources).
317        let t = format!(
318            "x -----BEGIN {k}-----\nFAKE\n-----END {k}----- y",
319            k = "PRIVATE KEY"
320        );
321        let s = find_secrets(&t);
322        assert_eq!(s.len(), 1);
323        assert_eq!(s[0].kind, "PRIVATEKEY");
324    }
325}