rightkit_agent_engine_protocol/
secret_scan.rs1use std::borrow::Cow;
9
10pub const SECRET_PLACEHOLDER_OPEN: &str = "[[CR_SECRET_";
12pub 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#[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
101fn 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 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
169pub 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
219pub 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
254pub 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 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}