Skip to main content

npm_utils/audit/
osv.rs

1//! The OSV (osv.dev) advisory source.
2//!
3//! A batched `POST /v1/querybatch` returns vulnerability ids per query (positionally, one result
4//! list per queried component); each id is then hydrated with `GET /v1/vulns/{id}` for the full
5//! record — structured `affected` ranges, aliases (CVE/GHSA), and severity. The endpoint rejects
6//! more than `QUERYBATCH_LIMIT` queries per request (`400 "Too many queries."`), so larger
7//! component sets are sent as pages. OSV records span many packages and ecosystems, so a record
8//! is only relevant when one of its `affected` entries is the npm package we asked about; that
9//! entry's SEMVER `events` are turned into a `>=`/`<` range string the shared
10//! [`Range`] matcher can post-filter.
11
12use 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
24/// The most queries OSV accepts per `querybatch` request — one more and the endpoint answers
25/// `400 {"code":3,"message":"Too many queries."}` (verified empirically), which the audit would
26/// misreport as an unreachable source. Bigger component sets are paged at this size.
27const QUERYBATCH_LIMIT: usize = 1000;
28
29/// Queries the public OSV database (osv.dev).
30pub 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        // Page the batch at OSV's query cap; each page's positional results are read against
42        // that page's slice, so the pairing survives the split.
43        let mut wanted: Vec<(String, String, String)> = Vec::new(); // (name, version, vuln id)
44        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                // Unreachable endpoint (or a rejected request): report it as an error so
50                // `run_audit` records OSV as failed rather than treating it as "no
51                // vulnerabilities".
52                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        // Hydrate each distinct id once (a record can apply to several queried packages).
60        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
79/// The `(name, version, vuln id)` triples a querybatch response names: `results` is positional —
80/// `results[i]` holds the vuln ids for `queried[i]`, so `queried` must be exactly the slice the
81/// request was built from. Each component's version rides along so a confirmed hit can fall back
82/// to an exact `=version` range.
83fn 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
107/// The querybatch body: one `{ package, version }` query per component, in order.
108fn 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
126/// Parse a hydrated OSV record into an [`Advisory`] for the npm package `want_name` at
127/// `want_version`, or `None` when the record has no `affected` entry for that npm package. Severity
128/// is read from `database_specific.severity` (a bucket word); the CVSS vector, when present, is
129/// carried for display but not scored — an advisory with only a CVSS vector therefore has an unknown
130/// severity, which the audit treats conservatively (it still trips `--audit-level`).
131///
132/// The vulnerable range is the matching entry's SEMVER events (or its explicit `versions` list).
133/// When no range can be reconstructed but the querybatch already confirmed `want_version` is
134/// affected, it falls back to an exact `=want_version` so a confirmed hit is never dropped.
135pub 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    // Prefer the record's own affected range, but only when it actually covers the version the
144    // querybatch confirmed as affected. Otherwise fall back to an exact `=version`: OSV already told
145    // us this version is affected, so a range we can't reconstruct (ECOSYSTEM-only or unparseable)
146    // must not turn a confirmed hit into a false negative.
147    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
195/// Turn an `affected` entry into an npm-style range string. SEMVER ranges are preferred: within a
196/// range the events are an ordered sequence — an `introduced` opens an interval (`>=A`, or nothing
197/// for `"0"`), a `fixed`/`last_affected` closes it (`<B` / `<=B`); an interval still open at the end
198/// is open-ended. Intervals are ANDed within a range and ORed (`||`) across ranges — so e.g.
199/// `[introduced:0, fixed:4.17.12]` → `<4.17.12`. When no SEMVER range is expressed, the explicit
200/// affected `versions` list is used instead (each as `=v`). `None` if neither is present.
201fn 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    // Fall back to the explicit affected `versions` list (e.g. an ECOSYSTEM-only advisory that
233    // carries no SEMVER range): each exact version becomes an `=v` alternative.
234    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
244/// Whether `range` (npm grammar) contains `version` — used to decide whether a synthesized OSV range
245/// can be trusted for the querybatch-confirmed version.
246fn 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
253/// One affected interval as comparators: a lower `>=A` (when bounded) ANDed with an upper `<B`/`<=B`
254/// (when present). An interval with neither bound is "all versions" (`*`).
255fn 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        // 1001 components → two pages of 1000 + 1 queries; one more query per request and OSV
299        // answers 400 "Too many queries." (the bug that made a 1077-package audit report OSV as
300        // unreachable).
301        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        // results[i] pairs with queried[i] — of the queried PAGE, not any global index.
314        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        // A malformed response (no `results`) yields nothing rather than panicking.
336        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        // No SEMVER range (an ECOSYSTEM range is skipped) but an explicit affected versions list.
374        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        // The RubyGems ecosystem and unrelated names are ignored.
412        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        // An npm entry with no SEMVER range and no versions list, and no severity bucket (only a
419        // CVSS vector): the querybatch-confirmed version must still be kept — with an exact fallback
420        // range and an unknown (None) severity that the audit treats conservatively.
421        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}