use serde_json::{json, Value};
use traits::ArtifactFormat;
pub use modgunn::core::{ArtifactRef, Decision, Finding, Severity, Verdict};
pub use modgunn::scan::{
Advisory, AdvisoryDb, Artifact, LicensePolicy, OsvScanner, Policy, Provenance,
ProvenancePolicy, ScanEngine,
};
pub fn ecosystem_for(repo_type: &str) -> String {
match ArtifactFormat::from_format_str(repo_type) {
Some(fmt) => ecosystem_of(fmt).to_string(),
None => repo_type.to_ascii_lowercase(),
}
}
fn ecosystem_of(fmt: ArtifactFormat) -> &'static str {
match fmt {
ArtifactFormat::Rust => "cargo",
ArtifactFormat::Pip => "pip",
ArtifactFormat::Maven3 => "maven",
ArtifactFormat::Go => "go",
ArtifactFormat::Nuget => "nuget",
ArtifactFormat::Npm => "npm",
ArtifactFormat::Gem => "gem",
ArtifactFormat::Deb => "deb",
ArtifactFormat::Rpm => "rpm",
ArtifactFormat::Helm => "helm",
ArtifactFormat::Docker => "docker",
ArtifactFormat::Conda => "conda",
ArtifactFormat::Composer => "composer",
ArtifactFormat::Znippy => "znippy",
ArtifactFormat::Raw => "raw",
}
}
pub fn ecosystem_for_format(fmt: ArtifactFormat) -> String {
ecosystem_of(fmt).to_string()
}
fn is_metadata_path(path: &str) -> bool {
let lower = path.to_ascii_lowercase();
let base = lower.rsplit('/').next().unwrap_or(&lower);
lower.contains("/index/")
|| lower.starts_with("index/")
|| base.ends_with(".json")
|| base.ends_with(".xml")
|| base == "packages"
|| base == "packages.gz"
|| base == "release"
|| base == "release.gz"
|| base.starts_with("repomd")
|| base.starts_with("repodata")
|| base == "config.json"
}
pub fn parse_coords(path: &str) -> Option<(String, String)> {
if is_metadata_path(path) {
return None;
}
let base = path.rsplit('/').next().unwrap_or(path);
let exts = [
".tar.gz", ".tar.zst", ".tar.bz2", ".crate", ".whl", ".tgz", ".jar",
".pom", ".war", ".gem", ".deb", ".rpm", ".nupkg", ".conda", ".zip",
".tar", ".gz", ".tbz",
];
let stem = exts
.iter()
.find_map(|e| base.strip_suffix(e))
.unwrap_or(base);
if stem.is_empty() {
return None;
}
let bytes = stem.as_bytes();
let mut split = None;
for i in 0..bytes.len().saturating_sub(1) {
if bytes[i] == b'-' && bytes[i + 1].is_ascii_digit() {
split = Some(i);
break;
}
}
let idx = split?;
let name = &stem[..idx];
let rest = &stem[idx + 1..];
let version = rest.split('-').next().unwrap_or(rest);
if name.is_empty() || version.is_empty() {
return None;
}
Some((name.to_string(), version.to_string()))
}
pub fn artifact_for(
ecosystem: &str,
path: &str,
license: Option<String>,
signed: bool,
) -> Option<Artifact> {
let (name, version) = parse_coords(path)?;
let provenance = if signed {
Provenance::signed_by("holger-archive")
} else {
Provenance::unsigned()
};
Some(Artifact::new(
ArtifactRef {
ecosystem: ecosystem.to_string(),
name,
version,
blob_sha256: String::new(),
},
license,
provenance,
))
}
pub fn artifact_from_coords(ecosystem: &str, name: &str, version: &str, signed: bool) -> Artifact {
let provenance = if signed {
Provenance::signed_by("holger-archive")
} else {
Provenance::unsigned()
};
Artifact::new(
ArtifactRef {
ecosystem: ecosystem.to_string(),
name: name.to_string(),
version: version.to_string(),
blob_sha256: String::new(),
},
None,
provenance,
)
}
pub fn demo_advisory_db() -> AdvisoryDb {
let mut log4shell = Advisory::basic(
"CVE-2021-44228",
"maven",
"log4j-core",
vec!["2.14.1".into(), "2.14.0".into(), "2.15.0".into()],
Severity::Critical,
"Log4Shell: JNDI RCE in Apache Log4j2",
);
log4shell.kev = true; AdvisoryDb::new(
"holger-demo-db-v1",
vec![
Advisory::basic(
"RUSTSEC-2023-0044",
"cargo",
"openssl",
vec!["0.10.55".into()],
Severity::High,
"use-after-free in openssl::x509",
),
Advisory::basic(
"GHSA-pad-left",
"npm",
"left-pad",
vec![], Severity::Low,
"left-pad unpublish incident",
),
Advisory::basic(
"CVE-2018-18074",
"pip",
"requests",
vec!["2.19.0".into()],
Severity::Medium,
"requests leaks Authorization header on redirect",
),
log4shell,
],
)
}
pub fn demo_policy() -> Policy {
Policy {
version: "holger-demo-v1".into(),
license: LicensePolicy {
allow: Vec::new(),
deny: vec!["GPL-3.0".into(), "AGPL-3.0".into()],
on_unknown: Decision::Pass,
},
provenance: ProvenancePolicy::Ignore,
..Default::default()
}
}
pub fn demo_scanner() -> OsvScanner {
OsvScanner::new(demo_policy(), demo_advisory_db())
}
#[derive(Debug, Clone, Default)]
pub struct ScanReport {
pub ecosystem: String,
pub verdicts: Vec<Verdict>,
pub pass: usize,
pub warn: usize,
pub block: usize,
pub skipped: usize,
}
impl ScanReport {
pub fn decision(&self) -> Decision {
self.verdicts.iter().map(|v| v.decision).max().unwrap_or(Decision::Pass)
}
pub fn state_json(&self) -> Value {
let decision = match self.decision() {
Decision::Pass => "pass",
Decision::Warn => "warn",
Decision::Block => "block",
};
let findings: Vec<Value> = self
.verdicts
.iter()
.filter(|v| !v.findings.is_empty())
.map(|v| {
json!({
"name": v.artifact.name,
"version": v.artifact.version,
"decision": match v.decision {
Decision::Pass => "pass",
Decision::Warn => "warn",
Decision::Block => "block",
},
"findings": v.findings.iter().map(|f| json!({
"id": f.id,
"severity": format!("{:?}", f.severity),
"summary": f.summary,
})).collect::<Vec<_>>(),
})
})
.collect();
json!({
"ecosystem": self.ecosystem,
"scanned": self.verdicts.len(),
"skipped": self.skipped,
"pass": self.pass,
"warn": self.warn,
"block": self.block,
"decision": decision,
"findings": findings,
})
}
}
pub fn scan_listing<'a, I>(ecosystem: &str, files: I, scanner: &OsvScanner) -> ScanReport
where
I: IntoIterator<Item = &'a str>,
{
let mut report = ScanReport {
ecosystem: ecosystem.to_string(),
..Default::default()
};
for path in files {
match artifact_for(ecosystem, path, None, false) {
Some(art) => {
let v = scanner.scan(&art);
match v.decision {
Decision::Pass => report.pass += 1,
Decision::Warn => report.warn += 1,
Decision::Block => report.block += 1,
}
report.verdicts.push(v);
}
None => report.skipped += 1,
}
}
report
}
pub fn advisory_db_from_osv_file(path: &str) -> anyhow::Result<AdvisoryDb> {
let text = std::fs::read_to_string(path)
.map_err(|e| anyhow::anyhow!("reading OSV advisory file '{path}': {e}"))?;
let advisories = modgunn::osv::parse_doc(&text)
.map_err(|e| anyhow::anyhow!("parsing OSV advisory file '{path}': {e}"))?;
Ok(AdvisoryDb::new(format!("osv-file:{path}"), advisories))
}
pub fn promotion_policy(deny_licenses: Vec<String>) -> Policy {
Policy {
version: "holger-promote-v1".into(),
license: LicensePolicy {
allow: Vec::new(),
deny: deny_licenses,
on_unknown: Decision::Pass,
},
provenance: ProvenancePolicy::Ignore,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn ecosystem_mapping_is_canonical() {
assert_eq!(ecosystem_for("rust"), "cargo");
assert_eq!(ecosystem_for("cargo"), "cargo");
assert_eq!(ecosystem_for("maven2"), "maven");
assert_eq!(ecosystem_for("pypi"), "pip");
assert_eq!(ecosystem_for("npm"), "npm");
assert_eq!(ecosystem_for("WeirdFmt"), "weirdfmt");
}
#[test]
fn parse_coords_across_ecosystems() {
assert_eq!(parse_coords("crates/serde-1.0.0.crate"), Some(("serde".into(), "1.0.0".into())));
assert_eq!(parse_coords("left-pad-1.3.0.tgz"), Some(("left-pad".into(), "1.3.0".into())));
assert_eq!(
parse_coords("requests-2.19.0-py3-none-any.whl"),
Some(("requests".into(), "2.19.0".into()))
);
assert_eq!(
parse_coords("org/apache/logging/log4j/log4j-core/2.14.1/log4j-core-2.14.1.jar"),
Some(("log4j-core".into(), "2.14.1".into()))
);
assert_eq!(parse_coords("openssl-0.10.55.crate"), Some(("openssl".into(), "0.10.55".into())));
}
#[test]
fn parse_coords_skips_metadata() {
assert_eq!(parse_coords("index/config.json"), None);
assert_eq!(parse_coords("repodata.json"), None);
assert_eq!(parse_coords("dists/stable/Packages"), None);
assert_eq!(parse_coords("repomd.xml"), None);
}
#[test]
fn clean_listing_passes() {
let s = demo_scanner();
let report = scan_listing(
"cargo",
["serde-1.0.0.crate", "tokio-1.40.0.crate"],
&s,
);
assert_eq!(report.decision(), Decision::Pass);
assert_eq!(report.pass, 2);
assert_eq!(report.warn, 0);
assert_eq!(report.block, 0);
}
#[test]
fn known_high_vuln_blocks() {
let s = demo_scanner();
let report = scan_listing("cargo", ["openssl-0.10.55.crate", "serde-1.0.0.crate"], &s);
assert_eq!(report.decision(), Decision::Block);
assert_eq!(report.block, 1);
assert_eq!(report.pass, 1);
let blocked = report.verdicts.iter().find(|v| v.decision == Decision::Block).unwrap();
assert_eq!(blocked.findings[0].id, "RUSTSEC-2023-0044");
}
#[test]
fn low_npm_advisory_warns_all_versions() {
let s = demo_scanner();
let report = scan_listing("npm", ["left-pad-1.3.0.tgz"], &s);
assert_eq!(report.decision(), Decision::Warn);
assert_eq!(report.warn, 1);
}
#[test]
fn log4shell_kev_blocks_as_critical() {
let s = demo_scanner();
let report = scan_listing("maven", ["log4j-core-2.14.1.jar"], &s);
assert_eq!(report.decision(), Decision::Block);
let v = &report.verdicts[0];
assert_eq!(v.findings[0].id, "CVE-2021-44228");
assert_eq!(v.findings[0].severity, Severity::Critical);
assert!(v.findings[0].summary.contains("[KEV]"));
}
#[test]
fn metadata_files_are_skipped_not_scanned() {
let s = demo_scanner();
let report = scan_listing(
"cargo",
["serde-1.0.0.crate", "index/config.json", "crates.io-index/cfg"],
&s,
);
assert_eq!(report.verdicts.len(), 1);
assert!(report.skipped >= 1);
}
#[test]
fn all_repository_types_map_and_scan_clean() {
let cases: &[(&str, &str)] = &[
("rust", "serde-1.0.0.crate"),
("pip", "flask-3.0.0-py3-none-any.whl"),
("maven3", "com/foo/bar/1.0/bar-1.0.jar"),
("go", "golang.org/x/text-0.14.0.zip"),
("nuget", "Newtonsoft.Json-13.0.3.nupkg"),
("npm", "express-4.18.2.tgz"),
("gem", "rails-7.1.0.gem"),
("deb", "nginx-1.24.0.deb"),
("rpm", "httpd-2.4.57.rpm"),
("helm", "mychart-1.2.3.tgz"),
("docker", "alpine-3.20.tar"),
("conda", "numpy-1.26.0.conda"),
("composer", "monolog-2.9.0.zip"),
("znippy", "bundle-1.0.0.crate"),
];
let s = demo_scanner();
for (repo_type, file) in cases {
let eco = ecosystem_for(repo_type);
assert!(!eco.is_empty(), "{repo_type} has no ecosystem label");
let report = scan_listing(&eco, [*file], &s);
assert_eq!(
report.decision(),
Decision::Pass,
"clean {repo_type} artifact {file} (eco {eco}) should Pass: {report:?}"
);
}
}
#[test]
fn verdict_states_pass_warn_block_and_strongest_wins() {
let s = demo_scanner();
assert_eq!(scan_listing("cargo", ["serde-1.0.0.crate"], &s).decision(), Decision::Pass);
assert_eq!(scan_listing("npm", ["left-pad-1.3.0.tgz"], &s).decision(), Decision::Warn);
assert_eq!(scan_listing("cargo", ["openssl-0.10.55.crate"], &s).decision(), Decision::Block);
let mixed = scan_listing(
"cargo",
["serde-1.0.0.crate", "openssl-0.10.55.crate"],
&s,
);
assert_eq!(mixed.decision(), Decision::Block);
assert_eq!(mixed.pass, 1);
assert_eq!(mixed.block, 1);
}
#[test]
fn report_state_json_carries_counts_and_findings() {
let s = demo_scanner();
let report = scan_listing("cargo", ["openssl-0.10.55.crate", "serde-1.0.0.crate"], &s);
let j = report.state_json();
assert_eq!(j["ecosystem"], "cargo");
assert_eq!(j["scanned"], 2);
assert_eq!(j["block"], 1);
assert_eq!(j["decision"], "block");
assert!(j["findings"].as_array().unwrap().iter().any(|f| f["name"] == "openssl"));
}
}