use std::collections::{HashMap, HashSet};
use std::io::Read;
use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::{DateTime, Utc};
use holger_update_bundle::digest::sha256_hex;
use modgunn::scan::Advisory;
use crate::set::{AdvisorySet, SourceRecord};
pub const OSV_BUCKET: &str = "https://osv-vulnerabilities.storage.googleapis.com";
pub const NVD_FEEDS: &str = "https://nvd.nist.gov/feeds/json/cve/2.0";
pub const KEV_URL: &str =
"https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
pub const DEFAULT_ECOSYSTEMS: &[&str] = &[
"PyPI",
"crates.io",
"npm",
"Maven",
"Go",
"NuGet",
"RubyGems",
"Packagist",
"Wolfi",
"Chainguard",
];
const MAX_OSV_RECORD: usize = 16 * 1024 * 1024;
const MAX_OSV_MEMBERS: usize = 4_000_000;
const MAX_NVD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FeedKind {
Osv { ecosystem: String },
Nvd { name: String },
Kev,
}
impl FeedKind {
pub fn label(&self) -> String {
match self {
FeedKind::Osv { ecosystem } => format!("osv:{ecosystem}"),
FeedKind::Nvd { name } => format!("nvd:{name}"),
FeedKind::Kev => "kev".into(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FeedSpec {
pub kind: FeedKind,
pub uri: String,
}
impl FeedSpec {
pub fn osv(ecosystem: &str) -> Self {
Self {
kind: FeedKind::Osv {
ecosystem: ecosystem.to_string(),
},
uri: format!("{OSV_BUCKET}/{ecosystem}/all.zip"),
}
}
pub fn nvd(name: &str) -> Self {
Self {
kind: FeedKind::Nvd {
name: name.to_string(),
},
uri: format!("{NVD_FEEDS}/nvdcve-2.0-{name}.json.gz"),
}
}
pub fn kev() -> Self {
Self {
kind: FeedKind::Kev,
uri: KEV_URL.to_string(),
}
}
pub fn default_set(ecosystems: &[String], this_year: i32) -> Vec<FeedSpec> {
let mut out: Vec<FeedSpec> = ecosystems.iter().map(|e| FeedSpec::osv(e)).collect();
for y in 2002..=this_year {
out.push(FeedSpec::nvd(&y.to_string()));
}
out.push(FeedSpec::nvd("modified"));
out.push(FeedSpec::kev());
out
}
pub fn from_dir(dir: &Path) -> Result<Vec<FeedSpec>> {
let mut names: Vec<String> = std::fs::read_dir(dir)
.with_context(|| format!("read feeds dir {}", dir.display()))?
.filter_map(|e| e.ok())
.filter(|e| e.path().is_file())
.map(|e| e.file_name().to_string_lossy().into_owned())
.collect();
names.sort();
let mut out = Vec::new();
for n in names {
let uri = format!("file:{}", dir.join(&n).display());
let stem = n.strip_suffix(".gz").unwrap_or(&n);
let stem = stem
.strip_suffix(".zip")
.or_else(|| stem.strip_suffix(".json"))
.unwrap_or(stem);
let kind = if let Some(eco) = stem.strip_prefix("osv-") {
FeedKind::Osv {
ecosystem: eco.to_string(),
}
} else if let Some(name) = stem.strip_prefix("nvd-") {
FeedKind::Nvd {
name: name.to_string(),
}
} else if stem == "kev" {
FeedKind::Kev
} else {
continue;
};
out.push(FeedSpec { kind, uri });
}
if out.is_empty() {
bail!(
"no feed files in {} (expected osv-<Ecosystem>.zip|.json, nvd-<name>.json[.gz], kev.json)",
dir.display()
);
}
Ok(out)
}
}
#[derive(Debug, Clone)]
pub struct RawFeed {
pub spec: FeedSpec,
pub bytes: Vec<u8>,
pub fetched_at: DateTime<Utc>,
}
impl RawFeed {
pub fn read_file(spec: FeedSpec) -> Result<Self> {
let path = spec
.uri
.strip_prefix("file:")
.ok_or_else(|| anyhow::anyhow!("{} is not a file: feed", spec.uri))?;
let bytes = std::fs::read(path).with_context(|| format!("read feed {path}"))?;
Ok(Self {
spec,
bytes,
fetched_at: Utc::now(),
})
}
}
fn is_zip(b: &[u8]) -> bool {
b.starts_with(b"PK\x03\x04") || b.starts_with(b"PK\x05\x06")
}
fn is_gzip(b: &[u8]) -> bool {
b.starts_with(&[0x1f, 0x8b])
}
fn gunzip_bounded(bytes: &[u8]) -> Result<Vec<u8>> {
let mut out = Vec::new();
flate2::read::MultiGzDecoder::new(bytes)
.take(MAX_NVD_BYTES + 1)
.read_to_end(&mut out)
.context("gunzip")?;
if out.len() as u64 > MAX_NVD_BYTES {
bail!("decompressed feed exceeds {MAX_NVD_BYTES} bytes");
}
Ok(out)
}
pub const DEBIAN_RELEASE_PREFIX: &str = "debian-";
fn debian_by_release(affected: &mut serde_json::Value) {
let Some(pkg) = affected.get_mut("package") else {
return;
};
let Some(release) = pkg
.get("ecosystem")
.and_then(|e| e.as_str())
.and_then(|e| e.strip_prefix("Debian:"))
.map(|r| r.trim().to_string())
else {
return;
};
if release.is_empty() {
return;
}
if let Some(name) = pkg.get("name").and_then(|n| n.as_str()).map(str::to_string) {
pkg["name"] = serde_json::Value::String(format!("{DEBIAN_RELEASE_PREFIX}{release}/{name}"));
}
}
pub fn parse_osv_feed(bytes: &[u8]) -> Result<Vec<Advisory>> {
if !is_zip(bytes) {
let text = std::str::from_utf8(bytes).context("OSV feed is not UTF-8")?;
return parse_osv_doc(text).context("OSV feed is not an OSV document");
}
let mut out = Vec::new();
for m in zip_entry::members_up_to(bytes, MAX_OSV_MEMBERS)
.context("OSV dump is not a readable zip")?
{
if !m.name.ends_with(".json") {
continue;
}
let body = zip_entry::read_member_capped(bytes, &m, MAX_OSV_RECORD)
.with_context(|| format!("OSV dump member {}", m.name))?;
let text = std::str::from_utf8(&body)
.with_context(|| format!("OSV dump member {} is not UTF-8", m.name))?;
let advisories = parse_osv_doc(text)
.with_context(|| format!("OSV dump member {} is not an OSV record", m.name))?;
out.extend(advisories);
}
Ok(out)
}
fn parse_osv_doc(text: &str) -> Result<Vec<Advisory>, serde_json::Error> {
if text.contains("\"Debian:") {
if let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(text) {
let mut each = |rec: &mut serde_json::Value| {
if let Some(affected) = rec.get_mut("affected").and_then(|a| a.as_array_mut()) {
affected.retain(|a| a.get("package").is_some());
for a in affected.iter_mut() {
debian_by_release(a);
}
}
};
match &mut doc {
serde_json::Value::Array(recs) => recs.iter_mut().for_each(&mut each),
rec => each(rec),
}
return modgunn::osv::parse_doc(&doc.to_string());
}
}
let first = match modgunn::osv::parse_doc(text) {
Ok(v) => return Ok(v),
Err(e) => e,
};
let Ok(mut doc) = serde_json::from_str::<serde_json::Value>(text) else {
return Err(first);
};
let mut dropped = false;
let mut drop_unpackaged = |rec: &mut serde_json::Value| {
if let Some(affected) = rec.get_mut("affected").and_then(|a| a.as_array_mut()) {
let before = affected.len();
affected.retain(|a| a.get("package").is_some());
dropped |= affected.len() != before;
}
};
match &mut doc {
serde_json::Value::Array(recs) => recs.iter_mut().for_each(&mut drop_unpackaged),
rec => drop_unpackaged(rec),
}
if !dropped {
return Err(first);
}
modgunn::osv::parse_doc(&doc.to_string())
}
fn merge_by_alias_per_package(advisories: Vec<Advisory>) -> Vec<Advisory> {
let mut buckets: HashMap<(String, String), Vec<Advisory>> = HashMap::new();
let mut order: Vec<(String, String)> = Vec::new();
for a in advisories {
let key = (a.ecosystem.clone(), a.package.clone());
let bucket = buckets.entry(key.clone()).or_default();
if bucket.is_empty() {
order.push(key);
}
bucket.push(a);
}
order
.into_iter()
.flat_map(|k| modgunn::scan::merge_by_alias(buckets.remove(&k).unwrap_or_default()))
.collect()
}
pub fn normalize(feeds: &[RawFeed], generated_at: DateTime<Utc>) -> Result<AdvisorySet> {
let mut advisories: Vec<Advisory> = Vec::new();
let mut nvd = Vec::new();
let mut kev: HashSet<String> = HashSet::new();
let mut sources = Vec::new();
for f in feeds {
let label = f.spec.kind.label();
let records = match &f.spec.kind {
FeedKind::Osv { .. } => {
let a = parse_osv_feed(&f.bytes).with_context(|| format!("feed {label}"))?;
let n = a.len();
advisories.extend(a);
n
}
FeedKind::Nvd { .. } => {
let plain = if is_gzip(&f.bytes) {
gunzip_bounded(&f.bytes)?
} else {
f.bytes.clone()
};
let text = std::str::from_utf8(&plain)
.with_context(|| format!("feed {label} is not UTF-8"))?;
let entries =
modgunn::nvd::parse_nvd(text).with_context(|| format!("feed {label}"))?;
let n = entries.len();
nvd.extend(entries);
n
}
FeedKind::Kev => {
let text = std::str::from_utf8(&f.bytes)
.with_context(|| format!("feed {label} is not UTF-8"))?;
let ids = modgunn::feeds::parse_kev_json(text)
.with_context(|| format!("feed {label}"))?;
let n = ids.len();
kev.extend(ids);
n
}
};
sources.push(SourceRecord {
feed: label,
uri: f.spec.uri.clone(),
sha256: sha256_hex(&f.bytes),
bytes: f.bytes.len() as u64,
fetched_at: f.fetched_at,
records: records as u64,
});
}
if !feeds
.iter()
.any(|f| matches!(f.spec.kind, FeedKind::Osv { .. }))
{
bail!("no OSV feed: NVD and KEV only enrich, so a set without OSV matches nothing");
}
let mut advisories = merge_by_alias_per_package(advisories);
modgunn::nvd::enrich_with_nvd(&mut advisories, &nvd);
modgunn::feeds::enrich(&mut advisories, &HashMap::new(), &kev);
Ok(AdvisorySet::from_advisories(
generated_at,
sources,
&advisories,
))
}