//! The secret check: lines a change adds that look like real keys or //! tokens stop it from landing. The CLI checks before it lands; the hosts //! check edits that land without a workspace. use std::sync::OnceLock; use regex_lite::Regex; use serde::Serialize; use crate::{Id, Result, Source, chunk, diff, tree}; #[derive(Debug, Clone, Serialize, PartialEq)] 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. Each has text that every match contains, /// so most lines skip the regex. fn patterns() -> &'static [(&'static str, &'static [&'static str], Regex)] { static P: OnceLock> = OnceLock::new(); P.get_or_init(|| { let list: [(&'static str, &'static [&'static str], &str); 14] = [ ("AWS access key", &["AKIA", "ASIA"], r"\b(AKIA|ASIA)[0-9A-Z]{16}\b"), ("GitHub token", &["ghp_", "gho_", "ghu_", "ghs_", "ghr_"], r"\b(ghp|gho|ghu|ghs|ghr)_[A-Za-z0-9]{36}\b"), ("GitHub token", &["github_pat_"], r"\bgithub_pat_[A-Za-z0-9_]{60,}\b"), ("Stripe live key", &["_live_"], r"\b(sk|rk)_live_[A-Za-z0-9]{20,}\b"), ("Slack token", &["xox"], r"\bxox[abprs]-[A-Za-z0-9-]{20,}\b"), ("Google API key", &["AIza"], r"\bAIza[0-9A-Za-z_-]{35}\b"), ("private key", &["PRIVATE KEY"], r"-----BEGIN (RSA |EC |DSA |OPENSSH |PGP |ENCRYPTED )?PRIVATE KEY( BLOCK)?-----"), ("OpenAI key", &["T3BlbkFJ"], r"\bsk-(proj-)?[A-Za-z0-9_-]{20,}T3BlbkFJ[A-Za-z0-9_-]{20,}\b"), ("OpenAI key", &["sk-proj-"], r"\bsk-proj-[A-Za-z0-9_-]{60,}\b"), ("Anthropic key", &["sk-ant-"], r"\bsk-ant-(api|admin)[0-9]{2}-[A-Za-z0-9_-]{60,}\b"), ("Upstream token", &["upt_", "ucs_"], r"\b(upt|ucs)_[A-Za-z0-9]{24,}\b"), ("npm token", &["npm_"], r"\bnpm_[A-Za-z0-9]{36}\b"), ("SendGrid key", &["SG."], r"\bSG\.[A-Za-z0-9_-]{22}\.[A-Za-z0-9_-]{43}\b"), ("Twilio key", &["SK"], r"\bSK[0-9a-f]{32}\b"), ]; list.into_iter().map(|(k, needles, p)| (k, needles, Regex::new(p).expect("secret pattern"))).collect() }) } /// The kind of secret `line` looks like, if any. fn find(line: &str) -> Option<&'static str> { patterns().iter().find(|(_, needles, re)| needles.iter().any(|n| line.contains(n)) && re.is_match(line)).map(|(kind, _, _)| *kind) } /// `text` with everything that looks like a secret replaced, and how many /// were (an agent session is kept this way). pub fn redact(text: &str) -> (String, usize) { let mut out = text.to_string(); let mut count = 0; for (_, needles, re) in patterns() { if !needles.iter().any(|n| out.contains(n)) { continue; } let found = re.find_iter(&out).count(); if found > 0 { count += found; out = re.replace_all(&out, "[secret removed by vcs]").into_owned(); } } (out, count) } /// Secrets in lines `new` adds over `old`. pub fn scan(src: &dyn Source, old: &Id, new: &Id) -> Result> { let mut hits = Vec::new(); for (path, change) in tree::diff_trees(src, old, new)? { 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 = chunk::read_file(src, &n.id)?; if diff::is_binary(&new_bytes) { continue; } let old_bytes = match o { Some(o) => chunk::read_file(src, &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() { if let Some(kind) = find(&String::from_utf8_lossy(lines[i as usize])) { hits.push(Hit { path: path.clone(), line: i as usize + 1, kind }); } } } } Ok(hits) } #[cfg(test)] mod tests { use super::*; #[test] fn finds_live_keys_only() { let hit = |s: &str| find(s).is_some(); 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;")); } /// Every pattern's needles are in everything it matches. #[test] fn needles_cover_their_patterns() { let samples = [ "AKIAIOSFODNN7EXAMPLE".to_string(), format!("ghp_{}", "a".repeat(36)), format!("github_pat_{}", "a".repeat(60)), format!("rk_live_{}", "a".repeat(20)), format!("xoxb-{}", "a".repeat(20)), format!("AIza{}", "a".repeat(35)), "-----BEGIN PRIVATE KEY-----".to_string(), format!("sk-{}T3BlbkFJ{}", "a".repeat(20), "a".repeat(20)), format!("sk-proj-{}", "a".repeat(60)), format!("sk-ant-api03-{}", "a".repeat(60)), format!("upt_{}", "a".repeat(24)), format!("npm_{}", "a".repeat(36)), format!("SG.{}.{}", "a".repeat(22), "a".repeat(43)), format!("SK{}", "0".repeat(32)), ]; for (kind, needles, re) in patterns() { let matched: Vec<&String> = samples.iter().filter(|s| re.is_match(s)).collect(); assert!(!matched.is_empty(), "no sample for {kind}"); for s in matched { assert!(needles.iter().any(|n| s.contains(n)), "{kind}: {s} has none of {needles:?}"); } } } }