1use std::collections::HashMap;
13
14use recall_wire::EvaluateFile;
15
16struct Pattern {
18 what: &'static str,
19 prefix: &'static str,
20 allowed: fn(u8) -> bool,
21 min: usize,
22 max: usize,
23}
24
25fn alnum(b: u8) -> bool {
26 b.is_ascii_alphanumeric()
27}
28fn upper_digit(b: u8) -> bool {
29 b.is_ascii_uppercase() || b.is_ascii_digit()
30}
31fn word(b: u8) -> bool {
32 b.is_ascii_alphanumeric() || b == b'_'
33}
34fn dashed(b: u8) -> bool {
35 b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
36}
37fn keyish(b: u8) -> bool {
38 b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')
39}
40fn base64ish(b: u8) -> bool {
41 b.is_ascii_alphanumeric() || matches!(b, b'/' | b'+')
42}
43fn hex(b: u8) -> bool {
44 b.is_ascii_hexdigit()
45}
46fn unspaced(b: u8) -> bool {
47 b.is_ascii_graphic() && !matches!(b, b'"' | b'\'' | b'`' | b',' | b';')
48}
49
50const fn pattern(
52 what: &'static str,
53 prefix: &'static str,
54 allowed: fn(u8) -> bool,
55 min: usize,
56 max: usize,
57) -> Pattern {
58 Pattern {
59 what,
60 prefix,
61 allowed,
62 min,
63 max,
64 }
65}
66
67const PATTERNS: &[Pattern] = &[
68 pattern("AWS access key", "AKIA", upper_digit, 16, 16),
69 pattern("AWS access key", "ASIA", upper_digit, 16, 16),
70 pattern("GitHub token", "ghp_", alnum, 36, 255),
71 pattern("GitHub token", "gho_", alnum, 36, 255),
72 pattern("GitHub token", "ghu_", alnum, 36, 255),
73 pattern("GitHub token", "ghs_", alnum, 36, 255),
74 pattern("GitHub token", "ghr_", alnum, 36, 255),
75 pattern("GitHub token", "github_pat_", word, 40, 255),
76 pattern("GitLab token", "glpat-", dashed, 20, 255),
77 pattern("Slack token", "xoxb-", dashed, 20, 255),
78 pattern("Slack token", "xoxp-", dashed, 20, 255),
79 pattern("Slack token", "xoxa-", dashed, 20, 255),
80 pattern("Slack token", "xoxr-", dashed, 20, 255),
81 pattern("Slack token", "xoxs-", dashed, 20, 255),
82 pattern("Slack app token", "xapp-", dashed, 20, 255),
83 pattern("Stripe key", "sk_live_", alnum, 20, 255),
84 pattern("Stripe key", "rk_live_", alnum, 20, 255),
85 pattern("Anthropic API key", "sk-ant-", dashed, 30, 255),
86 pattern("OpenAI API key", "sk-proj-", dashed, 20, 255),
87 pattern("OpenAI API key", "sk-svcacct-", dashed, 20, 255),
88 pattern("OpenAI API key", "sk-admin-", dashed, 20, 255),
89 pattern("OpenAI API key", "sk-", alnum, 40, 255),
90 pattern("Google API key", "AIza", dashed, 35, 35),
91 pattern("npm token", "npm_", alnum, 36, 36),
92 pattern("Hugging Face token", "hf_", alnum, 30, 40),
93 pattern("Recall authkey", "recall-ak-", alnum, 20, 255),
94 pattern("Recall enrolment key", "recall-ek-", keyish, 16, 255),
95 pattern("Recall recovery key", "recall-rk-", keyish, 16, 255),
96];
97
98struct Named {
102 what: &'static str,
103 names: &'static [&'static str],
104 value: fn(u8) -> bool,
105 min: usize,
106 plausible: fn(&str) -> bool,
107}
108
109fn any_value(_: &str) -> bool {
110 true
111}
112
113fn plausible_password(value: &str) -> bool {
117 let lower = value.to_ascii_lowercase();
118 let starts_odd = value.starts_with(['$', '<', '{', '*', '%', '(', '[', '/', '~', '.']);
119 let names_a_vault = [
120 "1password",
121 "bitwarden",
122 "keychain",
123 "lastpass",
124 "keepass",
125 "vault",
126 "redacted",
127 "secret",
128 ]
129 .iter()
130 .any(|v| lower.contains(v));
131 let letters = value.bytes().any(|b| b.is_ascii_alphabetic());
132 let others = value.bytes().any(|b| !b.is_ascii_alphabetic());
133 (8..=128).contains(&value.len())
134 && !starts_odd
135 && !names_a_vault
136 && !lower.contains("://")
137 && letters
138 && others
139}
140
141const NAMED: &[Named] = &[
142 Named {
143 what: "secret value",
144 names: &[
145 "secret",
146 "token",
147 "password",
148 "passwd",
149 "api_key",
150 "apikey",
151 "api-key",
152 "private_key",
153 "access_key",
154 ],
155 value: hex,
156 min: 32,
157 plausible: any_value,
158 },
159 Named {
160 what: "AWS secret access key",
161 names: &[
162 "aws_secret_access_key",
163 "secret_access_key",
164 "aws_secret_key",
165 ],
166 value: base64ish,
167 min: 40,
168 plausible: any_value,
169 },
170 Named {
171 what: "password",
172 names: &["password", "passwd", "passphrase"],
173 value: unspaced,
174 min: 8,
175 plausible: plausible_password,
176 },
177];
178
179const ASSIGN_WINDOW: usize = 4;
183
184pub(crate) fn tokens_in(line: &str) -> Vec<(usize, usize, &'static str)> {
191 let bytes = line.as_bytes();
192 let mut out: Vec<(usize, usize, &'static str)> = Vec::new();
193 let run = |from: usize, allowed: fn(u8) -> bool| {
194 bytes[from..]
195 .iter()
196 .position(|b| !allowed(*b))
197 .map_or(bytes.len(), |n| from + n)
198 };
199 for p in PATTERNS {
200 let mut from = 0;
201 while let Some(at) = line[from..].find(p.prefix).map(|i| from + i) {
202 from = at + p.prefix.len();
203 if at > 0 && dashed(bytes[at - 1]) {
204 continue;
205 }
206 let end = run(from, p.allowed);
207 let len = end - from;
208 from = from.max(end);
210 let ends_clean = end == bytes.len() || !word(bytes[end]);
213 if (p.min..=p.max).contains(&len) && ends_clean {
214 out.push((at, end, p.what));
215 }
216 }
217 }
218 let mut from = 0;
220 while let Some(at) = line[from..].find("eyJ").map(|i| from + i) {
221 from = at + 3;
222 if at > 0 && dashed(bytes[at - 1]) {
223 continue;
224 }
225 let mut end = at;
226 let mut parts = 0;
227 loop {
228 let next = run(end, dashed);
229 if next - end < 10 {
230 break;
231 }
232 parts += 1;
233 end = next;
234 if parts == 3 || bytes.get(end) != Some(&b'.') {
235 break;
236 }
237 end += 1;
238 }
239 from = from.max(end);
240 if parts == 3 {
241 out.push((at, end, "JSON Web Token"));
242 }
243 }
244 let lower = line.to_ascii_lowercase();
247 for named in NAMED {
248 for name in named.names {
249 let mut from = 0;
250 while let Some(at) = lower[from..].find(name).map(|i| from + i) {
251 from = at + name.len();
252 let mut i = from;
253 while i < bytes.len()
254 && i < from + ASSIGN_WINDOW
255 && matches!(bytes[i], b' ' | b'\t' | b'"' | b'\'')
256 {
257 i += 1;
258 }
259 if !matches!(bytes.get(i), Some(b':' | b'=')) {
260 continue;
261 }
262 let mut start = i + 1;
263 while start < bytes.len()
264 && matches!(bytes[start], b' ' | b'\t' | b'"' | b'\'' | b'`')
265 {
266 start += 1;
267 }
268 let end = run(start, named.value);
269 from = from.max(end);
270 let ends_clean = end == bytes.len() || !word(bytes[end]);
271 if end - start >= named.min && ends_clean && (named.plausible)(&line[start..end]) {
272 out.push((start, end, named.what));
273 }
274 }
275 }
276 }
277 let mut from = 0;
279 while let Some(at) = line[from..].find("://").map(|i| from + i) {
280 let start = at + 3;
281 let end = run(start, |b| {
282 b.is_ascii_graphic()
283 && !matches!(b, b'/' | b'?' | b'#' | b'@' | b'"' | b'\'' | b'<' | b'>')
284 });
285 from = end.max(start);
286 if bytes.get(end) != Some(&b'@') {
287 continue;
288 }
289 let Some(colon) = line[start..end].find(':').map(|i| start + i) else {
290 continue;
291 };
292 let password = &line[colon + 1..end];
293 if !password.is_empty() && !password.starts_with(['$', '<', '{', '*', '%']) {
294 out.push((colon + 1, end, "password in a URL"));
295 }
296 }
297 let last_end = line.rfind("-----END ");
302 let mut from = 0;
303 while let Some(header_end) = key_header_end(&line[from..]).map(|e| from + e) {
304 let at = line[from..header_end]
305 .rfind("-----BEGIN ")
306 .map_or(from, |i| from + i);
307 from = header_end;
308 let key_text = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'\\');
309 let body_end = run(header_end, key_text);
310 let end = match last_end.filter(|e| *e >= header_end) {
311 Some(_) if line[body_end..].starts_with("-----END ") => line[body_end + 9..]
314 .find("-----")
315 .map_or(bytes.len(), |i| body_end + 9 + i + 5),
316 _ => body_end,
317 };
318 if body_end >= header_end + 16 {
319 out.push((at, end, KEY_WHAT));
320 from = end;
321 }
322 }
323 out.sort();
324 let mut kept: Vec<(usize, usize, &'static str)> = Vec::new();
326 for t in out {
327 match kept.last_mut() {
328 Some(last) if t.0 < last.1 => last.1 = last.1.max(t.1),
329 _ => kept.push(t),
330 }
331 }
332 kept
333}
334
335const KEY_WHAT: &str = "private key";
337
338fn public_prefix(token: &str, what: &str) -> Option<&'static str> {
343 if what == "JSON Web Token" {
344 return Some("eyJ");
345 }
346 PATTERNS
347 .iter()
348 .filter(|p| p.what == what && token.starts_with(p.prefix))
349 .map(|p| p.prefix)
350 .max_by_key(|prefix| prefix.len())
351}
352
353pub(crate) fn mask(token: &str, what: &str) -> String {
356 let count = token.chars().count();
357 match public_prefix(token, what) {
358 Some(prefix) => format!("{prefix}… ({count} characters, masked)"),
359 None => format!("[{what}, {count} characters, masked]"),
360 }
361}
362
363pub(crate) fn replace_tokens(
365 line: &str,
366 tokens: &[(usize, usize, &str)],
367 with: impl Fn(&str, &str) -> String,
368) -> String {
369 let mut out = String::with_capacity(line.len());
370 let mut at = 0;
371 for (start, end, what) in tokens {
372 out.push_str(&line[at..*start]);
373 out.push_str(&with(&line[*start..*end], what));
374 at = *end;
375 }
376 out.push_str(&line[at..]);
377 out
378}
379
380fn key_header_end(text: &str) -> Option<usize> {
384 let mut from = 0;
385 while let Some(at) = text[from..].find("-----BEGIN ").map(|i| from + i) {
386 let name = at + 11;
387 let close = text[name..].find("-----").map(|i| name + i)?;
388 if text[name..close].ends_with("PRIVATE KEY") {
389 return Some(close + 5);
390 }
391 from = close;
392 }
393 None
394}
395
396fn unquoted(line: &str) -> &str {
398 let mut t = line.trim_start();
399 while let Some(rest) = t.strip_prefix('>') {
400 t = rest.trim_start();
401 }
402 for marker in ["- ", "* ", "+ "] {
403 if let Some(rest) = t.strip_prefix(marker) {
404 return rest.trim_start();
405 }
406 }
407 t
408}
409
410fn opens_key(line: &str) -> bool {
414 key_header_end(line).is_some_and(|end| {
415 line[end..]
416 .trim()
417 .trim_matches(['"', '\'', ',', '`'])
418 .is_empty()
419 })
420}
421
422pub(crate) fn closes_key(line: &str) -> bool {
424 line.contains("-----END ") && line.contains("PRIVATE KEY")
425}
426
427fn key_body(line: &str) -> bool {
431 let t = unquoted(line).trim();
432 t.len() >= 16
433 && t.bytes()
434 .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
435}
436
437const MIN_KNOWN_BYTES: usize = 8;
443
444const ANCHOR_BYTES: usize = 16;
446
447#[derive(Debug, Default)]
462pub struct Redactor {
463 known: HashMap<Vec<u8>, Vec<(String, String)>>,
466 anchors: Vec<usize>,
468 #[cfg(feature = "client")]
472 pub(crate) prompt_bytes: std::sync::atomic::AtomicUsize,
473}
474
475const KEY_LINE_MASK: &str = "[a line of a private key, masked]";
477
478impl Redactor {
479 pub fn new(files: &[EvaluateFile]) -> Self {
482 let mut found: HashMap<String, String> = HashMap::new();
483 for file in files {
484 let lines: Vec<&str> = file.content.lines().collect();
485 let mut in_key = false;
486 for (n, line) in lines.iter().enumerate() {
487 for (start, end, what) in tokens_in(line) {
488 let token = &line[start..end];
489 found
490 .entry(token.to_string())
491 .or_insert_with(|| mask(token, what));
492 }
493 if opens_key_block(&lines, n) {
494 in_key = true;
495 continue;
496 }
497 if in_key {
498 if closes_key(line) || !key_body(line) {
499 in_key = false;
500 } else {
501 found.insert(unquoted(line).trim().to_string(), KEY_LINE_MASK.into());
502 }
503 }
504 }
505 }
506 let mut known: HashMap<Vec<u8>, Vec<(String, String)>> = HashMap::new();
507 for (secret, masked) in found {
508 if secret.len() < MIN_KNOWN_BYTES {
509 continue;
510 }
511 let anchor = secret.as_bytes()[..secret.len().min(ANCHOR_BYTES)].to_vec();
512 known.entry(anchor).or_default().push((secret, masked));
513 }
514 for bucket in known.values_mut() {
515 bucket.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.cmp(b)));
516 }
517 let mut anchors: Vec<usize> = known.keys().map(Vec::len).collect();
518 anchors.sort_unstable_by(|a, b| b.cmp(a));
519 anchors.dedup();
520 Self {
521 known,
522 anchors,
523 #[cfg(feature = "client")]
524 prompt_bytes: Default::default(),
525 }
526 }
527
528 fn known_at(&self, bytes: &[u8]) -> Option<(usize, &str)> {
531 let mut best: Option<(usize, &str)> = None;
532 for &anchor in &self.anchors {
533 let Some(prefix) = bytes.get(..anchor) else {
534 continue;
535 };
536 let Some(bucket) = self.known.get(prefix) else {
537 continue;
538 };
539 if let Some((secret, masked)) = bucket
540 .iter()
541 .find(|(secret, _)| bytes.starts_with(secret.as_bytes()))
542 {
543 if best.is_none_or(|(len, _)| secret.len() > len) {
544 best = Some((secret.len(), masked));
545 }
546 }
547 }
548 best
549 }
550
551 pub fn text(&self, text: &str) -> String {
554 let bytes = text.as_bytes();
556 let mut known = Vec::with_capacity(bytes.len());
557 let mut i = 0;
558 while i < bytes.len() {
559 if text.is_char_boundary(i) {
561 if let Some((len, masked)) = self.known_at(&bytes[i..]) {
562 known.extend_from_slice(masked.as_bytes());
563 i += len;
564 continue;
565 }
566 }
567 known.push(bytes[i]);
568 i += 1;
569 }
570 let known = String::from_utf8(known).expect("whole secrets replaced by text");
571 let mut out = String::with_capacity(known.len());
573 for line in known.split_inclusive('\n') {
574 let body = line.trim_end_matches(['\n', '\r']);
575 let ending = &line[body.len()..];
576 let found = tokens_in(body);
577 out.push_str(&replace_tokens(body, &found, mask));
578 out.push_str(ending);
579 }
580 out
581 }
582
583 #[cfg(feature = "client")]
587 pub(crate) fn prompt_text(&self, content: &str) -> String {
588 self.prompt_bytes
589 .fetch_add(content.len(), std::sync::atomic::Ordering::Relaxed);
590 self.text(content)
591 }
592}
593
594pub(crate) fn opens_key_block(lines: &[&str], n: usize) -> bool {
598 opens_key(lines[n]) && lines.get(n + 1).is_some_and(|next| key_body(next))
599}