use std::collections::HashMap;
use serde_json::{json, Value};
use super::{Advisory, AdvisorySource, Severity};
use crate::download;
use crate::package_json::spec::Range;
use crate::sbom::Component;
const QUERYBATCH_URL: &str = "https://api.osv.dev/v1/querybatch";
const VULN_URL_BASE: &str = "https://api.osv.dev/v1/vulns";
const QUERYBATCH_LIMIT: usize = 1000;
pub struct OsvSource;
impl AdvisorySource for OsvSource {
fn name(&self) -> &'static str {
"osv"
}
fn query(&self, components: &[Component]) -> crate::Result<Vec<Advisory>> {
if components.is_empty() {
return Ok(Vec::new());
}
let mut wanted: Vec<(String, String, String)> = Vec::new(); for page in components.chunks(QUERYBATCH_LIMIT) {
let raw = serde_json::to_vec(&querybatch_body(page))?;
let Some(resp) =
download::post_json(QUERYBATCH_URL, &raw, None, Some("application/json"))
else {
return Err(
"OSV querybatch endpoint unreachable or returned no usable data".into(),
);
};
wanted.extend(wanted_ids(&resp, page));
}
let mut records: HashMap<String, Option<Value>> = HashMap::new();
let mut out = Vec::new();
for (name, version, id) in wanted {
let record = records.entry(id.clone()).or_insert_with(|| hydrate(&id));
match record {
Some(record) => {
if let Some(advisory) = parse_osv_vuln(record, &name, &version) {
out.push(advisory);
}
}
None => crate::warn::warn(&format!(
"OSV record {id} could not be fetched; audit results may be incomplete"
)),
}
}
Ok(out)
}
}
fn wanted_ids(resp: &Value, queried: &[Component]) -> Vec<(String, String, String)> {
let mut out = Vec::new();
if let Some(results) = resp.get("results").and_then(Value::as_array) {
for (i, result) in results.iter().enumerate() {
let Some(component) = queried.get(i) else {
continue;
};
let Some(vulns) = result.get("vulns").and_then(Value::as_array) else {
continue;
};
for v in vulns {
if let Some(id) = v.get("id").and_then(Value::as_str) {
out.push((
component.name.clone(),
component.version.clone(),
id.to_string(),
));
}
}
}
}
out
}
fn querybatch_body(components: &[Component]) -> Value {
let queries: Vec<Value> = components
.iter()
.map(|c| {
json!({
"package": { "name": c.name, "ecosystem": "npm" },
"version": c.version,
})
})
.collect();
json!({ "queries": queries })
}
fn hydrate(id: &str) -> Option<Value> {
let bytes = download::fetch(&format!("{VULN_URL_BASE}/{id}")).ok()?;
serde_json::from_slice::<Value>(&bytes).ok()
}
pub fn parse_osv_vuln(record: &Value, want_name: &str, want_version: &str) -> Option<Advisory> {
let id = record.get("id").and_then(Value::as_str)?.to_string();
let affected = record.get("affected").and_then(Value::as_array)?;
let entry = affected.iter().find(|a| {
let pkg = a.get("package");
pkg.and_then(|p| p.get("ecosystem")).and_then(Value::as_str) == Some("npm")
&& pkg.and_then(|p| p.get("name")).and_then(Value::as_str) == Some(want_name)
})?;
let vulnerable_range = osv_range_string(entry)
.filter(|r| range_covers(r, want_version))
.unwrap_or_else(|| format!("={want_version}"));
let database_specific = record.get("database_specific");
let severity = database_specific
.and_then(|d| d.get("severity"))
.and_then(Value::as_str)
.and_then(Severity::from_str_loose);
let cvss_vector = record
.get("severity")
.and_then(Value::as_array)
.and_then(|arr| {
arr.iter()
.find_map(|s| s.get("score").and_then(Value::as_str))
})
.map(str::to_string);
let url = record
.get("references")
.and_then(Value::as_array)
.and_then(|refs| {
refs.iter()
.find_map(|r| r.get("url").and_then(Value::as_str))
})
.map(str::to_string)
.or_else(|| Some(format!("https://osv.dev/vulnerability/{id}")));
Some(Advisory {
source: "osv",
id,
aliases: string_array(record.get("aliases")),
package: want_name.to_string(),
vulnerable_range,
severity,
title: record
.get("summary")
.or_else(|| record.get("details"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string(),
url,
cwe: string_array(database_specific.and_then(|d| d.get("cwe_ids"))),
cvss_score: None,
cvss_vector,
matched_version: String::new(),
})
}
fn osv_range_string(entry: &Value) -> Option<String> {
let mut alternatives: Vec<String> = Vec::new();
if let Some(ranges) = entry.get("ranges").and_then(Value::as_array) {
for r in ranges {
if r.get("type").and_then(Value::as_str) != Some("SEMVER") {
continue;
}
let Some(events) = r.get("events").and_then(Value::as_array) else {
continue;
};
let mut lower: Option<String> = None;
let mut open = false;
for e in events {
if let Some(introduced) = e.get("introduced").and_then(Value::as_str) {
lower = (introduced != "0").then(|| introduced.to_string());
open = true;
} else if let Some(fixed) = e.get("fixed").and_then(Value::as_str) {
alternatives.push(interval(lower.as_deref(), Some(("<", fixed))));
lower = None;
open = false;
} else if let Some(last) = e.get("last_affected").and_then(Value::as_str) {
alternatives.push(interval(lower.as_deref(), Some(("<=", last))));
lower = None;
open = false;
}
}
if open {
alternatives.push(interval(lower.as_deref(), None));
}
}
}
if alternatives.is_empty() {
if let Some(versions) = entry.get("versions").and_then(Value::as_array) {
for v in versions.iter().filter_map(Value::as_str) {
alternatives.push(format!("={v}"));
}
}
}
(!alternatives.is_empty()).then(|| alternatives.join(" || "))
}
fn range_covers(range: &str, version: &str) -> bool {
match (Range::parse(range), semver::Version::parse(version)) {
(Ok(r), Ok(v)) => r.matches(&v),
_ => false,
}
}
fn interval(lower: Option<&str>, upper: Option<(&str, &str)>) -> String {
let mut parts = Vec::new();
if let Some(l) = lower {
parts.push(format!(">={l}"));
}
if let Some((op, v)) = upper {
parts.push(format!("{op}{v}"));
}
if parts.is_empty() {
"*".to_string()
} else {
parts.join(" ")
}
}
fn string_array(v: Option<&Value>) -> Vec<String> {
v.and_then(Value::as_array)
.map(|a| {
a.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
#[cfg(test)]
mod tests {
use super::*;
fn component(name: &str, version: &str) -> Component {
Component {
name: name.into(),
version: version.into(),
purl: format!("pkg:npm/{name}@{version}"),
license: None,
resolved: None,
integrity: None,
}
}
#[test]
fn querybatch_pages_split_at_the_limit() {
let components: Vec<Component> = (0..=QUERYBATCH_LIMIT)
.map(|i| component(&format!("pkg{i}"), "1.0.0"))
.collect();
let query_counts: Vec<usize> = components
.chunks(QUERYBATCH_LIMIT)
.map(|page| querybatch_body(page)["queries"].as_array().unwrap().len())
.collect();
assert_eq!(query_counts, [QUERYBATCH_LIMIT, 1]);
}
#[test]
fn wanted_ids_maps_results_to_the_queried_page_positionally() {
let queried = [component("a", "1.0.0"), component("b", "2.0.0")];
let resp = json!({ "results": [
{ "vulns": [{ "id": "GHSA-aaaa" }, { "id": "GHSA-bbbb" }] },
{},
]});
let wanted = wanted_ids(&resp, &queried);
assert_eq!(
wanted,
[
(
"a".to_string(),
"1.0.0".to_string(),
"GHSA-aaaa".to_string()
),
(
"a".to_string(),
"1.0.0".to_string(),
"GHSA-bbbb".to_string()
),
]
);
assert!(wanted_ids(&json!({}), &queried).is_empty());
}
#[test]
fn osv_range_from_simple_introduced_zero_fixed() {
let entry = json!({
"package": { "name": "lodash", "ecosystem": "npm" },
"ranges": [{ "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "4.17.12" }] }]
});
assert_eq!(osv_range_string(&entry).as_deref(), Some("<4.17.12"));
}
#[test]
fn osv_range_from_bounded_and_multi_interval() {
let bounded = json!({ "ranges": [{ "type": "SEMVER",
"events": [{ "introduced": "1.0.0" }, { "fixed": "1.5.0" }] }] });
assert_eq!(
osv_range_string(&bounded).as_deref(),
Some(">=1.0.0 <1.5.0")
);
let multi = json!({ "ranges": [{ "type": "SEMVER", "events": [
{ "introduced": "1.0.0" }, { "fixed": "1.2.0" },
{ "introduced": "2.0.0" }, { "fixed": "2.2.0" }
] }] });
assert_eq!(
osv_range_string(&multi).as_deref(),
Some(">=1.0.0 <1.2.0 || >=2.0.0 <2.2.0")
);
let open =
json!({ "ranges": [{ "type": "SEMVER", "events": [{ "introduced": "3.0.0" }] }] });
assert_eq!(osv_range_string(&open).as_deref(), Some(">=3.0.0"));
}
#[test]
fn osv_range_falls_back_to_explicit_versions_list() {
let entry = json!({
"ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }] }],
"versions": ["1.2.3", "1.2.4"]
});
assert_eq!(
osv_range_string(&entry).as_deref(),
Some("=1.2.3 || =1.2.4")
);
}
#[test]
fn parse_osv_vuln_matches_npm_package_only() {
let record = json!({
"id": "GHSA-jf85-cpcp-j695",
"summary": "Prototype Pollution in lodash",
"aliases": ["CVE-2019-10744"],
"database_specific": { "severity": "CRITICAL", "cwe_ids": ["CWE-1321", "CWE-20"] },
"references": [{ "type": "ADVISORY", "url": "https://nvd.nist.gov/vuln/detail/CVE-2019-10744" }],
"affected": [
{ "package": { "name": "lodash", "ecosystem": "npm" },
"ranges": [{ "type": "SEMVER", "events": [{ "introduced": "0" }, { "fixed": "4.17.12" }] }] },
{ "package": { "name": "lodash-rails", "ecosystem": "RubyGems" },
"ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }, { "fixed": "4.17.12" }] }] }
]
});
let adv = parse_osv_vuln(&record, "lodash", "4.17.11").expect("npm lodash affected");
assert_eq!(adv.source, "osv");
assert_eq!(adv.id, "GHSA-jf85-cpcp-j695");
assert_eq!(adv.aliases, vec!["CVE-2019-10744"]);
assert_eq!(adv.severity, Some(Severity::Critical));
assert_eq!(adv.vulnerable_range, "<4.17.12");
assert_eq!(adv.cwe, vec!["CWE-1321", "CWE-20"]);
assert_eq!(
adv.url.as_deref(),
Some("https://nvd.nist.gov/vuln/detail/CVE-2019-10744")
);
assert!(parse_osv_vuln(&record, "lodash-rails", "4.17.11").is_none());
assert!(parse_osv_vuln(&record, "express", "1.0.0").is_none());
}
#[test]
fn parse_osv_vuln_keeps_a_confirmed_hit_without_a_semver_range() {
let record = json!({
"id": "GHSA-xxxx-yyyy-zzzz",
"summary": "Something in demo",
"severity": [{ "type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H" }],
"affected": [
{ "package": { "name": "demo", "ecosystem": "npm" },
"ranges": [{ "type": "ECOSYSTEM", "events": [{ "introduced": "0" }] }] }
]
});
let adv = parse_osv_vuln(&record, "demo", "1.2.3").expect("confirmed hit is kept");
assert_eq!(adv.vulnerable_range, "=1.2.3");
assert_eq!(adv.severity, None);
assert!(adv.cvss_vector.is_some());
}
}