use serde::{Deserialize, Serialize};
use std::path::Path;
#[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") {
if 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(|c: char| c == '.' || c == ')' || c == ']');
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
}
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")
}
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());
}
}