Skip to main content

recall_worker/
redact.rs

1//! Finding and masking secrets in memory text.
2//!
3//! Split out of the evaluation (behind the `client` feature) so
4//! [`Redactor`], and the local `claude` call in [`crate::merge`], are
5//! usable without the worker's HTTP client: `docs/history/memory-truth.md`
6//! decision 2. `recall-server` already depends on this crate the same way
7//! for the merge; this module is exactly as unconditional.
8//!
9//! Everything here reads text and returns text or spans: no I/O, no
10//! network, nothing that needs the `client` feature.
11
12use std::collections::HashMap;
13
14use recall_wire::EvaluateFile;
15
16/// One kind of token: a prefix, and what may follow it.
17struct 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
50/// A token of `what`: `prefix`, then `min` to `max` bytes `allowed` takes.
51const 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
98/// A value assigned to a name that says it is a secret: `names`, then `:`
99/// or `=`, then at least `min` bytes `value` takes, that `plausible`
100/// accepts.
101struct 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
113/// Whether what follows `password:` looks like a password rather than a
114/// word about one: long enough, of more than one kind of character, and
115/// not a placeholder, a path, or the name of where it is kept.
116fn 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
179/// How far after a name its `:` or `=` may be: a closing quote and some
180/// space, and no further, so a name mentioned in prose is not read as an
181/// assignment made halfway along the line.
182const ASSIGN_WINDOW: usize = 4;
183
184/// Every token in `line`: where it starts and ends, and what it is.
185///
186/// Linear in the line's length: every search moves on past what it has
187/// already read, so a line built to make a scan go back over itself (a
188/// prefix repeated inside what it allows, over and over) costs no more
189/// than any other line of its length.
190pub(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            // Past the run this read: nothing inside it is read again.
209            from = from.max(end);
210            // A token ends where the word does: a longer run of the same
211            // letters is something else.
212            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    // A JSON Web Token: three base64url parts, the first a JSON object.
219    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    // A value assigned to a name that says it is a secret: every such
245    // name on the line, not only the first.
246    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    // A password in a URL: `scheme://user:password@host`.
278    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    // A private key on one line: its `-----BEGIN … PRIVATE KEY-----`
298    // header with the body after it on the same line, its line breaks
299    // written `\n` as a JSON string holds them (a cloud service account's
300    // `"private_key"`), up to its `-----END …-----` or the end of the body.
301    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            // The body runs, key text and nothing else, up to the `-----END`:
312            // prose that names both markers on one line is not a key.
313            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    // Overlaps, such as `sk-proj-` inside a match of `sk-`, count once.
325    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
335/// What a private key is called, as a token and as a finding.
336const KEY_WHAT: &str = "private key";
337
338/// The public prefix a token of `what` starts with, when its kind has one
339/// (`ghp_`, `sk-proj-`, `AKIA`, `eyJ`): the longest that fits. [`None`]
340/// for a value that is secret from its first character: a password, an
341/// AWS secret access key, a hex secret, a URL's password, a private key.
342fn 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
353/// `token`, a `what`, masked: its length, and its public prefix when its
354/// kind has one. Nothing of the secret itself.
355pub(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
363/// `line` with each token replaced by `with(token, what)`.
364pub(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
380/// Where the first `-----BEGIN … PRIVATE KEY-----` header in `text` ends,
381/// wherever on the line it is: after a `> ` or a list marker, inside a
382/// JSON string.
383fn 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
396/// `line` without the quote markers and list marker before its text.
397fn 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
410/// Whether `line` opens a private key block: a `-----BEGIN … PRIVATE
411/// KEY-----` header anywhere on it, with nothing but quoting after it (a
412/// key on one line is a token instead: see [`tokens_in`]).
413fn 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
422/// Whether `line` closes one.
423pub(crate) fn closes_key(line: &str) -> bool {
424    line.contains("-----END ") && line.contains("PRIVATE KEY")
425}
426
427/// Whether `line` could be a line of a key block's body: base64 and
428/// nothing else, once its quote or list marker is off, long enough not to
429/// be a word.
430fn 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
437/// The shortest string [`Redactor`] masks wherever it appears: a token
438/// found as a secret in one place is masked in any other text only if it
439/// is at least this long, since a shorter one (a one-letter password in a
440/// URL) would be masked in every word that holds it. It is still masked
441/// wherever [`tokens_in`] finds it in its own right.
442const MIN_KNOWN_BYTES: usize = 8;
443
444/// The longest prefix a known secret is looked up by.
445const ANCHOR_BYTES: usize = 16;
446
447/// What masks every secret in memory wherever a report quotes it, not
448/// only in the `secret` finding that names it: a duplicate, a stale note,
449/// a note in the wrong scope or a contradiction may quote the same line.
450///
451/// Built from every file an evaluation reads: each token `tokens_in`
452/// finds, and each line of a private key's body. Every string that goes
453/// into `details`, and every note handed to `claude`, passes through
454/// [`Redactor::text`], which replaces each of them, and any token it finds
455/// itself, with a mask.
456///
457/// Near-linear in the text, however many secrets it knows: each is looked
458/// up by its first `ANCHOR_BYTES` bytes (all of it, when shorter), so
459/// each position of the text costs a few hash lookups rather than one
460/// comparison per secret. The longest secret starting at a position wins.
461#[derive(Debug, Default)]
462pub struct Redactor {
463    /// Every known secret with what it becomes, by the prefix it is looked
464    /// up by; within a prefix, longest first.
465    known: HashMap<Vec<u8>, Vec<(String, String)>>,
466    /// The prefix lengths in `known`, longest first.
467    anchors: Vec<usize>,
468    /// Bytes of notes masked for the contradiction prompt, so a test can
469    /// hold that to once per file per run. Only the evaluation's
470    /// contradiction check (behind `client`) reads this.
471    #[cfg(feature = "client")]
472    pub(crate) prompt_bytes: std::sync::atomic::AtomicUsize,
473}
474
475/// What a line of a private key's body becomes.
476const KEY_LINE_MASK: &str = "[a line of a private key, masked]";
477
478impl Redactor {
479    /// Learns every secret in `files`: each token `tokens_in` finds, and
480    /// each line of a private key's body.
481    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    /// The longest known secret at the start of `bytes`: its length and
529    /// what it becomes.
530    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    /// `text` with every secret this knows of, and every token it finds,
552    /// masked, and every line of a private key's body replaced.
553    pub fn text(&self, text: &str) -> String {
554        // Every known secret, in one pass.
555        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            // A secret is text, so starts where a character does.
560            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        // Then any token of its own on each line.
572        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    /// A note's content as the contradiction prompt holds it: masked, and
584    /// counted. Only the evaluation's contradiction check (behind
585    /// `client`) calls this.
586    #[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
594/// Whether line `n` of `lines` opens a private key block: a key header
595/// with nothing after it, and the next line a line of a key's body. Prose
596/// that ends with a header, or a list of headers, is not a key.
597pub(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}