pub struct Finding {
pub kind: &'static str,
pub preview: String,
}
fn is_tok(c: char) -> bool {
c.is_ascii_alphanumeric() || c == '_' || c == '-'
}
fn redact(tok: &str) -> String {
let n = tok.chars().count();
if n <= 10 {
format!("{}…", &tok[..tok.len().min(4)])
} else {
let head: String = tok.chars().take(6).collect();
format!("{head}…({n} chars)")
}
}
fn classify(tok: &str) -> Option<&'static str> {
let n = tok.len();
let after = |p: &str| tok.strip_prefix(p);
let alnum = |s: &str| s.chars().all(|c| c.is_ascii_alphanumeric());
let alnum_us = |s: &str| s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-');
if let Some(rest) = after("AKIA") {
if rest.len() == 16 && rest.chars().all(|c| c.is_ascii_uppercase() || c.is_ascii_digit()) {
return Some("aws-access-key-id");
}
}
for p in ["ghp_", "gho_", "ghu_", "ghs_", "ghr_"] {
if let Some(rest) = after(p) {
if rest.len() >= 30 && alnum(rest) {
return Some("github-token");
}
}
}
if let Some(rest) = after("github_pat_") {
if rest.len() >= 40 && alnum_us(rest) {
return Some("github-token");
}
}
if tok.starts_with("xox") && n >= 15 && tok.as_bytes().get(4) == Some(&b'-') {
return Some("slack-token");
}
if let Some(rest) = after("AIza") {
if rest.len() == 35 && alnum_us(rest) {
return Some("google-api-key");
}
}
if (tok.starts_with("sk_live_") || tok.starts_with("rk_live_")) && n >= 20 && alnum_us(&tok[8..]) {
return Some("stripe-live-key");
}
if let Some(rest) = after("sk-") {
if rest.len() >= 32 && alnum_us(rest) {
return Some("api-secret-key");
}
}
None
}
pub fn scan(text: &str) -> Vec<Finding> {
let mut out: Vec<Finding> = Vec::new();
let mut push = |kind: &'static str, tok: &str| {
let preview = redact(tok);
if !out.iter().any(|f| f.kind == kind && f.preview == preview) {
out.push(Finding { kind, preview });
}
};
if text.contains("PRIVATE KEY-----") && text.contains("-----BEGIN") {
push("private-key-block", "-----BEGIN…PRIVATE KEY-----");
}
for raw in text.split(|c: char| !is_tok(c)) {
if raw.len() < 8 {
continue;
}
if let Some(kind) = classify(raw) {
push(kind, raw);
}
}
out
}
pub fn summarize(findings: &[Finding]) -> String {
findings
.iter()
.map(|f| format!("{} ({})", f.kind, f.preview))
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(text: &str) -> Vec<&'static str> {
let mut k: Vec<&'static str> = scan(text).into_iter().map(|f| f.kind).collect();
k.sort();
k.dedup();
k
}
#[test]
fn catches_common_secret_shapes() {
assert_eq!(kinds("key AKIAIOSFODNN7EXAMPLE here"), vec!["aws-access-key-id"]);
assert!(kinds(&format!("token=ghp_{}", "a".repeat(36))).contains(&"github-token"));
assert!(kinds(&format!("pat github_pat_{}", "A1_".repeat(20))).contains(&"github-token"));
assert!(kinds("slack xoxb-2401-abc-def123").contains(&"slack-token"));
assert!(kinds(&format!("g AIza{}", "b".repeat(35))).contains(&"google-api-key")); assert!(kinds(&format!("stripe sk_live_{}", "c".repeat(24))).contains(&"stripe-live-key"));
assert!(kinds(&format!("openai sk-{}", "d".repeat(36))).contains(&"api-secret-key"));
assert!(kinds("-----BEGIN OPENSSH PRIVATE KEY-----\nabc\n-----END").contains(&"private-key-block"));
}
#[test]
fn ignores_ordinary_prose_and_ids() {
for clean in [
"please rebake the plate gallery and reply when done",
"see request 01KXA3TBY1G859FHKNFZ7Q91D1 on topic general",
"commit b2308ab fixes the watch loop; see DESIGN.md",
"full sha a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4 landed on main",
"sha256 e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855 digest",
"https://github.com/codeshrew/team-hub.git",
"the AKIA prefix alone is not a key",
] {
assert!(scan(clean).is_empty(), "false positive on: {clean:?} -> {:?}", kinds(clean));
}
}
#[test]
fn dedupes_repeats() {
let t = "ghp_0123456789abcdefghijklmnopqrstuvwxyz and again ghp_0123456789abcdefghijklmnopqrstuvwxyz";
assert_eq!(scan(t).len(), 1);
}
}