1use std::collections::HashMap;
13
14use serde_json::{json, Value};
15
16use super::{Advisory, AdvisorySource, Severity};
17use crate::download;
18use crate::package_json::spec::Range;
19use crate::sbom::Component;
20
21const QUERYBATCH_URL: &str = "https://api.osv.dev/v1/querybatch";
22const VULN_URL_BASE: &str = "https://api.osv.dev/v1/vulns";
23
24const QUERYBATCH_LIMIT: usize = 1000;
28
29pub struct OsvSource;
31
32impl AdvisorySource for OsvSource {
33 fn name(&self) -> &'static str {
34 "osv"
35 }
36
37 fn query(&self, components: &[Component]) -> crate::Result<Vec<Advisory>> {
38 if components.is_empty() {
39 return Ok(Vec::new());
40 }
41 let mut wanted: Vec<(String, String, String)> = Vec::new(); for page in components.chunks(QUERYBATCH_LIMIT) {
45 let raw = serde_json::to_vec(&querybatch_body(page))?;
46 let Some(resp) =
47 download::post_json(QUERYBATCH_URL, &raw, None, Some("application/json"))
48 else {
49 return Err(
53 "OSV querybatch endpoint unreachable or returned no usable data".into(),
54 );
55 };
56 wanted.extend(wanted_ids(&resp, page));
57 }
58
59 let mut records: HashMap<String, Option<Value>> = HashMap::new();
61 let mut out = Vec::new();
62 for (name, version, id) in wanted {
63 let record = records.entry(id.clone()).or_insert_with(|| hydrate(&id));
64 match record {
65 Some(record) => {
66 if let Some(advisory) = parse_osv_vuln(record, &name, &version) {
67 out.push(advisory);
68 }
69 }
70 None => crate::warn::warn(&format!(
71 "OSV record {id} could not be fetched; audit results may be incomplete"
72 )),
73 }
74 }
75 Ok(out)
76 }
77}
78
79fn wanted_ids(resp: &Value, queried: &[Component]) -> Vec<(String, String, String)> {
84 let mut out = Vec::new();
85 if let Some(results) = resp.get("results").and_then(Value::as_array) {
86 for (i, result) in results.iter().enumerate() {
87 let Some(component) = queried.get(i) else {
88 continue;
89 };
90 let Some(vulns) = result.get("vulns").and_then(Value::as_array) else {
91 continue;
92 };
93 for v in vulns {
94 if let Some(id) = v.get("id").and_then(Value::as_str) {
95 out.push((
96 component.name.clone(),
97 component.version.clone(),
98 id.to_string(),
99 ));
100 }
101 }
102 }
103 }
104 out
105}
106
107fn querybatch_body(components: &[Component]) -> Value {
109 let queries: Vec<Value> = components
110 .iter()
111 .map(|c| {
112 json!({
113 "package": { "name": c.name, "ecosystem": "npm" },
114 "version": c.version,
115 })
116 })
117 .collect();
118 json!({ "queries": queries })
119}
120
121fn hydrate(id: &str) -> Option<Value> {
122 let bytes = download::fetch(&format!("{VULN_URL_BASE}/{id}")).ok()?;
123 serde_json::from_slice::<Value>(&bytes).ok()
124}
125
126pub fn parse_osv_vuln(record: &Value, want_name: &str, want_version: &str) -> Option<Advisory> {
136 let id = record.get("id").and_then(Value::as_str)?.to_string();
137 let affected = record.get("affected").and_then(Value::as_array)?;
138 let entry = affected.iter().find(|a| {
139 let pkg = a.get("package");
140 pkg.and_then(|p| p.get("ecosystem")).and_then(Value::as_str) == Some("npm")
141 && pkg.and_then(|p| p.get("name")).and_then(Value::as_str) == Some(want_name)
142 })?;
143 let vulnerable_range = osv_range_string(entry)
148 .filter(|r| range_covers(r, want_version))
149 .unwrap_or_else(|| format!("={want_version}"));
150
151 let database_specific = record.get("database_specific");
152 let severity = database_specific
153 .and_then(|d| d.get("severity"))
154 .and_then(Value::as_str)
155 .and_then(Severity::from_str_loose);
156 let cvss_vector = record
157 .get("severity")
158 .and_then(Value::as_array)
159 .and_then(|arr| {
160 arr.iter()
161 .find_map(|s| s.get("score").and_then(Value::as_str))
162 })
163 .map(str::to_string);
164 let url = record
165 .get("references")
166 .and_then(Value::as_array)
167 .and_then(|refs| {
168 refs.iter()
169 .find_map(|r| r.get("url").and_then(Value::as_str))
170 })
171 .map(str::to_string)
172 .or_else(|| Some(format!("https://osv.dev/vulnerability/{id}")));
173
174 Some(Advisory {
175 source: "osv",
176 id,
177 aliases: string_array(record.get("aliases")),
178 package: want_name.to_string(),
179 vulnerable_range,
180 severity,
181 title: record
182 .get("summary")
183 .or_else(|| record.get("details"))
184 .and_then(Value::as_str)
185 .unwrap_or_default()
186 .to_string(),
187 url,
188 cwe: string_array(database_specific.and_then(|d| d.get("cwe_ids"))),
189 cvss_score: None,
190 cvss_vector,
191 matched_version: String::new(),
192 })
193}
194
195fn osv_range_string(entry: &Value) -> Option<String> {
202 let mut alternatives: Vec<String> = Vec::new();
203 if let Some(ranges) = entry.get("ranges").and_then(Value::as_array) {
204 for r in ranges {
205 if r.get("type").and_then(Value::as_str) != Some("SEMVER") {
206 continue;
207 }
208 let Some(events) = r.get("events").and_then(Value::as_array) else {
209 continue;
210 };
211 let mut lower: Option<String> = None;
212 let mut open = false;
213 for e in events {
214 if let Some(introduced) = e.get("introduced").and_then(Value::as_str) {
215 lower = (introduced != "0").then(|| introduced.to_string());
216 open = true;
217 } else if let Some(fixed) = e.get("fixed").and_then(Value::as_str) {
218 alternatives.push(interval(lower.as_deref(), Some(("<", fixed))));
219 lower = None;
220 open = false;
221 } else if let Some(last) = e.get("last_affected").and_then(Value::as_str) {
222 alternatives.push(interval(lower.as_deref(), Some(("<=", last))));
223 lower = None;
224 open = false;
225 }
226 }
227 if open {
228 alternatives.push(interval(lower.as_deref(), None));
229 }
230 }
231 }
232 if alternatives.is_empty() {
235 if let Some(versions) = entry.get("versions").and_then(Value::as_array) {
236 for v in versions.iter().filter_map(Value::as_str) {
237 alternatives.push(format!("={v}"));
238 }
239 }
240 }
241 (!alternatives.is_empty()).then(|| alternatives.join(" || "))
242}
243
244fn range_covers(range: &str, version: &str) -> bool {
247 match (Range::parse(range), semver::Version::parse(version)) {
248 (Ok(r), Ok(v)) => r.matches(&v),
249 _ => false,
250 }
251}
252
253fn interval(lower: Option<&str>, upper: Option<(&str, &str)>) -> String {
256 let mut parts = Vec::new();
257 if let Some(l) = lower {
258 parts.push(format!(">={l}"));
259 }
260 if let Some((op, v)) = upper {
261 parts.push(format!("{op}{v}"));
262 }
263 if parts.is_empty() {
264 "*".to_string()
265 } else {
266 parts.join(" ")
267 }
268}
269
270fn string_array(v: Option<&Value>) -> Vec<String> {
271 v.and_then(Value::as_array)
272 .map(|a| {
273 a.iter()
274 .filter_map(Value::as_str)
275 .map(str::to_string)
276 .collect()
277 })
278 .unwrap_or_default()
279}
280
281#[cfg(test)]
282mod tests {
283 use super::*;
284
285 fn component(name: &str, version: &str) -> Component {
286 Component {
287 name: name.into(),
288 version: version.into(),
289 purl: format!("pkg:npm/{name}@{version}"),
290 license: None,
291 resolved: None,
292 integrity: None,
293 }
294 }
295
296 #[test]
297 fn querybatch_pages_split_at_the_limit() {
298 let components: Vec<Component> = (0..=QUERYBATCH_LIMIT)
302 .map(|i| component(&format!("pkg{i}"), "1.0.0"))
303 .collect();
304 let query_counts: Vec<usize> = components
305 .chunks(QUERYBATCH_LIMIT)
306 .map(|page| querybatch_body(page)["queries"].as_array().unwrap().len())
307 .collect();
308 assert_eq!(query_counts, [QUERYBATCH_LIMIT, 1]);
309 }
310
311 #[test]
312 fn wanted_ids_maps_results_to_the_queried_page_positionally() {
313 let queried = [component("a", "1.0.0"), component("b", "2.0.0")];
315 let resp = json!({ "results": [
316 { "vulns": [{ "id": "GHSA-aaaa" }, { "id": "GHSA-bbbb" }] },
317 {},
318 ]});
319 let wanted = wanted_ids(&resp, &queried);
320 assert_eq!(
321 wanted,
322 [
323 (
324 "a".to_string(),
325 "1.0.0".to_string(),
326 "GHSA-aaaa".to_string()
327 ),
328 (
329 "a".to_string(),
330 "1.0.0".to_string(),
331 "GHSA-bbbb".to_string()
332 ),
333 ]
334 );
335 assert!(wanted_ids(&json!({}), &queried).is_empty());
337 }
338
339 #[test]
340 fn osv_range_from_simple_introduced_zero_fixed() {
341 let entry = json!({
342 "package": { "name": "lodash", "ecosystem": "npm" },
343 "ranges": [{ "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "4.17.12" }] }]
344 });
345 assert_eq!(osv_range_string(&entry).as_deref(), Some("<4.17.12"));
346 }
347
348 #[test]
349 fn osv_range_from_bounded_and_multi_interval() {
350 let bounded = json!({ "ranges": [{ "type": "SEMVER",
351 "events": [{ "introduced": "1.0.0" }, { "fixed": "1.5.0" }] }] });
352 assert_eq!(
353 osv_range_string(&bounded).as_deref(),
354 Some(">=1.0.0 <1.5.0")
355 );
356
357 let multi = json!({ "ranges": [{ "type": "SEMVER", "events": [
358 { "introduced": "1.0.0" }, { "fixed": "1.2.0" },
359 { "introduced": "2.0.0" }, { "fixed": "2.2.0" }
360 ] }] });
361 assert_eq!(
362 osv_range_string(&multi).as_deref(),
363 Some(">=1.0.0 <1.2.0 || >=2.0.0 <2.2.0")
364 );
365
366 let open =
367 json!({ "ranges": [{ "type": "SEMVER", "events": [{ "introduced": "3.0.0" }] }] });
368 assert_eq!(osv_range_string(&open).as_deref(), Some(">=3.0.0"));
369 }
370
371 #[test]
372 fn osv_range_falls_back_to_explicit_versions_list() {
373 let entry = json!({
375 "ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }] }],
376 "versions": ["1.2.3", "1.2.4"]
377 });
378 assert_eq!(
379 osv_range_string(&entry).as_deref(),
380 Some("=1.2.3 || =1.2.4")
381 );
382 }
383
384 #[test]
385 fn parse_osv_vuln_matches_npm_package_only() {
386 let record = json!({
387 "id": "GHSA-jf85-cpcp-j695",
388 "summary": "Prototype Pollution in lodash",
389 "aliases": ["CVE-2019-10744"],
390 "database_specific": { "severity": "CRITICAL", "cwe_ids": ["CWE-1321", "CWE-20"] },
391 "references": [{ "type": "ADVISORY", "url": "https://nvd.nist.gov/vuln/detail/CVE-2019-10744" }],
392 "affected": [
393 { "package": { "name": "lodash", "ecosystem": "npm" },
394 "ranges": [{ "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "4.17.12" }] }] },
395 { "package": { "name": "lodash-rails", "ecosystem": "RubyGems" },
396 "ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }, { "fixed": "4.17.12" }] }] }
397 ]
398 });
399 let adv = parse_osv_vuln(&record, "lodash", "4.17.11").expect("npm lodash affected");
400 assert_eq!(adv.source, "osv");
401 assert_eq!(adv.id, "GHSA-jf85-cpcp-j695");
402 assert_eq!(adv.aliases, vec!["CVE-2019-10744"]);
403 assert_eq!(adv.severity, Some(Severity::Critical));
404 assert_eq!(adv.vulnerable_range, "<4.17.12");
405 assert_eq!(adv.cwe, vec!["CWE-1321", "CWE-20"]);
406 assert_eq!(
407 adv.url.as_deref(),
408 Some("https://nvd.nist.gov/vuln/detail/CVE-2019-10744")
409 );
410
411 assert!(parse_osv_vuln(&record, "lodash-rails", "4.17.11").is_none());
413 assert!(parse_osv_vuln(&record, "express", "1.0.0").is_none());
414 }
415
416 #[test]
417 fn parse_osv_vuln_keeps_a_confirmed_hit_without_a_semver_range() {
418 let record = json!({
422 "id": "GHSA-xxxx-yyyy-zzzz",
423 "summary": "Something in demo",
424 "severity": [{ "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" }],
425 "affected": [
426 { "package": { "name": "demo", "ecosystem": "npm" },
427 "ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }] }] }
428 ]
429 });
430 let adv = parse_osv_vuln(&record, "demo", "1.2.3").expect("confirmed hit is kept");
431 assert_eq!(adv.vulnerable_range, "=1.2.3");
432 assert_eq!(adv.severity, None);
433 assert!(adv.cvss_vector.is_some());
434 }
435}