use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum SensitiveKind {
SshDir,
AwsDir,
GcloudDir,
KubeConfig,
DockerConfig,
PrivateKeyFile,
Dotenv,
Npmrc,
Pypirc,
Tfstate,
PemBlob,
}
impl SensitiveKind {
pub fn as_str(self) -> &'static str {
match self {
Self::SshDir => "ssh_dir",
Self::AwsDir => "aws_dir",
Self::GcloudDir => "gcloud_dir",
Self::KubeConfig => "kube_config",
Self::DockerConfig => "docker_config",
Self::PrivateKeyFile => "private_key_file",
Self::Dotenv => "dotenv",
Self::Npmrc => "npmrc",
Self::Pypirc => "pypirc",
Self::Tfstate => "tfstate",
Self::PemBlob => "pem_blob",
}
}
pub fn parse(s: &str) -> Option<Self> {
Some(match s {
"ssh_dir" => Self::SshDir,
"aws_dir" => Self::AwsDir,
"gcloud_dir" => Self::GcloudDir,
"kube_config" => Self::KubeConfig,
"docker_config" => Self::DockerConfig,
"private_key_file" => Self::PrivateKeyFile,
"dotenv" => Self::Dotenv,
"npmrc" => Self::Npmrc,
"pypirc" => Self::Pypirc,
"tfstate" => Self::Tfstate,
"pem_blob" => Self::PemBlob,
_ => return None,
})
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct SensitivePath {
pub kind: SensitiveKind,
pub raw: String,
}
#[expect(
clippy::expect_used,
reason = "static pattern literal validated by tests"
)]
fn build(pat: &str) -> Regex {
Regex::new(pat).expect("sensitive variant regex")
}
const PROBES: &[(SensitiveKind, &str, &str)] = &[
(
SensitiveKind::SshDir,
".ssh",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.ssh)(?:/|$|\b)",
),
(
SensitiveKind::AwsDir,
".aws",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.aws)(?:/|$|\b)",
),
(
SensitiveKind::GcloudDir,
"gcloud",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.config/gcloud)(?:/|$|\b)",
),
(
SensitiveKind::KubeConfig,
".kube",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.kube/config)\b",
),
(
SensitiveKind::DockerConfig,
".docker",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.docker/config\.json)\b",
),
(
SensitiveKind::PrivateKeyFile,
"id_",
r"\b(?i-u:id_(?:rsa|dsa|ecdsa|ed25519))\b",
),
(
SensitiveKind::Dotenv,
".env",
r"(?:^|/|\s|[*?\[\]={},])(?i-u:\.env)(?:\.[A-Za-z0-9_-]+)?\b",
),
(
SensitiveKind::Npmrc,
".npmrc",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.npmrc)\b",
),
(
SensitiveKind::Pypirc,
".pypirc",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.pypirc)\b",
),
(SensitiveKind::Tfstate, ".tfstate", r"\S+(?i-u:\.tfstate)\b"),
(
SensitiveKind::PemBlob,
"-----begin",
r"-----BEGIN\s+[A-Z\s]+PRIVATE\s+KEY-----",
),
];
static SENSITIVE_REGEXES: [OnceLock<Regex>; PROBES.len()] =
[const { OnceLock::new() }; PROBES.len()];
pub fn classify(token: &str) -> Vec<SensitivePath> {
let mut out = Vec::new();
let lower = token.to_ascii_lowercase();
for (idx, (kind, needle, pat)) in PROBES.iter().enumerate() {
if !lower.contains(needle) {
continue;
}
let re = SENSITIVE_REGEXES[idx].get_or_init(|| build(pat));
for m in re.find_iter(token) {
out.push(SensitivePath {
kind: *kind,
raw: m.as_str().to_string(),
});
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn kinds(s: &str) -> Vec<SensitiveKind> {
classify(s).into_iter().map(|m| m.kind).collect()
}
#[test]
fn classifies_ssh_dir() {
assert!(kinds("~/.ssh/id_rsa").contains(&SensitiveKind::SshDir));
assert!(kinds("$HOME/.ssh/").contains(&SensitiveKind::SshDir));
assert!(kinds("/home/user/.ssh/config").contains(&SensitiveKind::SshDir));
assert!(kinds("/root/.ssh/id_rsa").contains(&SensitiveKind::SshDir));
}
#[test]
fn classifies_aws_dir() {
assert!(kinds("~/.aws/credentials").contains(&SensitiveKind::AwsDir));
assert!(kinds("/home/user/.aws/credentials").contains(&SensitiveKind::AwsDir));
}
#[test]
fn classifies_gcloud_dir() {
assert!(
kinds("~/.config/gcloud/application_default_credentials.json")
.contains(&SensitiveKind::GcloudDir)
);
}
#[test]
fn classifies_kube_and_docker_config() {
assert!(kinds("~/.kube/config").contains(&SensitiveKind::KubeConfig));
assert!(kinds("~/.docker/config.json").contains(&SensitiveKind::DockerConfig));
assert!(kinds("/home/alice/.kube/config").contains(&SensitiveKind::KubeConfig));
assert!(kinds("/var/root/.docker/config.json").contains(&SensitiveKind::DockerConfig));
}
#[test]
fn classifies_private_key_file() {
assert!(kinds("id_rsa").contains(&SensitiveKind::PrivateKeyFile));
assert!(kinds("id_dsa").contains(&SensitiveKind::PrivateKeyFile));
assert!(kinds("id_ecdsa").contains(&SensitiveKind::PrivateKeyFile));
assert!(kinds("id_ed25519").contains(&SensitiveKind::PrivateKeyFile));
assert!(kinds("~/.ssh/id_dsa").contains(&SensitiveKind::PrivateKeyFile));
assert!(!kinds("id_dss").contains(&SensitiveKind::PrivateKeyFile));
assert!(!kinds("id_rsational").contains(&SensitiveKind::PrivateKeyFile));
}
#[test]
fn classifies_dotenv_variants() {
assert!(kinds(".env").contains(&SensitiveKind::Dotenv));
assert!(kinds(".env.production").contains(&SensitiveKind::Dotenv));
assert!(kinds("/srv/app/.env").contains(&SensitiveKind::Dotenv));
}
#[test]
fn classifies_case_variant_paths() {
assert!(kinds(".ENV").contains(&SensitiveKind::Dotenv));
assert!(kinds(".Env.PRODUCTION").contains(&SensitiveKind::Dotenv));
assert!(kinds("~/.SSH/id_rsa").contains(&SensitiveKind::SshDir));
assert!(kinds("~/.AWS/credentials").contains(&SensitiveKind::AwsDir));
assert!(kinds("~/.Kube/config").contains(&SensitiveKind::KubeConfig));
assert!(kinds(".NPMRC").contains(&SensitiveKind::Npmrc));
assert!(kinds("ID_RSA").contains(&SensitiveKind::PrivateKeyFile));
}
#[test]
fn classifies_dotenv_through_glob_anchor() {
assert!(kinds("*.env").contains(&SensitiveKind::Dotenv));
assert!(kinds("?.env").contains(&SensitiveKind::Dotenv));
assert!(kinds("[abc].env").contains(&SensitiveKind::Dotenv));
assert!(kinds("a*.env").contains(&SensitiveKind::Dotenv));
assert!(kinds("dir/*.env.local").contains(&SensitiveKind::Dotenv));
}
#[test]
fn classifies_dotenv_through_brace_expansion_anchor() {
assert!(kinds("{a,b}.env").contains(&SensitiveKind::Dotenv));
assert!(kinds("{x,y,z}.env").contains(&SensitiveKind::Dotenv));
assert!(kinds("{.env,.env.local}").contains(&SensitiveKind::Dotenv));
assert!(kinds("prefix{a,b}.env").contains(&SensitiveKind::Dotenv));
assert!(kinds("{app,web}.env.production").contains(&SensitiveKind::Dotenv));
}
#[test]
fn does_not_misclassify_dotenv_lookalikes() {
assert!(!kinds("envfile").contains(&SensitiveKind::Dotenv));
assert!(!kinds("data.env").contains(&SensitiveKind::Dotenv));
assert!(!kinds("benvironment").contains(&SensitiveKind::Dotenv));
}
#[test]
fn pem_blob_remains_case_sensitive() {
assert!(!kinds("-----begin rsa private key-----").contains(&SensitiveKind::PemBlob));
}
#[test]
fn classifies_npmrc_pypirc_tfstate() {
for token in [".npmrc", "~/.npmrc", "/home/user/.npmrc", "$HOME/.npmrc"] {
assert!(
kinds(token).contains(&SensitiveKind::Npmrc),
"missed {token:?}"
);
}
for token in [".pypirc", "~/.pypirc", "/root/.pypirc", "${HOME}/.pypirc"] {
assert!(
kinds(token).contains(&SensitiveKind::Pypirc),
"missed {token:?}"
);
}
assert!(kinds("infra/main.tfstate").contains(&SensitiveKind::Tfstate));
}
#[test]
fn does_not_misclassify_npmrc_pypirc_lookalikes() {
assert!(!kinds("data.npmrc").contains(&SensitiveKind::Npmrc));
assert!(!kinds("xpypirc").contains(&SensitiveKind::Pypirc));
assert!(!kinds("npmrc").contains(&SensitiveKind::Npmrc));
}
#[test]
fn classifies_pem_blob() {
assert!(
kinds("-----BEGIN RSA PRIVATE KEY-----").contains(&SensitiveKind::PemBlob),
"PEM header should classify"
);
}
#[test]
fn classify_returns_empty_for_safe_input() {
assert!(classify("ls -la").is_empty());
assert!(classify("https://example.com/data.json").is_empty());
}
#[test]
fn match_records_raw_substring() {
let m = classify("/tmp/.env.production").into_iter().next().unwrap();
assert_eq!(m.kind, SensitiveKind::Dotenv);
assert!(m.raw.contains(".env"));
}
#[test]
fn multiple_kinds_can_match_one_token() {
let ms = classify("~/.ssh/id_rsa");
assert!(ms.iter().any(|m| m.kind == SensitiveKind::SshDir));
assert!(ms.iter().any(|m| m.kind == SensitiveKind::PrivateKeyFile));
}
use crate::testing::proptest::sensitive_kind;
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_classify_never_panics(s in "[ -~]{0,80}") {
let _ = classify(&s);
}
#[test]
fn pbt_classify_matches_ungated_probes(s in "[ -~]{0,80}") {
static UNGATED: std::sync::LazyLock<Vec<Regex>> = std::sync::LazyLock::new(|| {
PROBES.iter().map(|(_, _, pat)| build(pat)).collect()
});
let mut reference = Vec::new();
for ((kind, _, _), re) in PROBES.iter().zip(UNGATED.iter()) {
for m in re.find_iter(&s) {
reference.push(SensitivePath {
kind: *kind,
raw: m.as_str().to_string(),
});
}
}
prop_assert_eq!(classify(&s), reference);
}
#[test]
fn pbt_kind_round_trips(k in sensitive_kind()) {
prop_assert_eq!(SensitiveKind::parse(k.as_str()), Some(k));
}
#[test]
fn pbt_match_raw_is_substring(s in "[ -~]{0,80}") {
for m in classify(&s) {
prop_assert!(s.contains(&m.raw), "raw {:?} not substring of {:?}", m.raw, s);
}
}
#[test]
fn pbt_plain_alphanumeric_never_classifies(s in "[A-Za-z0-9]{0,40}") {
prop_assert!(classify(&s).is_empty());
}
#[test]
fn pbt_brace_dotenv_tokens_always_classify(token in crate::testing::proptest::dotenv_brace_token()) {
let kinds: Vec<_> = classify(&token).into_iter().map(|m| m.kind).collect();
prop_assert!(
kinds.contains(&SensitiveKind::Dotenv),
"expected Dotenv for {token:?}, got {kinds:?}",
);
}
#[test]
fn pbt_anchored_dotenv_literals_match_sensitive_path(
token in crate::testing::proptest::dotenv_anchored_literal_token(),
) {
prop_assert!(
crate::rules::patterns::SENSITIVE_PATH.is_match(&token),
"SENSITIVE_PATH missed {token:?}",
);
}
#[test]
fn pbt_dotenv_false_positives_never_classify(
token in crate::testing::proptest::dotenv_false_positive_token(),
) {
prop_assert!(
classify(&token).iter().all(|m| m.kind != SensitiveKind::Dotenv),
"false positive Dotenv for {token:?}: {:?}",
classify(&token),
);
}
#[test]
fn pbt_ssh_key_family_always_classifies(
key in "id_(rsa|dsa|ecdsa|ed25519)",
prefix in "(|~/\\.ssh/|\\$HOME/\\.ssh/|/home/user/\\.ssh/|/root/\\.ssh/)",
) {
let s = format!("{prefix}{key}");
let kinds: Vec<_> = classify(&s).into_iter().map(|m| m.kind).collect();
prop_assert!(
kinds.contains(&SensitiveKind::PrivateKeyFile),
"expected PrivateKeyFile from {s:?}, got {kinds:?}",
);
}
#[test]
fn pbt_ssh_dir_always_classifies(tail in "[a-zA-Z0-9_./-]{0,16}") {
for prefix in ["~", "$HOME", "${HOME}"] {
let s = format!("{prefix}/.ssh/{tail}");
let kinds: Vec<_> = classify(&s).into_iter().map(|m| m.kind).collect();
prop_assert!(
kinds.contains(&SensitiveKind::SshDir),
"expected SshDir from {s:?}, got {kinds:?}",
);
}
}
}
}