use super::{Category, DetectorContext, Severity, Signal, SignalId};
#[must_use]
pub fn assess_target_binary_signals(ctx: &DetectorContext) -> Vec<Signal> {
let Some(binary_path) = ctx.binary_path else {
return Vec::new();
};
let mut signals = Vec::new();
if let Some(reason) = is_interpreter_basename(binary_path) {
signals.push(Signal::new(
SignalId::scoped("binary.interpreter", reason),
Category::BinaryRisk,
Severity::Warn,
"script interpreter target",
format!(
"'{binary_path}' is a script interpreter — the hash gate verifies the \
binary, not the script it runs, so an attacker-controlled script can \
still exfiltrate the secret"
),
"review the script source before approving, or invoke a fixed binary directly",
));
} else if interpreter_spoof_match(binary_path) {
signals.push(Signal::new(
SignalId::new("binary.interpreter.spoof"),
Category::BinaryRisk,
Severity::Hostile,
"renamed script interpreter",
format!(
"'{binary_path}' is a renamed copy of a system interpreter \
(size + SHA-256 match) — someone is hiding a script runner \
behind a benign-looking name"
),
"do not approve; verify the binary's true identity",
));
}
if let Some(untrusted) = untrusted_prefix(binary_path) {
signals.push(Signal::new(
SignalId::scoped("binary.untrusted_path", untrusted.scope),
Category::BinaryRisk,
untrusted.severity,
"binary in untrusted directory",
format!(
"'{binary_path}' is in {prefix} ({reason})",
prefix = untrusted.prefix,
reason = untrusted.reason
),
"move the binary to a system-managed location or invoke an alternate path",
));
}
signals
}
fn is_interpreter_basename(binary_path: &str) -> Option<&'static str> {
let basename = std::path::Path::new(binary_path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(binary_path);
let stem = basename.strip_suffix(".exe").unwrap_or(basename);
match stem {
"bash" => Some("bash"),
"sh" => Some("sh"),
"zsh" => Some("zsh"),
"fish" => Some("fish"),
"dash" => Some("dash"),
"csh" => Some("csh"),
"tcsh" => Some("tcsh"),
"ksh" => Some("ksh"),
"powershell" => Some("powershell"),
"pwsh" => Some("pwsh"),
"cmd" => Some("cmd"),
"python" | "python3" => Some("python"),
"node" => Some("node"),
"ruby" => Some("ruby"),
"perl" => Some("perl"),
_ => None,
}
}
struct UntrustedMatch {
scope: &'static str,
prefix: &'static str,
reason: &'static str,
severity: Severity,
}
fn untrusted_prefix(binary_path: &str) -> Option<UntrustedMatch> {
const WORLD_WRITABLE: &[(&str, &str, &str)] = &[
("/tmp", "tmp", "any process can write here"),
("/var/tmp", "var_tmp", "persistent temporary directory"),
(
"/dev/shm",
"dev_shm",
"shared memory — any process can write here",
),
];
const USER_WRITABLE: &[(&str, &str, &str, &str)] = &[
(
"Downloads",
"home_downloads",
"~/Downloads",
"user download directory — verify provenance",
),
(
"Desktop",
"home_desktop",
"~/Desktop",
"user desktop — verify provenance",
),
(
".cache",
"home_cache",
"~/.cache",
"user cache — overwriteable by any user-process",
),
(
".local/tmp",
"home_local_tmp",
"~/.local/tmp",
"user-local temp — overwriteable by any user-process",
),
];
for (prefix, scope, reason) in WORLD_WRITABLE {
if binary_path.starts_with(prefix) {
return Some(UntrustedMatch {
scope,
prefix,
reason,
severity: Severity::Hostile,
});
}
}
let home = std::env::var("HOME")
.or_else(|_| std::env::var("USERPROFILE"))
.unwrap_or_default();
if home.is_empty() {
return None;
}
for (dir, scope, display_prefix, reason) in USER_WRITABLE {
let mut probe = std::path::PathBuf::from(&home);
for seg in dir.split('/') {
probe.push(seg);
}
if binary_path.starts_with(probe.to_string_lossy().as_ref()) {
return Some(UntrustedMatch {
scope,
prefix: display_prefix,
reason,
severity: Severity::Warn,
});
}
}
None
}
#[must_use]
pub fn is_interpreter(binary_path: &str) -> bool {
if is_interpreter_basename(binary_path).is_some() {
return true;
}
interpreter_spoof_match(binary_path)
}
fn interpreter_spoof_match(binary_path: &str) -> bool {
let path = std::path::Path::new(binary_path);
if !path.exists() {
return false;
}
let Ok(meta) = std::fs::metadata(path) else {
return false;
};
let size = meta.len();
let common_interpreters = [
"/bin/bash",
"/usr/bin/bash",
"/bin/sh",
"/usr/bin/sh",
"/usr/bin/python3",
"/usr/bin/python",
"/usr/bin/node",
"/usr/bin/ruby",
"/usr/bin/perl",
"/usr/bin/zsh",
];
for interp in &common_interpreters {
if path.as_os_str() == *interp {
continue;
}
let p = std::path::Path::new(interp);
if let Ok(sys_meta) = std::fs::metadata(p) {
if sys_meta.len() == size {
if let (Ok(hash), Ok(sys_hash)) = (
crate::guard::hash_binary(path),
crate::guard::hash_binary(p),
) {
if hash == sys_hash {
return true;
}
}
}
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::guard::DetectorContext;
#[test]
fn ambient_context_emits_no_signals() {
let signals = assess_target_binary_signals(&DetectorContext::ambient());
assert!(signals.is_empty());
}
#[test]
fn interpreter_basename_matches_unix_path() {
assert_eq!(is_interpreter_basename("/usr/bin/python3"), Some("python"));
assert_eq!(is_interpreter_basename("/bin/bash"), Some("bash"));
}
#[cfg(windows)]
#[test]
fn interpreter_basename_matches_windows_path() {
assert_eq!(
is_interpreter_basename("C:\\Python311\\python.exe"),
Some("python")
);
assert_eq!(
is_interpreter_basename("C:\\Windows\\System32\\cmd.exe"),
Some("cmd")
);
}
#[test]
fn non_interpreter_returns_none() {
assert!(is_interpreter_basename("/usr/bin/git").is_none());
assert!(is_interpreter_basename("C:\\bin\\git.exe").is_none());
}
#[test]
fn target_binary_emits_interpreter_signal() {
let ctx = DetectorContext::builder()
.binary_path("/usr/bin/python3")
.build();
let signals = assess_target_binary_signals(&ctx);
assert!(signals
.iter()
.any(|s| s.id.as_str() == "binary.interpreter.python"));
}
#[test]
fn target_binary_emits_untrusted_path_for_tmp() {
let ctx = DetectorContext::builder().binary_path("/tmp/evil").build();
let signals = assess_target_binary_signals(&ctx);
assert!(signals
.iter()
.any(|s| s.id.as_str().starts_with("binary.untrusted_path.")));
}
#[test]
fn world_writable_paths_are_hostile_user_writable_are_warn() {
let ctx = DetectorContext::builder().binary_path("/tmp/evil").build();
let s = assess_target_binary_signals(&ctx);
let untrusted: Vec<_> = s
.iter()
.filter(|s| s.id.as_str().starts_with("binary.untrusted_path."))
.collect();
assert_eq!(untrusted.len(), 1);
assert_eq!(untrusted[0].severity, super::Severity::Hostile);
let old_home = std::env::var("HOME").ok();
std::env::set_var("HOME", "/test-home-stub");
let ctx = DetectorContext::builder()
.binary_path("/test-home-stub/Downloads/maybe-bad")
.build();
let s = assess_target_binary_signals(&ctx);
let untrusted: Vec<_> = s
.iter()
.filter(|s| s.id.as_str().starts_with("binary.untrusted_path."))
.collect();
match old_home {
Some(v) => std::env::set_var("HOME", v),
None => std::env::remove_var("HOME"),
}
for sig in &untrusted {
assert!(
sig.severity != super::Severity::Hostile,
"user-writable dir wrongly flagged Hostile: {}",
sig.id
);
}
}
}