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 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
104fn 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 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
172pub 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
222pub 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
257pub 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}