use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{Context, Result};
use serde_json::{json, Value};
#[derive(Clone)]
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 struct SecurityReport {
pub repo: String,
pub components: Vec<Component>,
pub vulns: Vec<Vuln>,
}
fn purl(name: &str, version: &str) -> String {
format!("pkg:cargo/{name}@{version}")
}
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 {
json!({
"bomFormat": "CycloneDX",
"specVersion": "1.5",
"version": 1,
"metadata": { "component": { "type": "application", "name": self.repo } },
"components": self.components.iter().map(|c| json!({
"type": "library",
"name": c.name,
"version": c.version,
"purl": purl(&c.name, &c.version),
"licenses": [{ "license": { "name": c.license } }],
})).collect::<Vec<_>>(),
"vulnerabilities": self.vulns.iter().flat_map(|v| {
let (name, ver) = (v.crate_name.clone(), v.version.clone());
let summary = v.summary.clone();
v.ids.iter().map(move |id| json!({
"id": id,
"source": { "name": "OSV", "url": format!("https://osv.dev/vulnerability/{id}") },
"affects": [{ "ref": purl(&name, &ver) }],
"description": summary,
})).collect::<Vec<_>>()
}).collect::<Vec<_>>(),
})
}
}
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();
match cargo_metadata_components(repo) {
Ok(c) => Ok((repo_name, c)),
Err(e) => {
eprintln!("nornir-security: cargo metadata unavailable ({e:#}); using Cargo.lock");
Ok((repo_name, cargo_lock_components(repo)?))
}
}
}
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 {
if let Err(e) = materialize_path_dep_siblings(repo, root) {
eprintln!("nornir-security: path-dep sibling materialize skipped for {}: {e:#}", repo.display());
}
}
let (repo_name, comps) = components(repo)?;
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((repo_name, comps))
}
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)
}
#[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 })
}
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)
}
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();
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());
}
}
}
if !ids.is_empty() {
out.push(Vuln { crate_name: c.name.clone(), version: c.version.clone(), ids, summary });
}
}
Ok(out)
}
pub fn osv_query(components: &[Component]) -> Result<Vec<Vuln>> {
if components.is_empty() {
return Ok(Vec::new());
}
let queries: Vec<Value> = components
.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")?;
let resp: Value = serde_json::from_str(&resp_str).context("parse OSV response")?;
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 >= components.len() {
continue;
}
let c = &components[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: vs.first().and_then(|v| v["summary"].as_str()).unwrap_or_default().to_string(),
});
}
}
Ok(vulns)
}
#[cfg(test)]
mod tests {
use super::*;
#[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);
}
#[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));
}
}