//! The secret check: lines a change adds that look like real keys or //! tokens stop it from landing. use std::sync::OnceLock; use anyhow::Result; use regex::Regex; use serde::Serialize; use vcs_core::{Id, diff, tree}; use crate::objects::Store; #[derive(Debug, Clone, Serialize)] pub struct Hit { pub path: String, pub line: usize, pub kind: &'static str, } /// High-confidence formats only: a false alarm on every landing would teach /// people to pass --allow-secrets. fn patterns() -> &'static [(&'static str, Regex)] { static P: OnceLock> = OnceLock::new(); P.get_or_init(|| { [ ("AWS access key", r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), ("GitHub token", r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"), ("GitHub token", r"\bgithub_pat_[A-Za-z0-9_]{60,}\b"), ("Stripe live key", r"\b(sk|rk)_live_[A-Za-z0-9]{20,}\b"), ("Slack token", r"\bxox[abprs]-[A-Za-z0-9-]{20,}\b"), ("Google API key", r"\bAIza[0-9A-Za-z_-]{35}\b"), ("private key", r"-----BEGIN (RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY( BLOCK)?-----"), ("OpenAI key", r"\bsk-(proj-)?[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}\b"), ("OpenAI key", r"\bsk-proj-[A-Za-z0-9_-]{60,}\b"), ("Anthropic key", r"\bsk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{60,}\b"), ("Upstream token", r"\b(upt|ucs)_[A-Za-z0-9]{24,}\b"), ("npm token", r"\bnpm_[A-Za-z0-9]{36}\b"), ("SendGrid key", r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"), ("Twilio key", r"\bSK[0-9a-f]{32}\b"), ] .into_iter() .map(|(k, p)| (k, Regex::new(p).expect("secret pattern"))) .collect() }) } /// Secrets in lines `new` adds over `old`. pub fn scan(store: &Store, old: &Id, new: &Id) -> Result> { let before = tree::flatten(store, old)?; let after = tree::flatten(store, new)?; let mut hits = Vec::new(); for (path, change) in tree::diff(&before, &after) { let (o, n) = match change { tree::Change::Added(e) => (None, e), tree::Change::Modified(o, n) => (Some(o), n), tree::Change::Removed(_) => continue, }; let new_bytes = store.read_file(&n.id)?; if diff::is_binary(&new_bytes) { continue; } let old_bytes = match o { Some(o) => store.read_file(&o.id)?, None => Vec::new(), }; let lines = diff::lines(&new_bytes); for h in diff::hunks(&old_bytes, &new_bytes) { for i in h.after.clone() { let text = String::from_utf8_lossy(lines[i as usize]); for (kind, re) in patterns() { if re.is_match(&text) { hits.push(Hit { path: path.clone(), line: i as usize + 1, kind }); break; } } } } } Ok(hits) } #[cfg(test)] mod tests { use super::*; #[test] fn finds_live_keys_only() { let hit = |s: &str| patterns().iter().any(|(_, re)| re.is_match(s)); assert!(hit(&format!("const k = \"sk_live_{}\";", "a".repeat(24)))); assert!(hit("AKIAIOSFODNN7EXAMPLE")); assert!(hit("-----BEGIN OPENSSH PRIVATE KEY-----")); assert!(!hit("sk_test_abcdefabcdefabcdefabcdef")); assert!(!hit("const apiKey = process.env.STRIPE_KEY;")); } }