use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
pub struct Finding {
pub path: String,
pub line: usize,
pub rule: String,
pub preview: String,
}
#[derive(Debug, Default, Serialize, Deserialize)]
pub struct Policy {
#[serde(default)]
pub never_send: Vec<String>,
#[serde(default)]
pub disable: Vec<String>,
}
impl Policy {
pub fn load() -> Self {
let p = crate::identity::home().join("scrub.json");
std::fs::read_to_string(p)
.ok()
.and_then(|s| serde_json::from_str(&s).ok())
.unwrap_or_default()
}
}
fn redact(s: &str) -> String {
let n = s.chars().count();
if n <= 6 {
return "*".repeat(8);
}
let head: String = s.chars().take(2).collect();
format!("{head}{}", "*".repeat(8))
}
fn scan_line(line: &str, policy: &Policy) -> Option<(String, String)> {
let disabled = |n: &str| policy.disable.iter().any(|d| d == n);
let lower = line.to_ascii_lowercase();
for lit in &policy.never_send {
if !lit.is_empty() && lower.contains(&lit.to_ascii_lowercase()) {
return Some(("never_send".into(), redact(lit)));
}
}
if !disabled("private_key") && line.contains("-----BEGIN") && line.contains("PRIVATE KEY") {
return Some((
"private_key".into(),
"-----BEGIN ... PRIVATE KEY-----".into(),
));
}
for (name, prefix) in [
("aws_key", "AKIA"),
("anthropic_key", "sk-ant-"),
("openai_key", "sk-proj-"),
("github_token", "ghp_"),
("github_pat", "github_pat_"),
("slack_token", "xox"),
("google_key", "AIza"),
("stripe_key", "sk_live_"),
] {
if disabled(name) {
continue;
}
if let Some(i) = line.find(prefix) {
let tok: String = line[i..]
.chars()
.take_while(|c| !c.is_whitespace() && *c != '"' && *c != '\'')
.collect();
if tok.len() >= prefix.len() + 8 {
return Some((name.to_string(), redact(&tok)));
}
}
}
if !disabled("bearer")
&& let Some(i) = lower.find("bearer ")
{
let tok: String = line[i + 7..]
.chars()
.take_while(|c| !c.is_whitespace())
.collect();
if tok.len() >= 16 {
return Some(("bearer".into(), redact(&tok)));
}
}
if !disabled("email") {
for word in line.split(|c: char| {
c.is_whitespace() || c == ',' || c == ';' || c == '<' || c == '>' || c == '"'
}) {
let w = word.trim_end_matches(['.', ')', ']']);
if let Some(at) = w.find('@') {
let (u, d) = (&w[..at], &w[at + 1..]);
if !u.is_empty()
&& d.contains('.')
&& !d.ends_with('.')
&& d.len() >= 4
&& u.chars().all(|c| c.is_ascii_alphanumeric() || "._%+-".contains(c))
&& d.chars().all(|c| c.is_ascii_alphanumeric() || ".-".contains(c))
&& !["example.com", "example.org", "example.net", "test.com", "localhost"]
.iter()
.any(|x| d.eq_ignore_ascii_case(x))
{
return Some(("email".into(), format!("**@{d}")));
}
}
}
}
None
}
const WINDOW: usize = 4000;
const OVERLAP: usize = 256;
pub fn scan(specs: &[(crate::manifest::ItemKind, String, std::path::PathBuf)]) -> Vec<Finding> {
let policy = Policy::load();
let mut out = Vec::new();
for (_, rel, src) in specs {
let Ok(raw) = std::fs::read(src) else {
continue;
};
let text = String::from_utf8_lossy(&raw);
for (n, line) in text.lines().enumerate() {
if let Some((rule, preview)) = scan_long_line(line, &policy) {
out.push(Finding {
path: display_path(rel),
line: n + 1,
rule,
preview,
});
break; }
}
}
out
}
fn scan_long_line(line: &str, policy: &Policy) -> Option<(String, String)> {
if line.len() <= WINDOW {
return scan_line(line, policy);
}
let chars: Vec<char> = line.chars().collect();
let step = WINDOW - OVERLAP;
let mut start = 0;
while start < chars.len() {
let end = (start + WINDOW).min(chars.len());
let window: String = chars[start..end].iter().collect();
if let Some(hit) = scan_line(&window, policy) {
return Some(hit);
}
if end == chars.len() {
break;
}
start += step;
}
None
}
pub fn scan_built(dir: &std::path::Path) -> Vec<Finding> {
let Ok(raw) = std::fs::read_to_string(dir.join("manifest.json")) else {
return Vec::new();
};
let Ok(m) = serde_json::from_str::<crate::manifest::Manifest>(&raw) else {
return Vec::new();
};
let specs: Vec<(crate::manifest::ItemKind, String, std::path::PathBuf)> = m
.items
.iter()
.map(|it| (it.kind, it.path.clone(), dir.join(&it.path)))
.collect();
scan(&specs)
}
pub fn refusal_lines(findings: &[Finding]) -> Vec<String> {
let mut lines = vec![format!(
"outbound scan found {} item(s) that should not leave:",
findings.len()
)];
for f in findings {
lines.push(format!(
" {}:{} [{}] {}",
f.path, f.line, f.rule, f.preview
));
}
lines.push(format!(
"refusing to push. Pushing is the moment it leaves this machine, and \
there is no --allow-findings here: a bundle assembled elsewhere is \
exactly the one nobody scanned. Fix the files and build it again, or \
add a rule to {}.",
policy_path().display()
));
lines
}
fn display_path(rel: &str) -> String {
rel.strip_prefix("items/").unwrap_or(rel).to_string()
}
pub fn policy_path() -> std::path::PathBuf {
crate::identity::home().join("scrub.json")
}
#[allow(dead_code)]
pub fn ensure_policy_file() -> std::io::Result<()> {
let p = policy_path();
if p.exists() {
return Ok(());
}
if let Some(d) = p.parent() {
std::fs::create_dir_all(d)?;
}
let seed = Policy {
never_send: vec![],
disable: vec![],
};
std::fs::write(p, serde_json::to_vec_pretty(&seed)?)
}
#[cfg(test)]
mod tests {
use super::*;
fn hit(line: &str) -> Option<String> {
scan_line(line, &Policy::default()).map(|(r, _)| r)
}
fn long_hit(line: &str) -> Option<String> {
scan_long_line(line, &Policy::default()).map(|(r, _)| r)
}
#[test]
fn a_long_line_is_scanned_rather_than_skipped() {
let padded = "x".repeat(5000) + " ghp_aAbBcCdDeEfFgGhHiIjJkKlLmMnN0123";
assert_eq!(long_hit(&padded).as_deref(), Some("github_token"));
}
#[test]
fn a_token_straddling_a_window_boundary_is_still_seen() {
let tok = "ghp_aAbBcCdDeEfFgGhHiIjJkKlLmMnN0123";
let line = "y".repeat(WINDOW - 10) + tok + &"z".repeat(5000);
assert_eq!(long_hit(&line).as_deref(), Some("github_token"));
}
#[test]
fn a_window_boundary_in_the_middle_of_a_character_does_not_panic() {
let line = "é".repeat(WINDOW + 50) + " ghp_aAbBcCdDeEfFgGhHiIjJkKlLmMnN0123";
assert_eq!(long_hit(&line).as_deref(), Some("github_token"));
}
#[test]
fn long_prose_with_nothing_in_it_is_still_clean() {
let line = "ordinary words about exponential backoff ".repeat(300);
assert_eq!(long_hit(&line), None);
}
#[test]
fn catches_credentials_in_otherwise_innocent_text() {
assert_eq!(
hit("export KEY=sk-ant-api03-abcdefghijklmnop").as_deref(),
Some("anthropic_key")
);
assert_eq!(
hit("Authorization: Bearer abcdefghijklmnopqrstuvwx").as_deref(),
Some("bearer")
);
assert_eq!(
hit("aws id AKIAIOSFODNN7EXAMPLE here").as_deref(),
Some("aws_key")
);
assert_eq!(
hit("-----BEGIN OPENSSH PRIVATE KEY-----").as_deref(),
Some("private_key")
);
assert_eq!(
hit("ping dana.smith@acmecorp.co.uk about it").as_deref(),
Some("email")
);
}
#[test]
fn does_not_fire_on_ordinary_prose() {
assert!(hit("we decided against tokio-retry for the backoff").is_none());
assert!(hit("see docs at example.com or mail me@example.com").is_none());
assert!(
hit("the sk-ant- prefix identifies an Anthropic key").is_none(),
"a bare prefix in prose is not a credential"
);
assert!(hit("bearer of bad news").is_none());
}
#[test]
fn a_redaction_reveals_neither_the_tail_nor_the_length() {
let tok = "ghp_aAbBcCdDeEfFgGhHiIjJkKlLmMnN0123";
let short = "ghp_aAbBcCdDeEf";
let (a, b) = (redact(tok), redact(short));
assert!(!a.contains("0123"), "the tail survived: {a}");
assert!(!a.contains("aAbBcCdD"), "the body survived: {a}");
assert_eq!(
a.chars().count(),
b.chars().count(),
"the mask leaks the length"
);
assert!(
a.starts_with("gh"),
"the prefix should stay recognisable: {a}"
);
}
#[test]
fn a_finding_never_exposes_the_secret() {
let (_, prev) =
scan_line("KEY=sk-ant-api03-SUPERSECRETVALUE1234", &Policy::default()).unwrap();
assert!(
!prev.contains("SUPERSECRETVALUE"),
"preview leaked the secret: {prev}"
);
assert!(prev.contains('*'), "preview was not redacted: {prev}");
}
#[test]
fn literal_never_send_entries_match_case_insensitively() {
let p = Policy {
never_send: vec!["ProjectHalibut".into()],
disable: vec![],
};
assert!(scan_line("notes on projecthalibut rollout", &p).is_some());
}
}