use anyhow::{Result, bail};
use std::path::{Path, PathBuf};
pub struct Language {
pub name: &'static str,
pub extensions: &'static [&'static str],
pub filenames: &'static [&'static str],
}
pub const LANGUAGES: &[Language] = &[
lang("c", &["c", "h"], &[]),
lang("clojure", &["clj", "cljc", "cljs", "edn"], &[]),
lang("cpp", &["cc", "cpp", "cxx", "hh", "hpp", "hxx"], &[]),
lang("csharp", &["cs"], &[]),
lang("css", &["css", "sass", "scss"], &[]),
lang("dart", &["dart"], &[]),
lang(
"dockerfile",
&["dockerfile"],
&["dockerfile", "dockerfile.*"],
),
lang("elixir", &["ex", "exs"], &[]),
lang("elm", &["elm"], &[]),
lang("erlang", &["erl", "hrl"], &[]),
lang("fortran", &["f", "f03", "f90", "f95"], &[]),
lang("go", &["go"], &[]),
lang("graphql", &["gql", "graphql"], &[]),
lang("groovy", &["gradle", "groovy"], &[]),
lang("haskell", &["hs"], &[]),
lang("html", &["htm", "html"], &[]),
lang("java", &["java"], &[]),
lang("javascript", &["cjs", "js", "jsx", "mjs"], &[]),
lang("json", &["json"], &[]),
lang("julia", &["jl"], &[]),
lang("kotlin", &["kt", "kts"], &[]),
lang("lua", &["lua"], &[]),
lang("makefile", &["mk"], &["gnumakefile", "makefile"]),
lang("markdown", &["markdown", "md"], &[]),
lang("nix", &["nix"], &[]),
lang("objective-c", &["m", "mm"], &[]),
lang("ocaml", &["ml", "mli"], &[]),
lang("perl", &["pl", "pm", "t"], &[]),
lang("php", &["php"], &[]),
lang("powershell", &["ps1", "psd1", "psm1"], &[]),
lang("proto", &["proto"], &[]),
lang("python", &["py", "pyi"], &[]),
lang("r", &["r"], &[]),
lang("ruby", &["rb"], &[]),
lang("rust", &["rs"], &[]),
lang("scala", &["sc", "scala"], &[]),
lang("shell", &["bash", "sh", "zsh"], &[]),
lang("sql", &["sql"], &[]),
lang("svelte", &["svelte"], &[]),
lang("swift", &["swift"], &[]),
lang("terraform", &["tf", "tfvars"], &[]),
lang("toml", &["toml"], &[]),
lang("typescript", &["cts", "mts", "ts", "tsx"], &[]),
lang("vue", &["vue"], &[]),
lang("yaml", &["yaml", "yml"], &[]),
lang("zig", &["zig"], &[]),
];
const fn lang(
name: &'static str,
extensions: &'static [&'static str],
filenames: &'static [&'static str],
) -> Language {
Language {
name,
extensions,
filenames,
}
}
const PROSE: &[&str] = &["css", "html", "json", "markdown", "toml", "yaml"];
pub fn language_of_path(path: &str) -> Option<&'static Language> {
let lower = path.to_ascii_lowercase();
let name = lower.rsplit(['/', '\\']).next().unwrap_or(&lower);
let ext = name.rsplit_once('.').map(|(_, e)| e).unwrap_or("");
LANGUAGES.iter().find(|entry| {
entry.extensions.contains(&ext)
|| entry.filenames.iter().any(|f| match f.strip_suffix(".*") {
Some(stem) => name.starts_with(stem),
None => *f == name,
})
})
}
pub fn is_code(path: &str) -> bool {
language_of_path(path).is_some_and(|entry| !PROSE.contains(&entry.name))
}
pub fn language(name: &str) -> Option<&'static Language> {
let wanted = name.to_ascii_lowercase();
LANGUAGES.iter().find(|entry| entry.name == wanted)
}
#[derive(Debug, Default, Clone, PartialEq)]
pub struct Filter {
groups: Vec<Vec<String>>,
}
impl Filter {
pub fn new(paths: &[String], exts: &[String], langs: &[String]) -> Result<Self> {
let mut groups = Vec::new();
if !paths.is_empty() {
groups.push(paths.iter().map(|p| anchor(p)).collect());
}
let mut patterns: Vec<String> = Vec::new();
for e in exts {
let (bang, value) = negation(e);
patterns.push(format!(
"{bang}*.{}",
value.trim_start_matches('.').to_ascii_lowercase()
));
}
for name in langs {
let (bang, value) = negation(name);
let Some(entry) = language(value) else {
bail!("unknown language {value:?}; run `semlith languages` for the list");
};
patterns.extend(entry.extensions.iter().map(|e| format!("{bang}*.{e}")));
patterns.extend(entry.filenames.iter().map(|f| format!("{bang}{f}")));
}
if !patterns.is_empty() {
groups.push(patterns.iter().map(|p| anchor(p)).collect());
}
Ok(Self { groups })
}
pub fn is_empty(&self) -> bool {
self.groups.is_empty()
}
pub fn and_language(&self, language: &str) -> Result<Self> {
let added = Self::new(&[], &[], std::slice::from_ref(&language.to_string()))?;
let mut groups = self.groups.clone();
groups.extend(added.groups);
Ok(Self { groups })
}
pub fn groups(&self) -> &[Vec<String>] {
&self.groups
}
}
fn negation(value: &str) -> (&'static str, &str) {
match value.strip_prefix('!') {
Some(rest) => ("!", rest),
None => ("", value),
}
}
fn anchor(pattern: &str) -> String {
if let ("!", rest) = negation(pattern) {
return format!("!{}", anchor(rest));
}
let pattern = pattern.to_lowercase().replace('\\', "/");
if is_absolute(&pattern) {
pattern
} else {
format!("*/{pattern}")
}
}
fn is_absolute(pattern: &str) -> bool {
if pattern.starts_with('/') {
return true;
}
cfg!(windows) && pattern.as_bytes().get(1) == Some(&b':')
}
#[cfg(test)]
mod tests {
#[test]
fn code_is_told_from_prose_by_the_language_table() {
for path in [
"src/lib.rs",
"a/b.py",
"Makefile",
"deploy/Dockerfile",
"x.ts",
] {
assert!(super::is_code(path), "{path} is code");
}
for path in [
"README.md",
"docs/architecture.md",
"Cargo.toml",
"page.html",
"data.json",
"notes.txt",
"book.epub",
] {
assert!(!super::is_code(path), "{path} is not code");
}
}
use super::*;
fn s(items: &[&str]) -> Vec<String> {
items.iter().map(|i| i.to_string()).collect()
}
#[test]
fn a_relative_pattern_is_anchored_and_an_absolute_one_is_not() {
let f = Filter::new(&s(&["src/**"]), &[], &[]).unwrap();
assert_eq!(f.groups(), [["*/src/**"]]);
let f = Filter::new(&s(&["/home/me/proj/src/*"]), &[], &[]).unwrap();
assert_eq!(f.groups(), [["/home/me/proj/src/*"]]);
let f = Filter::new(&s(&[r"src\**"]), &[], &[]).unwrap();
assert_eq!(f.groups(), [["*/src/**"]]);
}
#[test]
fn extensions_and_languages_share_one_group_and_union() {
let f = Filter::new(&[], &s(&["toml"]), &s(&["rust"])).unwrap();
assert_eq!(f.groups().len(), 1, "one group means they union");
assert_eq!(f.groups()[0], ["*/*.toml", "*/*.rs"]);
}
#[test]
fn paths_and_extensions_are_separate_groups_and_intersect() {
let f = Filter::new(&s(&["src/**"]), &s(&["md"]), &[]).unwrap();
assert_eq!(f.groups().len(), 2);
}
#[test]
fn a_leading_dot_on_an_extension_is_accepted() {
let plain = Filter::new(&[], &s(&["rs"]), &[]).unwrap();
let dotted = Filter::new(&[], &s(&[".rs"]), &[]).unwrap();
assert_eq!(plain, dotted);
}
#[test]
fn an_unknown_language_names_the_command_that_lists_them() {
let err = Filter::new(&[], &[], &s(&["klingon"]))
.unwrap_err()
.to_string();
assert!(err.contains("semlith languages"), "unhelpful error: {err}");
}
#[test]
fn a_language_with_no_extension_matches_by_filename() {
let f = Filter::new(&[], &[], &s(&["dockerfile"])).unwrap();
assert_eq!(
f.groups()[0],
["*/*.dockerfile", "*/dockerfile", "*/dockerfile.*"],
"a bare Dockerfile and a Dockerfile.prod are both the language"
);
let f = Filter::new(&[], &[], &s(&["makefile"])).unwrap();
assert_eq!(f.groups()[0], ["*/*.mk", "*/gnumakefile", "*/makefile"]);
let f = Filter::new(&[], &s(&["md"]), &s(&["dockerfile"])).unwrap();
assert_eq!(f.groups().len(), 1);
assert_eq!(f.groups()[0].len(), 4);
}
#[test]
fn a_language_name_is_matched_however_it_is_typed() {
let lower = Filter::new(&[], &[], &s(&["dockerfile"])).unwrap();
let shouted = Filter::new(&[], &[], &s(&["DockerFile"])).unwrap();
assert_eq!(lower, shouted);
}
#[test]
fn no_flags_is_no_filter() {
assert!(Filter::new(&[], &[], &[]).unwrap().is_empty());
}
#[test]
fn a_leading_bang_marks_a_pattern_as_an_exclusion() {
let f = Filter::new(&s(&["src/**", "!src/vendor/**"]), &[], &[]).unwrap();
assert_eq!(f.groups().len(), 1, "one kind is one group");
assert_eq!(f.groups()[0], ["*/src/**", "!*/src/vendor/**"]);
}
#[test]
fn an_excluded_pattern_is_anchored_under_its_bang() {
let f = Filter::new(&s(&["!/home/me/proj/vendor/*"]), &[], &[]).unwrap();
assert_eq!(f.groups()[0], ["!/home/me/proj/vendor/*"]);
let f = Filter::new(&s(&[r"!src\vendor\**"]), &[], &[]).unwrap();
assert_eq!(f.groups()[0], ["!*/src/vendor/**"]);
}
#[test]
fn an_extension_and_a_language_both_negate() {
let f = Filter::new(&[], &s(&["!md"]), &[]).unwrap();
assert_eq!(f.groups()[0], ["!*/*.md"]);
let f = Filter::new(&[], &[], &s(&["!makefile"])).unwrap();
assert_eq!(f.groups()[0], ["!*/*.mk", "!*/gnumakefile", "!*/makefile"]);
}
#[test]
fn an_excluded_unknown_language_still_names_the_language() {
let err = Filter::new(&[], &[], &s(&["!klingon"]))
.unwrap_err()
.to_string();
assert!(err.contains("klingon"), "unhelpful error: {err}");
assert!(!err.contains("!klingon"), "the bang is punctuation: {err}");
}
#[test]
fn an_exclusion_on_its_own_is_not_an_empty_filter() {
let f = Filter::new(&s(&["!vendor/**"]), &[], &[]).unwrap();
assert!(!f.is_empty());
}
#[test]
fn the_language_table_is_normalised_and_sorted() {
let mut previous = "";
for entry in LANGUAGES {
let name = entry.name;
assert!(name > previous, "language table is out of order at {name}");
previous = name;
assert_eq!(name, name.to_ascii_lowercase());
for set in [entry.extensions, entry.filenames] {
let mut sorted = set.to_vec();
sorted.sort_unstable();
assert_eq!(&sorted, &set, "{name}'s entries are out of order");
assert!(
set.iter().all(|e| *e == e.to_ascii_lowercase()),
"{name} has an uppercase entry"
);
}
}
}
}
pub const DENIED_DIRS: &[&str] = &[
".ssh",
".aws",
".gnupg",
".kube",
".config/gcloud",
".azure",
".docker",
"Library/Keychains",
".password-store",
".local/share/keyrings",
];
pub const DENIED_NAMES: &[&str] = &[
".env*",
"*.env",
"*.env.*",
"*.pem",
"*.key",
"*.p12",
"*.pfx",
"*.jks",
"id_rsa*",
"id_ed25519*",
"*credentials*",
"*secret*",
"*.tfstate",
"*.kdbx",
".npmrc",
".netrc",
".pypirc",
".pgpass",
".htpasswd",
".boto",
".s3cfg",
"*.ppk",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Denied {
Directory(&'static str),
Name(&'static str),
Hidden,
NoHome,
}
impl Denied {
pub fn reason(&self) -> String {
match self {
Denied::Directory(dir) => {
format!("under ~/{dir}, which holds credentials — semlith does not index it")
}
Denied::Name(pattern) => {
format!("matches {pattern}, which names a credential — semlith does not index it")
}
Denied::Hidden => "is a hidden file, which semlith skips when walking and does not \
index when named"
.to_string(),
Denied::NoHome => "cannot be checked against the credential directories, because \
semlith cannot tell where your home directory is — set SEMLITH_HOME, or HOME"
.to_string(),
}
}
}
pub fn denied(path: &Path) -> Option<Denied> {
let home = crate::home::user_home().ok().map(|h| crate::canonical(&h));
denied_against(&crate::canonical(path), home.as_deref())
}
pub(crate) fn denied_against(path: &Path, home: Option<&Path>) -> Option<Denied> {
let Some(home) = home else {
return Some(Denied::NoHome);
};
let real = path;
for dir in DENIED_DIRS {
if real.starts_with(home.join(dir)) {
return Some(Denied::Directory(dir));
}
}
let name = path
.file_name()
.map(|n| n.to_string_lossy().to_ascii_lowercase())
.unwrap_or_default();
for pattern in DENIED_NAMES {
if glob_match(pattern, &name) {
return Some(Denied::Name(pattern));
}
}
if name.starts_with('.') && name.len() > 1 {
return Some(Denied::Hidden);
}
None
}
fn glob_match(pattern: &str, name: &str) -> bool {
let pattern = pattern.to_ascii_lowercase();
let parts: Vec<&str> = pattern.split('*').collect();
if parts.len() == 1 {
return pattern == name;
}
let mut rest = name;
if let Some(first) = parts.first()
&& !first.is_empty()
{
match rest.strip_prefix(first) {
Some(tail) => rest = tail,
None => return false,
}
}
if let Some(last) = parts.last()
&& !last.is_empty()
{
match rest.strip_suffix(last) {
Some(head) => rest = head,
None => return false,
}
}
for middle in &parts[1..parts.len().saturating_sub(1)] {
if middle.is_empty() {
continue;
}
match rest.find(middle) {
Some(at) => rest = &rest[at + middle.len()..],
None => return false,
}
}
true
}
pub fn within_boundary(path: &Path, roots: &[PathBuf]) -> bool {
let real = comparable(path);
if roots.iter().any(|root| under(&real, &comparable(root))) {
return true;
}
roots.is_empty() && crate::home::user_home().is_ok_and(|home| under(&real, &comparable(&home)))
}
fn comparable(path: &Path) -> String {
crate::plain(&crate::canonical(path).to_string_lossy())
}
fn under(path: &str, root: &str) -> bool {
let root = root.trim_end_matches(['/', '\\']);
if root.is_empty() {
return true;
}
match path.strip_prefix(root) {
Some("") => true,
Some(rest) => rest.starts_with('/') || rest.starts_with('\\'),
None => false,
}
}
#[cfg(test)]
mod deny_tests {
use super::*;
#[test]
fn a_star_matches_a_run_of_anything() {
assert!(glob_match(".env", ".env"));
assert!(glob_match(".env.*", ".env.production"));
assert!(!glob_match(".env.*", ".environment"));
assert!(glob_match("*.pem", "server.pem"));
assert!(!glob_match("*.pem", "pem"));
assert!(glob_match("id_rsa*", "id_rsa"));
assert!(glob_match("id_rsa*", "id_rsa.pub"));
assert!(glob_match(
"*credentials*",
"service-account-credentials.json"
));
assert!(glob_match("*secret*", "my-secrets.yaml"));
assert!(!glob_match("*secret*", "secrat"));
assert!(glob_match("*.tfstate", "terraform.tfstate"));
}
#[test]
fn a_path_is_refused_when_the_home_directory_is_unknown() {
let home = Path::new("/home/someone");
assert_eq!(
denied_against(&home.join(".kube").join("config"), Some(home)),
Some(Denied::Directory(".kube")),
"the rule does not hold with a home"
);
assert_eq!(
denied_against(Path::new("/srv/corpus/notes.md"), None),
Some(Denied::NoHome),
"an unknown home let an ordinary path through, so it let ~/.kube through too"
);
assert_eq!(
denied_against(&home.join(".kube").join("config"), None),
Some(Denied::NoHome),
"the credential itself must not slip past either"
);
let refusal = Denied::NoHome.reason();
assert!(
refusal.contains("home directory"),
"the reason should say what semlith could not determine: {refusal}"
);
}
#[test]
fn every_credential_name_is_refused() {
for name in [
".env",
".env.local",
".env.vault",
".envrc",
".env-local",
".env.example",
"dev.env",
"production.env.local",
".npmrc",
".netrc",
".pypirc",
".pgpass",
".htpasswd",
".boto",
".s3cfg",
"server.ppk",
] {
let path = PathBuf::from("/work/api").join(name);
assert!(
matches!(
denied_against(&path, Some(Path::new("/home/x"))),
Some(Denied::Name(_))
),
"{name} must be refused by name, not by any other rule"
);
}
for name in ["environment.ts", "env.rs", "envelope.md", "preventable.go"] {
let path = PathBuf::from("/work/api").join(name);
assert_eq!(
denied_against(&path, Some(Path::new("/home/x"))),
None,
"{name} is not a credential"
);
}
}
#[test]
fn a_credential_is_denied_wherever_it_is_named() {
assert!(matches!(
denied(Path::new("/work/api/.env")),
Some(Denied::Name(".env*"))
));
assert!(matches!(
denied(Path::new("/work/api/service-account-credentials.json")),
Some(Denied::Name("*credentials*"))
));
assert!(matches!(
denied(Path::new("/work/api/tls/server.pem")),
Some(Denied::Name("*.pem"))
));
assert!(matches!(
denied(Path::new("/work/api/.hidden-notes")),
Some(Denied::Hidden)
));
assert_eq!(denied(Path::new("/work/api/src/lib.rs")), None);
assert_eq!(denied(Path::new("/work/api/README.md")), None);
assert_eq!(denied(Path::new("/work/api/.")), None);
}
#[test]
fn a_refusal_names_the_rule_rather_than_the_path() {
assert!(Denied::Name("*.pem").reason().contains("*.pem"));
assert!(Denied::Directory(".ssh").reason().contains(".ssh"));
assert!(Denied::Hidden.reason().contains("hidden"));
}
#[test]
fn a_verbatim_root_and_a_plain_path_are_the_same_place() {
let plain_root = vec![PathBuf::from(r"C:\work\api")];
let verbatim_root = vec![PathBuf::from(r"\\?\C:\work\api")];
assert!(within_boundary(
Path::new(r"\\?\C:\work\api\src\lib.rs"),
&plain_root
));
assert!(within_boundary(
Path::new(r"C:\work\api\src\lib.rs"),
&verbatim_root
));
assert!(!within_boundary(
Path::new(r"\\?\C:\work\api-old\lib.rs"),
&plain_root
));
assert!(under("/work/api/src/lib.rs", "/work/api"));
assert!(under("/work/api", "/work/api"));
assert!(under("/work/api/", "/work/api"));
assert!(!under("/work/api-old/src/lib.rs", "/work/api"));
}
#[test]
fn a_store_with_roots_is_confined_to_them() {
let roots = vec![PathBuf::from("/work/api")];
assert!(within_boundary(Path::new("/work/api/src/lib.rs"), &roots));
let Ok(home) = crate::home::user_home() else {
assert!(!within_boundary(
Path::new("/somewhere/else/lib.mjs"),
&roots
));
return;
};
let elsewhere = home.join("elsewhere").join("lib.mjs");
assert!(!within_boundary(&elsewhere, &roots));
assert!(within_boundary(&elsewhere, &[]));
}
#[test]
fn a_path_under_no_root_is_outside_the_boundary() {
let roots = vec![PathBuf::from("/work/api")];
assert!(within_boundary(Path::new("/work/api/src/lib.rs"), &roots));
assert!(within_boundary(Path::new("/work/api"), &roots));
assert!(!within_boundary(Path::new("/etc/hosts"), &roots));
assert!(!within_boundary(Path::new("/work/other"), &roots));
}
}
pub struct Shape {
pub kind: &'static str,
pub pattern: &'static str,
pub example: &'static str,
pub near_miss: &'static str,
}
pub const SHAPES: &[Shape] = &[
Shape {
kind: "an Anthropic API key",
pattern: r"\bsk-ant-[A-Za-z0-9_-]{24,}",
example: "sk-ant-api03-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHH",
near_miss: "sk-ant-short",
},
Shape {
kind: "an OpenAI API key",
pattern: r"\bsk-[A-Za-z0-9_-]{32,}",
example: "sk-proj-AAAABBBBCCCCDDDDEEEEFFFFGGGGHHHHIIII",
near_miss: "sk-tooshort",
},
Shape {
kind: "an AWS access key id",
pattern: r"\b(?:AKIA|ASIA)[0-9A-Z]{16}\b",
example: "AKIAIOSFODNN7EXAMPLE",
near_miss: "AKIAIOSFODNN7EXAMPL",
},
Shape {
kind: "a GitHub token",
pattern: r"\bgh[pousr]_[A-Za-z0-9]{36}\b",
example: "ghp_aaaaBBBBccccDDDDeeeeFFFFgggg12345678",
near_miss: "ghp_tooshortforatoken",
},
Shape {
kind: "a GitHub fine-grained token",
pattern: r"\bgithub_pat_[A-Za-z0-9_]{40,}",
example: "github_pat_11AAAAAAA0aaaaaaaaaaaa_aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa",
near_miss: "github_pat_11AAAAAAA0",
},
Shape {
kind: "a Slack token",
pattern: r"\bxox[abprs]-[0-9A-Za-z-]{12,}",
example: concat!(
"xox",
"b-123456789012-1234567890123-aaaaaaaaaaaaaaaaaaaaaaaa"
),
near_miss: "xoxb-123",
},
Shape {
kind: "a Stripe key",
pattern: r"\b[sr]k_(?:live|test)_[0-9A-Za-z]{16,}",
example: "sk_live_aaaaBBBBccccDDDD1234",
near_miss: "sk_live_tooshort",
},
Shape {
kind: "a Google API key",
pattern: r"\bAIza[0-9A-Za-z_-]{35}\b",
example: concat!("AIza", "SyA0aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"),
near_miss: "AIzaSyA0aaaaaaaaaaaa",
},
Shape {
kind: "a Twilio API key",
pattern: r"\bSK[0-9a-fA-F]{32}\b",
example: concat!("SK", "0123456789abcdef0123456789abcdef"),
near_miss: "SK0123456789abcdef",
},
Shape {
kind: "a SendGrid API key",
pattern: r"\bSG\.[A-Za-z0-9_-]{16,}\.[A-Za-z0-9_-]{16,}",
example: "SG.aaaaaaaaaaaaaaaaaaaaaa.bbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
near_miss: "SG.aaaa.bbbb",
},
Shape {
kind: "an npm token",
pattern: r"\bnpm_[A-Za-z0-9]{36}\b",
example: "npm_aaaaBBBBccccDDDDeeeeFFFFgggg12345678",
near_miss: "npm_install",
},
Shape {
kind: "a semlith agent key",
pattern: r"\bsml_[0-9a-f]{64}\b",
example: "sml_0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef",
near_miss: "sml_0123456789abcdef",
},
Shape {
kind: "a private key block",
pattern: r"-----BEGIN (?:[A-Z ]+ )?PRIVATE KEY-----",
example: "-----BEGIN RSA PRIVATE KEY-----",
near_miss: "-----BEGIN CERTIFICATE-----",
},
Shape {
kind: "a JSON web token",
pattern: r"\beyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}",
example: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.dBjftJeZ4CVPmB92K27uhbUJU1p1r_wW1gFWFOEjXk",
near_miss: "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ4In0",
},
];
const ASSIGNMENT: &str = r#"(?i)[a-z0-9_-]*(?:api[_-]?key|secret|token|password|passwd|auth)[a-z0-9_-]*[ \t]*[:=][ \t]*["']([^"'\r\n]{20,})["']"#;
const MIN_ENTROPY: f64 = 3.2;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Found {
pub kind: String,
pub line: u32,
}
impl Found {
pub fn reason(&self) -> String {
format!(
"holds what looks like {} at line {} — semlith does not index it",
self.kind, self.line
)
}
}
fn shapes() -> &'static (regex::RegexSet, Vec<regex::Regex>, regex::Regex) {
static COMPILED: std::sync::OnceLock<(regex::RegexSet, Vec<regex::Regex>, regex::Regex)> =
std::sync::OnceLock::new();
COMPILED.get_or_init(|| {
let set = regex::RegexSet::new(SHAPES.iter().map(|s| s.pattern))
.expect("the credential shapes compile");
let each = SHAPES
.iter()
.map(|s| regex::Regex::new(s.pattern).expect("the credential shapes compile"))
.collect();
let assignment = regex::Regex::new(ASSIGNMENT).expect("the assignment rule compiles");
(set, each, assignment)
})
}
pub fn scan_text(text: &str) -> Option<Found> {
let (set, each, assignment) = shapes();
let mut best: Option<(usize, &'static str)> = None;
for index in set.matches(text).iter() {
if let Some(m) = each[index].find(text) {
let at = m.start();
if best.is_none_or(|(prev, _)| at < prev) {
best = Some((at, SHAPES[index].kind));
}
}
}
if let Some(caps) = assignment.captures(text)
&& let Some(value) = caps.get(1)
&& !is_placeholder(value.as_str())
&& entropy(value.as_str()) >= MIN_ENTROPY
{
let at = caps.get(0).expect("the whole match").start();
if best.is_none_or(|(prev, _)| at < prev) {
best = Some((at, "a secret assigned to a key-like name"));
}
}
let (at, kind) = best?;
Some(Found {
kind: kind.to_string(),
line: line_of(text, at),
})
}
fn is_placeholder(value: &str) -> bool {
if value.contains("${") || value.contains("{{") || value.contains("<") || value.contains("%(") {
return true;
}
value.split_whitespace().last().is_some_and(|last| {
!last.is_empty()
&& last
.chars()
.all(|c| c.is_ascii_uppercase() || c.is_ascii_digit() || c == '_')
})
}
fn entropy(value: &str) -> f64 {
let mut counts = std::collections::HashMap::new();
for c in value.chars() {
*counts.entry(c).or_insert(0usize) += 1;
}
let total = value.chars().count() as f64;
if total == 0.0 {
return 0.0;
}
-counts
.values()
.map(|n| {
let p = *n as f64 / total;
p * p.log2()
})
.sum::<f64>()
}
fn line_of(text: &str, at: usize) -> u32 {
text[..at].bytes().filter(|b| *b == b'\n').count() as u32 + 1
}