src/security.rs
//! SBOM + vulnerability + license producer — pure Rust, no external tools, no C
//! deps. `cargo metadata` gives the full deep dependency tree; the OSV.dev batch
//! API gives vulnerabilities; the manifests give licenses. Emits a **CycloneDX
//! 1.5** SBOM. The standards-based feed for the warehouse
//! (`sbom_components` / `vuln_findings` / `license_facts`) and the viz Security tab.
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use serde_json::{Value, json};
#[derive(Clone, Debug)]
pub struct Component {
pub name: String,
pub version: String,
pub license: String,
}
#[derive(Clone)]
pub struct Vuln {
pub crate_name: String,
pub version: String,
pub ids: Vec<String>,
pub summary: String,
/// `Some(kind)` when EVERY matched advisory for this crate is a rustsec
/// **informational** advisory (`unmaintained` / `unsound` / `notice`) rather
/// than a real vulnerability. Such advisories must Warn, not Block (bug #15).
/// `None` for a real vuln (or the online OSV path, which carries no kind).
pub informational: Option<String>,
}
pub struct SecurityReport {
pub repo: String,
pub components: Vec<Component>,
pub vulns: Vec<Vuln>,
/// The advisory source revision the `vulns` were matched against (the rustsec
/// advisory-db git HEAD sha, or an OSV snapshot marker). This — NOT the repo
/// name — is the verdict-cache key component, so a new advisory invalidates a
/// stale Pass and the cache can be shared across repos (bug #16).
pub advisory_db_rev: String,
}
impl SecurityReport {
/// License → count, most-common first.
pub fn license_tally(&self) -> Vec<(String, usize)> {
let mut m: BTreeMap<String, usize> = BTreeMap::new();
for c in &self.components {
*m.entry(c.license.clone()).or_default() += 1;
}
let mut v: Vec<_> = m.into_iter().collect();
v.sort_by(|a, b| b.1.cmp(&a.1).then(a.0.cmp(&b.0)));
v
}
pub fn vuln_count(&self) -> usize {
self.vulns.iter().map(|v| v.ids.len()).sum()
}
/// CycloneDX 1.5 SBOM (components + vulnerabilities). Emitted by modgunn's
/// shared `sbom` module so the serialization lives in one place (multi-ecosystem
/// purls); nornir maps its cargo `Component`/`Vuln` into the modgunn shape. The
/// cargo output is byte-identical to nornir's prior in-place literal.
pub fn to_cyclonedx(&self) -> Value {
use modgunn::sbom::{SbomComponent, SbomInput, SbomVuln};
let input = SbomInput {
subject: self.repo.clone(),
components: self
.components
.iter()
.map(|c| SbomComponent {
ecosystem: "cargo".into(),
name: c.name.clone(),
version: c.version.clone(),
license: Some(c.license.clone()),
})
.collect(),
vulns: self
.vulns
.iter()
.flat_map(|v| {
// One SbomVuln per crate@version carrying all its ids — the
// emitter fans each id into its own `vulnerabilities` entry,
// matching the prior per-id output.
Some(SbomVuln {
ecosystem: "cargo".into(),
name: v.crate_name.clone(),
version: v.version.clone(),
ids: v.ids.clone(),
summary: v.summary.clone(),
})
})
.collect(),
};
modgunn::sbom::to_cyclonedx(&input)
}
}
/// The deep component list (the full transitive tree). No network. Returns the
/// repo display name + components — the SBOM half of a scan, split out so the
/// server can pair it with a warehouse `vuln_findings` cache.
///
/// Tries `cargo metadata` (rich: includes licenses); if that can't run — a
/// transient spawn failure, or a path-dep checkout whose siblings aren't cloned
/// beside it server-side — falls back to **`Cargo.lock`**, which pins every
/// resolved dep (name+version, enough for vuln matching; licenses unavailable).
pub fn components(repo: &Path) -> Result<(String, Vec<Component>)> {
let repo_name = repo
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
// Guard: a missing checkout dir otherwise surfaces as a cryptic "spawn cargo
// generate-lockfile" (Command current_dir doesn't exist). Say it plainly —
// usually a repo-name case mismatch (e.g. `Njord` vs the `njord` checkout).
if !repo.is_dir() {
anyhow::bail!(
"no checkout for `{repo_name}` at {} — is the repo name/case right and the workspace synced?",
repo.display()
);
}
// Tier 1: cargo metadata (rich — includes licenses).
let meta_err = match cargo_metadata_components(repo) {
Ok(c) => return Ok((repo_name, c)),
Err(e) => e,
};
eprintln!("nornir-security: cargo metadata unavailable ({meta_err:#}); using Cargo.lock");
// Tier 2: a committed Cargo.lock.
let lock = repo.join("Cargo.lock");
if lock.is_file() {
return Ok((repo_name, cargo_lock_components(repo)?));
}
// Tier 3: no Cargo.lock in the checkout — the norm for *library* crates,
// which gitignore it (nornir itself does). Synthesize one with
// `cargo generate-lockfile` (resolves versions, no build), then parse it.
if cargo_available() {
let r#gen = Command::new("cargo")
.args(["generate-lockfile"])
.current_dir(repo)
.output()
.context("spawn cargo generate-lockfile")?;
if r#gen.status.success() && lock.is_file() {
return Ok((repo_name, cargo_lock_components(repo)?));
}
// cargo is present but couldn't resolve (offline + cold registry, a
// path-dep whose sibling isn't checked out, …): surface WHY, clearly.
anyhow::bail!(
"could not resolve dependencies for `{repo_name}`: cargo metadata failed ({meta_err}) \
and `cargo generate-lockfile` could not produce a lockfile:\n{}",
String::from_utf8_lossy(&r#gen.stderr)
);
}
// Tier 4: cargo is not on PATH at all (e.g. the server service user has no
// toolchain). Without cargo AND without a committed Cargo.lock there is no
// SBOM source — say exactly that instead of a cryptic "read …/Cargo.lock".
anyhow::bail!(
"no SBOM source for `{repo_name}`: `cargo` is not on PATH (so cargo metadata / \
generate-lockfile can't run) and no Cargo.lock is committed (library crates gitignore it). \
Install a cargo toolchain for the scan host, or commit a Cargo.lock."
)
}
/// Is `cargo` runnable on PATH? Distinguishes "toolchain missing" (tier 4) from
/// "cargo present but resolve failed" (tier 3) so the scan error is actionable.
fn cargo_available() -> bool {
Command::new("cargo")
.arg("--version")
.output()
.map(|o| o.status.success())
.unwrap_or(false)
}
fn cargo_metadata_components(repo: &Path) -> Result<Vec<Component>> {
let out = Command::new("cargo")
.args(["metadata", "--format-version", "1"])
.current_dir(repo)
.output()
.context("spawn cargo metadata")?;
if !out.status.success() {
anyhow::bail!(
"cargo metadata failed:\n{}",
String::from_utf8_lossy(&out.stderr)
);
}
let md: Value = serde_json::from_slice(&out.stdout).context("parse cargo metadata")?;
Ok(md["packages"]
.as_array()
.map(|a| {
a.iter()
.map(|p| Component {
name: p["name"].as_str().unwrap_or_default().to_string(),
version: p["version"].as_str().unwrap_or_default().to_string(),
license: p["license"].as_str().unwrap_or("NOASSERTION").to_string(),
})
.collect()
})
.unwrap_or_default())
}
/// Parse `Cargo.lock` `[[package]]` entries → (name, version). Works with no
/// network and no sibling path-dep checkouts.
fn cargo_lock_components(repo: &Path) -> Result<Vec<Component>> {
let path = repo.join("Cargo.lock");
let text =
std::fs::read_to_string(&path).with_context(|| format!("read {}", path.display()))?;
let doc: toml::Value = toml::from_str(&text).context("parse Cargo.lock")?;
Ok(doc
.get("package")
.and_then(|p| p.as_array())
.map(|pkgs| {
pkgs.iter()
.filter_map(|p| {
Some(Component {
name: p.get("name")?.as_str()?.to_string(),
version: p.get("version")?.as_str()?.to_string(),
license: "NOASSERTION".to_string(),
})
})
.collect()
})
.unwrap_or_default())
}
/// Components, **warehouse-first**: if a deep-scan / metadata sweep of this
/// repo's dependency closure has already been captured (see [`warm`]), build the
/// SBOM component list from that warehouse snapshot — zero `cargo metadata`, zero
/// network. Falls back to live [`components`] (cargo metadata → Cargo.lock) when
/// the warehouse has no capture yet.
///
/// `repo_name` is taken from the directory name so it matches what [`warm`]
/// filed the components under.
pub fn components_warehouse_first(
wh: &crate::warehouse::iceberg::IcebergWarehouse,
repo: &Path,
) -> Result<(String, Vec<Component>)> {
let repo_name = repo
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_default();
match wh.query_sbom_components(&repo_name) {
Ok(Some(rows)) if !rows.is_empty() => {
let comps = rows
.into_iter()
.map(|r| Component {
name: r.name,
version: r.version,
license: r.license,
})
.collect();
Ok((repo_name, comps))
}
// No capture (or an empty/failed read) — resolve live.
_ => components(repo),
}
}
/// Pre-warm the warehouse SBOM cache for `repo`: resolve the component closure
/// **once** (live `cargo metadata`, materializing path-dep siblings first so a
/// headless server-side resolve succeeds for repos like znippy/knut) and persist
/// it under the repo name. After this, [`components_warehouse_first`] serves the
/// SBOM with no cargo/network. Idempotent enough for republish — each call files
/// a fresh capture snapshot and the latest wins.
///
/// Returns the `(repo_name, components)` it captured. `scan_root`, when given, is
/// where sibling path-dep checkouts are looked for / linked (typically the `git/`
/// dir holding all monitored member checkouts).
pub fn warm(
wh: &crate::warehouse::iceberg::IcebergWarehouse,
repo: &Path,
scan_root: Option<&Path>,
) -> Result<(String, Vec<Component>)> {
if let Some(root) = scan_root {
warm_prepare(repo, root);
}
let (repo_name, comps) = warm_resolve(repo)?;
persist_sbom(wh, &repo_name, &comps)?;
Ok((repo_name, comps))
}
/// The SERIAL, filesystem-mutating prelude of [`warm`]: clone any missing
/// path-dep siblings (feature `net-scan`) then link present ones under
/// `scan_root`, so a subsequent `cargo metadata` resolve can read them. Best-
/// effort — a miss just degrades to the Cargo.lock fallback inside [`components`];
/// prep never fails. Default nordisk URL resolution (override via
/// `NORNIR_DEP_CLONE_BASE`).
///
/// Kept SEPARATE from [`warm_resolve`] precisely so a multi-member warm can run
/// this serially (concurrent git clones / symlink creations into the shared
/// `scan_root` would otherwise race) and then fan the read-only resolve.
pub fn warm_prepare(repo: &Path, scan_root: &Path) {
let (cloned, linked) = prepare_path_deps(repo, scan_root, &|_| None);
if cloned + linked > 0 {
eprintln!(
"nornir-security: prepared path-deps for {} ({cloned} cloned, {linked} linked)",
repo.display()
);
}
}
/// The parallel-safe, read-only HALF of [`warm`]: resolve `repo`'s SBOM component
/// closure (live `cargo metadata`). Touches NO warehouse and mutates no shared
/// filesystem, so many members resolve concurrently. Persist the result with
/// [`persist_sbom`] (the serial single-writer half). Run [`warm_prepare`] first
/// when path-dep siblings need materializing.
pub fn warm_resolve(repo: &Path) -> Result<(String, Vec<Component>)> {
components(repo)
}
/// Persist a resolved SBOM closure under `repo_name` — the SERIAL single-writer
/// half of [`warm`] (the warehouse is single-writer; this does the redb/iceberg
/// append, no compute). Pair with [`warm_resolve`] when warming many members.
pub fn persist_sbom(
wh: &crate::warehouse::iceberg::IcebergWarehouse,
repo_name: &str,
comps: &[Component],
) -> Result<()> {
let rows: Vec<crate::warehouse::iceberg::SbomComponentRow> = comps
.iter()
.map(|c| crate::warehouse::iceberg::SbomComponentRow {
name: c.name.clone(),
version: c.version.clone(),
license: c.license.clone(),
derived_from: "source".into(),
})
.collect();
wh.append_sbom_components(repo_name, uuid::Uuid::new_v4(), &rows)
.with_context(|| format!("persist sbom components for {repo_name}"))?;
Ok(())
}
/// Materialize sibling **path-dep** checkouts next to `repo` under `scan_root`,
/// so a headless server-side `cargo metadata` (which needs the path-dep sources
/// on disk to resolve + read their licenses) succeeds for repos that depend on
/// sibling crates by relative path (e.g. znippy → zoomies, knut → skade).
///
/// For every `dep = { path = "..." }` in the repo's `Cargo.toml`, resolve the
/// target's crate/repo directory name; if it isn't already reachable at the path
/// the manifest expects but *is* present under `scan_root`, drop a symlink so the
/// relative path resolves. Pure-filesystem, no cargo, no network. Best-effort and
/// idempotent: already-resolvable deps and already-present links are left alone.
/// Returns the number of links created.
pub fn materialize_path_dep_siblings(repo: &Path, scan_root: &Path) -> Result<usize> {
let manifest = repo.join("Cargo.toml");
let text = match std::fs::read_to_string(&manifest) {
Ok(t) => t,
Err(_) => return Ok(0), // no manifest → nothing to do
};
let doc: toml::Value = toml::from_str(&text).context("parse Cargo.toml")?;
let mut linked = 0usize;
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
continue;
};
for (dep_name, spec) in deps {
let Some(path) = spec
.as_table()
.and_then(|t| t.get("path"))
.and_then(|p| p.as_str())
else {
continue;
};
// The location the manifest's relative `path` points at.
let target = repo.join(path);
if target.join("Cargo.toml").exists() {
continue; // already resolvable in place
}
// The directory name the dep expects (last segment of its path).
let leaf = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dep_name.clone());
// Look for a sibling checkout under scan_root by that leaf name, then
// by the dep's own name (repo dir often == crate name).
let candidate = [leaf.as_str(), dep_name.as_str()]
.into_iter()
.map(|n| scan_root.join(n))
.find(|c| c.join("Cargo.toml").exists());
let Some(src) = candidate else { continue };
if let Some(parent) = target.parent() {
let _ = std::fs::create_dir_all(parent);
}
if symlink_dir(&src, &target).is_ok() {
linked += 1;
}
}
}
Ok(linked)
}
/// List the **path-dep siblings of `repo` that are still unresolved** — i.e. the
/// manifest declares `dep = { path = "..." }` but neither the path itself nor a
/// `scan_root` sibling carries a `Cargo.toml`, so a headless `cargo metadata`
/// would fail to load them. Call this *after* [`prepare_path_deps`] to turn the
/// opaque `MetadataCommand::exec` failure into a clear, named diagnostic (DR2).
///
/// Returns `(dep_name, declared_path)` pairs. Empty ⇒ every path-dep resolves
/// (or the repo has no manifest / no path-deps). Pure filesystem, never errors
/// on a missing/garbled manifest (those degrade to "nothing unresolved" so the
/// caller's real cargo-metadata error still surfaces).
pub fn unresolved_path_dep_siblings(repo: &Path, scan_root: &Path) -> Vec<(String, String)> {
let Ok(text) = std::fs::read_to_string(repo.join("Cargo.toml")) else {
return Vec::new();
};
let Ok(doc) = toml::from_str::<toml::Value>(&text) else {
return Vec::new();
};
let mut unresolved = Vec::new();
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
continue;
};
for (dep_name, spec) in deps {
let Some(path) = spec
.as_table()
.and_then(|t| t.get("path"))
.and_then(|p| p.as_str())
else {
continue;
};
// Resolvable in place (path or, post-prep, via the symlink we dropped)?
if repo.join(path).join("Cargo.toml").exists() {
continue;
}
// Still resolvable from a scan_root sibling we could have linked?
let leaf = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dep_name.clone());
let present = [leaf.as_str(), dep_name.as_str()]
.into_iter()
.any(|n| scan_root.join(n).join("Cargo.toml").exists());
if !present {
unresolved.push((dep_name.clone(), path.to_string()));
}
}
}
unresolved
}
/// Default base URL for cloning a missing path-dep sibling whose git URL the
/// caller didn't supply. Override with `NORNIR_DEP_CLONE_BASE`.
pub fn dep_clone_base() -> String {
// SSH-first (deploy key, no credential prompt) — our sibling repos are
// private. scp-like base so `<base>/<repo>` is a valid SSH remote that
// `gitio::clone_or_fetch` routes straight through the key. Override with
// NORNIR_DEP_CLONE_BASE (an https base still works — it gets SSH-first'd).
std::env::var("NORNIR_DEP_CLONE_BASE")
.unwrap_or_else(|_| "ssh://git@git.gunnar.rs:2222/nordisk".to_string())
}
/// The sibling **repo dir name** a relative path-dep points at: the first
/// `Normal` component of the path (`../skade/skade` → `skade`, `../zoomies` →
/// `zoomies`). `None` for a path with no normal component.
fn sibling_repo_name(path: &str) -> Option<String> {
Path::new(path).components().find_map(|c| match c {
std::path::Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
}
/// Clone **missing** sibling repos that `repo`'s path-deps point at, so a
/// headless `cargo metadata` can resolve them. The complement of
/// [`materialize_path_dep_siblings`] (which only links siblings already on
/// disk): this *fetches* the ones that aren't checked out at all — the exact
/// gap that made "press Security" fail on `nornir` (its `skade`/`skade-katalog`
/// path-deps were never cloned beside it in the server's single-repo checkout).
///
/// `url_for(repo_name)` supplies the git URL for a sibling repo dir name (e.g.
/// the workspace descriptor's `git`); when it returns `None` we fall back to
/// `<NORNIR_DEP_CLONE_BASE>/<repo_name>` (default `ssh://git@git.gunnar.rs:2222/nordisk`).
///
/// **Network**: gated behind the `net-scan` feature. Without it this is a no-op
/// (returns `Ok(0)`) so an airgapped build never reaches out. Best-effort: a
/// clone failure is logged and skipped, never fatal — the scan then degrades to
/// the Cargo.lock fallback in [`components`]. Returns the number of repos cloned.
#[cfg(feature = "net-scan")]
pub fn clone_missing_path_dep_siblings(
repo: &Path,
scan_root: &Path,
url_for: &dyn Fn(&str) -> Option<String>,
) -> Result<usize> {
let manifest = repo.join("Cargo.toml");
let Ok(text) = std::fs::read_to_string(&manifest) else {
return Ok(0);
};
let doc: toml::Value = toml::from_str(&text).context("parse Cargo.toml")?;
let mut cloned = 0usize;
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
for table_key in ["dependencies", "dev-dependencies", "build-dependencies"] {
let Some(deps) = doc.get(table_key).and_then(|d| d.as_table()) else {
continue;
};
for (_dep_name, spec) in deps {
let Some(path) = spec
.as_table()
.and_then(|t| t.get("path"))
.and_then(|p| p.as_str())
else {
continue;
};
// Already resolvable in place → nothing to fetch.
if repo.join(path).join("Cargo.toml").exists() {
continue;
}
let Some(repo_name) = sibling_repo_name(path) else {
continue;
};
if !seen.insert(repo_name.clone()) {
continue; // one clone covers every path-dep into the same sibling repo
}
let dest = scan_root.join(&repo_name);
// Present already (full repo, or a virtual-manifest workspace root)?
// Leave it for `materialize_path_dep_siblings` to link if needed.
if dest.join("Cargo.toml").exists() {
continue;
}
let url = url_for(&repo_name)
.unwrap_or_else(|| format!("{}/{}", dep_clone_base(), repo_name));
match crate::procreap::clone_or_fetch(
&url,
&dest,
crate::gitio::nornir_ssh_key_path().as_deref(),
) {
Ok(sha) => {
eprintln!(
"nornir-security: cloned path-dep sibling `{repo_name}` from {url} @ {sha}"
);
cloned += 1;
}
Err(e) => eprintln!(
"nornir-security: clone path-dep sibling `{repo_name}` from {url} failed \
(scan will degrade to Cargo.lock): {e:#}"
),
}
}
}
Ok(cloned)
}
/// Airgapped build (`net-scan` off): never fetch. The scan resolves only what's
/// already on disk, then falls back to the committed Cargo.lock.
#[cfg(not(feature = "net-scan"))]
pub fn clone_missing_path_dep_siblings(
_repo: &Path,
_scan_root: &Path,
_url_for: &dyn Fn(&str) -> Option<String>,
) -> Result<usize> {
Ok(0)
}
/// The docs `depgraph` section's injected network half: exactly the closure
/// `docs::sections::render_depgraph` used to hard-code before the assembly half
/// moved to the `nornir-docgen` leaf — clone missing sibling path-dep repos
/// (default URL base, no workspace override) and never fail the render over it.
/// Every nornir `docs::Ctx` construction passes this through
/// [`Ctx::with_path_dep_cloner`](nornir_docgen::sections::Ctx::with_path_dep_cloner)
/// so CLI/server/gate behaviour is identical to the pre-extraction code; the
/// leaf itself defaults to no cloning (pure symlink materialization).
pub fn docs_path_dep_cloner(repo: &Path, scan_root: &Path) -> usize {
clone_missing_path_dep_siblings(repo, scan_root, &|_| None).unwrap_or(0)
}
/// Make `repo`'s sibling path-deps resolvable for a headless `cargo metadata`:
/// **clone** the missing ones (feature `net-scan`), then **symlink** any
/// present-but-misplaced ones. Best-effort and idempotent; errors are logged,
/// not propagated (a failed prep just degrades the scan to the Cargo.lock
/// fallback). Returns `(cloned, linked)`. This is the single entry point both
/// [`warm`] and the server's `Mimir.SecurityScan` handler call before
/// [`components`].
pub fn prepare_path_deps(
repo: &Path,
scan_root: &Path,
url_for: &dyn Fn(&str) -> Option<String>,
) -> (usize, usize) {
let cloned = clone_missing_path_dep_siblings(repo, scan_root, url_for).unwrap_or_else(|e| {
eprintln!(
"nornir-security: clone path-dep siblings for {} skipped: {e:#}",
repo.display()
);
0
});
let linked = materialize_path_dep_siblings(repo, scan_root).unwrap_or_else(|e| {
eprintln!(
"nornir-security: link path-dep siblings for {} skipped: {e:#}",
repo.display()
);
0
});
(cloned, linked)
}
/// Create a directory symlink `link → src`. No-op if `link` already exists.
#[cfg(unix)]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
if link.exists() {
return Ok(());
}
std::os::unix::fs::symlink(src, link)
}
#[cfg(not(unix))]
fn symlink_dir(src: &Path, link: &Path) -> std::io::Result<()> {
if link.exists() {
return Ok(());
}
std::os::windows::fs::symlink_dir(src, link)
}
/// Scan `repo`: deep dependency tree (`cargo metadata`) + OSV vulnerabilities.
/// The uncached path — for the warehouse-first path see the server's
/// `Mimir.SecurityScan` (cache lookup + `osv_query` of misses only).
pub fn scan(repo: &Path) -> Result<SecurityReport> {
let (repo_name, components) = components(repo)?;
let vulns = query_vulns(&components)?;
Ok(SecurityReport {
repo: repo_name,
components,
vulns,
advisory_db_rev: advisory_db_rev(),
})
}
/// The advisory source revision (bug #16): the rustsec advisory-db mirror's git
/// HEAD sha when seeded offline, else an OSV online marker. Used as the
/// verdict-cache key component so a new advisory invalidates a stale Pass.
pub fn advisory_db_rev() -> String {
match advisory_db_path() {
Some(db) => crate::gitio::head_sha(&db)
.map(|sha| format!("rustsec:{sha}"))
.unwrap_or_else(|_| "rustsec:unknown".to_string()),
None => "osv:online".to_string(),
}
}
/// Look up vulnerabilities for `components`, **airgap-first**: the local
/// rustsec/advisory-db mirror (offline) when seeded, else OSV.dev.
pub fn query_vulns(components: &[Component]) -> Result<Vec<Vuln>> {
if components.is_empty() {
return Ok(Vec::new());
}
match advisory_db_path() {
Some(db) => advisory_db_query(components, &db),
None => osv_query(components),
}
}
/// The local rustsec/advisory-db mirror path (`NORNIR_ADVISORY_DB`, else
/// `/opt/nornir/advisory-db`) — `Some` only once seeded (has a `crates/` dir).
pub fn advisory_db_path() -> Option<PathBuf> {
let p = std::env::var_os("NORNIR_ADVISORY_DB")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/opt/nornir/advisory-db"));
p.join("crates").is_dir().then_some(p)
}
/// Clone or update the advisory-db mirror. Airgap: point `NORNIR_ADVISORY_DB_URL`
/// at holger (it serves the repo). Pure Rust (gix, no git2). Returns the path.
pub fn update_advisory_db() -> Result<PathBuf> {
let dest = std::env::var_os("NORNIR_ADVISORY_DB")
.map(PathBuf::from)
.unwrap_or_else(|| PathBuf::from("/opt/nornir/advisory-db"));
let url = std::env::var("NORNIR_ADVISORY_DB_URL")
.unwrap_or_else(|_| "https://github.com/rustsec/advisory-db".to_string());
crate::procreap::clone_or_fetch(&url, &dest, None)
.with_context(|| format!("clone/fetch advisory-db from {url}"))?;
Ok(dest)
}
/// Walk the seeded rustsec advisory-db mirror (`crates/<crate>/*.md`) into modgunn
/// advisories via [`modgunn::osv::parse_rustsec_md`] (the patched/unaffected → range
/// inversion). The **offline** half of the advisory corpus; empty when no mirror is
/// seeded ([`advisory_db_path`] is `None`). Fully offline, no network.
pub fn gather_rustsec_advisories() -> Vec<modgunn::scan::Advisory> {
let Some(db) = advisory_db_path() else {
return Vec::new(); // no seeded mirror ⇒ nothing to gather
};
let crates = db.join("crates");
let mut advisories = Vec::new();
for crate_dir in std::fs::read_dir(&crates).into_iter().flatten().flatten() {
let cdir = crate_dir.path();
if !cdir.is_dir() {
continue;
}
for entry in std::fs::read_dir(&cdir).into_iter().flatten().flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
if let Some(adv) = modgunn::osv::parse_rustsec_md(&text) {
advisories.push(adv);
}
}
}
advisories
}
/// Fetch one full OSV record (`GET /v1/vulns/{id}`) as a JSON string. querybatch
/// returns only ids — the severity/EPSS/ranges/aliases the verdict needs live in the
/// full record, so the importer fetches each id here. `None` on any network/HTTP
/// error: one unreachable record never sinks the whole import (airgap-safe).
fn fetch_osv_record(id: &str) -> Option<String> {
let url = format!("https://api.osv.dev/v1/vulns/{id}");
match crate::http_agent().get(&url).call() {
Ok(resp) => resp.into_body().read_to_string().ok(),
Err(e) => {
eprintln!("nornir-security: OSV.dev fetch failed for {id} ({e}); skipping");
None
}
}
}
/// Live-OSV importer feed: full OSV advisories for `components`. querybatch
/// ([`osv_query`]) resolves *which* advisory ids affect the fleet; each id's full
/// record is then fetched ([`fetch_osv_record`]) and folded across feeds by alias
/// ([`modgunn::osv::parse_and_merge`]) into warehouse-ready advisories with **real**
/// severity/EPSS/ranges. The **online** half of the advisory corpus — network stays
/// behind the same graceful-degradation contract as [`osv_query`] (an unreachable
/// OSV.dev or empty input ⇒ an empty set, never an error), so an airgap import simply
/// yields the offline rustsec half. Both feeds land on modgunn's `"cargo"` ecosystem
/// key (OSV `crates.io` is normalized), so the verdict's `load_advisory_db(Some("cargo"))`
/// sees them.
pub fn gather_osv_advisories(components: &[Component]) -> Vec<modgunn::scan::Advisory> {
if components.is_empty() {
return Vec::new();
}
// Which advisory ids touch the fleet (querybatch → ids only).
let ids: std::collections::BTreeSet<String> = match osv_query(components) {
Ok(vulns) => vulns.into_iter().flat_map(|v| v.ids).collect(),
Err(_) => return Vec::new(),
};
// Fetch each id's FULL record (severity/ranges live here, not in querybatch),
// then normalize+dedup across feeds by alias.
let docs: Vec<String> = ids.iter().filter_map(|id| fetch_osv_record(id)).collect();
modgunn::osv::parse_and_merge(docs)
}
/// Import BOTH advisory feeds into modgunn's shared `cve_advisories` warehouse table
/// under a **single** `db_rev` — the offline rustsec mirror
/// ([`gather_rustsec_advisories`]) plus, when `components` are supplied and OSV.dev is
/// reachable, live OSV records for them ([`gather_osv_advisories`]). Folded across
/// feeds by alias. This is the single advisory MATCH source the warehouse-sourced
/// verdict reads back with **real** severity — the completion of the de-duplication:
/// OSV stops being a second live matcher and becomes an importer that feeds this table.
///
/// **One rev is required, not cosmetic:** [`modgunn::warehouse::Warehouse::load_advisory_db`]
/// returns only the rows of the greatest `db_rev`, so splitting the two feeds across
/// two revs would make one silently shadow the other. Returns the count imported
/// (`0` when neither feed yields anything).
///
/// Opens its own modgunn warehouse handle, so it must not run while another writer
/// (a live `nornir-server`) holds the same `catalog.redb` — the §8 single-writer
/// constraint; callers treat an open failure as non-fatal.
pub fn import_advisories_into_warehouse(root: &Path, components: &[Component]) -> Result<usize> {
let mut advisories = gather_rustsec_advisories();
advisories.extend(gather_osv_advisories(components));
import_advisories(root, advisories)
}
/// The importer proper, with the two feeds already gathered — merge by alias, fold
/// under one `db_rev`, append.
///
/// Split out as a dependency-injected seam so the import can be tested against a
/// STATED corpus. Before this, the only entry point gathered the feeds itself, and
/// "an empty corpus imports nothing" could only be asserted by having no advisory
/// mirror on the machine: `advisory_db_path()` falls back to the absolute
/// `/opt/nornir/advisory-db`, so the test passed on a bare box and failed on any box
/// where the mirror is actually seeded (this one has 852 crates → 1096 advisories).
/// The test was reading the developer's filesystem, not the function.
pub fn import_advisories(root: &Path, advisories: Vec<modgunn::scan::Advisory>) -> Result<usize> {
if advisories.is_empty() {
return Ok(0); // neither feed yielded anything ⇒ nothing to import
}
// Fold a CVE arriving under several ids / from several feeds into one row.
let advisories = modgunn::scan::merge_by_alias(advisories);
let n = advisories.len();
let rev = advisory_db_rev();
let wh = modgunn::warehouse::Warehouse::open(root)
.context("open modgunn warehouse for advisory import")?;
wh.ensure_modgunn_tables()
.context("ensure modgunn advisory tables")?;
wh.append_advisories(&rev, &advisories)
.context("append advisories to cve_advisories")?;
Ok(n)
}
/// Rustsec-only import (back-compat wrapper): [`import_advisories_into_warehouse`]
/// with no components, so the live-OSV feed is skipped and only the offline mirror is
/// imported. Fully offline. Returns the count imported (`0` when no mirror is seeded).
pub fn import_advisory_db_into_warehouse(root: &Path) -> Result<usize> {
import_advisories_into_warehouse(root, &[])
}
/// The fleet component closure the warehouse already knows — `(name, version)` for
/// every captured SBOM row — as the live-OSV importer's query set
/// ([`gather_osv_advisories`]). Best-effort: an empty, unreadable, or absent warehouse
/// yields an empty set (⇒ a rustsec-only import). The read handle is dropped before the
/// importer re-opens for the write (§8 single-writer).
pub fn fleet_components(root: &Path) -> Vec<Component> {
match modgunn::warehouse::Warehouse::open(root).and_then(|wh| wh.read_all_sbom_components()) {
Ok(rows) => rows
.into_iter()
.map(|(_repo, name, version)| Component {
name,
version,
license: String::new(),
})
.collect(),
Err(_) => Vec::new(),
}
}
fn req_matches(reqs: &[String], v: &semver::Version) -> bool {
// Also test the pre-release-stripped version: semver excludes pre-releases
// (`>= 0.10` won't match `0.11.0-rc.4`), which would wrongly flag an rc as
// unpatched. Treating `0.11.0-rc.4` as `0.11.0` for the patched/unaffected
// check matches OSV's behaviour.
let mut bare = v.clone();
bare.pre = semver::Prerelease::EMPTY;
reqs.iter().any(|r| {
semver::VersionReq::parse(r)
.map(|req| req.matches(v) || req.matches(&bare))
.unwrap_or(false)
})
}
/// Extract the ```toml fenced front-matter from a rustsec advisory `.md`.
fn extract_toml_front_matter(text: &str) -> Option<&str> {
let after = &text[text.find("```toml")? + "```toml".len()..];
let end = after.find("```")?;
Some(after[..end].trim_matches('\n'))
}
fn string_list(v: Option<&toml::Value>) -> Vec<String> {
v.and_then(|v| v.as_array())
.map(|a| {
a.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default()
}
/// Match `components` against a local advisory-db (`crates/<crate>/*.toml`) —
/// cargo-audit's logic: a version is affected when no `patched` requirement
/// matches it and it isn't listed `unaffected`. Fully offline.
pub fn advisory_db_query(components: &[Component], db: &Path) -> Result<Vec<Vuln>> {
let crates = db.join("crates");
let mut out = Vec::new();
for c in components {
let dir = crates.join(&c.name);
if !dir.is_dir() {
continue;
}
let Ok(version) = semver::Version::parse(&c.version) else {
continue;
};
let mut ids = Vec::new();
let mut summary = String::new();
// Track whether any matched advisory is a REAL vuln (vs informational):
// a crate that has even one real advisory must Block; a crate matched
// ONLY by informational advisories (unmaintained/unsound/notice) Warns.
let mut has_real = false;
let mut info_kind: Option<String> = None;
for entry in std::fs::read_dir(&dir).into_iter().flatten().flatten() {
let p = entry.path();
if p.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let Ok(text) = std::fs::read_to_string(&p) else {
continue;
};
// Each advisory is Markdown with a ```toml front-matter block.
let Some(front) = extract_toml_front_matter(&text) else {
continue;
};
let Ok(doc) = toml::from_str::<toml::Value>(front) else {
continue;
};
let adv = doc.get("advisory");
let versions = doc.get("versions");
let patched = string_list(versions.and_then(|v| v.get("patched")));
let unaffected = string_list(versions.and_then(|v| v.get("unaffected")));
if !req_matches(&patched, &version) && !req_matches(&unaffected, &version) {
if summary.is_empty() {
summary = adv
.and_then(|a| a.get("title"))
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
}
if let Some(id) = adv.and_then(|a| a.get("id")).and_then(|v| v.as_str()) {
ids.push(id.to_string());
}
match adv
.and_then(|a| a.get("informational"))
.and_then(|v| v.as_str())
{
Some(kind) => info_kind.get_or_insert_with(|| kind.to_string()),
None => {
has_real = true;
continue;
}
};
}
}
if !ids.is_empty() {
// Informational ONLY when no real advisory matched the crate.
let informational = if has_real { None } else { info_kind };
out.push(Vuln {
crate_name: c.name.clone(),
version: c.version.clone(),
ids,
summary,
informational,
});
}
}
Ok(out)
}
/// OSV.dev batch query (one POST, no `ureq` json feature needed: serialise +
/// parse with `serde_json` ourselves). Empty input → no network.
pub fn osv_query(components: &[Component]) -> Result<Vec<Vuln>> {
if components.is_empty() {
return Ok(Vec::new());
}
// OSV.dev caps querybatch at 1000 queries per request — chunk and concatenate,
// each chunk parsed against its own slice so the results[] index alignment is
// preserved per chunk (bug #18).
const MAX_QUERIES: usize = 1000;
let mut vulns = Vec::new();
for chunk in components.chunks(MAX_QUERIES) {
let queries: Vec<Value> = chunk
.iter()
.map(|c| json!({ "package": { "ecosystem": "crates.io", "name": c.name }, "version": c.version }))
.collect();
let body = serde_json::to_string(&json!({ "queries": queries }))?;
let resp = match crate::http_agent().post("https://api.osv.dev/v1/querybatch")
.header("Content-Type", "application/json")
.send(&body)
{
Ok(r) => r,
// Airgap / offline: degrade gracefully rather than failing the scan.
// Seed NORNIR_ADVISORY_DB (a local rustsec/advisory-db mirror) for offline
// matching instead.
Err(e) => {
eprintln!(
"nornir-security: OSV.dev unreachable ({e}); skipping vuln lookup (seed NORNIR_ADVISORY_DB for offline)"
);
return Ok(Vec::new());
}
};
let resp_str = resp.into_body().read_to_string().context("read OSV response")?;
vulns.extend(parse_osv_batch(&resp_str, chunk).context("parse OSV response")?);
}
Ok(vulns)
}
/// Pure parse of an OSV `/v1/querybatch` response against the chunk it was issued
/// for (index-aligned: `results[i]` ↔ `chunk[i]`). querybatch returns only
/// `id` (+ `modified`) per vuln — NOT `summary` — so the online path leaves the
/// summary empty (enrich via `/v1/vulns/{id}` if a description is ever needed).
/// Extracted so the alignment + id extraction is unit-testable without network.
pub fn parse_osv_batch(resp_str: &str, chunk: &[Component]) -> Result<Vec<Vuln>> {
let resp: Value = serde_json::from_str(resp_str)?;
let mut vulns = Vec::new();
if let Some(results) = resp["results"].as_array() {
for (i, r) in results.iter().enumerate() {
let Some(vs) = r["vulns"].as_array() else {
continue;
};
if vs.is_empty() || i >= chunk.len() {
continue;
}
let c = &chunk[i];
vulns.push(Vuln {
crate_name: c.name.clone(),
version: c.version.clone(),
ids: vs
.iter()
.filter_map(|v| v["id"].as_str().map(String::from))
.collect(),
// querybatch does not return summary; the online path leaves it blank.
summary: String::new(),
// The online querybatch path carries no advisory kind.
informational: None,
});
}
}
Ok(vulns)
}
/// `security scan` rendered as the uniform [`crate::cli_outcome::CommandOutcome`]
/// — the shared face for BOTH the fat (`nornir::security::scan`) and thin
/// (`Mimir.SecurityScan` RPC) CLI paths, so they emit the SAME shape (CLI⟺UI
/// parity with the viz Security tab, which reads the same `sbom_components` /
/// `vuln_findings`). The inputs are ALREADY computed by the chosen data path —
/// this only SHAPES + renders.
///
/// **`ok`-semantics for a scan/audit report:** `ok ⟺ the scan RAN and resolved a
/// real component closure`. *Finding vulnerabilities is still a true (sannr)
/// result* — a populated SBOM with vulns is a successful, meaningful scan, not a
/// RED state. Only a scan that resolved **zero components** (couldn't read the
/// dep tree — empty SBOM) is RED, exactly the RAGNARÖK "empty pane is red" rule.
/// The exit-code-as-vuln-count CI gate is orthogonal and stays in the handler.
pub fn scan_outcome(
repo: &str,
component_count: usize,
vulns: &[Vuln],
license_top: &[(String, usize)],
cache: Option<(u64, u64)>,
) -> crate::cli_outcome::CommandOutcome {
use crate::cli_outcome::CommandOutcome;
// Empty component closure ⇒ the scan produced nothing readable ⇒ RED.
if component_count == 0 {
return CommandOutcome::fail(
"security scan",
format!(
"{repo}: scan resolved 0 components — no SBOM source (cargo metadata / \
Cargo.lock both unavailable)"
),
);
}
let vuln_total: usize = vulns.iter().map(|v| v.ids.len()).sum();
let vulns_json: Vec<Value> = vulns
.iter()
.map(|v| {
json!({
"crate": v.crate_name,
"version": v.version,
"ids": v.ids,
"summary": v.summary,
})
})
.collect();
let licenses_json: Vec<Value> = license_top
.iter()
.map(|(k, n)| json!({ "license": k, "count": n }))
.collect();
let mut data = json!({
"repo": repo,
"components": component_count,
"vulnerabilities": vuln_total,
"vulnerable_crates": vulns.len(),
"vulns": vulns_json,
"licenses": licenses_json,
});
if let Some((hits, misses)) = cache {
data["cache_hits"] = json!(hits);
data["cache_misses"] = json!(misses);
}
let mut human = format!("repo {repo}\n");
human.push_str(&format!(
"components {component_count} crates (deep, incl. transitive)\n"
));
if let Some((hits, misses)) = cache {
human.push_str(&format!("cache {hits} hit / {misses} miss\n"));
}
human.push_str(&format!(
"vulnerabilities {vuln_total} across {} crate(s)",
vulns.len()
));
for v in vulns {
human.push_str(&format!(
"\n ⚠ {} {}: {}",
v.crate_name,
v.version,
v.ids.join(", ")
));
}
let top: Vec<String> = license_top
.iter()
.take(6)
.map(|(k, n)| format!("{k}×{n}"))
.collect();
if !top.is_empty() {
human.push_str(&format!("\nlicenses {}", top.join(", ")));
}
CommandOutcome::ok("security scan", data, human)
}
// ── modgunn verdict bridge (Engine A) ───────────────────────────────────────
//
// The security DOMAIN — folding CVE / license / provenance into one
// Pass/Warn/Block decision — is modgunn's (the leaf both nornir and holger share,
// per `.nornir/modgunn-security-scanner.md` + the master-plan §6 cutover). nornir
// keeps its BUILD-orchestration half (cargo-metadata / Cargo.lock component
// extraction, sibling-repo cloning, the network OSV / offline advisory-db query,
// the warehouse SBOM cache) — none of which modgunn owns — and feeds the resolved
// data INTO modgunn to obtain a `core::Verdict`. This bridge is the seam: it maps
// a nornir [`SecurityReport`] / [`Component`] / [`Vuln`] into modgunn's
// `scan::Artifact` + `scan::AdvisoryDb`, runs `scan::OsvScanner`, and returns the
// same `core::Verdict` shape holger's gate consumes — so the verdict logic lives
// in exactly one place.
/// Re-export of modgunn's shared verdict contract so consumers can name the types
/// through `nornir::security::{Verdict, Decision, …}` (a single import path; the
/// definitions live in modgunn, the leaf that breaks the nornir↔holger cycle).
pub use modgunn::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
/// Re-export of modgunn's Engine-A scan surface (the verdict producer + policy).
pub use modgunn::scan::{
Advisory, AdvisoryDb, Artifact, OsvScanner, Policy, Provenance, ScanEngine,
};
pub mod verdict {
//! Map nornir's resolved [`Component`]s into modgunn's Engine-A inputs and
//! produce `core::Verdict`s. The advisory set is sourced from modgunn's shared
//! `cve_advisories` warehouse table (real per-advisory **severity / EPSS /
//! KEV**), so the decision reflects the true severity — not the historical
//! all-High fallback, and the match happens **once**, in modgunn (no more
//! nornir-side OSV + rustsec double-match). nornir resolves the component
//! closure; modgunn owns the match and the verdict, in one place for both nornir
//! and holger (master-plan §6 cutover).
use std::path::Path;
use anyhow::{Context, Result};
use super::Component;
use modgunn::core::{ArtifactRef, Verdict};
use modgunn::scan::{AdvisoryDb, Artifact, OsvScanner, Policy, Provenance, ScanEngine};
/// The cargo purl used as the content key stand-in (nornir's SBOM path is
/// `name@version`-keyed, not content-addressed). Mirrors `super::purl`.
fn purl(name: &str, version: &str) -> String {
format!("pkg:cargo/{name}@{version}")
}
/// A nornir [`Component`] → modgunn [`Artifact`] (ecosystem `cargo`). License
/// `NOASSERTION`/empty ⇒ unknown (`None`); provenance unsigned (nornir's SBOM
/// path carries no signature).
pub fn artifact(c: &Component) -> Artifact {
let license = match c.license.trim() {
"" | "NOASSERTION" => None,
lic => Some(lic.to_string()),
};
Artifact::new(
ArtifactRef {
ecosystem: "cargo".to_string(),
name: c.name.clone(),
version: c.version.clone(),
blob_sha256: purl(&c.name, &c.version),
},
license,
Provenance::unsigned(),
)
}
/// One [`Verdict`] per component, scanned against `db` under `policy`. `db` is
/// modgunn's real advisory set (warehouse-sourced, or seeded in tests) — its
/// per-advisory severity / EPSS / KEV drives the decision, and its `rev` keys
/// the verdict cache. This is the seam a caller holding a live warehouse handle
/// uses: load `db` once, then scan, so the catalog is opened a single time
/// (the §8 single-`catalog.redb`-open constraint).
pub fn verdicts_with(db: AdvisoryDb, components: &[Component], policy: Policy) -> Vec<Verdict> {
let scanner = OsvScanner::new(policy, db);
components
.iter()
.map(|c| scanner.scan(&artifact(c)))
.collect()
}
/// Per-component verdicts sourced from the shared `cve_advisories` table at the
/// warehouse `root` nornir already uses. Loads the latest advisory revision
/// (real severity) and scans each component. Uses modgunn's **lock-tolerant**
/// `open_read_only`, so it works even inside the live `nornir-server` (which
/// holds the `catalog.redb` write lock): a contended open transparently reads a
/// copied-aside snapshot rather than failing (§8 single-writer).
pub fn verdicts_from_warehouse(
root: &Path,
components: &[Component],
policy: Policy,
) -> Result<Vec<Verdict>> {
let wh = modgunn::warehouse::Warehouse::open_read_only(root)
.context("open modgunn warehouse (read-only) for verdict")?;
let db = wh
.load_advisory_db(Some("cargo"))
.context("load cve_advisories")?;
Ok(verdicts_with(db, components, policy))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn scan_outcome_with_components_is_sannr_even_with_vulns() {
// A scan that resolved real components is a TRUE (sannr) result — finding
// vulnerabilities does NOT make it RED (it's still a meaningful scan).
let vulns = vec![Vuln {
crate_name: "openssl".into(),
version: "0.1.0".into(),
ids: vec!["RUSTSEC-2024-0001".into(), "RUSTSEC-2024-0002".into()],
summary: "use after free".into(),
informational: None,
}];
let licenses = vec![("MIT".to_string(), 12usize), ("Apache-2.0".to_string(), 3)];
let o = scan_outcome("nornir", 42, &vulns, &licenses, Some((40, 2)));
assert!(
o.is_sannr(),
"a populated scan (even with vulns) is sannr/true"
);
assert_eq!(o.command, "security scan");
assert_eq!(o.data["repo"], json!("nornir"));
assert_eq!(o.data["components"], json!(42));
assert_eq!(
o.data["vulnerabilities"],
json!(2),
"2 advisory ids across 1 crate"
);
assert_eq!(o.data["vulnerable_crates"], json!(1));
assert_eq!(o.data["cache_hits"], json!(40));
assert_eq!(
o.data["vulns"].as_array().unwrap()[0]["crate"],
json!("openssl")
);
assert_eq!(
o.data["licenses"].as_array().unwrap()[0]["license"],
json!("MIT")
);
}
#[test]
fn scan_outcome_clean_scan_is_sannr() {
// No vulnerabilities found but real components resolved ⇒ still TRUE: the
// scan ran and the SBOM is real. (A clean repo is a success, not RED.)
let o = scan_outcome("holger", 7, &[], &[("MIT".into(), 7)], None);
assert!(
o.is_sannr(),
"a clean scan with real components is a true result"
);
assert_eq!(o.data["vulnerabilities"], json!(0));
assert_eq!(o.data["vulns"].as_array().unwrap().len(), 0);
assert!(
o.data.get("cache_hits").is_none(),
"no cache stats in fat mode"
);
}
#[test]
fn scan_outcome_empty_component_closure_is_red() {
// RAGNARÖK: 0 components ⇒ the scan produced nothing ⇒ RED, regardless of
// process exit. The reason names the missing SBOM source.
let o = scan_outcome("ghost", 0, &[], &[], None);
assert!(!o.is_sannr(), "an empty SBOM (0 components) is RED");
assert_eq!(o.command, "security scan");
assert_eq!(o.data, Value::Null);
assert!(
o.human.contains("0 components"),
"human names the empty closure"
);
}
#[test]
fn extract_toml_front_matter_pulls_the_advisory_block() {
let md = "```toml\n[advisory]\nid = \"RUSTSEC-2024-0001\"\n[versions]\npatched = [\">= 1.0.0\"]\n```\n\n# Title\n\nbody";
let front = extract_toml_front_matter(md).expect("front matter");
let doc: toml::Value = toml::from_str(front).unwrap();
assert_eq!(doc["advisory"]["id"].as_str(), Some("RUSTSEC-2024-0001"));
assert_eq!(
string_list(doc.get("versions").and_then(|v| v.get("patched"))),
vec![
"\
>= 1.0.0"
.trim_start()
]
);
}
#[test]
fn req_matches_handles_prerelease_versions() {
// `>= 0.10` must consider a later pre-release patched (semver excludes it).
let rc = semver::Version::parse("0.11.0-rc.4").unwrap();
assert!(
req_matches(&[">= 0.10.0".into()], &rc),
"0.11.0-rc.4 should satisfy >= 0.10.0"
);
// an earlier version is genuinely unpatched
let old = semver::Version::parse("0.9.0").unwrap();
assert!(
!req_matches(&[">= 0.10.0".into()], &old),
"0.9.0 must NOT satisfy >= 0.10.0"
);
}
#[test]
fn components_warehouse_first_uses_capture_then_falls_back() {
use crate::warehouse::iceberg::{IcebergWarehouse, SbomComponentRow};
let whdir = tempfile::tempdir().unwrap();
let wh = IcebergWarehouse::open(whdir.path()).unwrap();
// A repo dir named "myrepo" whose Cargo.lock provides the live fallback.
let repodir = tempfile::tempdir().unwrap();
let repo = repodir.path().join("myrepo");
std::fs::create_dir_all(&repo).unwrap();
std::fs::write(
repo.join("Cargo.lock"),
"[[package]]\nname = \"fallbackdep\"\nversion = \"9.9.9\"\n",
)
.unwrap();
// No Cargo.toml manifest → cargo metadata fails → Cargo.lock fallback.
// No warehouse capture yet → warehouse_first delegates to live components,
// which (no manifest) lands on the Cargo.lock fallback.
let (name, comps) = components_warehouse_first(&wh, &repo).unwrap();
assert_eq!(name, "myrepo");
assert!(
comps
.iter()
.any(|c| c.name == "fallbackdep" && c.version == "9.9.9")
);
// After a warehouse capture, warehouse_first serves THAT (not the lock).
wh.append_sbom_components(
"myrepo",
uuid::Uuid::new_v4(),
&[SbomComponentRow {
name: "cached".into(),
version: "1.2.3".into(),
license: "MIT".into(),
derived_from: "source".into(),
}],
)
.unwrap();
let (name, comps) = components_warehouse_first(&wh, &repo).unwrap();
assert_eq!(name, "myrepo");
assert_eq!(comps.len(), 1);
assert_eq!(comps[0].name, "cached");
assert_eq!(comps[0].license, "MIT");
}
#[test]
fn materialize_path_dep_siblings_links_missing_sibling() {
// repo `znippy` depends on `../zoomies` by path, but that relative path
// doesn't exist next to znippy in the headless scan layout — the sibling
// checkout instead lives flat under scan_root. We should link it in.
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
// scan_root/znippy/Cargo.toml with a path dep `../zoomies`
let znippy = root.join("znippy");
std::fs::create_dir_all(&znippy).unwrap();
std::fs::write(
znippy.join("Cargo.toml"),
"[package]\nname=\"znippy\"\nversion=\"0.1.0\"\n\n[dependencies]\nzoomies = { path = \"../zoomies\" }\n",
)
.unwrap();
// scan_root/zoomies exists (a sibling), but ../zoomies relative to znippy
// is scan_root/zoomies too here — so to actually exercise the link path,
// point the dep one level deeper where it is NOT present.
std::fs::write(
znippy.join("Cargo.toml"),
"[package]\nname=\"znippy\"\nversion=\"0.1.0\"\n\n[dependencies]\nzoomies = { path = \"vendor/zoomies\" }\n",
)
.unwrap();
let zoomies = root.join("zoomies");
std::fs::create_dir_all(&zoomies).unwrap();
std::fs::write(
zoomies.join("Cargo.toml"),
"[package]\nname=\"zoomies\"\nversion=\"0.1.0\"\n",
)
.unwrap();
// Before: vendor/zoomies/Cargo.toml is absent.
assert!(!znippy.join("vendor/zoomies/Cargo.toml").exists());
let n = materialize_path_dep_siblings(&znippy, root).unwrap();
assert_eq!(n, 1, "one sibling link created");
// After: the path dep resolves (through the symlink) to the sibling.
assert!(znippy.join("vendor/zoomies/Cargo.toml").exists());
// Idempotent: a second run links nothing new.
let n2 = materialize_path_dep_siblings(&znippy, root).unwrap();
assert_eq!(n2, 0, "already-linked sibling is not re-linked");
}
#[test]
fn materialize_is_noop_when_path_dep_already_resolves() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let a = root.join("a");
std::fs::create_dir_all(&a).unwrap();
let b = root.join("b");
std::fs::create_dir_all(&b).unwrap();
std::fs::write(
b.join("Cargo.toml"),
"[package]\nname=\"b\"\nversion=\"0.1.0\"\n",
)
.unwrap();
std::fs::write(
a.join("Cargo.toml"),
"[package]\nname=\"a\"\nversion=\"0.1.0\"\n\n[dependencies]\nb = { path = \"../b\" }\n",
)
.unwrap();
// `../b` already resolves → nothing to link.
let n = materialize_path_dep_siblings(&a, root).unwrap();
assert_eq!(n, 0);
}
/// The live "press Security on nornir" fix: a repo whose path-dep siblings
/// are MISSING from the scan root must have them CLONED in, so `cargo
/// metadata` can then resolve. Uses a local git repo as the "remote" (a
/// `file://` URL via the `url_for` closure) — real clone, no network.
#[cfg(feature = "net-scan")]
#[test]
fn clone_missing_sibling_makes_path_dep_resolve() {
let tmp = tempfile::tempdir().unwrap();
// The "remote": a git repo named `skade` that mirrors nornir's real
// layout — a repo root crate (`../skade`) PLUS a `skade/` member crate
// (`../skade/skade`), so both of nornir's path-deps are satisfiable.
let remote = tmp.path().join("remote");
let skade_src = remote.join("skade");
std::fs::create_dir_all(skade_src.join("skade")).unwrap();
std::fs::write(
skade_src.join("Cargo.toml"),
"[package]\nname=\"skade-katalog\"\nversion=\"0.1.0\"\nedition=\"2021\"\n",
)
.unwrap();
std::fs::write(
skade_src.join("skade").join("Cargo.toml"),
"[package]\nname=\"skade\"\nversion=\"0.4.0\"\nedition=\"2021\"\n",
)
.unwrap();
crate::gitio::init(&skade_src).unwrap();
crate::gitio::commit_all(&skade_src, "seed skade").unwrap();
// The scan root: only `nornir` is checked out, with the two path-deps
// pointing at siblings that are NOT present.
let scan_root = tmp.path().join("git");
let nornir = scan_root.join("nornir");
std::fs::create_dir_all(&nornir).unwrap();
std::fs::write(
nornir.join("Cargo.toml"),
"[package]\nname=\"nornir\"\nversion=\"0.4.0\"\nedition=\"2021\"\n\n\
[dependencies]\n\
skade = { path = \"../skade/skade\" }\n\
skade-katalog = { path = \"../skade\" }\n",
)
.unwrap();
// BEFORE: neither path-dep resolves.
assert!(!nornir.join("../skade/skade/Cargo.toml").exists());
// `url_for` hands back the local `file://` remote for the `skade` repo.
let remote_str = remote.to_string_lossy().into_owned();
let url_for = move |name: &str| Some(format!("file://{remote_str}/{name}"));
let cloned = clone_missing_path_dep_siblings(&nornir, &scan_root, &url_for).unwrap();
assert_eq!(cloned, 1, "the single missing `skade` repo is cloned once");
// AFTER: BOTH path-deps now resolve to real manifests.
assert!(
nornir.join("../skade/skade").join("Cargo.toml").exists(),
"`../skade/skade` (the skade crate) resolves after clone"
);
assert!(
nornir.join("../skade").join("Cargo.toml").exists(),
"`../skade` (the skade-katalog root crate) resolves after clone"
);
// Idempotent: a second prep clones nothing (sibling already present).
let again = clone_missing_path_dep_siblings(&nornir, &scan_root, &url_for).unwrap();
assert_eq!(again, 0, "already-present sibling is not re-cloned");
}
#[test]
fn verdict_bridge_blocks_vulnerable_component_and_passes_clean() {
use modgunn::core::Severity;
use modgunn::scan::{Advisory, AdvisoryDb};
// Warehouse-sourced advisory db (real severity): one High advisory for
// openssl@0.1.0. The vulnerable component Blocks; the clean one Passes.
let components = vec![
Component {
name: "openssl".into(),
version: "0.1.0".into(),
license: "MIT".into(),
},
Component {
name: "serde".into(),
version: "1.0.0".into(),
license: "MIT".into(),
},
];
let db = AdvisoryDb::new(
"db-rev-1",
vec![Advisory::basic(
"RUSTSEC-2024-0001",
"cargo",
"openssl",
vec!["0.1.0".into()],
Severity::High,
"use-after-free",
)],
);
let vs = verdict::verdicts_with(db, &components, Policy::default());
assert_eq!(vs.len(), 2);
let openssl = vs.iter().find(|v| v.artifact.name == "openssl").unwrap();
assert_eq!(openssl.decision, Decision::Block, "a High vuln blocks");
assert_eq!(openssl.findings[0].id, "RUSTSEC-2024-0001");
assert_eq!(
openssl.advisory_db_rev, "db-rev-1",
"the advisory SOURCE rev (the warehouse db rev) keys the cache"
);
let serde = vs.iter().find(|v| v.artifact.name == "serde").unwrap();
assert_eq!(serde.decision, Decision::Pass, "a clean component passes");
assert!(serde.findings.is_empty());
}
/// Bug #18: parse_osv_batch aligns results[] to the chunk by index and
/// extracts ids (querybatch carries no summary → blank).
#[test]
fn parse_osv_batch_aligns_results_to_chunk() {
let chunk = vec![
Component {
name: "a".into(),
version: "1.0.0".into(),
license: "MIT".into(),
},
Component {
name: "b".into(),
version: "2.0.0".into(),
license: "MIT".into(),
},
Component {
name: "c".into(),
version: "3.0.0".into(),
license: "MIT".into(),
},
];
// b (index 1) has two advisories; a and c have none.
let resp = r#"{"results":[
{},
{"vulns":[{"id":"RUSTSEC-2024-0001","modified":"x"},{"id":"GHSA-xxxx","modified":"y"}]},
{"vulns":[]}
]}"#;
let vulns = parse_osv_batch(resp, &chunk).unwrap();
assert_eq!(vulns.len(), 1, "only b has vulns");
assert_eq!(vulns[0].crate_name, "b");
assert_eq!(vulns[0].version, "2.0.0");
assert_eq!(vulns[0].ids, vec!["RUSTSEC-2024-0001", "GHSA-xxxx"]);
assert!(vulns[0].summary.is_empty(), "querybatch carries no summary");
assert!(vulns[0].informational.is_none());
}
/// The real-severity fix (was the all-High bug): a **Low** advisory (e.g. an
/// informational unmaintained/unsound/notice, which the importer maps to Low)
/// Warns, while a **High** advisory Blocks — the decision follows the warehouse
/// severity, not a blanket High.
#[test]
fn verdict_warns_on_low_severity_blocks_on_high() {
use modgunn::core::Severity;
use modgunn::scan::{Advisory, AdvisoryDb};
let components = vec![
Component {
name: "stale".into(),
version: "1.0.0".into(),
license: "MIT".into(),
},
Component {
name: "boom".into(),
version: "1.0.0".into(),
license: "MIT".into(),
},
];
let db = AdvisoryDb::new(
"rustsec:abc",
vec![
Advisory::basic(
"RUSTSEC-2021-0139",
"cargo",
"stale",
vec!["1.0.0".into()],
Severity::Low,
"unmaintained",
),
Advisory::basic(
"RUSTSEC-2024-9999",
"cargo",
"boom",
vec!["1.0.0".into()],
Severity::High,
"RCE",
),
],
);
let vs = verdict::verdicts_with(db, &components, Policy::default());
let stale = vs.iter().find(|v| v.artifact.name == "stale").unwrap();
assert_eq!(
stale.decision,
Decision::Warn,
"a Low advisory warns, not blocks"
);
let boom = vs.iter().find(|v| v.artifact.name == "boom").unwrap();
assert_eq!(boom.decision, Decision::Block, "a High advisory blocks");
}
/// End-to-end: append advisories to a real modgunn warehouse (the importer's
/// write shape) then source the verdict back from it — the Low advisory Warns
/// (real severity survives the round-trip), the High Blocks, the clean Passes.
#[test]
fn warehouse_sourced_verdict_uses_real_severity() {
use modgunn::core::Severity;
use modgunn::scan::Advisory;
let tmp = tempfile::tempdir().unwrap();
{
let wh = modgunn::warehouse::Warehouse::open(tmp.path()).unwrap();
wh.ensure_modgunn_tables().unwrap();
wh.append_advisories(
"rev-1",
&[
Advisory::basic(
"RUSTSEC-X",
"cargo",
"lowcrate",
vec!["1.0.0".into()],
Severity::Low,
"note",
),
Advisory::basic(
"RUSTSEC-Y",
"cargo",
"highcrate",
vec!["1.0.0".into()],
Severity::High,
"rce",
),
],
)
.unwrap();
} // drop the write handle before re-opening below (single catalog.redb open)
let components = vec![
Component {
name: "lowcrate".into(),
version: "1.0.0".into(),
license: "MIT".into(),
},
Component {
name: "highcrate".into(),
version: "1.0.0".into(),
license: "MIT".into(),
},
Component {
name: "cleancrate".into(),
version: "2.0.0".into(),
license: "MIT".into(),
},
];
let vs =
verdict::verdicts_from_warehouse(tmp.path(), &components, Policy::default()).unwrap();
let dec = |n: &str| vs.iter().find(|v| v.artifact.name == n).unwrap().decision;
assert_eq!(
dec("lowcrate"),
Decision::Warn,
"Low ⇒ Warn (real severity from warehouse)"
);
assert_eq!(dec("highcrate"), Decision::Block, "High ⇒ Block");
assert_eq!(dec("cleancrate"), Decision::Pass, "no advisory ⇒ Pass");
}
/// The live-OSV importer feed is airgap-safe by construction: an empty component
/// set short-circuits BEFORE any network call, so it can never fail or hang an
/// import that has nothing to query. (A populated set that can't reach OSV.dev
/// degrades the same way — [`osv_query`] returns empty on a network error.)
#[test]
fn gather_osv_advisories_empty_input_is_offline_safe() {
assert!(gather_osv_advisories(&[]).is_empty());
}
/// With no seeded rustsec mirror (the test env) and no fleet components, the
/// combined importer touches neither feed and reports nothing imported — the
/// offline, empty-fleet bootstrap case — never erroring.
#[test]
fn import_advisories_empty_corpus_imports_nothing() {
// The premise is STATED (an empty corpus), not inherited from the machine.
// This used to call the gathering entry point and assert 0, which only held
// where no advisory mirror existed — `advisory_db_path()` defaults to the
// absolute `/opt/nornir/advisory-db`, so on a box with the mirror seeded the
// test read 1096 real advisories and failed. It was asserting a property of
// the filesystem, not of the importer.
let tmp = tempfile::tempdir().unwrap();
let n = import_advisories(tmp.path(), Vec::new()).unwrap();
assert_eq!(n, 0, "an empty corpus imports nothing");
// …and nothing was created: an empty import must not even open a warehouse.
assert!(
std::fs::read_dir(tmp.path()).unwrap().next().is_none(),
"an empty import must leave the root untouched"
);
}
/// The counterpart, so the empty case above cannot pass vacuously: a NON-empty
/// corpus really does import, through the same seam.
#[test]
fn import_advisories_with_a_corpus_imports_it() {
use modgunn::core::Severity;
use modgunn::scan::Advisory;
let tmp = tempfile::tempdir().unwrap();
let corpus = vec![
Advisory::basic(
"RUSTSEC-A",
"cargo",
"acrate",
vec!["1.0.0".into()],
Severity::High,
"a",
),
Advisory::basic(
"CVE-B",
"cargo",
"bcrate",
vec!["2.0.0".into()],
Severity::Low,
"b",
),
];
let n = import_advisories(tmp.path(), corpus).unwrap();
assert_eq!(n, 2, "both advisories import");
}
/// Why the importer folds BOTH feeds under ONE `db_rev`: the warehouse's
/// `load_advisory_db` returns only the rows of the greatest rev, so two feeds under
/// two revs make one silently shadow the other — but under a single rev both
/// survive. Locks the load-time contract the combined importer relies on (if it
/// ever regressed, OSV or rustsec would vanish from every verdict).
#[test]
fn both_feeds_survive_only_under_one_rev() {
use modgunn::core::Severity;
use modgunn::scan::Advisory;
let rustsec = Advisory::basic(
"RUSTSEC-1",
"cargo",
"acrate",
vec!["1.0.0".into()],
Severity::High,
"a",
);
let osv = Advisory::basic(
"CVE-2",
"cargo",
"bcrate",
vec!["1.0.0".into()],
Severity::Low,
"b",
);
// Two revs ⇒ only the greatest rev's feed loads (the shadowing this avoids:
// "rustsec:2" > "osv:1" lexically, so the OSV feed is dropped).
let split = tempfile::tempdir().unwrap();
{
let wh = modgunn::warehouse::Warehouse::open(split.path()).unwrap();
wh.ensure_modgunn_tables().unwrap();
wh.append_advisories("osv:1", std::slice::from_ref(&osv))
.unwrap();
wh.append_advisories("rustsec:2", std::slice::from_ref(&rustsec))
.unwrap();
}
{
let wh = modgunn::warehouse::Warehouse::open(split.path()).unwrap();
let pkgs: Vec<String> = wh
.load_advisory_db(Some("cargo"))
.unwrap()
.advisories
.iter()
.map(|a| a.package.clone())
.collect();
assert!(
pkgs.iter().any(|p| p == "acrate") && !pkgs.iter().any(|p| p == "bcrate"),
"two revs ⇒ only the greatest rev survives (OSV shadowed): {pkgs:?}"
);
}
// One rev ⇒ BOTH feeds load — exactly what import_advisories_into_warehouse does.
let joined = tempfile::tempdir().unwrap();
{
let wh = modgunn::warehouse::Warehouse::open(joined.path()).unwrap();
wh.ensure_modgunn_tables().unwrap();
wh.append_advisories("rustsec:2", &[rustsec, osv]).unwrap();
}
{
let wh = modgunn::warehouse::Warehouse::open(joined.path()).unwrap();
let pkgs: Vec<String> = wh
.load_advisory_db(Some("cargo"))
.unwrap()
.advisories
.iter()
.map(|a| a.package.clone())
.collect();
assert!(
pkgs.iter().any(|p| p == "acrate") && pkgs.iter().any(|p| p == "bcrate"),
"one rev ⇒ both feeds coexist: {pkgs:?}"
);
}
}
/// Test #13: verdicts() under a NON-default Policy (license deny + provenance
/// Require). A GPL component Blocks via MODGUNN-LICENSE-DENIED; an MIT
/// component ALSO Blocks via MODGUNN-PROVENANCE-MISSING (artifact() hardcodes
/// unsigned provenance) — and Passes once provenance is Ignored, isolating the
/// cause.
#[test]
fn verdicts_under_license_deny_and_provenance_require() {
use modgunn::scan::{AdvisoryDb, LicensePolicy, ProvenancePolicy};
let components = vec![
Component {
name: "gpl".into(),
version: "1.0.0".into(),
license: "GPL-3.0".into(),
},
Component {
name: "mit".into(),
version: "1.0.0".into(),
license: "MIT".into(),
},
];
// No vulns — license + provenance policy alone drive the decision.
let db = || AdvisoryDb::new("rustsec:abc", vec![]);
let policy = |prov| Policy {
license: LicensePolicy {
allow: vec!["MIT".into()],
deny: vec!["GPL-3.0".into()],
on_unknown: Decision::Warn,
},
provenance: prov,
..Policy::default()
};
let vs = verdict::verdicts_with(db(), &components, policy(ProvenancePolicy::Require));
let gpl = vs.iter().find(|v| v.artifact.name == "gpl").unwrap();
assert_eq!(gpl.decision, Decision::Block);
assert!(
gpl.findings
.iter()
.any(|f| f.id.contains("MODGUNN-LICENSE-DENIED")),
"GPL must carry a license-denied finding: {:?}",
gpl.findings
);
let mit = vs.iter().find(|v| v.artifact.name == "mit").unwrap();
assert_eq!(
mit.decision,
Decision::Block,
"unsigned provenance Required ⇒ Block"
);
assert!(
mit.findings
.iter()
.any(|f| f.id == "MODGUNN-PROVENANCE-MISSING")
);
// Isolate the cause: with provenance Ignored, the allowed MIT crate Passes.
let vs2 = verdict::verdicts_with(db(), &components, policy(ProvenancePolicy::Ignore));
let mit2 = vs2.iter().find(|v| v.artifact.name == "mit").unwrap();
assert_eq!(
mit2.decision,
Decision::Pass,
"MIT allowed + provenance ignored ⇒ Pass"
);
}
/// Test #15: a Vuln carrying TWO ids yields one verdict with two findings;
/// empty components → no verdicts; a Vuln for a crate absent from components
/// contributes nothing.
#[test]
fn verdicts_multi_id_and_empty_cases() {
use modgunn::core::Severity;
use modgunn::scan::{Advisory, AdvisoryDb};
// Two advisories matching the same crate@version ⇒ one verdict, two findings.
let db = || {
AdvisoryDb::new(
"rustsec:abc",
vec![
Advisory::basic(
"RUSTSEC-2024-0001",
"cargo",
"x",
vec!["1.0.0".into()],
Severity::High,
"a",
),
Advisory::basic(
"GHSA-yyyy",
"cargo",
"x",
vec!["1.0.0".into()],
Severity::High,
"b",
),
],
)
};
let x = vec![Component {
name: "x".into(),
version: "1.0.0".into(),
license: "MIT".into(),
}];
let vs = verdict::verdicts_with(db(), &x, Policy::default());
assert_eq!(vs.len(), 1);
assert_eq!(
vs[0].findings.len(),
2,
"both matching advisories fan out into findings"
);
assert_eq!(vs[0].decision, Decision::Block);
// Empty components → no verdicts.
assert!(verdict::verdicts_with(db(), &[], Policy::default()).is_empty());
// An advisory for a crate NOT in components contributes no finding.
let ghost_db = AdvisoryDb::new(
"rustsec:abc",
vec![Advisory::basic(
"RUSTSEC-2024-0002",
"cargo",
"ghost",
vec!["9.9.9".into()],
Severity::High,
"nope",
)],
);
let y = vec![Component {
name: "y".into(),
version: "1.0.0".into(),
license: "MIT".into(),
}];
let vs3 = verdict::verdicts_with(ghost_db, &y, Policy::default());
assert_eq!(vs3.len(), 1, "one verdict for the one component y");
assert!(
vs3[0].findings.is_empty(),
"ghost's advisory does not attach to y"
);
assert_eq!(vs3[0].decision, Decision::Pass);
}
#[test]
fn verdict_bridge_maps_noassertion_license_to_unknown() {
let c = Component {
name: "x".into(),
version: "1.0.0".into(),
license: "NOASSERTION".into(),
};
let art = verdict::artifact(&c);
assert_eq!(art.license, None, "NOASSERTION ⇒ unknown license");
assert!(!art.provenance.signed, "nornir SBOM path is unsigned");
assert_eq!(art.artifact.ecosystem, "cargo");
assert_eq!(art.artifact.blob_sha256, "pkg:cargo/x@1.0.0");
}
/// N4: the cargo CycloneDX output stays byte-stable after `to_cyclonedx`
/// delegates to `modgunn::sbom` (same shape, `pkg:cargo/…` purls).
#[test]
fn to_cyclonedx_cargo_output_is_stable() {
let report = SecurityReport {
repo: "myrepo".into(),
advisory_db_rev: "rev".into(),
components: vec![Component {
name: "serde".into(),
version: "1.0.0".into(),
license: "MIT OR Apache-2.0".into(),
}],
vulns: vec![Vuln {
crate_name: "serde".into(),
version: "1.0.0".into(),
ids: vec!["RUSTSEC-2024-0001".into()],
summary: "x".into(),
informational: None,
}],
};
let want = json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": { "component": { "type": "application", "name": "myrepo" } },
"components": [{
"type": "library", "name": "serde", "version": "1.0.0",
"purl": "pkg:cargo/serde@1.0.0",
"licenses": [{ "license": { "name": "MIT OR Apache-2.0" } }],
}],
"vulnerabilities": [{
"id": "RUSTSEC-2024-0001",
"source": { "name": "OSV", "url": "https://osv.dev/vulnerability/RUSTSEC-2024-0001" },
"affects": [{ "ref": "pkg:cargo/serde@1.0.0" }],
"description": "x",
}],
});
assert_eq!(report.to_cyclonedx(), want);
}
#[test]
fn affected_logic_unpatched_between_ranges() {
// patched >= 0.39, unaffected < 0.15 → 0.20 is affected (the RUSTSEC aws-lc case)
let v = semver::Version::parse("0.20.0").unwrap();
let patched = vec![">= 0.39.0".to_string()];
let unaffected = vec!["< 0.15.0".to_string()];
let is_affected = !req_matches(&patched, &v) && !req_matches(&unaffected, &v);
assert!(is_affected);
// a patched version is not affected
let v2 = semver::Version::parse("0.40.0").unwrap();
assert!(req_matches(&patched, &v2));
}
}