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 run.starts_with("gh")
49        && matches!(
50            run.as_bytes().get(2),
51            Some(b'p' | b'o' | b'u' | b's' | b'r')
52        )
53        && run.as_bytes().get(3) == Some(&b'_')
54        && len >= 20
55    {
56        return Some("GITHUB");
57    }
58    if run.starts_with(concat!("github", "_pat_")) && len >= 30 {
59        return Some("GITHUB");
60    }
61    if (run.starts_with("AKIA") || run.starts_with("ASIA"))
62        && len == 20
63        && run
64            .bytes()
65            .all(|b| b.is_ascii_uppercase() || b.is_ascii_digit())
66    {
67        return Some("AWS");
68    }
69    if run.starts_with("AIza") && len == 39 {
70        return Some("GOOGLE");
71    }
72    if run.starts_with("xox")
73        && len >= 15
74        && matches!(
75            run.as_bytes().get(3),
76            Some(b'b' | b'a' | b'p' | b'r' | b's')
77        )
78        && run.as_bytes().get(4) == Some(&b'-')
79    {
80        return Some("SLACK");
81    }
82    if run.starts_with("eyJ") && len >= 30 {
83        let parts: Vec<&str> = run.split('.').collect();
84        if parts.len() == 3 && parts.iter().all(|p| !p.is_empty()) {
85            return Some("JWT");
86        }
87    }
88    if after_bearer && len >= 16 {
89        return Some("BEARER");
90    }
91    None
92}
93
94fn name_is_secretish(name: &str) -> bool {
95    let norm = name.to_ascii_uppercase().replace('-', "_");
96    NAME_KEYWORDS.iter().any(|k| norm.contains(k))
97}
98
99fn ends_with_bearer(prefix: &str) -> bool {
100    let b = prefix.as_bytes();
101    b.len() >= 7 && b[b.len() - 7..].eq_ignore_ascii_case(b"bearer ")
102}
103
104/// Byte span of the value in `NAME=value`, `NAME: value`, `"name": "value"`
105/// style assignments, where `name_end` is the end of the name token.
106fn assigned_value_span(s: &str, name_end: usize) -> Option<(usize, usize)> {
107    let b = s.as_bytes();
108    let n = b.len();
109    let mut i = name_end;
110    if i < n && (b[i] == b'"' || b[i] == b'\'') {
111        i += 1;
112    }
113    while i < n && b[i] == b' ' {
114        i += 1;
115    }
116    if i >= n || (b[i] != b'=' && b[i] != b':') {
117        return None;
118    }
119    i += 1;
120    while i < n && b[i] == b' ' {
121        i += 1;
122    }
123    if i < n && (b[i] == b'"' || b[i] == b'\'') {
124        i += 1;
125    }
126    let vs = i;
127    while i < n
128        && !b[i].is_ascii_whitespace()
129        && !matches!(b[i], b'"' | b'\'' | b',' | b';' | b'}' | b']')
130    {
131        i += 1;
132    }
133    if i > vs {
134        Some((vs, i))
135    } else {
136        None
137    }
138}
139
140fn pem_spans(text: &str) -> Vec<SecretSpan> {
141    let mut spans = Vec::new();
142    let mut base = 0usize;
143    while let Some(rel) = text[base..].find("-----BEGIN ") {
144        let begin = base + rel;
145        let after = &text[begin..];
146        let header_end = match after[11..].find("-----") {
147            Some(e) => e + 11 + 5,
148            None => break,
149        };
150        if !after[..header_end].contains("PRIVATE KEY") {
151            base = begin + header_end;
152            continue;
153        }
154        // Unterminated block: redact through end of text (fail closed).
155        let block_end = match after.find("-----END ") {
156            Some(e) => after[e + 9..]
157                .find("-----")
158                .map(|x| e + 9 + x + 5)
159                .unwrap_or(after.len()),
160            None => after.len(),
161        };
162        spans.push(SecretSpan {
163            start: begin,
164            end: begin + block_end,
165            kind: "PRIVATEKEY",
166        });
167        base = begin + block_end;
168    }
169    spans
170}
171
172/// Find every secret in `text`, as non-overlapping spans sorted by start.
173pub fn find_secrets(text: &str) -> Vec<SecretSpan> {
174    let pem = pem_spans(text);
175    let in_pem = |i: usize| pem.iter().any(|s| i >= s.start && i < s.end);
176    let bytes = text.as_bytes();
177    let n = bytes.len();
178    let mut found: Vec<SecretSpan> = Vec::new();
179    let mut i = 0usize;
180    while i < n {
181        if !is_tok(bytes[i]) {
182            i += 1;
183            continue;
184        }
185        let start = i;
186        while i < n && is_tok(bytes[i]) {
187            i += 1;
188        }
189        let mut end = i;
190        while end > start && bytes[end - 1] == b'.' {
191            end -= 1;
192        }
193        if end == start || in_pem(start) {
194            continue;
195        }
196        let run = &text[start..end];
197        if let Some(kind) = classify(run, ends_with_bearer(&text[..start])) {
198            found.push(SecretSpan { start, end, kind });
199            continue;
200        }
201        if name_is_secretish(run) {
202            if let Some((vs, ve)) = assigned_value_span(text, end) {
203                let value = &text[vs..ve];
204                if value.len() >= MIN_ASSIGNED_VALUE_LEN
205                    && !value.starts_with(SECRET_PLACEHOLDER_OPEN)
206                {
207                    found.push(SecretSpan {
208                        start: vs,
209                        end: ve,
210                        kind: "ASSIGNED",
211                    });
212                    i = ve;
213                }
214            }
215        }
216    }
217    found.extend(pem);
218    found.sort_by_key(|s| s.start);
219    found
220}
221
222/// Irreversibly replace every secret with `[[CR_SECRET_<KIND>_REDACTED]]`.
223/// Returns the input unchanged (borrowed) when nothing matched.
224pub fn redact_irreversible(text: &str) -> Cow<'_, str> {
225    let spans = find_secrets(text);
226    if spans.is_empty() {
227        return Cow::Borrowed(text);
228    }
229    let mut out = String::with_capacity(text.len());
230    let mut copied = 0usize;
231    for s in spans {
232        out.push_str(&text[copied..s.start]);
233        out.push_str(SECRET_PLACEHOLDER_OPEN);
234        out.push_str(s.kind);
235        out.push_str("_REDACTED");
236        out.push_str(SECRET_PLACEHOLDER_CLOSE);
237        copied = s.end;
238    }
239    out.push_str(&text[copied..]);
240    Cow::Owned(out)
241}
242
243fn walk(v: &mut serde_json::Value, changed: &mut bool) {
244    match v {
245        serde_json::Value::String(s) => {
246            if let Cow::Owned(r) = redact_irreversible(s) {
247                *s = r;
248                *changed = true;
249            }
250        }
251        serde_json::Value::Array(a) => a.iter_mut().for_each(|x| walk(x, changed)),
252        serde_json::Value::Object(o) => o.iter_mut().for_each(|(_, x)| walk(x, changed)),
253        _ => {}
254    }
255}
256
257/// Redact every string inside a serialized JSON payload (so escaped quotes
258/// and newline escapes cannot hide a secret), re-serializing only when
259/// something changed. Non-JSON input is redacted as plain text.
260pub fn redact_json_payload(payload: &str) -> Cow<'_, str> {
261    match serde_json::from_str::<serde_json::Value>(payload) {
262        Ok(mut v) => {
263            let mut changed = false;
264            walk(&mut v, &mut changed);
265            if !changed {
266                return Cow::Borrowed(payload);
267            }
268            match serde_json::to_string(&v) {
269                Ok(s) => Cow::Owned(s),
270                Err(_) => redact_irreversible(payload),
271            }
272        }
273        Err(_) => redact_irreversible(payload),
274    }
275}