use std::collections::HashMap;
use recall_wire::EvaluateFile;
struct Pattern {
what: &'static str,
prefix: &'static str,
allowed: fn(u8) -> bool,
min: usize,
max: usize,
}
fn alnum(b: u8) -> bool {
b.is_ascii_alphanumeric()
}
fn upper_digit(b: u8) -> bool {
b.is_ascii_uppercase() || b.is_ascii_digit()
}
fn word(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_'
}
fn dashed(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'_' || b == b'-'
}
fn keyish(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.')
}
fn base64ish(b: u8) -> bool {
b.is_ascii_alphanumeric() || matches!(b, b'/' | b'+')
}
fn hex(b: u8) -> bool {
b.is_ascii_hexdigit()
}
fn unspaced(b: u8) -> bool {
b.is_ascii_graphic() && !matches!(b, b'"' | b'\'' | b'`' | b',' | b';')
}
const fn pattern(
what: &'static str,
prefix: &'static str,
allowed: fn(u8) -> bool,
min: usize,
max: usize,
) -> Pattern {
Pattern {
what,
prefix,
allowed,
min,
max,
}
}
const PATTERNS: &[Pattern] = &[
pattern("AWS access key", "AKIA", upper_digit, 16, 16),
pattern("AWS access key", "ASIA", upper_digit, 16, 16),
pattern("GitHub token", "ghp_", alnum, 36, 255),
pattern("GitHub token", "gho_", alnum, 36, 255),
pattern("GitHub token", "ghu_", alnum, 36, 255),
pattern("GitHub token", "ghs_", alnum, 36, 255),
pattern("GitHub token", "ghr_", alnum, 36, 255),
pattern("GitHub token", "github_pat_", word, 40, 255),
pattern("GitLab token", "glpat-", dashed, 20, 255),
pattern("Slack token", "xoxb-", dashed, 20, 255),
pattern("Slack token", "xoxp-", dashed, 20, 255),
pattern("Slack token", "xoxa-", dashed, 20, 255),
pattern("Slack token", "xoxr-", dashed, 20, 255),
pattern("Slack token", "xoxs-", dashed, 20, 255),
pattern("Slack app token", "xapp-", dashed, 20, 255),
pattern("Stripe key", "sk_live_", alnum, 20, 255),
pattern("Stripe key", "rk_live_", alnum, 20, 255),
pattern("Anthropic API key", "sk-ant-", dashed, 30, 255),
pattern("OpenAI API key", "sk-proj-", dashed, 20, 255),
pattern("OpenAI API key", "sk-svcacct-", dashed, 20, 255),
pattern("OpenAI API key", "sk-admin-", dashed, 20, 255),
pattern("OpenAI API key", "sk-", alnum, 40, 255),
pattern("Google API key", "AIza", dashed, 35, 35),
pattern("npm token", "npm_", alnum, 36, 36),
pattern("Hugging Face token", "hf_", alnum, 30, 40),
pattern("Recall authkey", "recall-ak-", alnum, 20, 255),
pattern("Recall enrolment key", "recall-ek-", keyish, 16, 255),
pattern("Recall recovery key", "recall-rk-", keyish, 16, 255),
];
struct Named {
what: &'static str,
names: &'static [&'static str],
value: fn(u8) -> bool,
min: usize,
plausible: fn(&str) -> bool,
}
fn any_value(_: &str) -> bool {
true
}
fn plausible_password(value: &str) -> bool {
let lower = value.to_ascii_lowercase();
let starts_odd = value.starts_with(['$', '<', '{', '*', '%', '(', '[', '/', '~', '.']);
let names_a_vault = [
"1password",
"bitwarden",
"keychain",
"lastpass",
"keepass",
"vault",
"redacted",
"secret",
]
.iter()
.any(|v| lower.contains(v));
let letters = value.bytes().any(|b| b.is_ascii_alphabetic());
let others = value.bytes().any(|b| !b.is_ascii_alphabetic());
(8..=128).contains(&value.len())
&& !starts_odd
&& !names_a_vault
&& !lower.contains("://")
&& letters
&& others
}
const NAMED: &[Named] = &[
Named {
what: "secret value",
names: &[
"secret",
"token",
"password",
"passwd",
"api_key",
"apikey",
"api-key",
"private_key",
"access_key",
],
value: hex,
min: 32,
plausible: any_value,
},
Named {
what: "AWS secret access key",
names: &[
"aws_secret_access_key",
"secret_access_key",
"aws_secret_key",
],
value: base64ish,
min: 40,
plausible: any_value,
},
Named {
what: "password",
names: &["password", "passwd", "passphrase"],
value: unspaced,
min: 8,
plausible: plausible_password,
},
];
const ASSIGN_WINDOW: usize = 4;
pub(crate) fn tokens_in(line: &str) -> Vec<(usize, usize, &'static str)> {
let bytes = line.as_bytes();
let mut out: Vec<(usize, usize, &'static str)> = Vec::new();
let run = |from: usize, allowed: fn(u8) -> bool| {
bytes[from..]
.iter()
.position(|b| !allowed(*b))
.map_or(bytes.len(), |n| from + n)
};
for p in PATTERNS {
let mut from = 0;
while let Some(at) = line[from..].find(p.prefix).map(|i| from + i) {
from = at + p.prefix.len();
if at > 0 && dashed(bytes[at - 1]) {
continue;
}
let end = run(from, p.allowed);
let len = end - from;
from = from.max(end);
let ends_clean = end == bytes.len() || !word(bytes[end]);
if (p.min..=p.max).contains(&len) && ends_clean {
out.push((at, end, p.what));
}
}
}
let mut from = 0;
while let Some(at) = line[from..].find("eyJ").map(|i| from + i) {
from = at + 3;
if at > 0 && dashed(bytes[at - 1]) {
continue;
}
let mut end = at;
let mut parts = 0;
loop {
let next = run(end, dashed);
if next - end < 10 {
break;
}
parts += 1;
end = next;
if parts == 3 || bytes.get(end) != Some(&b'.') {
break;
}
end += 1;
}
from = from.max(end);
if parts == 3 {
out.push((at, end, "JSON Web Token"));
}
}
let lower = line.to_ascii_lowercase();
for named in NAMED {
for name in named.names {
let mut from = 0;
while let Some(at) = lower[from..].find(name).map(|i| from + i) {
from = at + name.len();
let mut i = from;
while i < bytes.len()
&& i < from + ASSIGN_WINDOW
&& matches!(bytes[i], b' ' | b'\t' | b'"' | b'\'')
{
i += 1;
}
if !matches!(bytes.get(i), Some(b':' | b'=')) {
continue;
}
let mut start = i + 1;
while start < bytes.len()
&& matches!(bytes[start], b' ' | b'\t' | b'"' | b'\'' | b'`')
{
start += 1;
}
let end = run(start, named.value);
from = from.max(end);
let ends_clean = end == bytes.len() || !word(bytes[end]);
if end - start >= named.min && ends_clean && (named.plausible)(&line[start..end]) {
out.push((start, end, named.what));
}
}
}
}
let mut from = 0;
while let Some(at) = line[from..].find("://").map(|i| from + i) {
let start = at + 3;
let end = run(start, |b| {
b.is_ascii_graphic()
&& !matches!(b, b'/' | b'?' | b'#' | b'@' | b'"' | b'\'' | b'<' | b'>')
});
from = end.max(start);
if bytes.get(end) != Some(&b'@') {
continue;
}
let Some(colon) = line[start..end].find(':').map(|i| start + i) else {
continue;
};
let password = &line[colon + 1..end];
if !password.is_empty() && !password.starts_with(['$', '<', '{', '*', '%']) {
out.push((colon + 1, end, "password in a URL"));
}
}
let last_end = line.rfind("-----END ");
let mut from = 0;
while let Some(header_end) = key_header_end(&line[from..]).map(|e| from + e) {
let at = line[from..header_end]
.rfind("-----BEGIN ")
.map_or(from, |i| from + i);
from = header_end;
let key_text = |b: u8| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'=' | b'\\');
let body_end = run(header_end, key_text);
let end = match last_end.filter(|e| *e >= header_end) {
Some(_) if line[body_end..].starts_with("-----END ") => line[body_end + 9..]
.find("-----")
.map_or(bytes.len(), |i| body_end + 9 + i + 5),
_ => body_end,
};
if body_end >= header_end + 16 {
out.push((at, end, KEY_WHAT));
from = end;
}
}
out.sort();
let mut kept: Vec<(usize, usize, &'static str)> = Vec::new();
for t in out {
match kept.last_mut() {
Some(last) if t.0 < last.1 => last.1 = last.1.max(t.1),
_ => kept.push(t),
}
}
kept
}
const KEY_WHAT: &str = "private key";
fn public_prefix(token: &str, what: &str) -> Option<&'static str> {
if what == "JSON Web Token" {
return Some("eyJ");
}
PATTERNS
.iter()
.filter(|p| p.what == what && token.starts_with(p.prefix))
.map(|p| p.prefix)
.max_by_key(|prefix| prefix.len())
}
pub(crate) fn mask(token: &str, what: &str) -> String {
let count = token.chars().count();
match public_prefix(token, what) {
Some(prefix) => format!("{prefix}… ({count} characters, masked)"),
None => format!("[{what}, {count} characters, masked]"),
}
}
pub(crate) fn replace_tokens(
line: &str,
tokens: &[(usize, usize, &str)],
with: impl Fn(&str, &str) -> String,
) -> String {
let mut out = String::with_capacity(line.len());
let mut at = 0;
for (start, end, what) in tokens {
out.push_str(&line[at..*start]);
out.push_str(&with(&line[*start..*end], what));
at = *end;
}
out.push_str(&line[at..]);
out
}
fn key_header_end(text: &str) -> Option<usize> {
let mut from = 0;
while let Some(at) = text[from..].find("-----BEGIN ").map(|i| from + i) {
let name = at + 11;
let close = text[name..].find("-----").map(|i| name + i)?;
if text[name..close].ends_with("PRIVATE KEY") {
return Some(close + 5);
}
from = close;
}
None
}
fn unquoted(line: &str) -> &str {
let mut t = line.trim_start();
while let Some(rest) = t.strip_prefix('>') {
t = rest.trim_start();
}
for marker in ["- ", "* ", "+ "] {
if let Some(rest) = t.strip_prefix(marker) {
return rest.trim_start();
}
}
t
}
fn opens_key(line: &str) -> bool {
key_header_end(line).is_some_and(|end| {
line[end..]
.trim()
.trim_matches(['"', '\'', ',', '`'])
.is_empty()
})
}
pub(crate) fn closes_key(line: &str) -> bool {
line.contains("-----END ") && line.contains("PRIVATE KEY")
}
fn key_body(line: &str) -> bool {
let t = unquoted(line).trim();
t.len() >= 16
&& t.bytes()
.all(|b| b.is_ascii_alphanumeric() || matches!(b, b'+' | b'/' | b'='))
}
const MIN_KNOWN_BYTES: usize = 8;
const ANCHOR_BYTES: usize = 16;
#[derive(Debug, Default)]
pub struct Redactor {
known: HashMap<Vec<u8>, Vec<(String, String)>>,
anchors: Vec<usize>,
#[cfg(feature = "client")]
pub(crate) prompt_bytes: std::sync::atomic::AtomicUsize,
}
const KEY_LINE_MASK: &str = "[a line of a private key, masked]";
impl Redactor {
pub fn new(files: &[EvaluateFile]) -> Self {
let mut found: HashMap<String, String> = HashMap::new();
for file in files {
let lines: Vec<&str> = file.content.lines().collect();
let mut in_key = false;
for (n, line) in lines.iter().enumerate() {
for (start, end, what) in tokens_in(line) {
let token = &line[start..end];
found
.entry(token.to_string())
.or_insert_with(|| mask(token, what));
}
if opens_key_block(&lines, n) {
in_key = true;
continue;
}
if in_key {
if closes_key(line) || !key_body(line) {
in_key = false;
} else {
found.insert(unquoted(line).trim().to_string(), KEY_LINE_MASK.into());
}
}
}
}
let mut known: HashMap<Vec<u8>, Vec<(String, String)>> = HashMap::new();
for (secret, masked) in found {
if secret.len() < MIN_KNOWN_BYTES {
continue;
}
let anchor = secret.as_bytes()[..secret.len().min(ANCHOR_BYTES)].to_vec();
known.entry(anchor).or_default().push((secret, masked));
}
for bucket in known.values_mut() {
bucket.sort_by(|a, b| b.0.len().cmp(&a.0.len()).then(a.cmp(b)));
}
let mut anchors: Vec<usize> = known.keys().map(Vec::len).collect();
anchors.sort_unstable_by(|a, b| b.cmp(a));
anchors.dedup();
Self {
known,
anchors,
#[cfg(feature = "client")]
prompt_bytes: Default::default(),
}
}
fn known_at(&self, bytes: &[u8]) -> Option<(usize, &str)> {
let mut best: Option<(usize, &str)> = None;
for &anchor in &self.anchors {
let Some(prefix) = bytes.get(..anchor) else {
continue;
};
let Some(bucket) = self.known.get(prefix) else {
continue;
};
if let Some((secret, masked)) = bucket
.iter()
.find(|(secret, _)| bytes.starts_with(secret.as_bytes()))
{
if best.is_none_or(|(len, _)| secret.len() > len) {
best = Some((secret.len(), masked));
}
}
}
best
}
pub fn text(&self, text: &str) -> String {
let bytes = text.as_bytes();
let mut known = Vec::with_capacity(bytes.len());
let mut i = 0;
while i < bytes.len() {
if text.is_char_boundary(i) {
if let Some((len, masked)) = self.known_at(&bytes[i..]) {
known.extend_from_slice(masked.as_bytes());
i += len;
continue;
}
}
known.push(bytes[i]);
i += 1;
}
let known = String::from_utf8(known).expect("whole secrets replaced by text");
let mut out = String::with_capacity(known.len());
for line in known.split_inclusive('\n') {
let body = line.trim_end_matches(['\n', '\r']);
let ending = &line[body.len()..];
let found = tokens_in(body);
out.push_str(&replace_tokens(body, &found, mask));
out.push_str(ending);
}
out
}
#[cfg(feature = "client")]
pub(crate) fn prompt_text(&self, content: &str) -> String {
self.prompt_bytes
.fetch_add(content.len(), std::sync::atomic::Ordering::Relaxed);
self.text(content)
}
}
pub(crate) fn opens_key_block(lines: &[&str], n: usize) -> bool {
opens_key(lines[n]) && lines.get(n + 1).is_some_and(|next| key_body(next))
}