src/main.rs
//! `modgunn` CLI — the secure-side advisory-warehouse driver.
//!
//! Verbs (`warehouse …` need the warehouse seam; `ingest` needs `manifests`):
//! ```text
//! modgunn ingest --path <manifest-file|dir> [--sbom] [--subject <name>] [--ecosystem <eco>]
//! modgunn scorecard --path <report.json> [--subject <name>] [--warehouse <root>]
//! modgunn behavior --path <package.json|setup.py|pyproject.toml|setup.cfg> [--ecosystem npm|pypi|pyproject|setupcfg] [--subject <name>] [--fail-on-finding] [--warehouse <root>]
//! modgunn warehouse import --warehouse <root> --rev <db_rev> \
//! [--osv <dir>] [--malicious <dir>] [--gitlab <dir>] [--nvd <dir>] [--epss <epss.csv>] [--kev <kev.json>] [--vulncheck-kev <file>] [--euvd-exploited <dir>] [--exploit-poc <dir>] [--metasploit <file>] [--nuclei <file>] [--exploitdb <files_exploits.csv>] \
//! [--crates-yanks <dir>] [--npm-deprecations <dir>] [--pypi-yanks <dir>] [--go-retract <dir>] [--ecosystems <dir>] [--librariesio <dir>] [--ossindex <dir>] [--socket <dir>] [--guac <dir>]
//! modgunn warehouse import-deps --warehouse <root> --path <Cargo.lock> --snapshot <id>
//! modgunn warehouse import-iocs --warehouse <root> --snapshot <id> [--urlhaus <f>] [--feodo <f>] [--sslbl <f>] [--greynoise <f>]
//! modgunn warehouse license-lint --spdx-list <licenses.json> --license "<SPDX expr>"
//! modgunn warehouse attack-map --capec <capec.xml> [--cwe CWE-89]
//! modgunn warehouse import-vuln-symbols --warehouse <root> --snapshot <id> --go-osv <dir>
//! modgunn warehouse vuln-symbols-log --warehouse <root> --snapshot <id>
//! modgunn warehouse reachable-vuln --warehouse <root> --snapshot <id>
//! modgunn warehouse findings-log --warehouse <root> --snapshot <id>
//! modgunn warehouse license-compliance --definitions <file|dir> [--fail-on-finding]
//! modgunn warehouse rekor --entries <file|dir> [--expect-sha256 <hex>] [--trusted-signer <id>]
//! modgunn warehouse ioc-scan --warehouse <root> --snapshot <id> --observables <file>
//! modgunn warehouse ioc-log --warehouse <root> --snapshot <id> [--kind url|ipv4|domain|sha1|sha256|md5] [--threatfox <f>] [--malwarebazaar <f>] [--tor <f>] [--et-compromised <f>] [--spamhaus <f>] [--misp <f>] [--otx <f>]
//! modgunn warehouse scan --warehouse <root> [--since <snapshot_id>] [--vex <dir>] [--persist <snapshot>] [--gate-on <world_snapshot>]
//! modgunn warehouse stats --warehouse <root> [--ecosystem <eco>]
//! modgunn warehouse behavior-log --warehouse <root> [--ecosystem <eco>] [--subject <name>]
//! ```
//!
//! `ingest` is the OFFLINE, zero-shell read-side of `sbom` emit: it parses a
//! project's dependency manifest/lockfile (Cargo.lock, requirements.txt,
//! package-lock.json, pom.xml, go.mod, packages.lock.json, Gemfile.lock, …) into
//! modgunn's shared `sbom::SbomComponent` model and prints the resolved components
//! (default) or a CycloneDX 1.5 SBOM (`--sbom`). No warehouse, no network — a
//! consumer can run it on a laptop against a checked-out repo.
//!
//! `scorecard` is the OFFLINE, zero-shell read-side of the OSSF **Scorecard** feed:
//! it folds a `scorecard --format json` report into the shared
//! [`modgunn::provenance::ScorecardSignals`] model (via `parse_scorecard`), prints the
//! signals + the advisory findings they generate (low score / unmaintained / dangerous
//! workflow / no branch-protection), and — when `--warehouse <root>` is given —
//! PERSISTS the signals via `append_scorecard` so a later `warehouse provenance-log`
//! reads them back (the append→load path). No network — the report itself is produced
//! internet-side and carried across the airgap.
//!
//! `behavior` is the OFFLINE, zero-shell **A23 package-behaviour** read-side: it
//! reads ONE package's published metadata (an npm `package.json`, a PyPI `setup.py`,
//! a PEP 621/517 `pyproject.toml`, or a setuptools `setup.cfg`), folds it into the
//! shared [`modgunn::behavior::PackageBehavior`] feature vector (install hooks /
//! network-in-install / URLs / obfuscation entropy / sensitive-API set) via
//! `parse_npm_package` / `parse_pypi_setup` / `parse_pyproject_toml` /
//! `parse_setup_cfg`, and prints the vector + the advisory findings it generates
//! (dropper network fetch → High, sensitive-API / obfuscation → Medium, install-hook →
//! Low). The `pyproject.toml` and `setup.cfg` folds are **section-aware** — they scan
//! only the build/install-active sections (build-system / cmdclass / build-hook;
//! `[options]` cmdclass / dependency_links / build entry-points), never the declarative
//! `[project]` / `[metadata]` deps & URLs — so an honest project's `urllib3` dep or
//! `https://` homepage does not false-positive. The ecosystem is detected from the
//! basename (or forced with `--ecosystem`). No code is executed — the file is only read as text;
//! `--fail-on-finding` exits non-zero (3) when any finding fires, so a promotion gate
//! can block on a suspicious package.
//!
//! `scan` joins the WHOLE SBOM (`sbom_components`, all repos) against the advisory
//! db and prints every known-vulnerable dependency; `--since <snapshot_id>` does an
//! incremental CDC delta — only the components added since that warehouse snapshot,
//! printing the next watermark to poll from.
//!
//! `import` reads every `*.json` OSV document under `--osv` (and OpenSSF
//! Malicious-Packages docs under `--malicious`, imported Critical), normalizes +
//! dedups them across feeds (range-native — ranges are carried, not pre-expanded),
//! optionally enriches with FIRST **EPSS** + CISA **KEV**, and appends the corpus
//! as a new `db_rev` generation into the shared Iceberg warehouse. Offline by
//! construction: the network fetch that fills `<dir>` runs internet-side and hands
//! a sealed znippy across the airgap; this verb is the secure-side import.
use std::collections::{HashMap, HashSet};
use std::path::Path;
use anyhow::{bail, Context, Result};
use modgunn::engine::EngineKind;
use modgunn::scan::{merge_by_alias, Advisory};
use modgunn::warehouse::{Warehouse, Zone};
use modgunn::{feeds, nvd, osv, registry, vex};
fn main() -> Result<()> {
let args: Vec<String> = std::env::args().skip(1).collect();
let v: Vec<&str> = args.iter().map(String::as_str).collect();
match v.as_slice() {
// `--version` / `-V`: the single-source identity string (semver + git-hash),
// handled before anything else so it always answers (build-version-githash-law).
["--version"] | ["-V"] | ["version"] => {
println!("modgunn v{}", modgunn::version_with_hash());
Ok(())
}
["warehouse", "import", rest @ ..] => cmd_import(rest),
["warehouse", "import-deps", rest @ ..] => cmd_import_deps(rest),
["warehouse", "import-iocs", rest @ ..] => cmd_import_iocs(rest),
["warehouse", "license-lint", rest @ ..] => cmd_license_lint(rest),
["warehouse", "license-compliance", rest @ ..] => cmd_license_compliance(rest),
["warehouse", "rekor", rest @ ..] => cmd_rekor(rest),
["warehouse", "attack-map", rest @ ..] => cmd_attack_map(rest),
["warehouse", "import-vuln-symbols", rest @ ..] => cmd_import_vuln_symbols(rest),
["warehouse", "vuln-symbols-log", rest @ ..] => cmd_vuln_symbols_log(rest),
["warehouse", "reachable-vuln", rest @ ..] => cmd_reachable_vuln(rest),
["warehouse", "ioc-log", rest @ ..] => cmd_ioc_log(rest),
["warehouse", "ioc-scan", rest @ ..] => cmd_ioc_scan(rest),
["warehouse", "findings-log", rest @ ..] => cmd_findings_log(rest),
["warehouse", "stats", rest @ ..] => cmd_stats(rest),
["warehouse", "scan", rest @ ..] => cmd_scan(rest),
["warehouse", "rollup", rest @ ..] => cmd_rollup(rest),
["warehouse", "provenance-log", rest @ ..] => cmd_provenance_log(rest),
["warehouse", "behavior-log", rest @ ..] => cmd_behavior_log(rest),
["warehouse", "provenance-diff", rest @ ..] => cmd_provenance_diff(rest),
["warehouse", "verify-provenance", rest @ ..] => cmd_verify_provenance(rest),
["ingest", rest @ ..] => cmd_ingest(rest),
["scorecard", rest @ ..] => cmd_scorecard(rest),
["behavior", rest @ ..] => cmd_behavior(rest),
// ── Common framework surfaces (About + Tools menu + service lifecycle) ──
["about", rest @ ..] => cmd_about(rest),
#[cfg(feature = "framework")]
["tools", rest @ ..] => cmd_tools(rest),
#[cfg(feature = "framework")]
["demo", rest @ ..] => cmd_demo(rest),
#[cfg(feature = "framework")]
["service", rest @ ..] => cmd_service(rest),
#[cfg(not(feature = "framework"))]
["tools", ..] | ["demo", ..] | ["service", ..] => {
eprintln!(
"the Tools menu / demo / service lifecycle need the shared framework crates \
(jera + skidbladnir): rebuild with `--features framework` (add `framework-oci` \
to run the demo container live). `modgunn about` works without it."
);
std::process::exit(2);
}
_ => {
eprintln!(
"usage:\n \
modgunn about [--json]\n \
modgunn tools [--json] (requires --features framework)\n \
modgunn demo advisory-feed --out <dir> [--json] (requires --features framework; live run needs framework-oci)\n \
modgunn service [--unit] [--json] [--exec <p>] [--addr <a>] [--user <u>] [--user-scope] (requires --features framework)\n \
modgunn ingest --path <manifest-file|dir> [--sbom] [--subject <name>] [--ecosystem <eco>] (requires --features manifests)\n \
modgunn scorecard --path <report.json> [--subject <name>] [--warehouse <root>]\n \
modgunn behavior --path <package.json|setup.py|pyproject.toml|setup.cfg> [--ecosystem npm|pypi|pyproject|setupcfg] [--subject <name>] [--fail-on-finding]\n \
modgunn warehouse import --warehouse <root> --rev <db_rev> [--osv <dir>] [--malicious <dir>] [--gitlab <dir>] [--nvd <dir>] [--nvd-backfill <dir>] [--epss <csv>] [--kev <json>] [--vulncheck-kev <file>] [--euvd-exploited <dir>] [--exploit-poc <dir>] [--metasploit <file>] [--nuclei <file>] [--exploitdb <csv>] [--crates-yanks <dir>] [--npm-deprecations <dir>] [--pypi-yanks <dir>] [--go-retract <dir>] [--ecosystems <dir>] [--librariesio <dir>] [--ossindex <dir>] [--socket <dir> (SCAFFOLD)] [--guac <dir> (SCAFFOLD)]\n \
modgunn warehouse scan --warehouse <root> [--since <snapshot_id>] [--vex <dir>] [--sql] [--engine a|b] [--persist <snapshot>] [--gate-on <world_snapshot>]\n \
modgunn warehouse rollup --warehouse <root> [--engine a|b]\n \
modgunn warehouse stats --warehouse <root> [--ecosystem <eco>]\n \
modgunn warehouse provenance-log --warehouse <root> [--repo <r>] [--id <artifact_id>]\n \
modgunn warehouse behavior-log --warehouse <root> [--ecosystem <eco>] [--subject <name>]\n \
modgunn warehouse provenance-diff --warehouse <root> [--from <snapshot_id>] [--to <snapshot_id>] [--repo <r>] [--fail-on-regression]\n \
modgunn warehouse verify-provenance --warehouse <root> --roots <ca-der-file|dir> (--blob <x.build-provenance.znippy> | --blob-dir <dir>) [--sha256 <hash>] [--min-slsa <n>] [--trusted-signer <id>]... [--expected-repo <r>] [--expected-commit <sha>] (requires --features provenance-verify)\n\n \
every `warehouse` verb (and `scorecard`/`behavior --warehouse`) takes [--zone demo|test|prod] (default demo):\n \
the shared environment axis — scopes modgunn's data to the `modgunn_<zone>` namespace, so a `test` populate never touches `demo`/`prod`."
);
std::process::exit(2);
}
}
}
/// The value following `--name`, if present.
fn flag(args: &[&str], name: &str) -> Option<String> {
args.iter()
.position(|a| *a == name)
.and_then(|i| args.get(i + 1))
.map(|s| s.to_string())
}
/// Whether a boolean `--name` flag is present.
fn has_flag(args: &[&str], name: &str) -> bool {
args.iter().any(|a| *a == name)
}
/// Parse the shared **`--zone demo|test|prod`** flag — the constellation-wide
/// environment axis (the ONE `nornir_zone::Zone` leaf, consumed not twinned, L5).
/// Absent → [`Zone::Demo`] (the DEFAULT, so every pre-zone invocation is unchanged);
/// a recognised key/alias → that zone; an UNKNOWN value → a clear hard error naming
/// the valid zones (never a silent fall-through to the wrong environment). The
/// selected zone scopes modgunn's warehouse data to `modgunn_<zone>`
/// (`Zone::namespace("modgunn")`), so `import`/`scan`/`stats`/… operate within one
/// isolated zone.
#[cfg(feature = "warehouse")]
fn parse_zone(args: &[&str]) -> Result<Zone> {
match flag(args, "--zone") {
None => Ok(Zone::Demo),
Some(v) => Zone::from_key(&v).ok_or_else(|| {
anyhow::anyhow!(
"unknown --zone `{v}` (valid: {})",
Zone::ALL
.iter()
.map(|z| z.key())
.collect::<Vec<_>>()
.join(" | "),
)
}),
}
}
/// `modgunn about [--json]` — the COMMON FRAMEWORK **About** surface: modgunn's
/// identity card (name · Móðguðr · version · role · both engines). Always available
/// (needs no framework crate). `--json` prints the [`modgunn::about::state_json`]
/// oracle a robot asserts; otherwise a human-readable card.
fn cmd_about(args: &[&str]) -> Result<()> {
let id = modgunn::about::identity();
if has_flag(args, "--json") {
println!(
"{}",
serde_json::to_string_pretty(&modgunn::about::state_json())?
);
return Ok(());
}
println!("{} — {} v{}", id.display_name, id.name, id.version);
println!(" {}", id.subtitle);
println!(" {}", id.tagline);
for e in &id.engines {
println!(" · {e}");
}
println!(" repository: {}", id.repository);
Ok(())
}
/// `modgunn tools [--json]` — list the COMMON FRAMEWORK **🧰 Tools menu**: the
/// jera-backed demo actions modgunn ships (start a container → generate data → scan).
/// `--json` prints the [`modgunn::tools::state_json`] roster oracle.
#[cfg(feature = "framework")]
fn cmd_tools(args: &[&str]) -> Result<()> {
if has_flag(args, "--json") {
println!(
"{}",
serde_json::to_string_pretty(&modgunn::tools::state_json(None))?
);
return Ok(());
}
println!("modgunn 🧰 Tools (jera-backed demo actions):");
for e in modgunn::tools::entries() {
println!(
" {} [starts: {} → view: `modgunn {}`]",
e.title,
e.effect.needs_container(),
e.view_verb
);
println!(" {}", e.hover);
}
Ok(())
}
/// `modgunn demo advisory-feed --out <dir> [--json]` — run the Tools-menu **demo
/// advisory feed** THROUGH jera (a tracked container job that writes a demo OSV doc
/// into `<dir>`), then point `modgunn warehouse import --osv <dir>` at it. The jera
/// `ContainerSpec` is printed either way; with `--features framework-oci` the
/// container is actually started (routed THROUGH draupnir's ONE OCI engine).
#[cfg(feature = "framework")]
fn cmd_demo(args: &[&str]) -> Result<()> {
match args.first() {
Some(&"advisory-feed") | None => {}
Some(other) => bail!("unknown demo '{other}' (only 'advisory-feed')"),
}
let out = flag(args, "--out").unwrap_or_else(|| "./modgunn-demo-out".to_string());
let spec = modgunn::tools::demo_feed_spec(&out);
if has_flag(args, "--json") {
println!(
"{}",
serde_json::to_string_pretty(&modgunn::tools::state_json(Some(
modgunn::tools::ToolEffect::DemoAdvisoryFeed
)))?
);
}
println!(
"demo advisory-feed → jera ContainerSpec: image={} name={} mount={}:/out",
spec.image, spec.name, out
);
#[cfg(feature = "framework-oci")]
{
println!("starting THROUGH jera (draupnir OCI engine) …");
let outcome = modgunn::tools::run_demo_feed(&out)?;
println!(
"done: exit={:?}. now: `modgunn warehouse import --osv {out}`",
outcome.exit_code
);
}
#[cfg(not(feature = "framework-oci"))]
{
println!(
"(plan only) rebuild with `--features framework-oci` to start the container live; \
then `modgunn warehouse import --osv {out}`"
);
}
Ok(())
}
/// `modgunn service [--unit] [--json] [--exec <p>] [--addr <a>] [--user <u>]
/// [--user-scope]` — the COMMON FRAMEWORK **lifecycle** surface: modgunn's world-delta
/// gatekeeper declared as a skidbladnir `ServiceSpec` (dedicated `modgunn` system
/// user). `--unit` renders the systemd unit through the shared ship renderer; `--json`
/// dumps the [`modgunn::service::state_json`] oracle.
#[cfg(feature = "framework")]
fn cmd_service(args: &[&str]) -> Result<()> {
let exec = flag(args, "--exec").unwrap_or_else(|| "/usr/local/bin/modgunn".to_string());
let addr = flag(args, "--addr").unwrap_or_else(|| modgunn::service::DEFAULT_ADDR.to_string());
let user_scope = has_flag(args, "--user-scope");
let user = flag(args, "--user");
let run_as: Option<&str> = if user_scope {
None
} else {
Some(user.as_deref().unwrap_or(modgunn::service::SERVICE_USER))
};
if has_flag(args, "--json") {
println!(
"{}",
serde_json::to_string_pretty(&modgunn::service::state_json(
&exec, &addr, run_as, user_scope
))?
);
return Ok(());
}
if has_flag(args, "--unit") {
print!(
"{}",
modgunn::service::modgunn_service_unit(&exec, &addr, run_as, user_scope)
);
return Ok(());
}
println!(
"modgunn service (skidbladnir lifecycle): unit=modgunn.service user={} addr={addr}",
run_as.unwrap_or("<caller>")
);
println!(" `modgunn service --unit` renders the systemd unit; `--json` dumps the oracle");
Ok(())
}
/// Every value following a repeatable `--name` (e.g. `--trusted-signer a
/// --trusted-signer b`), in order.
#[cfg(feature = "provenance-verify")]
fn flags_all(args: &[&str], name: &str) -> Vec<String> {
args.iter()
.enumerate()
.filter(|(_, a)| **a == name)
.filter_map(|(i, _)| args.get(i + 1).map(|s| s.to_string()))
.collect()
}
/// Read every `*.json` file directly under `dir` as a string.
fn read_json_dir(dir: &Path) -> Result<Vec<String>> {
Ok(read_json_dir_named(dir)?
.into_iter()
.map(|(_, s)| s)
.collect())
}
/// As [`read_json_dir`], but keeps each file's path alongside its contents — so a
/// parse failure during import can name the offending file instead of silently
/// dropping CVE coverage.
fn read_json_dir_named(dir: &Path) -> Result<Vec<(std::path::PathBuf, String)>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
let path = entry?.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
let body = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
out.push((path, body));
}
}
Ok(out)
}
/// Read every `*.yml` / `*.yaml` text file under `dir` — the gemnasium collector's
/// input (GitLab advisories are YAML).
fn read_yaml_dir_named(dir: &Path) -> Result<Vec<(std::path::PathBuf, String)>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
let path = entry?.path();
let ext = path.extension().and_then(|e| e.to_str());
if ext == Some("yml") || ext == Some("yaml") {
let body = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
out.push((path, body));
}
}
Ok(out)
}
/// Read every `go.mod` / `*.mod` text file under `dir` — the go.mod collector's
/// input (go.mod is not JSON, so `read_json_dir_named` won't see it).
fn read_mod_dir_named(dir: &Path) -> Result<Vec<(std::path::PathBuf, String)>> {
let mut out = Vec::new();
for entry in std::fs::read_dir(dir).with_context(|| format!("read_dir {}", dir.display()))? {
let path = entry?.path();
let is_mod = path.extension().and_then(|e| e.to_str()) == Some("mod")
|| path.file_name().and_then(|n| n.to_str()) == Some("go.mod");
if is_mod {
let body = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
out.push((path, body));
}
}
Ok(out)
}
/// `modgunn ingest --path <manifest-file|dir> [--sbom] [--subject <name>]
/// [--ecosystem <eco>]` — the OFFLINE, zero-shell **manifest → SBOM ingest** verb:
/// read a project's dependency manifest/lockfile (or every recognised manifest
/// under a directory, VCS/build trees skipped) and turn it into modgunn's shared
/// [`modgunn::sbom::SbomComponent`] model via
/// [`modgunn::manifests::parse_manifest`], then print the resolved components
/// (default) or a CycloneDX 1.5 SBOM (`--sbom`, via [`modgunn::sbom::to_cyclonedx`]).
/// `--ecosystem <eco>` narrows to one ecosystem. No warehouse, no network — the
/// read-side counterpart to `sbom` emit that a consumer can drive on a laptop.
#[cfg(feature = "manifests")]
fn cmd_ingest(args: &[&str]) -> Result<()> {
use modgunn::sbom::{to_cyclonedx, SbomInput};
let path = flag(args, "--path").context("--path <manifest-file|dir> is required")?;
let p = Path::new(&path);
let eco_filter = flag(args, "--ecosystem");
let want_sbom = args.contains(&"--sbom");
// Gather (basename, content): one explicit file, or every recognised manifest
// under a directory. `parse_manifest` dispatches on the basename, so the key is
// the basename — a repo with several package-lock.json all fold in.
let mut files: Vec<(String, String)> = Vec::new();
let meta = std::fs::metadata(p).with_context(|| format!("stat {}", p.display()))?;
if meta.is_dir() {
collect_manifests(p, &mut files)?;
if files.is_empty() {
bail!(
"no recognised manifest/lockfile found under {} (supported: {})",
p.display(),
supported_manifests(),
);
}
} else {
let base = p
.file_name()
.and_then(|n| n.to_str())
.unwrap_or(&path)
.to_string();
let content =
std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()))?;
files.push((base, content));
}
// Parse each. An explicit single file no parser owns is a hard error (usage
// signal); a malformed manifest is named on stderr and counted (never a panic).
let mut components: Vec<modgunn::sbom::SbomComponent> = Vec::new();
let mut parsed_files = 0usize;
let mut n_fail = 0usize;
for (base, content) in &files {
match modgunn::manifests::parse_manifest(base, content) {
Some(Ok(mut comps)) => {
parsed_files += 1;
components.append(&mut comps);
}
Some(Err(e)) => {
n_fail += 1;
eprintln!("warning: {base}: {e}");
}
None => bail!(
"no manifest parser owns `{base}` (supported: {})",
supported_manifests()
),
}
}
if let Some(eco) = &eco_filter {
components.retain(|c| &c.ecosystem == eco);
}
// Stable, deduped ordering so the output is reproducible across a dir walk.
components.sort_by(|a, b| {
(a.ecosystem.as_str(), a.name.as_str(), a.version.as_str()).cmp(&(
b.ecosystem.as_str(),
b.name.as_str(),
b.version.as_str(),
))
});
components
.dedup_by(|a, b| a.ecosystem == b.ecosystem && a.name == b.name && a.version == b.version);
// Introspection marker — the `ingest` verb reports to the test matrix (GREEN =
// it produced components; RED = nothing ingested / all manifests malformed).
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-ingest",
"manifest_ingested",
!components.is_empty(),
&format!(
"{} component(s) from {parsed_files} manifest(s)",
components.len()
),
);
if want_sbom {
let subject = flag(args, "--subject")
.or_else(|| p.file_name().and_then(|n| n.to_str()).map(String::from))
.unwrap_or_else(|| "ingest".to_string());
let input = SbomInput {
subject,
components,
vulns: Vec::new(),
};
println!("{}", serde_json::to_string_pretty(&to_cyclonedx(&input))?);
return Ok(());
}
for c in &components {
println!(
"{:<7} {} {}{}",
c.ecosystem,
c.name,
c.version,
c.license
.as_ref()
.map(|l| format!(" {l}"))
.unwrap_or_default(),
);
}
// Per-ecosystem tally so the footer summarises the fleet the manifest describes.
let mut per_eco: std::collections::BTreeMap<&str, usize> = std::collections::BTreeMap::new();
for c in &components {
*per_eco.entry(c.ecosystem.as_str()).or_default() += 1;
}
let breakdown = per_eco
.iter()
.map(|(e, n)| format!("{e} {n}"))
.collect::<Vec<_>>()
.join(", ");
println!(
"{} component(s) from {parsed_files} manifest(s){}{}",
components.len(),
if breakdown.is_empty() {
String::new()
} else {
format!(" [{breakdown}]")
},
if n_fail > 0 {
format!("; {n_fail} manifest(s) skipped (parse errors)")
} else {
String::new()
},
);
Ok(())
}
/// Recursively collect every recognised manifest/lockfile under `dir` as
/// `(basename, content)`, skipping VCS / build / vendored trees (they hold no
/// authoritative lockfile and can be enormous). Pure filesystem walk — zero shell.
#[cfg(feature = "manifests")]
fn collect_manifests(dir: &Path, out: &mut Vec<(String, String)>) -> Result<()> {
let parsers = modgunn::manifests::all();
let mut stack = vec![dir.to_path_buf()];
while let Some(d) = stack.pop() {
for entry in std::fs::read_dir(&d).with_context(|| format!("read_dir {}", d.display()))? {
let path = entry?.path();
let name = match path.file_name().and_then(|n| n.to_str()) {
Some(n) => n.to_string(),
None => continue,
};
if path.is_dir() {
if matches!(
name.as_str(),
".git"
| "target"
| "node_modules"
| ".venv"
| "venv"
| "vendor"
| ".cargo"
| ".hg"
| ".svn"
) {
continue;
}
stack.push(path);
} else if parsers.iter().any(|p| p.handles(&name)) {
let content = std::fs::read_to_string(&path)
.with_context(|| format!("read {}", path.display()))?;
out.push((name, content));
}
}
}
out.sort_by(|a, b| a.0.cmp(&b.0));
Ok(())
}
/// The manifest basenames the ingest verb recognises, for the usage/error hint.
#[cfg(feature = "manifests")]
fn supported_manifests() -> String {
let mut names: Vec<&'static str> = modgunn::manifests::all()
.iter()
.flat_map(|p| p.manifests().iter().copied())
.collect();
names.push("*.csproj");
names.join(", ")
}
/// Without the `manifests` feature the ecosystem parsers are not compiled in — tell
/// the operator how to enable it rather than silently doing nothing (mirrors the
/// `verify-provenance` gate).
#[cfg(not(feature = "manifests"))]
fn cmd_ingest(_args: &[&str]) -> Result<()> {
bail!(
"ingest needs the `manifests` feature — rebuild with \
`cargo run --features warehouse,manifests -- ingest --path <manifest-file|dir>`"
)
}
/// `modgunn scorecard --path <report.json> [--subject <name>] [--warehouse <root>]`
/// — the OFFLINE, zero-shell **OSSF Scorecard ingest** verb: read a
/// `scorecard --format json` report, fold it into the shared
/// [`modgunn::provenance::ScorecardSignals`] via
/// [`modgunn::provenance::parse_scorecard`] (L5 reuse — the SAME struct the warehouse
/// round-trips and `warehouse provenance-log` reads, no twin), print the signals + the
/// advisory findings they generate ([`ScorecardSignals::findings`] — low score /
/// unmaintained / dangerous workflow / no branch-protection), and — when
/// `--warehouse <root>` is given — PERSIST them via
/// [`modgunn::warehouse::Warehouse::append_scorecard`] so a later
/// `warehouse provenance-log` reads them back (the append→load path). `--subject`
/// overrides the report's `repo.name` (which may be a full URL, or absent for a
/// locally-run report) so the persisted/printed key matches the fleet. Without
/// `--warehouse` it is a pure parse+print (like `ingest`) — a report can be inspected
/// on a laptop with no catalog.
fn cmd_scorecard(args: &[&str]) -> Result<()> {
use modgunn::core::Severity;
use modgunn::provenance::parse_scorecard;
let path = flag(args, "--path").context("--path <report.json> is required")?;
let p = Path::new(&path);
let body = std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()))?;
let mut signals = parse_scorecard(&body)
.with_context(|| format!("parse scorecard report {}", p.display()))?;
if let Some(subject) = flag(args, "--subject") {
signals.subject = subject;
}
// The advisory findings the signals fold into (the SAME fold `provenance-log` prints).
let findings = signals.findings();
println!(
"scorecard {:<24} {:>4.1}/10 maintained={} branch_protection={} signed_releases={} dangerous_workflow={}",
signals.subject,
signals.overall,
signals.maintained,
signals.branch_protection,
signals.signed_releases,
signals.dangerous_workflow,
);
let mut worst: Option<Severity> = None;
for f in &findings {
worst = Some(worst.map_or(f.severity, |w| w.max(f.severity)));
println!(" {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
}
// Optional persistence into the shared warehouse (append-only; latest row per
// subject wins on read) via the EXISTING append_scorecard/load_scorecard path.
let persisted = if let Some(root) = flag(args, "--warehouse") {
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
wh.append_scorecard(std::slice::from_ref(&signals))?;
true
} else {
false
};
// Introspection marker — the `scorecard` verb reports to the test matrix (GREEN =
// it folded a report into signals; the finding count + persist flag ride the detail).
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-scorecard",
"scorecard_ingested",
true,
&format!(
"{}: {:.1}/10, {} finding(s){}",
signals.subject,
signals.overall,
findings.len(),
if persisted { ", persisted" } else { "" },
),
);
println!(
"{} scorecard finding(s) for `{}`{}{}",
findings.len(),
signals.subject,
worst.map(|w| format!("; worst {w}")).unwrap_or_default(),
if persisted {
" — persisted into scorecard_signals"
} else {
""
},
);
Ok(())
}
/// `modgunn behavior --path <package.json|setup.py|pyproject.toml|setup.cfg>
/// [--ecosystem npm|pypi|pyproject|setupcfg] [--subject <name>] [--fail-on-finding]` —
/// the OFFLINE, zero-shell **A23 package-behaviour** verb: read ONE package's published
/// metadata and fold it into the shared [`modgunn::behavior::PackageBehavior`] feature
/// vector (via [`modgunn::behavior::parse_npm_package`] for npm `package.json`,
/// [`modgunn::behavior::parse_pypi_setup`] for a PyPI `setup.py`,
/// [`modgunn::behavior::parse_pyproject_toml`] for a PEP 621/517 `pyproject.toml`, or
/// [`modgunn::behavior::parse_setup_cfg`] for a setuptools `setup.cfg`), then print the
/// vector + the advisory findings it generates
/// ([`modgunn::behavior::PackageBehavior::findings`] — dropper network fetch → High,
/// sensitive-API / obfuscation → Medium, install-hook → Low). The `pyproject.toml` and
/// `setup.cfg` folds are section-aware (only build/install-active sections scanned), so
/// a benign project's `urllib3` dep / `https://` homepage never false-positives.
///
/// The ecosystem is chosen from `--ecosystem npm|pypi|pyproject|setupcfg` when given,
/// else inferred from the basename (`package.json` → npm, `setup.py` → pypi,
/// `pyproject.toml` → pyproject, `setup.cfg` → setupcfg). `--subject` overrides the
/// name parsed from the file (useful when the metadata omits it). No network, **no
/// code executed** — the file is read only as text, so a consumer can vet a package
/// on a laptop. `--fail-on-finding` exits non-zero (3) when any finding fires
/// (distinct from a usage error, exit 2), so a promotion gate can block on it.
///
/// When `--warehouse <root>` is given the analysis is PERSISTED (the ecosystem-tagged
/// feature vector + its derived verdict) via
/// [`modgunn::warehouse::Warehouse::append_package_behavior`] — the SAME warehouse
/// path `scorecard --warehouse` uses (no second seam) — so `warehouse behavior-log`
/// reads it back (the append→load round-trip). Without `--warehouse` it is a pure
/// parse+print, so a package can be vetted with no warehouse build touched.
fn cmd_behavior(args: &[&str]) -> Result<()> {
use modgunn::behavior::{
parse_npm_package, parse_pypi_setup, parse_pyproject_toml, parse_setup_cfg, PackageBehavior,
};
use modgunn::core::Severity;
let path = flag(args, "--path")
.context("--path <package.json|setup.py|pyproject.toml|setup.cfg> is required")?;
let p = Path::new(&path);
let body = std::fs::read_to_string(p).with_context(|| format!("read {}", p.display()))?;
let base = p.file_name().and_then(|n| n.to_str()).unwrap_or(&path);
// Ecosystem: an explicit --ecosystem wins; otherwise infer from the basename. An
// unrecognised file with no --ecosystem is a usage error (never a silent guess).
let eco = match flag(args, "--ecosystem") {
Some(e) => e,
None if base.eq_ignore_ascii_case("package.json") => "npm".to_string(),
None if base.eq_ignore_ascii_case("setup.py") => "pypi".to_string(),
None if base.eq_ignore_ascii_case("pyproject.toml") => "pyproject".to_string(),
None if base.eq_ignore_ascii_case("setup.cfg") => "setupcfg".to_string(),
None => bail!(
"cannot infer ecosystem from `{base}` — pass --ecosystem npm|pypi|pyproject|setupcfg \
(npm reads a package.json; pypi reads a setup.py; pyproject reads a pyproject.toml; \
setupcfg reads a setup.cfg)"
),
};
// npm is structured JSON (a malformed file is a hard error); a setup.py is
// arbitrary Python folded as text; a pyproject.toml / setup.cfg is folded
// section-aware (only build/install-active sections scanned). All produce the SAME
// vector. `canon_eco` is the canonical ecosystem tag persisted with the vector
// (aliases like `pip`/`toml`/`cfg` fold to their canonical name).
let (canon_eco, mut behavior): (&str, PackageBehavior) = match eco.as_str() {
"npm" => (
"npm",
parse_npm_package(&body)
.with_context(|| format!("parse npm package.json {}", p.display()))?,
),
"pypi" | "pip" | "python" => ("pypi", parse_pypi_setup(&body)),
"pyproject" | "toml" => ("pyproject", parse_pyproject_toml(&body)),
"setupcfg" | "setup.cfg" | "cfg" => ("setupcfg", parse_setup_cfg(&body)),
other => bail!("unknown --ecosystem `{other}` (expected npm|pypi|pyproject|setupcfg)"),
};
if let Some(subject) = flag(args, "--subject") {
behavior.subject = subject;
}
let findings = behavior.findings();
let apis = behavior
.sensitive_apis
.iter()
.cloned()
.collect::<Vec<_>>()
.join(",");
println!(
"behavior {:<24} hooks={:?} network_in_install={} urls={} entropy={:.2} apis=[{}]",
behavior.subject,
behavior.install_hooks,
behavior.network_in_install,
behavior.url_count,
behavior.max_token_entropy,
apis,
);
let mut worst: Option<Severity> = None;
for f in &findings {
worst = Some(worst.map_or(f.severity, |w| w.max(f.severity)));
println!(" {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
}
// Optional persistence into the shared warehouse (append-only; latest row per
// (ecosystem, subject) wins on read), via the EXISTING warehouse-persist path
// `append_package_behavior` / `load_package_behaviors` — the SAME plumbing
// `scorecard --warehouse` uses (no second warehouse seam, L5). Read back with
// `warehouse behavior-log`.
let persisted = if let Some(root) = flag(args, "--warehouse") {
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
wh.append_package_behavior(canon_eco, &behavior)?;
true
} else {
false
};
// Introspection marker — the `behavior` verb reports to the test matrix. A package
// with NO finding is the healthy verdict (ok=true); any finding flips it red so
// `nornir test` sees a suspicious package surfaced through the CLI flow.
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-behavior",
"package_behavior_analyzed",
findings.is_empty(),
&format!(
"{} [{}]: {} finding(s){}{}",
behavior.subject,
canon_eco,
findings.len(),
worst.map(|w| format!(", worst {w}")).unwrap_or_default(),
if persisted { ", persisted" } else { "" },
),
);
println!(
"{} behavior finding(s) for `{}`{}{}",
findings.len(),
behavior.subject,
worst.map(|w| format!("; worst {w}")).unwrap_or_default(),
if persisted {
" — persisted into behavior_features"
} else {
""
},
);
// A suspicious package is a gate failure when asked — exit non-zero so a promotion
// check can block on it, distinct from a usage error (exit 2).
if args.contains(&"--fail-on-finding") && !findings.is_empty() {
std::process::exit(3);
}
Ok(())
}
/// `warehouse import-deps --warehouse <root> --path <lockfile> --snapshot <id>` —
/// extract a dependency graph from a lockfile (Cargo.lock → `cargo:` nodes,
/// poetry.lock → `pip:` nodes) and land it in the `dep_edges` table, the
/// multi-ecosystem dep-graph the baddie-detection joins walk. Offline; no SCIP index.
#[cfg(all(feature = "warehouse", feature = "manifests"))]
fn cmd_import_deps(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
// Two input shapes: a resolved-graph LOCKFILE (--path, dispatched by basename) or
// a deps.dev `:dependencies` JSON (--deps-dev, the cross-ecosystem registry graph).
let (edges, label) = if let Some(dd) = flag(args, "--deps-dev") {
let content = std::fs::read_to_string(&dd).with_context(|| format!("read {dd}"))?;
(
modgunn::manifests::depsdev_edges(&content).map_err(|e| anyhow::anyhow!("{e:?}"))?,
format!("deps.dev {dd}"),
)
} else {
let path =
flag(args, "--path").context("--path <lockfile> or --deps-dev <file> is required")?;
let content = std::fs::read_to_string(&path).with_context(|| format!("read {path}"))?;
let base = Path::new(&path)
.file_name()
.and_then(|n| n.to_str())
.unwrap_or("")
.to_string();
let e = match base.as_str() {
"Cargo.lock" => modgunn::manifests::cargo_lock_edges(&content),
"poetry.lock" => modgunn::manifests::poetry_lock_edges(&content),
"package-lock.json" => modgunn::manifests::npm_lock_edges(&content),
_ => bail!("import-deps --path supports Cargo.lock / poetry.lock / package-lock.json; got `{base}` (or use --deps-dev)"),
}
.map_err(|e| anyhow::anyhow!("{e:?}"))?;
(e, base)
};
if edges.is_empty() {
bail!("no dependency edges parsed — expected a resolved dependency graph");
}
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
wh.append_dep_edges(&snapshot, &edges)?;
println!(
"imported {} dependency edges from {label} into snapshot '{snapshot}' at {root}",
edges.len()
);
Ok(())
}
#[cfg(not(all(feature = "warehouse", feature = "manifests")))]
fn cmd_import_deps(_args: &[&str]) -> Result<()> {
bail!("`warehouse import-deps` needs the `warehouse` + `manifests` features");
}
/// `warehouse import-iocs --warehouse <root> --snapshot <id> [--urlhaus <f>]
/// [--feodo <f>] [--sslbl <f>] [--threatfox <f>] [--malwarebazaar <f>] [--tor <f>] [--et-compromised <f>] [--spamhaus <f>] [--misp <f>] [--otx <f>] [--greynoise <f>]` — import threat-intel IOCs into the shared
/// `ioc_indicators` table (the garm-side signal). Anti-silent-empty: a feed provided
/// but parsing 0 indicators is a MISSING-FEED error, never a fake pass.
/// `warehouse findings-log --warehouse <root> --snapshot <id>` — read back a
/// materialized `vuln_findings` snapshot (what `scan --persist` wrote), so smid /
/// an operator can verify the purl-keyed verdict the Mímir lens will join.
fn cmd_findings_log(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
let rows = wh.read_vuln_findings(&snapshot)?;
// modgunn DECIDED the verdict at persist time; this verb prints it rather than
// re-deriving one, exactly as the Mímir lens will (baddie-detection b5).
let refuse = rows.iter().filter(|r| r.label() == "refuse").count();
let hold = rows.iter().filter(|r| r.label() == "hold").count();
println!(
"{} materialized finding(s) in snapshot '{snapshot}': {refuse} refuse 🔴 / {hold} hold 🟠",
rows.len()
);
for r in &rows {
let mark = match r.label() {
"refuse" => "🔴",
"hold" => "🟠",
"act" => "🟢",
_ => "❔",
};
println!(
" {mark} {:<6} {:<15} r{:<2} {:<12} {} {} {}",
r.label(),
r.axis,
r.rank,
r.repo,
r.purl,
r.advisory_id,
r.severity
);
}
Ok(())
}
/// `warehouse reachable-vuln --warehouse <root> --snapshot <id>` — run the
/// baddie-detection **b1 reachable-vuln join** over a captured snapshot: reconstruct
/// its symbol/call graph + vulnerable sinks from the warehouse and gate each CVE'd
/// symbol on call-reachability from an entrypoint. Prints the RED (reachable) set —
/// a CVE'd symbol transitively CALLED, not merely present — and the count the gate
/// suppressed as present-but-unreachable (the 80–90%-FP kill).
///
/// **Engine B.** [`modgunn::warehouse::Warehouse::reachable_vuln_findings`] is
/// `#[cfg(feature = "scip")]` (src/warehouse.rs:1635), so this verb is too. Measured
/// 2026-08-01: without this gate, `cargo check -p modgunn --features warehouse` — the
/// recipe Cargo.toml:238 itself documents, and a combination that satisfies the bin's
/// `required-features = ["warehouse", "scan"]` — failed with
/// `error[E0599]: no method named 'reachable_vuln_findings' found for struct 'Warehouse'`.
/// The declared build space never caught it because the only atom that builds this bin
/// enables `scip` too; `warehouse-sql` and `test-support` (both ⇒ `warehouse`, neither
/// ⇒ `scip`) are declared-but-never-built and were the two that surfaced it.
#[cfg(feature = "scip")]
fn cmd_reachable_vuln(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
let (reachable, suppressed) = wh.reachable_vuln_findings(&snapshot)?;
println!(
"reachable-vuln (b1) for snapshot '{snapshot}': {} RED / {} suppressed (present-but-unreachable)",
reachable.len(),
suppressed.len()
);
for f in &reachable {
println!(" 🔴 {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
}
Ok(())
}
/// Engine B is not compiled in: say so instead of failing to build. Mirrors the
/// `cmd_import_deps` / `cmd_ingest` fallbacks above.
#[cfg(not(feature = "scip"))]
fn cmd_reachable_vuln(_args: &[&str]) -> Result<()> {
bail!(
"`warehouse reachable-vuln` is the Engine-B b1 join and needs the `scip` feature: \
rebuild with `--features scip` (the shipped `modgunn` atom has it on)"
);
}
/// `warehouse ioc-log --warehouse <root> --snapshot <id> [--kind url|ipv4|…]` — read
/// back the imported IOC corpus for a snapshot (operator introspection), optionally
/// filtered by indicator kind.
fn cmd_ioc_log(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
let kind_filter = flag(args, "--kind");
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
let rows = wh.read_iocs(&snapshot)?;
let shown: Vec<_> = rows
.iter()
.filter(|(kind, ..)| kind_filter.as_deref().map(|k| k == kind).unwrap_or(true))
.collect();
println!(
"{} IOC indicator(s) in snapshot '{snapshot}'{}:",
shown.len(),
kind_filter
.as_deref()
.map(|k| format!(" (kind={k})"))
.unwrap_or_default()
);
for (kind, value, threat, source) in shown {
println!(" [{kind:<6}] {value} ({threat}, {source})");
}
Ok(())
}
/// `warehouse ioc-scan --warehouse <root> --snapshot <id> --observables <file>` —
/// the baddie-detection **b6 join**: score a workspace's observables (one per line:
/// a dependency download URL, a released artifact hash, an egress IP/domain; `#`
/// comments and blanks skipped) against the persisted active-malware corpus. Each
/// hit prints modgunn's decided verdict — an unconditional 🔴 refuse, because a match
/// IS the compromise, not a latent vulnerability. Exits non-zero on any hit so a gate
/// can act on it. A clean sweep prints 0 hits and exits 0.
fn cmd_ioc_scan(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
let obs_path = flag(args, "--observables").context("--observables <file> is required")?;
let body = std::fs::read_to_string(&obs_path).with_context(|| format!("read {obs_path}"))?;
let observables: Vec<String> = body
.lines()
.map(str::trim)
.filter(|l| !l.is_empty() && !l.starts_with('#'))
.map(str::to_string)
.collect();
if observables.is_empty() {
bail!("--observables {obs_path} has no observables — an empty sweep is not a clean sweep");
}
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
// A corpus row whose kind label we cannot read is reported, never silently
// dropped — matching against a short list would be a fake clean sweep.
let (_, skipped) = wh.ioc_index(&snapshot)?;
if skipped > 0 {
eprintln!("warning: {skipped} ioc_indicators row(s) in '{snapshot}' had an unrecognized kind label and were NOT indexed");
}
let hits = wh.ioc_scan(&snapshot, &observables)?;
println!(
"b6 ioc-scan: {} observable(s) against snapshot '{snapshot}' — {} hit(s)",
observables.len(),
hits.len()
);
for h in &hits {
println!(
" 🔴 {:<6} {} [{}] matched {} ({}, {})",
h.verdict.label(),
h.observable,
h.kind,
h.indicator,
h.threat,
h.source
);
}
if hits.is_empty() {
println!(" 🟢 act — no observable is on the active-malware bad-list");
return Ok(());
}
bail!(
"{} observable(s) matched the active-malware bad-list — refuse",
hits.len()
);
}
/// `warehouse rekor --entries <file|dir> [--expect-sha256 <hex>] [--trusted-signer <id>]`
/// — read Sigstore **Rekor** transparency-log entries offline and report what each
/// one actually establishes: the artifact digest logged, by which Fulcio/OIDC
/// identity, at which log index and time, and whether the entry carries an inclusion
/// proof (real evidence) or is merely self-asserted. `--expect-sha256` asserts a
/// specific artifact IS in the log; `--trusted-signer` asserts who logged it. Both
/// exit non-zero when unsatisfied. Anti-silent-empty: a corpus yielding 0 decodable
/// entries is a MISSING-FEED error, never "the artifact is fine".
fn cmd_rekor(args: &[&str]) -> Result<()> {
let path = flag(args, "--entries").context("--entries <file|dir> is required")?;
let p = Path::new(&path);
let docs: Vec<(std::path::PathBuf, String)> = if p.is_dir() {
read_json_dir_named(p)?
} else {
vec![(
p.to_path_buf(),
std::fs::read_to_string(p).with_context(|| format!("read {path}"))?,
)]
};
let (mut entries, mut n_fail) = (Vec::new(), 0usize);
for (file, body) in &docs {
match modgunn::provenance::parse_rekor_entries(body) {
Ok(list) => entries.extend(list),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable Rekor response {}: {e}",
file.display()
);
}
}
}
if entries.is_empty() {
bail!("--entries {path} yielded 0 decodable Rekor entries ({} doc(s), {n_fail} unparseable) — a truncated/wrong log response is a MISSING-FEED, never a pass", docs.len());
}
entries.sort_by_key(|e| e.log_index);
println!("{} Rekor entry/entries:", entries.len());
for e in &entries {
let proof = match (e.has_inclusion_proof, e.has_signed_timestamp) {
(true, true) => "proof+timestamp",
(true, false) => "proof only",
(false, true) => "timestamp only",
(false, false) => "SELF-ASSERTED",
};
println!(
" #{:<10} {:<14} {} signer={} [{proof}]",
e.log_index,
e.kind,
if e.artifact_sha256.is_empty() {
"(no digest)"
} else {
&e.artifact_sha256
},
e.signer_identity.as_deref().unwrap_or("(none)")
);
for f in e.findings() {
println!(" {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
}
}
// --expect-sha256: is THIS artifact in the log at all, and with real evidence?
if let Some(want) = flag(args, "--expect-sha256") {
let want = want.trim().to_ascii_lowercase();
let hit = entries.iter().find(|e| e.artifact_sha256 == want);
match hit {
None => bail!("no Rekor entry logs artifact sha256 {want} — the artifact is NOT in the transparency log"),
Some(e) if !e.has_inclusion_proof => {
bail!("artifact sha256 {want} appears at log index {} but the entry has NO inclusion proof — self-asserted, not evidence", e.log_index)
}
Some(e) => println!("✔ artifact {want} logged at index {} with an inclusion proof", e.log_index),
}
}
// --trusted-signer: was it logged by an identity we accept?
if let Some(want) = flag(args, "--trusted-signer") {
if !entries
.iter()
.any(|e| e.signer_identity.as_deref() == Some(want.as_str()))
{
bail!("no Rekor entry was logged by trusted signer `{want}`");
}
println!("✔ signed by trusted signer `{want}`");
}
if n_fail > 0 {
eprintln!("warning: {n_fail} Rekor response doc(s) skipped due to parse failures");
}
Ok(())
}
/// `warehouse license-compliance --definitions <file|dir> [--fail-on-finding]` —
/// the **ClearlyDefined** compliance leg: for each curated definition, compare the
/// license a package DECLARES against the licenses its files actually contain, and
/// report the gap. The classic supply-chain trap (an npm package declaring MIT while
/// shipping a GPL-3.0 file) is a `High` finding no vulnerability feed carries.
/// Offline, pure parse. Anti-silent-empty: a directory with no usable definition is
/// a MISSING-FEED error, never a fake clean bill.
fn cmd_license_compliance(args: &[&str]) -> Result<()> {
let path = flag(args, "--definitions").context("--definitions <file|dir> is required")?;
let p = Path::new(&path);
let docs: Vec<(std::path::PathBuf, String)> = if p.is_dir() {
read_json_dir_named(p)?
} else {
vec![(
p.to_path_buf(),
std::fs::read_to_string(p).with_context(|| format!("read {path}"))?,
)]
};
let (mut defs, mut n_fail) = (Vec::new(), 0usize);
for (file, body) in &docs {
match modgunn::spdx::parse_clearlydefined_batch(body) {
Ok(list) => defs.extend(list),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable ClearlyDefined definition {}: {e}",
file.display()
);
}
}
}
if defs.is_empty() {
bail!("--definitions {path} yielded 0 usable ClearlyDefined definitions ({} doc(s), {n_fail} unparseable) — a truncated/wrong corpus is a MISSING-FEED, not a clean bill of health", docs.len());
}
let mut worst: Option<modgunn::core::Severity> = None;
let mut n_findings = 0usize;
for d in &defs {
let findings = d.findings();
if findings.is_empty() {
println!(
" 🟢 {}/{}@{} declared `{}` — consistent",
d.ecosystem, d.name, d.version, d.declared
);
continue;
}
println!(" {}/{}@{}:", d.ecosystem, d.name, d.version);
for f in &findings {
n_findings += 1;
worst = Some(worst.map_or(f.severity, |w| w.max(f.severity)));
println!(" {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
}
}
println!(
"license-compliance: {} definition(s), {n_findings} finding(s){}",
defs.len(),
worst
.map(|w| format!(", worst {}", w.as_str()))
.unwrap_or_default()
);
if n_fail > 0 {
eprintln!("warning: {n_fail} definition doc(s) skipped due to parse failures");
}
if n_findings > 0 && args.contains(&"--fail-on-finding") {
bail!("{n_findings} license-compliance finding(s)");
}
Ok(())
}
/// `warehouse license-lint --spdx-list <licenses.json> --license "<SPDX expr>"` —
/// validate a declared SPDX license expression against the official SPDX license
/// list: report each id that is DEPRECATED (superseded by a non-deprecated form) or
/// UNKNOWN (not an SPDX id — a typo/made-up license the gate can't evaluate). Exit
/// non-zero when any id is not current-and-known, so CI can gate on it. Anti-silent-
/// empty: a `--spdx-list` that parses 0 ids is a MISSING-FEED error.
fn cmd_license_lint(args: &[&str]) -> Result<()> {
let list_path = flag(args, "--spdx-list").context("--spdx-list <licenses.json> is required")?;
let expr = flag(args, "--license").context("--license \"<SPDX expression>\" is required")?;
let body = std::fs::read_to_string(&list_path).with_context(|| format!("read {list_path}"))?;
let list = modgunn::spdx::parse_spdx_license_list(&body)
.with_context(|| format!("parse SPDX list {list_path}"))?;
if list.is_empty() {
bail!("--spdx-list {list_path} parsed 0 license ids — a truncated/wrong SPDX list is a MISSING-FEED, not a fake pass");
}
let issues = list.lint_expression(&expr);
if issues.is_empty() {
println!(
"license `{expr}` — all ids current & known ({} SPDX ids loaded)",
list.len()
);
return Ok(());
}
println!("license `{expr}` — {} id(s) to fix:", issues.len());
for (id, status) in &issues {
println!(" {id}: {}", status.label());
}
bail!("{} non-current license id(s)", issues.len());
}
/// `warehouse attack-map --capec <capec.xml> [--cwe CWE-89]` — bridge MITRE CAPEC's
/// CWE↔ATT&CK edges into a CWE → technique map. With `--cwe` it prints that CWE's
/// ATT&CK techniques (the finding-enrichment lookup); without it, a summary. Anti-
/// silent-empty: a CAPEC file yielding 0 CWE→technique edges is a MISSING-FEED error.
#[cfg(feature = "manifests")]
fn cmd_attack_map(args: &[&str]) -> Result<()> {
let capec_path = flag(args, "--capec").context("--capec <capec.xml> is required")?;
let body =
std::fs::read_to_string(&capec_path).with_context(|| format!("read {capec_path}"))?;
let map = modgunn::capec::parse_capec_xml(&body)
.with_context(|| format!("parse CAPEC XML {capec_path}"))?;
if map.is_empty() {
bail!("--capec {capec_path} yielded 0 CWE→ATT&CK edges — a truncated/wrong CAPEC catalog is a MISSING-FEED, not a fake pass");
}
match flag(args, "--cwe") {
Some(cwe) => {
let techs = map.techniques_for_cwe(&cwe);
if techs.is_empty() {
println!(
"{cwe}: no ATT&CK techniques mapped ({} CWEs in catalog)",
map.len()
);
} else {
println!("{cwe} → {} ATT&CK technique(s):", techs.len());
for t in &techs {
println!(" {} {}", t.id, t.name);
}
}
}
None => println!(
"CAPEC→ATT&CK map: {} CWE(s) with ≥1 technique loaded from {capec_path}",
map.len()
),
}
Ok(())
}
#[cfg(not(feature = "manifests"))]
fn cmd_attack_map(_args: &[&str]) -> Result<()> {
bail!("`warehouse attack-map` needs the `manifests` feature (roxmltree)");
}
fn cmd_import_iocs(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
let mut iocs: Vec<modgunn::ioc::Ioc> = Vec::new();
let feeds: [(&str, fn(&str) -> Vec<modgunn::ioc::Ioc>); 8] = [
("--urlhaus", modgunn::ioc::parse_urlhaus_csv),
("--feodo", modgunn::ioc::parse_feodo_csv),
("--sslbl", modgunn::ioc::parse_sslbl_csv),
("--threatfox", modgunn::ioc::parse_threatfox_csv),
("--malwarebazaar", modgunn::ioc::parse_malwarebazaar_csv),
("--tor", modgunn::ioc::parse_tor_exit_list),
("--et-compromised", modgunn::ioc::parse_emergingthreats_ips),
("--spamhaus", modgunn::ioc::parse_spamhaus_drop),
];
let mut any = false;
for (name, parse) in feeds {
if let Some(path) = flag(args, name) {
any = true;
let body =
std::fs::read_to_string(&path).with_context(|| format!("read {name} {path}"))?;
let rows = parse(&body);
if rows.is_empty() {
bail!("{name} {path} parsed 0 indicators — an empty/truncated IOC feed is a MISSING-FEED, not a fake pass");
}
iocs.extend(rows);
}
}
// --misp <file>: a MISP event is JSON (a fallible parse), unlike the CSV feeds.
if let Some(path) = flag(args, "--misp") {
any = true;
let body = std::fs::read_to_string(&path).with_context(|| format!("read --misp {path}"))?;
let rows = modgunn::ioc::parse_misp_event(&body)
.with_context(|| format!("parse MISP event {path}"))?;
if rows.is_empty() {
bail!("--misp {path} parsed 0 indicators — an empty/malformed MISP event is a MISSING-FEED, not a fake pass");
}
iocs.extend(rows);
}
// --otx <file>: an AlienVault OTX pulse export (JSON, fallible parse).
if let Some(path) = flag(args, "--otx") {
any = true;
let body = std::fs::read_to_string(&path).with_context(|| format!("read --otx {path}"))?;
let rows = modgunn::ioc::parse_otx_pulse(&body)
.with_context(|| format!("parse OTX pulse {path}"))?;
if rows.is_empty() {
bail!("--otx {path} parsed 0 indicators — an empty/malformed OTX pulse is a MISSING-FEED, not a fake pass");
}
iocs.extend(rows);
}
// --greynoise <file>: a GreyNoise GNQL/IP-context result (JSON, fallible parse).
// Only `malicious`/`suspicious` records become indicators — benign + RIOT
// (known-good services) are dropped by the parser, because a b6 IOC hit is an
// unconditional refuse and importing GreyNoise's benign half would blocklist
// public DNS. The uniform importer law still applies: a file that contributes 0
// indicators is a MISSING-FEED (wrong/truncated document, or an all-benign page
// that has nothing to add), never a fake pass — don't pass the flag in that case.
if let Some(path) = flag(args, "--greynoise") {
any = true;
let body =
std::fs::read_to_string(&path).with_context(|| format!("read --greynoise {path}"))?;
let rows = modgunn::ioc::parse_greynoise(&body)
.with_context(|| format!("parse GreyNoise result {path}"))?;
if rows.is_empty() {
bail!("--greynoise {path} parsed 0 malicious/suspicious indicators — an empty/all-benign/truncated GreyNoise result is a MISSING-FEED, not a fake pass");
}
iocs.extend(rows);
}
if !any {
bail!("nothing to import — provide --urlhaus/--feodo/--sslbl/--threatfox/--malwarebazaar/--tor/--et-compromised/--spamhaus/--misp/--otx/--greynoise <file>");
}
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
wh.append_iocs(&snapshot, &iocs)?;
println!(
"imported {} IOC indicators into snapshot '{snapshot}' at {root}",
iocs.len()
);
Ok(())
}
/// `warehouse import-vuln-symbols --warehouse <root> --snapshot <id> --go-osv <dir>`
/// — parse the Go vuln DB's OSV docs (`ecosystem_specific.imports`) into the
/// `vuln_symbols` table: the `(advisory, import-path, symbol)` tuples the A8
/// reachability gate loads as `scip::VulnSink`s (baddie-detection b1). Anti-silent-
/// empty: a Go OSV dir that yields 0 symbols is a MISSING-FEED error — the Go DB
/// always carries symbol-level `imports`, so 0 means a truncated/wrong corpus.
fn cmd_import_vuln_symbols(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
let dir =
flag(args, "--go-osv").context("--go-osv <dir> (the Go vuln DB OSV docs) is required")?;
let mut symbols: Vec<modgunn::osv::VulnSymbol> = Vec::new();
let mut n_fail = 0usize;
let docs = read_json_dir_named(Path::new(&dir))?;
if docs.is_empty() {
bail!(
"--go-osv {dir} has no JSON docs — nothing to import (a MISSING-FEED, not a fake pass)"
);
}
for (path, doc) in &docs {
match modgunn::osv::parse_osv_vuln_symbols(doc) {
Ok(mut s) => symbols.append(&mut s),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable OSV file {}: {e}",
path.display()
);
}
}
}
if symbols.is_empty() {
bail!("--go-osv {dir} ({} docs) parsed 0 symbol-level records — the Go vuln DB always ships `ecosystem_specific.imports`, so 0 is a truncated/wrong corpus (MISSING-FEED), not a fake pass", docs.len());
}
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
wh.append_vuln_symbols(&snapshot, &symbols)?;
let n_named = symbols.iter().filter(|s| !s.symbol.is_empty()).count();
println!(
"imported {} vuln-symbol record(s) ({n_named} function-level, {} whole-package) from {} docs into snapshot '{snapshot}' at {root}{}",
symbols.len(),
symbols.len() - n_named,
docs.len(),
if n_fail > 0 { format!(" ({n_fail} unparseable doc(s) skipped)") } else { String::new() },
);
Ok(())
}
/// `warehouse vuln-symbols-log --warehouse <root> --snapshot <id>` — read back the
/// imported symbol-level vuln records (operator/smid verification that the rows the
/// reachability gate needs actually landed).
fn cmd_vuln_symbols_log(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let snapshot = flag(args, "--snapshot").context("--snapshot <id> is required")?;
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
let rows = wh.read_vuln_symbols(&snapshot)?;
println!(
"{} vuln-symbol record(s) in snapshot '{snapshot}':",
rows.len()
);
for (advisory, ecosystem, package, symbol, severity) in &rows {
let sym = if symbol.is_empty() {
"(whole package)"
} else {
symbol.as_str()
};
println!(" {advisory:<16} [{ecosystem}] {package} → {sym} ({severity})");
}
Ok(())
}
fn cmd_import(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let rev = flag(args, "--rev").context("--rev <db_rev> is required")?;
// 1 · gather advisories from the OSV dir and/or the OpenSSF Malicious-Packages
// dir. At least one source is required. A malformed/truncated doc is never
// silently dropped: it is named on stderr and counted (`n_fail`), and the
// tally is surfaced in the import summary so reduced coverage is visible.
let (mut n_osv, mut n_mal) = (0usize, 0usize);
let mut n_fail = 0usize;
let mut advisories: Vec<Advisory> = Vec::new();
if let Some(osv_dir) = flag(args, "--osv") {
let docs = read_json_dir_named(Path::new(&osv_dir))?;
n_osv = docs.len();
let mut parsed = Vec::new();
for (path, doc) in &docs {
match osv::parse_doc(doc) {
Ok(mut a) => parsed.append(&mut a),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable OSV file {}: {e}",
path.display()
);
}
}
}
// normalize + dedup across feeds (range-native).
advisories = merge_by_alias(parsed);
}
if let Some(mal_dir) = flag(args, "--malicious") {
let docs = read_json_dir_named(Path::new(&mal_dir))?;
n_mal = docs.len();
for (path, d) in &docs {
match feeds::parse_malicious(d) {
Ok(list) => {
for mut a in list {
a.source = "openssf".into(); // malicious-packages provenance
advisories.push(a);
}
}
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable malicious-packages file {}: {e}",
path.display()
);
}
}
}
// Re-unify so a MAL row and any aliasing CVE fold together.
advisories = merge_by_alias(advisories);
}
// 1b · GitLab gemnasium advisories (YAML, one file per advisory) → Advisory rows.
// A file with no usable coords/id yields None (skipped, not an error).
let mut n_gitlab = 0usize;
if let Some(dir) = flag(args, "--gitlab") {
for (path, y) in &read_yaml_dir_named(Path::new(&dir))? {
match modgunn::gitlab::parse_gemnasium_yaml(y) {
Some(a) => {
advisories.push(a);
n_gitlab += 1;
}
None => {
// No usable coords/id, or an unparseable affected_range (the
// all-versions-FP guard) — never silently dropped.
n_fail += 1;
eprintln!(
"warning: skipping gemnasium advisory (no coords / unparseable range): {}",
path.display()
);
}
}
}
advisories = merge_by_alias(advisories);
}
// 1c · ecosyste.ms package-status (cross-ecosystem removed/unpublished/deprecated)
// → an all-versions Low advisory per pulled package. A doc with active/null
// status yields None (a real zero, NOT counted as a failure); a doc that
// won't parse is counted + named. Package-level "stop depending on this".
let mut n_ecosystems = 0usize;
if let Some(dir) = flag(args, "--ecosystems") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_ecosystems_package(d) {
Ok(Some(a)) => {
advisories.push(a);
n_ecosystems += 1;
}
Ok(None) => {} // active/unknown status ⇒ nothing to flag (real zero)
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable ecosyste.ms doc {}: {e}",
path.display()
);
}
}
}
advisories = merge_by_alias(advisories);
}
// 1d · libraries.io project-status (the elder cross-ecosystem index; ecosyste.ms
// is its open successor) → an all-versions Low advisory per pulled/deprecated/
// unmaintained project. Same shape/handling as --ecosystems: active/null ⇒ a
// real None (not a failure); an unparseable doc is counted + named.
// NOTE: the live libraries.io API needs a free registered api_key; this reads
// the already-downloaded project JSON (obtaining it is out of scope).
let mut n_librariesio = 0usize;
if let Some(dir) = flag(args, "--librariesio") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_librariesio_project(d) {
Ok(Some(a)) => {
advisories.push(a);
n_librariesio += 1;
}
Ok(None) => {} // active/unknown status ⇒ nothing to flag (real zero)
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable libraries.io doc {}: {e}",
path.display()
);
}
}
}
advisories = merge_by_alias(advisories);
}
// 2 · registry yank / deprecation signals (crates.io yanks, npm deprecations) —
// CVE-less supply-chain "stop depending on this version" rows that land in
// the same `cve_advisories` table. A malformed doc (no `versions`) is a hard
// parse error (counted in `n_fail`), never a silent empty; a well-formed doc
// with nothing yanked/deprecated is a real zero.
let (mut n_yank, mut n_dep) = (0usize, 0usize);
if let Some(dir) = flag(args, "--crates-yanks") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_crates_yanks(d) {
Ok(list) => {
n_yank += list.len();
advisories.extend(list);
}
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable crates.io doc {}: {e}",
path.display()
);
}
}
}
}
if let Some(dir) = flag(args, "--npm-deprecations") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_npm_deprecations(d) {
Ok(list) => {
n_dep += list.len();
advisories.extend(list);
}
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable npm packument {}: {e}",
path.display()
);
}
}
}
}
if let Some(dir) = flag(args, "--pypi-yanks") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_pypi_yanks(d) {
Ok(list) => {
n_yank += list.len();
advisories.extend(list);
}
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable PyPI project doc {}: {e}",
path.display()
);
}
}
}
}
if let Some(dir) = flag(args, "--go-retract") {
// go.mod retractions are the only Go yank signal (no central registry API).
// The parser is tolerant (returns rows, never errors); a go.mod with no
// `module` line yields none.
for (_path, d) in &read_mod_dir_named(Path::new(&dir))? {
let list = registry::parse_go_retractions(d);
n_yank += list.len();
advisories.extend(list);
}
}
if n_yank > 0 || n_dep > 0 {
// Fold a yank/deprecation into any aliasing CVE row for the same coords.
advisories = merge_by_alias(advisories);
}
// 1e · Sonatype OSS-Index component reports (purl-keyed, cross-ecosystem).
// A CVE-carrying finding is keyed BY its CVE and re-merged, so it folds into
// the row the OSV/NVD import already produced instead of double-reporting one
// vulnerability; only a Sonatype-only finding lands a MODGUNN-OSSINDEX-* id.
let mut n_ossindex = 0usize;
if let Some(dir) = flag(args, "--ossindex") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_ossindex_report(d) {
Ok(list) => {
n_ossindex += list.len();
advisories.extend(list);
}
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable OSS-Index report {}: {e}",
path.display()
);
}
}
}
if n_ossindex > 0 {
advisories = merge_by_alias(advisories);
}
}
// 1f · socket.dev supply-chain alerts (per-package-version behaviour findings —
// malware/install-scripts/obfuscation/…). ⚠ SCAFFOLD: parses Socket's public
// alert schema, but the LIVE API is PAID so the envelope is unverified against
// a real feed (see registry.rs / feeds-design.md §D). A doc with no alerts is
// a real zero; an unparseable doc is counted + named.
let mut n_socket = 0usize;
if let Some(dir) = flag(args, "--socket") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_socket_alerts(d) {
Ok(list) => {
n_socket += list.len();
advisories.extend(list);
}
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable socket.dev doc {}: {e}",
path.display()
);
}
}
}
if n_socket > 0 {
advisories = merge_by_alias(advisories);
}
}
// 1g · GUAC CertifyVuln ingest (package↔vuln certifications from GUAC's graph).
// ⚠ SCAFFOLD: parses GUAC's documented CertifyVuln GraphQL shape, but needs a
// RUNNING GUAC instance (POST /query) — unverified against a live endpoint
// here (see registry.rs / feeds-design.md §D). CVE/GHSA ids are keyed so they
// fold into the OSV/NVD row; an unparseable doc is counted + named.
let mut n_guac = 0usize;
if let Some(dir) = flag(args, "--guac") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match registry::parse_guac_certify_vuln(d) {
Ok(list) => {
n_guac += list.len();
advisories.extend(list);
}
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable GUAC CertifyVuln doc {}: {e}",
path.display()
);
}
}
}
if n_guac > 0 {
advisories = merge_by_alias(advisories);
}
}
// At least one source flag must have been provided. A source that was provided
// but is legitimately all-clean (0 yanks) is NOT an error here — that zero is
// real; it is a MISSING `versions` doc that errors, up in the per-doc parse.
let any_source = [
"--osv",
"--malicious",
"--gitlab",
"--ecosystems",
"--librariesio",
"--ossindex",
"--socket",
"--guac",
"--crates-yanks",
"--npm-deprecations",
"--pypi-yanks",
"--go-retract",
]
.iter()
.any(|f| flag(args, f).is_some());
if !any_source {
bail!("nothing to import — provide --osv/--malicious/--gitlab/--ecosystems/--librariesio/--ossindex/--socket/--guac/--crates-yanks/--npm-deprecations/--pypi-yanks/--go-retract <dir> with *.json files");
}
if advisories.is_empty() {
println!(
"no advisories to import — every source parsed clean (0 rows); no generation written"
);
return Ok(());
}
// 2b · optional NVD enrichment (CVSS vector + CWE + severity lift, by CVE id).
if let Some(nvd_dir) = flag(args, "--nvd") {
let mut entries = Vec::new();
for (path, d) in &read_json_dir_named(Path::new(&nvd_dir))? {
match nvd::parse_nvd(d) {
Ok(mut es) => entries.append(&mut es),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable NVD file {}: {e}",
path.display()
);
}
}
}
if !entries.is_empty() {
nvd::enrich_with_nvd(&mut advisories, &entries);
}
}
// 2c · optional CPE-keyed backfill: standalone advisories for CVEs OSV misses
// (ecosystem "cpe"; lossy — opt-in).
if let Some(dir) = flag(args, "--nvd-backfill") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match nvd::parse_nvd_advisories(d) {
Ok(mut es) => advisories.append(&mut es),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable NVD-backfill file {}: {e}",
path.display()
);
}
}
}
advisories = merge_by_alias(advisories);
}
// 3 · optional exploit-intelligence enrichment (EPSS probability + KEV flag).
// Anti-silent-empty: a feed that was PROVIDED but parsed to 0 rows is a
// truncated/empty catalog, not a legit no-op — silently skipping it is a fake
// pass where the KEV→Critical / EPSS→High escalation never fires. So a
// provided-but-empty --epss/--kev is a hard error, not a shrug.
let epss = match flag(args, "--epss") {
Some(p) => {
let m = feeds::parse_epss_csv(
&std::fs::read_to_string(&p).with_context(|| format!("read EPSS {p}"))?,
);
if m.is_empty() {
bail!("--epss {p} parsed 0 scores — an empty/truncated EPSS feed would silently disable EPSS escalation; refusing to import a fake pass");
}
m
}
None => HashMap::new(),
};
let mut kev = match flag(args, "--kev") {
Some(p) => {
let s = feeds::parse_kev_json(
&std::fs::read_to_string(&p).with_context(|| format!("read KEV {p}"))?,
)
.context("parse KEV json")?;
if s.is_empty() {
bail!("--kev {p} parsed 0 CVEs — an empty/truncated KEV catalog would silently disable KEV escalation; refusing to import a fake pass");
}
s
}
None => HashSet::new(),
};
// ENISA EUVD `exploitedSince` — a KEV-grade "exploited in the wild" signal from a
// second (EU) authority, folded into the SAME kev escalation set (independent
// corroboration + coverage CISA KEV may lack). Additive to --kev, so no separate
// bail; a malformed doc is counted.
if let Some(dir) = flag(args, "--euvd-exploited") {
for (path, d) in &read_json_dir_named(Path::new(&dir))? {
match feeds::parse_euvd_exploited(d) {
Ok(set) => kev.extend(set),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable EUVD doc {}: {e}",
path.display()
);
}
}
}
}
// VulnCheck KEV — a superset of CISA KEV (adds exploited CVEs CISA has not yet
// catalogued), a KEV-grade "exploited in the wild" signal from a third authority.
// Folded into the SAME kev escalation set. Its own empty-is-RED: if the flag was
// given yet 0 CVEs parsed (empty/truncated catalog), that would silently add
// nothing — a hard error, not a shrug.
if let Some(p) = flag(args, "--vulncheck-kev") {
let s = feeds::parse_vulncheck_kev(
&std::fs::read_to_string(&p).with_context(|| format!("read VulnCheck KEV {p}"))?,
)
.context("parse VulnCheck KEV json")?;
if s.is_empty() {
bail!("--vulncheck-kev {p} parsed 0 CVEs — an empty/truncated VulnCheck KEV catalog would silently disable the KEV escalation it feeds; refusing to import a fake pass");
}
kev.extend(s);
}
if !epss.is_empty() || !kev.is_empty() {
feeds::enrich(&mut advisories, &epss, &kev);
}
// 3b · optional public-exploit enrichment: a CVE with a public exploit — a
// PoC-in-GitHub proof-of-concept (`--exploit-poc <dir>`) OR a weaponized
// Metasploit module (`--metasploit <file>`), a Nuclei template
// (`--nuclei <file>`) OR a published **Exploit-DB** entry
// (`--exploitdb <files_exploits.csv>`) — is lifted to at least High +
// summary-tagged. Both sources union into one exploit-CVE set. Same
// anti-silent-empty law: if an exploit source was provided yet the union is
// 0 CVEs (every doc empty/truncated) it would silently disable the lift, so
// that is a hard error, not a shrug.
let mut n_exploited = 0usize;
let exploit_poc = flag(args, "--exploit-poc");
let metasploit = flag(args, "--metasploit");
let nuclei = flag(args, "--nuclei");
let exploitdb = flag(args, "--exploitdb");
if exploit_poc.is_some() || metasploit.is_some() || nuclei.is_some() || exploitdb.is_some() {
let mut exploited: HashSet<String> = HashSet::new();
if let Some(dir) = &exploit_poc {
for (path, d) in &read_json_dir_named(Path::new(dir))? {
match feeds::parse_poc_in_github(d) {
Ok(set) => exploited.extend(set),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable PoC-in-GitHub doc {}: {e}",
path.display()
);
}
}
}
}
if let Some(path) = &metasploit {
let doc =
std::fs::read_to_string(path).with_context(|| format!("read Metasploit {path}"))?;
match feeds::parse_metasploit_cves(&doc) {
Ok(set) => exploited.extend(set),
Err(e) => {
n_fail += 1;
eprintln!("warning: skipping unparseable Metasploit index {path}: {e}");
}
}
}
if let Some(path) = &nuclei {
let doc =
std::fs::read_to_string(path).with_context(|| format!("read Nuclei {path}"))?;
match feeds::parse_nuclei_cves(&doc) {
Ok(set) => exploited.extend(set),
Err(e) => {
n_fail += 1;
eprintln!("warning: skipping unparseable Nuclei cves.json {path}: {e}");
}
}
}
if let Some(path) = &exploitdb {
let doc =
std::fs::read_to_string(path).with_context(|| format!("read Exploit-DB {path}"))?;
match feeds::parse_exploitdb_csv(&doc) {
Ok(set) => exploited.extend(set),
Err(e) => {
n_fail += 1;
eprintln!(
"warning: skipping unparseable Exploit-DB files_exploits.csv {path}: {e}"
);
}
}
}
if exploited.is_empty() {
bail!("exploit source(s) provided but parsed 0 exploit CVEs — an empty/truncated exploit index would silently disable the public-exploit lift; refusing to import a fake pass");
}
feeds::enrich_exploited(&mut advisories, &exploited);
n_exploited = advisories
.iter()
.filter(|a| a.summary.contains(feeds::PUBLIC_EXPLOIT_MARKER))
.count();
}
// 4 · append as a new generation; latest db_rev wins on read.
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
wh.append_advisories(&rev, &advisories)?;
// Introspection marker — the `warehouse import` verb reports to the test matrix.
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-import",
"advisories_imported",
true,
&format!("imported {} advisories as rev '{}'", advisories.len(), rev),
);
println!(
"imported {} advisories (from {} OSV + {} malicious + {} GitLab docs, {} registry yanks [crates.io/pypi/go], {} npm deprecations, {} ecosyste.ms package-status, {} libraries.io package-status, {} OSS-Index findings, {} socket.dev alerts, {} GUAC CertifyVuln) as rev '{}' into {} [{} KEV, {} EPSS-scored, {} public-exploit, {} CVSS-vectored, {} source files skipped]",
advisories.len(),
n_osv,
n_mal,
n_gitlab,
n_yank,
n_dep,
n_ecosystems,
n_librariesio,
n_ossindex,
n_socket,
n_guac,
rev,
root,
advisories.iter().filter(|a| a.kev).count(),
advisories.iter().filter(|a| a.epss.is_some()).count(),
n_exploited,
advisories.iter().filter(|a| !a.cvss_vector.is_empty()).count(),
n_fail,
);
if n_fail > 0 {
eprintln!("warning: {n_fail} source file(s) skipped due to parse failures — CVE coverage may be reduced");
}
Ok(())
}
/// Full fleet scan, dispatching to the DataFusion SQL pushdown when `--sql` is
/// asked AND the `warehouse-sql` feature is compiled in; otherwise the in-memory
/// scan (so `--sql` on a lean build degrades, it doesn't fail).
#[cfg(feature = "warehouse-sql")]
fn fleet(
wh: &Warehouse,
sql: bool,
engine: EngineKind,
) -> Result<Vec<modgunn::warehouse::FleetFinding>> {
if sql {
// The SQL pushdown is its own candidate-narrowing strategy (the equi-join
// runs IN the warehouse), orthogonal to the in-RAM A/B engines.
wh.scan_fleet_sql()
} else {
wh.scan_fleet_with(engine)
}
}
#[cfg(not(feature = "warehouse-sql"))]
fn fleet(
wh: &Warehouse,
_sql: bool,
engine: EngineKind,
) -> Result<Vec<modgunn::warehouse::FleetFinding>> {
wh.scan_fleet_with(engine)
}
fn cmd_scan(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
// --engine a|b selects the matching strategy (A = linear, B = inverted index);
// an unrecognised value falls back to the default (A) with a warning rather than
// failing the scan. Env `MODGUNN_ENGINE` is the config fallback when no flag.
let engine = match flag(args, "--engine") {
Some(s) => EngineKind::parse(&s).unwrap_or_else(|| {
eprintln!(
"warning: unknown --engine `{s}` (expected a|b|linear|indexed); using default"
);
EngineKind::default()
}),
None => EngineKind::from_env(),
};
// --since <snapshot> → incremental CDC delta (only newly-added components);
// otherwise a full fleet scan over the whole SBOM.
let (mut findings, watermark) = match flag(args, "--since") {
Some(s) => {
let from: i64 = s
.parse()
.context("--since <snapshot_id> must be an integer")?;
wh.scan_sbom_delta_with(Some(from), engine)?
}
// current_snapshot_id errors if nornir hasn't created the table yet — a
// missing SBOM just means "no watermark", not a failure.
None => (
fleet(&wh, has_flag(args, "--sql"), engine)?,
wh.current_snapshot_id("sbom_components").unwrap_or(None),
),
};
// VEX suppression: drop findings whose advisory the consumer asserted
// not-affected (CSAF `known_not_affected`).
let mut suppressed = 0usize;
if let Some(vex_dir) = flag(args, "--vex") {
let set = vex::collect_suppressions(&read_json_dir(Path::new(&vex_dir))?);
let before = findings.len();
findings.retain(|f| !set.contains(&f.advisory_id));
suppressed = before - findings.len();
}
// --persist <snapshot>: materialize the (VEX-suppressed) findings into the
// `vuln_findings` table so the Mímir lens reads modgunn's DECIDED verdict
// (baddie-detection b5: refuse/hold/act + the axis + the evidence rank) instead
// of recomputing the fleet scan or re-deriving the verdict from id prefixes.
//
// `--gate-on <world_snapshot>` additionally grades each finding through the b1
// call closure of a captured world snapshot: proven-called ⇒ 🔴 refuse,
// proven-dead ⇒ 🟠 hold-demoted, no symbol-level sink ⇒ Unknown (a plain
// present-vuln hold — never demoted on no evidence).
if let Some(snap) = flag(args, "--persist") {
match flag(args, "--gate-on") {
// b1 reachability grading is Engine B (scip). Compiled in only with the
// `scip` feature; without it, fail loudly instead of silently ignoring
// --gate-on (and instead of the whole warehouse module failing to build
// for lean consumers like holger's Tools#3, which take scan+warehouse only).
#[cfg(feature = "scip")]
Some(world) => {
let (refused, held) = wh.append_vuln_findings_gated(&snap, &findings, &world)?;
println!(
"persisted {} finding(s) to vuln_findings snapshot '{snap}', b1-gated on world snapshot '{world}': {refused} refuse 🔴 / {held} hold 🟠",
findings.len()
);
}
#[cfg(not(feature = "scip"))]
Some(_) => {
anyhow::bail!(
"--gate-on (b1 reachability demotion) is Engine B and needs the `scip` \
feature; rebuild modgunn with --features scip, or drop --gate-on to persist \
ungraded present-vuln findings"
);
}
None => {
wh.append_vuln_findings(&snap, &findings)?;
let refuse = findings
.iter()
.filter(|f| {
f.verdict(modgunn::baddie::Reachability::Unknown).decision
== modgunn::core::Decision::Block
})
.count();
println!(
"persisted {} finding(s) to vuln_findings snapshot '{snap}' [{refuse} refuse 🔴; reachability not computed — add --gate-on <world_snapshot> for the b1 demotion]",
findings.len()
);
}
}
}
for f in &findings {
println!(
"{:<10} {} {} {} {}",
f.repo, f.name, f.version, f.advisory_id, f.severity
);
}
let crit = findings
.iter()
.filter(|f| f.severity >= modgunn::core::Severity::High)
.count();
// Introspection marker — the `warehouse scan` verb reports fleet health to the
// test matrix. A clean fleet (no high/critical finding) is the healthy verdict.
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-scan",
"fleet_scan",
crit == 0,
&format!("{} finding(s), {} high/critical", findings.len(), crit),
);
// Per-repo health / severity-histogram rollup — the fleet AT ALTITUDE, folded
// from the same finding rows, so an operator sees which repos are worst instead
// of only a raw list + a global crit count.
let rollup = modgunn::rollup::rollup_fleet_findings(&findings);
println!("{}", modgunn::rollup::render_fleet_summary(&rollup));
println!(
"{} finding(s) ({} high/critical{}){}",
findings.len(),
crit,
if suppressed > 0 {
format!(", {suppressed} VEX-suppressed")
} else {
String::new()
},
watermark
.map(|w| format!("; next --since {w}"))
.unwrap_or_default(),
);
Ok(())
}
/// `warehouse rollup` — the fleet security posture at altitude: load the whole-SBOM
/// fleet findings and print the per-repo health / severity-histogram summary
/// ([`rollup::rollup_fleet_findings`] + [`rollup::render_fleet_summary`]) without the
/// raw per-finding rows `scan` prints. The org-overview verb.
fn cmd_rollup(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
let engine = match flag(args, "--engine") {
Some(s) => EngineKind::parse(&s).unwrap_or_else(|| {
eprintln!(
"warning: unknown --engine `{s}` (expected a|b|linear|indexed); using default"
);
EngineKind::default()
}),
None => EngineKind::from_env(),
};
let findings = fleet(&wh, has_flag(args, "--sql"), engine)?;
let rollup = modgunn::rollup::rollup_fleet_findings(&findings);
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-rollup",
"fleet_rollup",
rollup.severity.critical == 0 && rollup.severity.high == 0,
&format!(
"{} repo(s) affected, {} finding(s)",
rollup.affected_repos, rollup.severity.total
),
);
println!("{}", modgunn::rollup::render_fleet_summary(&rollup));
Ok(())
}
fn cmd_stats(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let eco = flag(args, "--ecosystem");
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
let db = wh.load_advisory_db(eco.as_deref())?;
// Introspection marker — the `warehouse stats` verb reports to the test matrix.
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-stats",
"advisory_db_loaded",
!db.advisories.is_empty(),
&format!("rev '{}' — {} advisories", db.rev, db.advisories.len()),
);
println!(
"advisory db rev '{}' — {} advisories ({})",
db.rev,
db.advisories.len(),
eco.as_deref().unwrap_or("all ecosystems"),
);
Ok(())
}
/// `warehouse provenance-log` — read back the build-provenance VERIFY ledger
/// ([`Warehouse::query_build_provenance`], newest-first) that `verify-provenance`
/// writes, optionally scoped by `--id <artifact_id>` and/or `--repo <r>`, and print
/// each verdict row (verified flag + signer + reason). Also surfaces the stored OSSF
/// **Scorecard** signals ([`Warehouse::load_scorecard`]) AND the advisory findings
/// they generate ([`ScorecardSignals::findings`] — low score / unmaintained /
/// dangerous workflow), so scorecard risk is visible instead of inert in the table.
fn cmd_provenance_log(args: &[&str]) -> Result<()> {
use modgunn::core::Severity;
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let repo = flag(args, "--repo");
let id = flag(args, "--id");
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
// Build-provenance VERIFY verdicts, newest-first (query already scopes by --id).
let mut rows = wh.query_build_provenance(id.as_deref())?;
if let Some(r) = &repo {
rows.retain(|v| &v.repo == r);
}
for v in &rows {
println!(
"{:<9} {:<12} {:<38} signer={:<20} {}",
if v.verified { "VERIFIED" } else { "REJECTED" },
v.repo,
v.artifact_id,
if v.signer.is_empty() {
"-"
} else {
v.signer.as_str()
},
v.reason,
);
}
let verified = rows.iter().filter(|v| v.verified).count();
// OSSF Scorecard signals + the advisory findings they fold into. Sorted worst
// (lowest overall) first so the riskiest subject reads at the top.
let mut cards = wh.load_scorecard()?;
cards.sort_by(|a, b| {
a.overall
.partial_cmp(&b.overall)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| a.subject.cmp(&b.subject))
});
let mut scorecard_findings = 0usize;
let mut worst_scorecard: Option<Severity> = None;
for c in &cards {
println!(
"scorecard {:<24} {:>4.1}/10 maintained={} signed_releases={}",
c.subject, c.overall, c.maintained, c.signed_releases,
);
for f in c.findings() {
scorecard_findings += 1;
worst_scorecard = Some(worst_scorecard.map_or(f.severity, |w| w.max(f.severity)));
println!(" {:<8} {} — {}", f.severity.as_str(), f.id, f.summary);
}
}
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-provenance-log",
"provenance_ledger_read",
rows.iter().all(|v| v.verified) && scorecard_findings == 0,
&format!(
"{} verdict(s) ({verified} verified), {} scorecard subject(s), {scorecard_findings} scorecard finding(s)",
rows.len(),
cards.len(),
),
);
println!(
"{} provenance verdict(s) ({verified} verified, {} rejected); {} scorecard subject(s), {scorecard_findings} scorecard finding(s){}",
rows.len(),
rows.len() - verified,
cards.len(),
worst_scorecard.map(|w| format!("; worst scorecard {w}")).unwrap_or_default(),
);
Ok(())
}
/// `warehouse behavior-log` — read back the A23 package-behaviour VERDICT ledger
/// ([`Warehouse::load_package_behaviors`], the latest analysis per
/// `(ecosystem, subject)`) that `behavior --warehouse` persists, optionally scoped by
/// `--ecosystem <eco>` and/or `--subject <name>`, and print each stored feature
/// vector + its derived verdict (worst severity + finding ids). The read-back leg of
/// the `behavior --warehouse` append→load round-trip. Sorted worst-severity first so
/// the riskiest package reads at the top.
fn cmd_behavior_log(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let eco = flag(args, "--ecosystem");
let subject = flag(args, "--subject");
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
let mut rows = wh.load_package_behaviors()?;
if let Some(e) = &eco {
rows.retain(|r| &r.ecosystem == e);
}
if let Some(s) = &subject {
rows.retain(|r| &r.behavior.subject == s);
}
// Worst-severity first (High before clean); then by ecosystem+subject for stability.
rows.sort_by(|a, b| {
b.worst_severity
.cmp(&a.worst_severity)
.then_with(|| a.ecosystem.cmp(&b.ecosystem))
.then_with(|| a.behavior.subject.cmp(&b.behavior.subject))
});
let mut flagged = 0usize;
for r in &rows {
let b = &r.behavior;
let apis = b
.sensitive_apis
.iter()
.cloned()
.collect::<Vec<_>>()
.join(",");
println!(
"behavior [{:<9}] {:<24} verdict={:<8} hooks={:?} network_in_install={} urls={} entropy={:.2} apis=[{}]{}",
r.ecosystem,
b.subject,
r.worst_severity.map(|w| w.as_str()).unwrap_or("clean"),
b.install_hooks,
b.network_in_install,
b.url_count,
b.max_token_entropy,
apis,
if r.finding_ids.is_empty() {
String::new()
} else {
format!(" findings=[{}]", r.finding_ids.join(","))
},
);
if r.worst_severity.is_some() {
flagged += 1;
}
}
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-behavior-log",
"behavior_ledger_read",
flagged == 0,
&format!(
"{} behaviour analysis(es) read, {flagged} flagged",
rows.len()
),
);
println!("{} behaviour analysis(es) ({flagged} flagged)", rows.len());
Ok(())
}
/// `warehouse provenance-diff` — compare the build-provenance VERIFY ledger at two
/// Iceberg snapshots ([`Warehouse::provenance_diff`]) and print the supply-chain
/// delta: artifacts that gained an attestation (`+`), lost one (`-`), or had their
/// verdict **transition** (`~`, e.g. VERIFIED → REJECTED or a signer swap). `--from`
/// defaults to the empty beginning, `--to` to the current snapshot (so
/// `provenance-diff --to <s>` shows everything attested up to `s`). A verdict
/// **regression** (was verified, now is not) is the gate-worthy signal:
/// `--fail-on-regression` exits non-zero (3) when any fires, so a promotion check can
/// block on it.
fn cmd_provenance_diff(args: &[&str]) -> Result<()> {
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let repo = flag(args, "--repo");
let from = match flag(args, "--from") {
Some(s) => Some(
s.parse::<i64>()
.context("--from <snapshot_id> must be an integer")?,
),
None => None,
};
let to = match flag(args, "--to") {
Some(s) => Some(
s.parse::<i64>()
.context("--to <snapshot_id> must be an integer")?,
),
None => None,
};
let fail_on_regression = args.contains(&"--fail-on-regression");
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
let mut delta = wh.provenance_diff(from, to)?;
// Optional repo scoping — drop rows/transitions for other repos.
if let Some(r) = &repo {
delta.added.retain(|v| &v.repo == r);
delta.removed.retain(|v| &v.repo == r);
delta
.changed
.retain(|t| &t.after.repo == r || &t.before.repo == r);
}
for v in &delta.added {
println!(
"+ {:<12} {:<38} {} signer={}",
v.repo,
v.artifact_id,
if v.verified { "VERIFIED" } else { "REJECTED" },
if v.signer.is_empty() {
"-"
} else {
v.signer.as_str()
},
);
}
for v in &delta.removed {
println!("- {:<12} {:<38} attestation dropped", v.repo, v.artifact_id);
}
for t in &delta.changed {
let tag = if t.is_regression() {
"REGRESSION"
} else if t.is_recovery() {
"recovered"
} else if t.signer_changed() {
"signer-changed"
} else {
"changed"
};
println!(
"~ {:<12} {:<38} {} → {} [{tag}] {}",
t.after.repo,
t.artifact_id,
if t.before.verified {
"VERIFIED"
} else {
"REJECTED"
},
if t.after.verified {
"VERIFIED"
} else {
"REJECTED"
},
t.after.reason,
);
}
let regressions = delta.regressions().len();
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-provenance-diff",
"provenance_ledger_diff",
regressions == 0,
&format!(
"{} added, {} removed, {} changed ({regressions} regression(s))",
delta.added.len(),
delta.removed.len(),
delta.changed.len(),
),
);
println!(
"{} added, {} removed, {} changed ({regressions} regression(s)){}",
delta.added.len(),
delta.removed.len(),
delta.changed.len(),
if delta.is_unchanged() {
" — provenance unchanged"
} else {
""
},
);
if fail_on_regression && regressions > 0 {
std::process::exit(3);
}
Ok(())
}
/// The secure-side **provenance carry import**: open the carried
/// `<artifact>.build-provenance.znippy` blob(s), cryptographically VERIFY each seal
/// against the trusted root(s), and record the verdicts into the `build_provenance`
/// warehouse ledger — the last hop of the chain nornir's seal starts (build →
/// provenance → znippy-sealed → carry across the airgap → **modgunn verify** →
/// warehouse ledger). Verifying requires the `provenance-verify` feature.
#[cfg(feature = "provenance-verify")]
fn cmd_verify_provenance(args: &[&str]) -> Result<()> {
use modgunn::provenance::{
verify_sealed_provenance, ProvenanceRequirement, SEALED_PROVENANCE_SUFFIX,
};
use modgunn::warehouse::BuildProvenanceVerdict;
let root = flag(args, "--warehouse").context("--warehouse <root> is required")?;
let roots_path = flag(args, "--roots").context("--roots <ca-der-file|dir> is required")?;
let expected_sha = flag(args, "--sha256");
// Optional A3 provenance POLICY: --min-slsa / --trusted-signer (repeatable) /
// --expected-repo. When ANY is given, each verified attestation is scored against
// the requirement (`evaluate_sealed`) and the run exits non-zero if any policy
// finding fires; with no policy flag we fall back to plain cryptographic verify.
let min_slsa = match flag(args, "--min-slsa") {
Some(s) => Some(s.parse::<u8>().context("--min-slsa <n> must be 0..=3")?),
None => None,
};
let trusted_signers = flags_all(args, "--trusted-signer");
let expected_repo = flag(args, "--expected-repo");
let expected_commit = flag(args, "--expected-commit");
let policy = if min_slsa.is_some()
|| !trusted_signers.is_empty()
|| expected_repo.is_some()
|| expected_commit.is_some()
{
Some(ProvenanceRequirement {
require_min_slsa: min_slsa.unwrap_or(0),
trusted_signers: trusted_signers.clone(),
expected_source_repo: expected_repo.clone(),
expected_source_sha: expected_commit.clone(),
})
} else {
None
};
// Trusted roots: read a single DER file, or every file directly under a dir, as
// DER-encoded CA certs the signer must chain to.
let roots = read_der_roots(Path::new(&roots_path))?;
if roots.is_empty() {
bail!("--roots {roots_path} yielded no certificate files");
}
// The carried blob(s): a single `--blob <file>`, or every
// `*.build-provenance.znippy` under `--blob-dir <dir>` (the carried batch).
let blobs: Vec<std::path::PathBuf> = if let Some(b) = flag(args, "--blob") {
vec![std::path::PathBuf::from(b)]
} else if let Some(dir) = flag(args, "--blob-dir") {
let mut out = Vec::new();
for entry in std::fs::read_dir(&dir).with_context(|| format!("read_dir {dir}"))? {
let p = entry?.path();
if p.file_name()
.and_then(|n| n.to_str())
.is_some_and(|n| n.ends_with(SEALED_PROVENANCE_SUFFIX))
{
out.push(p);
}
}
out.sort();
out
} else {
bail!("provide --blob <x.build-provenance.znippy> or --blob-dir <dir>");
};
if blobs.is_empty() {
bail!("no `{SEALED_PROVENANCE_SUFFIX}` blobs found to verify");
}
// A pinned content hash only makes sense for a single blob.
if expected_sha.is_some() && blobs.len() > 1 {
bail!("--sha256 pins one artifact; use it with a single --blob, not --blob-dir");
}
let mut verdicts = Vec::with_capacity(blobs.len());
let mut n_ok = 0usize;
let mut policy_findings = 0usize;
for blob in &blobs {
// In policy mode, `evaluate_sealed` verifies AND scores; otherwise plain verify.
let v = match &policy {
Some(req) => {
let (_prov, findings, v) =
req.evaluate_sealed(blob, &roots, expected_sha.as_deref());
for f in &findings {
policy_findings += 1;
println!(
" policy {:<8} {} — {}",
f.severity.as_str(),
f.id,
f.summary
);
}
v
}
None => verify_sealed_provenance(blob, &roots, expected_sha.as_deref()),
};
if v.verified {
n_ok += 1;
}
println!(
"{:<9} {} {}",
if v.verified { "VERIFIED" } else { "REJECTED" },
blob.display(),
v.reason,
);
verdicts.push(BuildProvenanceVerdict::from_verification(&v));
}
// Record every verdict (verified or not) into the append-only ledger.
let wh = Warehouse::open_in_zone(Path::new(&root), parse_zone(args)?)?;
wh.ensure_modgunn_tables()?;
wh.append_build_provenance(&verdicts)?;
let policy_clean = policy_findings == 0;
#[cfg(feature = "testmatrix")]
modgunn::functional_status(
"modgunn/cli-warehouse-verify-provenance",
"provenance_verified",
n_ok == verdicts.len() && policy_clean,
&format!(
"{n_ok}/{} blob(s) verified, {policy_findings} policy finding(s)",
verdicts.len()
),
);
if policy.is_some() {
println!(
"{n_ok}/{} blob(s) verified; {policy_findings} policy finding(s); {} verdict(s) recorded into build_provenance",
verdicts.len(),
verdicts.len(),
);
// A provenance-policy violation is a gate failure — exit non-zero so a caller
// (CI / promotion) can block on it, distinct from a usage error (exit 2).
if !policy_clean {
std::process::exit(3);
}
} else {
println!(
"{n_ok}/{} blob(s) verified; {} verdict(s) recorded into build_provenance",
verdicts.len(),
verdicts.len(),
);
}
Ok(())
}
/// Read DER-encoded trusted CA cert(s): a single file → one root; a directory →
/// every file directly under it. Bytes are handed straight to the verifier
/// (`CertStore::from_der_certs`), so each file must be a DER cert.
#[cfg(feature = "provenance-verify")]
fn read_der_roots(path: &Path) -> Result<Vec<Vec<u8>>> {
let meta =
std::fs::metadata(path).with_context(|| format!("stat --roots {}", path.display()))?;
let mut out = Vec::new();
if meta.is_dir() {
let mut files: Vec<std::path::PathBuf> = std::fs::read_dir(path)?
.filter_map(|e| e.ok().map(|e| e.path()))
.filter(|p| p.is_file())
.collect();
files.sort();
for f in files {
out.push(std::fs::read(&f).with_context(|| format!("read root {}", f.display()))?);
}
} else {
out.push(std::fs::read(path).with_context(|| format!("read root {}", path.display()))?);
}
Ok(out)
}
/// Without the `provenance-verify` feature the seal reader/verifier is not compiled
/// in — tell the operator how to enable it rather than silently accepting.
#[cfg(not(feature = "provenance-verify"))]
fn cmd_verify_provenance(_args: &[&str]) -> Result<()> {
bail!(
"verify-provenance needs the `provenance-verify` feature — rebuild with \
`cargo run --features warehouse,provenance-verify -- warehouse verify-provenance ...`"
);
}