use regex::Regex;
use std::sync::LazyLock;
use crate::facts::shell::Argv;
#[expect(
clippy::expect_used,
reason = "static pattern literal validated by tests"
)]
pub static SENSITIVE_PATH: LazyLock<Regex> = LazyLock::new(|| {
Regex::new(concat!(
r"(?x)",
r"(?:",
r"(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.ssh)(?:/|\b)",
r"|(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.aws)(?:/|\b)",
r"|(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.config/gcloud)(?:/|\b)",
r"|(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.kube/config)\b",
r"|(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.docker/config\.json)\b",
r"|\b(?i-u:id_(?:rsa|dsa|ecdsa|ed25519))\b",
r"|(?:^|/|\s|[*?\[\]={},])(?i-u:\.env)(?:\.[A-Za-z0-9_-]+)?\b",
r"|(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.npmrc)\b",
r"|(?:^|/|\s|(?:~|\$HOME|\$\{HOME\})/)(?i-u:\.pypirc)\b",
r"|\S+(?i-u:\.tfstate)\b",
r"|-----BEGIN\s+[A-Z\s]+PRIVATE\s+KEY-----",
r")",
))
.expect("SENSITIVE_PATH regex")
});
const SENSITIVE_NEEDLES: &[&str] = &[
".ssh",
".aws",
"gcloud",
".kube/config",
".docker/config",
"id_",
".env",
".npmrc",
".pypirc",
".tfstate",
"-----begin",
];
pub(super) fn matches_sensitive_path(token: &str) -> bool {
let lower = token.to_ascii_lowercase();
if !SENSITIVE_NEEDLES.iter().any(|n| lower.contains(n)) {
return false;
}
SENSITIVE_PATH.is_match(token)
}
pub(super) fn argv_references_sensitive(argv: &Argv) -> bool {
if matches_sensitive_path(&argv.head) {
return true;
}
if argv.args.iter().any(|a| matches_sensitive_path(a)) {
return true;
}
argv.env_assignments
.iter()
.any(|e| matches_sensitive_path(&e.value))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn sensitive_path_matches_dotenv_case_insensitive() {
let cases = [
(".env", true),
(".ENV", true),
("foo/.env.bar", true),
("/repo/.env", true),
];
for (token, expect) in cases {
assert_eq!(SENSITIVE_PATH.is_match(token), expect, "token {token:?}");
}
assert!(SENSITIVE_PATH.is_match("if=.env"));
for token in [
"{a,b}.env",
"{x,y,z}.env",
"{.env,.env.local}",
"prefix{a,b}.env",
"{app,web}.env.production",
] {
assert!(SENSITIVE_PATH.is_match(token), "token {token:?}");
}
}
#[test]
fn sensitive_path_rejects_non_secret_paths() {
assert!(!SENSITIVE_PATH.is_match("README"));
assert!(!SENSITIVE_PATH.is_match("/tmp/foo"));
}
#[test]
fn sensitive_path_matches_npmrc_pypirc_at_path_boundaries() {
for token in [
".npmrc",
"~/.npmrc",
"/home/user/.npmrc",
"$HOME/.npmrc",
".pypirc",
"~/.pypirc",
"/root/.pypirc",
] {
assert!(SENSITIVE_PATH.is_match(token), "missed {token:?}");
}
for token in ["data.npmrc", "xpypirc", "npmrc"] {
assert!(!SENSITIVE_PATH.is_match(token), "false positive {token:?}");
}
}
#[test]
fn sensitive_path_matches_all_ssh_key_families() {
for token in [
"id_rsa",
"id_dsa",
"id_ecdsa",
"id_ed25519",
"~/.ssh/id_dsa",
] {
assert!(SENSITIVE_PATH.is_match(token), "missed {token:?}");
}
}
#[test]
fn sensitive_path_matches_absolute_secret_directories() {
let cases = [
("/home/user/.ssh/config", true),
("/root/.ssh/id_rsa", true),
("/home/user/.aws/credentials", true),
("/root/.aws/credentials", true),
(
"/home/user/.config/gcloud/application_default_credentials.json",
true,
),
("/home/alice/.kube/config", true),
("/var/root/.docker/config.json", true),
("~/.ssh/id_rsa", true),
("$HOME/.aws/credentials", true),
];
for (token, expect) in cases {
assert_eq!(SENSITIVE_PATH.is_match(token), expect, "token {token:?}");
}
}
#[test]
fn argv_references_sensitive_checks_head_and_args() {
let bash = crate::facts::shell::parse("id_rsa");
let argv = bash.commands().into_iter().next().expect("argv");
assert!(argv_references_sensitive(argv));
}
use crate::testing::proptest::{
dotenv_anchored_literal_token, dotenv_brace_token, dotenv_false_positive_token,
};
use proptest::prelude::*;
proptest! {
#[test]
fn pbt_brace_dotenv_matches_sensitive_path(token in dotenv_brace_token()) {
prop_assert!(
SENSITIVE_PATH.is_match(&token),
"SENSITIVE_PATH missed brace token {token:?}",
);
}
#[test]
fn pbt_anchored_dotenv_literals_match_sensitive_path(token in dotenv_anchored_literal_token()) {
prop_assert!(
SENSITIVE_PATH.is_match(&token),
"SENSITIVE_PATH missed {token:?}",
);
}
#[test]
fn pbt_dotenv_false_positives_rejected(token in dotenv_false_positive_token()) {
prop_assert!(
!SENSITIVE_PATH.is_match(&token),
"SENSITIVE_PATH false positive on {token:?}",
);
}
#[test]
fn pbt_sensitive_path_is_match_never_panics(s in "[ -~]{0,80}") {
let _ = SENSITIVE_PATH.is_match(&s);
}
#[test]
fn pbt_prefilter_matches_regex(s in "[ -~]{0,80}") {
prop_assert_eq!(
matches_sensitive_path(&s),
SENSITIVE_PATH.is_match(&s),
"prefilter diverged on {:?}",
s,
);
}
#[test]
fn pbt_sensitive_path_matches_classify(s in "[ -~]{0,80}") {
prop_assert_eq!(
SENSITIVE_PATH.is_match(&s),
!crate::facts::sensitive::classify(&s).is_empty(),
"SENSITIVE_PATH and classify diverged on {:?}",
s,
);
}
#[test]
fn pbt_sensitive_path_matches_classify_on_secret_shapes(
s in crate::testing::proptest::sensitive_shaped_token(),
) {
prop_assert_eq!(
SENSITIVE_PATH.is_match(&s),
!crate::facts::sensitive::classify(&s).is_empty(),
"SENSITIVE_PATH and classify diverged on {:?}",
s,
);
}
}
}