1use std::collections::{HashMap, HashSet};
14use std::io::Read;
15use std::path::Path;
16
17use anyhow::{bail, Context, Result};
18use chrono::{DateTime, Utc};
19use holger_update_bundle::digest::sha256_hex;
20use modgunn::scan::Advisory;
21
22use crate::set::{AdvisorySet, SourceRecord};
23
24pub const OSV_BUCKET: &str = "https://osv-vulnerabilities.storage.googleapis.com";
26pub const NVD_FEEDS: &str = "https://nvd.nist.gov/feeds/json/cve/2.0";
28pub const KEV_URL: &str =
30 "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
31
32pub const DEFAULT_ECOSYSTEMS: &[&str] =
36 &["PyPI", "crates.io", "npm", "Maven", "Go", "NuGet", "RubyGems", "Packagist", "Wolfi", "Chainguard"];
37
38const MAX_OSV_RECORD: usize = 16 * 1024 * 1024;
40const MAX_NVD_BYTES: u64 = 2 * 1024 * 1024 * 1024;
42
43#[derive(Debug, Clone, PartialEq, Eq)]
45pub enum FeedKind {
46 Osv { ecosystem: String },
48 Nvd { name: String },
50 Kev,
52}
53
54impl FeedKind {
55 pub fn label(&self) -> String {
56 match self {
57 FeedKind::Osv { ecosystem } => format!("osv:{ecosystem}"),
58 FeedKind::Nvd { name } => format!("nvd:{name}"),
59 FeedKind::Kev => "kev".into(),
60 }
61 }
62}
63
64#[derive(Debug, Clone, PartialEq, Eq)]
66pub struct FeedSpec {
67 pub kind: FeedKind,
68 pub uri: String,
69}
70
71impl FeedSpec {
72 pub fn osv(ecosystem: &str) -> Self {
73 Self {
74 kind: FeedKind::Osv { ecosystem: ecosystem.to_string() },
75 uri: format!("{OSV_BUCKET}/{ecosystem}/all.zip"),
76 }
77 }
78
79 pub fn nvd(name: &str) -> Self {
80 Self {
81 kind: FeedKind::Nvd { name: name.to_string() },
82 uri: format!("{NVD_FEEDS}/nvdcve-2.0-{name}.json.gz"),
83 }
84 }
85
86 pub fn kev() -> Self {
87 Self { kind: FeedKind::Kev, uri: KEV_URL.to_string() }
88 }
89
90 pub fn default_set(ecosystems: &[String], this_year: i32) -> Vec<FeedSpec> {
93 let mut out: Vec<FeedSpec> = ecosystems.iter().map(|e| FeedSpec::osv(e)).collect();
94 for y in 2002..=this_year {
95 out.push(FeedSpec::nvd(&y.to_string()));
96 }
97 out.push(FeedSpec::nvd("modified"));
98 out.push(FeedSpec::kev());
99 out
100 }
101
102 pub fn from_dir(dir: &Path) -> Result<Vec<FeedSpec>> {
106 let mut names: Vec<String> = std::fs::read_dir(dir)
107 .with_context(|| format!("read feeds dir {}", dir.display()))?
108 .filter_map(|e| e.ok())
109 .filter(|e| e.path().is_file())
110 .map(|e| e.file_name().to_string_lossy().into_owned())
111 .collect();
112 names.sort();
113 let mut out = Vec::new();
114 for n in names {
115 let uri = format!("file:{}", dir.join(&n).display());
116 let stem = n.strip_suffix(".gz").unwrap_or(&n);
117 let stem = stem
118 .strip_suffix(".zip")
119 .or_else(|| stem.strip_suffix(".json"))
120 .unwrap_or(stem);
121 let kind = if let Some(eco) = stem.strip_prefix("osv-") {
122 FeedKind::Osv { ecosystem: eco.to_string() }
123 } else if let Some(name) = stem.strip_prefix("nvd-") {
124 FeedKind::Nvd { name: name.to_string() }
125 } else if stem == "kev" {
126 FeedKind::Kev
127 } else {
128 continue;
129 };
130 out.push(FeedSpec { kind, uri });
131 }
132 if out.is_empty() {
133 bail!(
134 "no feed files in {} (expected osv-<Ecosystem>.zip|.json, nvd-<name>.json[.gz], kev.json)",
135 dir.display()
136 );
137 }
138 Ok(out)
139 }
140}
141
142#[derive(Debug, Clone)]
144pub struct RawFeed {
145 pub spec: FeedSpec,
146 pub bytes: Vec<u8>,
147 pub fetched_at: DateTime<Utc>,
148}
149
150impl RawFeed {
151 pub fn read_file(spec: FeedSpec) -> Result<Self> {
153 let path = spec
154 .uri
155 .strip_prefix("file:")
156 .ok_or_else(|| anyhow::anyhow!("{} is not a file: feed", spec.uri))?;
157 let bytes = std::fs::read(path).with_context(|| format!("read feed {path}"))?;
158 Ok(Self { spec, bytes, fetched_at: Utc::now() })
159 }
160}
161
162fn is_zip(b: &[u8]) -> bool {
163 b.starts_with(b"PK\x03\x04") || b.starts_with(b"PK\x05\x06")
164}
165
166fn is_gzip(b: &[u8]) -> bool {
167 b.starts_with(&[0x1f, 0x8b])
168}
169
170fn gunzip_bounded(bytes: &[u8]) -> Result<Vec<u8>> {
171 let mut out = Vec::new();
172 flate2::read::MultiGzDecoder::new(bytes)
173 .take(MAX_NVD_BYTES + 1)
174 .read_to_end(&mut out)
175 .context("gunzip")?;
176 if out.len() as u64 > MAX_NVD_BYTES {
177 bail!("decompressed feed exceeds {MAX_NVD_BYTES} bytes");
178 }
179 Ok(out)
180}
181
182pub fn parse_osv_feed(bytes: &[u8]) -> Result<Vec<Advisory>> {
184 if !is_zip(bytes) {
185 let text = std::str::from_utf8(bytes).context("OSV feed is not UTF-8")?;
186 return modgunn::osv::parse_doc(text).context("OSV feed is not an OSV document");
187 }
188 let mut out = Vec::new();
189 for m in zip_entry::members(bytes).context("OSV dump is not a readable zip")? {
190 if !m.name.ends_with(".json") {
191 continue;
192 }
193 let body = zip_entry::read_member_capped(bytes, &m, MAX_OSV_RECORD)
194 .with_context(|| format!("OSV dump member {}", m.name))?;
195 let text = std::str::from_utf8(&body)
196 .with_context(|| format!("OSV dump member {} is not UTF-8", m.name))?;
197 let advisories = modgunn::osv::parse_doc(text)
198 .with_context(|| format!("OSV dump member {} is not an OSV record", m.name))?;
199 out.extend(advisories);
200 }
201 Ok(out)
202}
203
204pub fn normalize(feeds: &[RawFeed], generated_at: DateTime<Utc>) -> Result<AdvisorySet> {
208 let mut advisories: Vec<Advisory> = Vec::new();
209 let mut nvd = Vec::new();
210 let mut kev: HashSet<String> = HashSet::new();
211 let mut sources = Vec::new();
212 for f in feeds {
213 let label = f.spec.kind.label();
214 let records = match &f.spec.kind {
215 FeedKind::Osv { .. } => {
216 let a = parse_osv_feed(&f.bytes).with_context(|| format!("feed {label}"))?;
217 let n = a.len();
218 advisories.extend(a);
219 n
220 }
221 FeedKind::Nvd { .. } => {
222 let plain = if is_gzip(&f.bytes) { gunzip_bounded(&f.bytes)? } else { f.bytes.clone() };
223 let text = std::str::from_utf8(&plain).with_context(|| format!("feed {label} is not UTF-8"))?;
224 let entries = modgunn::nvd::parse_nvd(text).with_context(|| format!("feed {label}"))?;
225 let n = entries.len();
226 nvd.extend(entries);
227 n
228 }
229 FeedKind::Kev => {
230 let text = std::str::from_utf8(&f.bytes).with_context(|| format!("feed {label} is not UTF-8"))?;
231 let ids = modgunn::feeds::parse_kev_json(text).with_context(|| format!("feed {label}"))?;
232 let n = ids.len();
233 kev.extend(ids);
234 n
235 }
236 };
237 sources.push(SourceRecord {
238 feed: label,
239 uri: f.spec.uri.clone(),
240 sha256: sha256_hex(&f.bytes),
241 bytes: f.bytes.len() as u64,
242 fetched_at: f.fetched_at,
243 records: records as u64,
244 });
245 }
246 if !feeds.iter().any(|f| matches!(f.spec.kind, FeedKind::Osv { .. })) {
247 bail!("no OSV feed: NVD and KEV only enrich, so a set without OSV matches nothing");
248 }
249 let mut advisories = modgunn::scan::merge_by_alias(advisories);
250 modgunn::nvd::enrich_with_nvd(&mut advisories, &nvd);
251 modgunn::feeds::enrich(&mut advisories, &HashMap::new(), &kev);
252 Ok(AdvisorySet::from_advisories(generated_at, sources, &advisories))
253}