use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use serde_json::{json, Value};
#[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,
pub informational: Option<String>,
}
pub struct SecurityReport {
pub repo: String,
pub components: Vec<Component>,
pub vulns: Vec<Vuln>,
pub advisory_db_rev: String,
}
impl SecurityReport {
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()
}
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| {
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)
}
}
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();
if !repo.is_dir() {
anyhow::bail!(
"no checkout for `{repo_name}` at {} — is the repo name/case right and the workspace synced?",
repo.display()
);
}
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");
let lock = repo.join("Cargo.lock");
if lock.is_file() {
return Ok((repo_name, cargo_lock_components(repo)?));
}
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)?));
}
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)
);
}
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."
)
}
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())
}
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())
}
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))
}
_ => components(repo),
}
}
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))
}
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()
);
}
}
pub fn warm_resolve(repo: &Path) -> Result<(String, Vec<Component>)> {
components(repo)
}
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(),
})
.collect();
wh.append_sbom_components(repo_name, uuid::Uuid::new_v4(), &rows)
.with_context(|| format!("persist sbom components for {repo_name}"))?;
Ok(())
}
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), };
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;
};
let target = repo.join(path);
if target.join("Cargo.toml").exists() {
continue; }
let leaf = Path::new(path)
.file_name()
.map(|s| s.to_string_lossy().into_owned())
.unwrap_or_else(|| dep_name.clone());
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)
}
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;
};
if repo.join(path).join("Cargo.toml").exists() {
continue;
}
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
}
pub fn dep_clone_base() -> String {
std::env::var("NORNIR_DEP_CLONE_BASE")
.unwrap_or_else(|_| "git@codeberg.org:nordisk".to_string())
}
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,
})
}
#[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;
};
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; }
let dest = scan_root.join(&repo_name);
if dest.join("Cargo.toml").exists() {
continue;
}
let url =
url_for(&repo_name).unwrap_or_else(|| format!("{}/{}", dep_clone_base(), repo_name));
match crate::gitio::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)
}
#[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)
}
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)
}
#[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)
}
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() })
}
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(),
}
}
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),
}
}
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)
}
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::gitio::clone_or_fetch(&url, &dest, None)
.with_context(|| format!("clone/fetch advisory-db from {url}"))?;
Ok(dest)
}
pub fn gather_rustsec_advisories() -> Vec<modgunn::scan::Advisory> {
let Some(db) = advisory_db_path() else {
return Vec::new(); };
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
}
fn fetch_osv_record(id: &str) -> Option<String> {
let url = format!("https://api.osv.dev/v1/vulns/{id}");
match ureq::get(&url).call() {
Ok(resp) => resp.into_string().ok(),
Err(e) => {
eprintln!("nornir-security: OSV.dev fetch failed for {id} ({e}); skipping");
None
}
}
}
pub fn gather_osv_advisories(components: &[Component]) -> Vec<modgunn::scan::Advisory> {
if components.is_empty() {
return Vec::new();
}
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(),
};
let docs: Vec<String> = ids.iter().filter_map(|id| fetch_osv_record(id)).collect();
modgunn::osv::parse_and_merge(docs)
}
pub fn import_advisories_into_warehouse(root: &Path, components: &[Component]) -> Result<usize> {
let mut advisories = gather_rustsec_advisories();
advisories.extend(gather_osv_advisories(components));
if advisories.is_empty() {
return Ok(0); }
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)
}
pub fn import_advisory_db_into_warehouse(root: &Path) -> Result<usize> {
import_advisories_into_warehouse(root, &[])
}
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 {
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)
})
}
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()
}
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();
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 };
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() {
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)
}
pub fn osv_query(components: &[Component]) -> Result<Vec<Vuln>> {
if components.is_empty() {
return Ok(Vec::new());
}
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 ureq::post("https://api.osv.dev/v1/querybatch")
.set("Content-Type", "application/json")
.send_string(&body)
{
Ok(r) => r,
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_string().context("read OSV response")?;
vulns.extend(parse_osv_batch(&resp_str, chunk).context("parse OSV response")?);
}
Ok(vulns)
}
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(),
summary: String::new(),
informational: None,
});
}
}
Ok(vulns)
}
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;
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)
}
pub use modgunn::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
pub use modgunn::scan::{
Advisory, AdvisoryDb, Artifact, OsvScanner, Policy, Provenance, ScanEngine,
};
pub mod verdict {
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};
fn purl(name: &str, version: &str) -> String {
format!("pkg:cargo/{name}@{version}")
}
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(),
)
}
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()
}
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() {
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() {
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() {
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() {
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");
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();
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();
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"));
wh.append_sbom_components(
"myrepo",
uuid::Uuid::new_v4(),
&[SbomComponentRow { name: "cached".into(), version: "1.2.3".into(), license: "MIT".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() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
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();
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();
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");
assert!(znippy.join("vendor/zoomies/Cargo.toml").exists());
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();
let n = materialize_path_dep_siblings(&a, root).unwrap();
assert_eq!(n, 0);
}
#[cfg(feature = "net-scan")]
#[test]
fn clone_missing_sibling_makes_path_dep_resolve() {
let tmp = tempfile::tempdir().unwrap();
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();
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();
assert!(!nornir.join("../skade/skade/Cargo.toml").exists());
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");
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"
);
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};
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());
}
#[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() },
];
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());
}
#[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");
}
#[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();
} 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");
}
#[test]
fn gather_osv_advisories_empty_input_is_offline_safe() {
assert!(gather_osv_advisories(&[]).is_empty());
}
#[test]
fn import_advisories_empty_corpus_imports_nothing() {
let tmp = tempfile::tempdir().unwrap();
let n = import_advisories_into_warehouse(tmp.path(), &[]).unwrap();
assert_eq!(n, 0, "no mirror + no fleet ⇒ nothing imported");
}
#[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");
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:?}"
);
}
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]
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() },
];
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"));
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]
fn verdicts_multi_id_and_empty_cases() {
use modgunn::core::Severity;
use modgunn::scan::{Advisory, AdvisoryDb};
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);
assert!(verdict::verdicts_with(db(), &[], Policy::default()).is_empty());
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");
}
#[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() {
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);
let v2 = semver::Version::parse("0.40.0").unwrap();
assert!(req_matches(&patched, &v2));
}
}