Skip to main content

npm_utils/audit/
npm.rs

1//! npm's native bulk-advisory source.
2//!
3//! npm's legacy `audits` / `audits/quick` endpoints are retired (they answer `410`); the live
4//! contract is a single `POST` of a gzipped `{ "<name>": ["<version>", …] }` map to
5//! `/-/npm/v1/security/advisories/bulk`. The response is keyed by package name; each advisory
6//! carries `id` (numeric), `url`, `title`, `severity`, `vulnerable_versions`, `cwe[]`, and sometimes
7//! `cvss` — but **no CVE**, so the GHSA is parsed out of the advisory `url` for the alias set.
8
9use std::io::Write as _;
10
11use flate2::write::GzEncoder;
12use flate2::Compression;
13use serde_json::{json, Map, Value};
14
15use super::{severity_from_cvss, Advisory, AdvisorySource, Severity};
16use crate::download;
17use crate::sbom::Component;
18
19/// Queries npm's bulk security-advisory endpoint. `registry_base` lets the query target a private
20/// mirror; the public registry is `https://registry.npmjs.org`.
21pub struct NpmRegistrySource {
22    pub registry_base: String,
23}
24
25impl NpmRegistrySource {
26    pub fn new(registry_base: impl Into<String>) -> NpmRegistrySource {
27        NpmRegistrySource {
28            registry_base: registry_base.into(),
29        }
30    }
31}
32
33impl AdvisorySource for NpmRegistrySource {
34    fn name(&self) -> &'static str {
35        "npm"
36    }
37
38    fn query(&self, components: &[Component]) -> crate::Result<Vec<Advisory>> {
39        if components.is_empty() {
40            return Ok(Vec::new());
41        }
42        let gz = gzip(&serde_json::to_vec(&bulk_request_body(components))?)?;
43        let url = format!(
44            "{}/-/npm/v1/security/advisories/bulk",
45            self.registry_base.trim_end_matches('/')
46        );
47        match download::post_json(&url, &gz, Some("gzip"), Some("application/json")) {
48            Some(body) => Ok(parse_npm_bulk(&body)),
49            // Unreachable endpoint or unusable response: surface it as an error so `run_audit`
50            // records the source as failed rather than treating it as "no vulnerabilities".
51            None => Err("npm advisory endpoint unreachable or returned no usable data".into()),
52        }
53    }
54}
55
56/// The bulk request body — `{ "<name>": ["<version>", …], … }` over the installed components, each
57/// name's versions de-duplicated.
58fn bulk_request_body(components: &[Component]) -> Value {
59    let mut map: Map<String, Value> = Map::new();
60    for c in components {
61        let versions = map.entry(c.name.clone()).or_insert_with(|| json!([]));
62        if let Some(arr) = versions.as_array_mut() {
63            let v = Value::from(c.version.clone());
64            if !arr.contains(&v) {
65                arr.push(v);
66            }
67        }
68    }
69    Value::Object(map)
70}
71
72fn gzip(raw: &[u8]) -> crate::Result<Vec<u8>> {
73    let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
74    encoder.write_all(raw)?;
75    Ok(encoder.finish()?)
76}
77
78/// Parse the bulk endpoint's response: an object keyed by package name, each value an array of
79/// advisory objects. The GHSA is recovered from each advisory's `url` (the only cross-source id the
80/// payload offers) and used as the native id and an alias; severity comes from the `severity` word,
81/// falling back to the CVSS score.
82pub fn parse_npm_bulk(body: &Value) -> Vec<Advisory> {
83    let mut out = Vec::new();
84    let Some(obj) = body.as_object() else {
85        return out;
86    };
87    for (name, advisories) in obj {
88        let Some(arr) = advisories.as_array() else {
89            continue;
90        };
91        for adv in arr {
92            let url = adv.get("url").and_then(Value::as_str).map(str::to_string);
93            let ghsa = url.as_deref().and_then(ghsa_from_url);
94            let cvss_score = adv
95                .get("cvss")
96                .and_then(|c| c.get("score"))
97                .and_then(Value::as_f64);
98            let severity = adv
99                .get("severity")
100                .and_then(Value::as_str)
101                .and_then(Severity::from_str_loose)
102                .or_else(|| cvss_score.and_then(severity_from_cvss));
103            let aliases = ghsa.iter().cloned().collect();
104            out.push(Advisory {
105                source: "npm",
106                // Prefer the GHSA as the id; fall back to the numeric advisory id.
107                id: ghsa
108                    .or_else(|| adv.get("id").map(value_to_id))
109                    .unwrap_or_default(),
110                aliases,
111                package: name.clone(),
112                vulnerable_range: adv
113                    .get("vulnerable_versions")
114                    .and_then(Value::as_str)
115                    .unwrap_or_default()
116                    .to_string(),
117                severity,
118                title: adv
119                    .get("title")
120                    .and_then(Value::as_str)
121                    .unwrap_or_default()
122                    .to_string(),
123                url,
124                cwe: string_array(adv.get("cwe")),
125                cvss_score,
126                cvss_vector: adv
127                    .get("cvss")
128                    .and_then(|c| c.get("vectorString"))
129                    .and_then(Value::as_str)
130                    .map(str::to_string),
131                matched_version: String::new(),
132            });
133        }
134    }
135    out
136}
137
138/// Extract a `GHSA-…` id from a GitHub advisory URL (`https://github.com/advisories/GHSA-…`).
139pub fn ghsa_from_url(url: &str) -> Option<String> {
140    let after = url.split("/advisories/").nth(1)?;
141    let id: String = after
142        .chars()
143        .take_while(|c| c.is_ascii_alphanumeric() || *c == '-')
144        .collect();
145    id.starts_with("GHSA-").then_some(id)
146}
147
148fn value_to_id(v: &Value) -> String {
149    match v {
150        Value::String(s) => s.clone(),
151        Value::Number(n) => n.to_string(),
152        _ => String::new(),
153    }
154}
155
156fn string_array(v: Option<&Value>) -> Vec<String> {
157    v.and_then(Value::as_array)
158        .map(|a| {
159            a.iter()
160                .filter_map(Value::as_str)
161                .map(str::to_string)
162                .collect()
163        })
164        .unwrap_or_default()
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::sbom::Component;
171
172    #[test]
173    fn ghsa_parsed_from_advisory_url() {
174        assert_eq!(
175            ghsa_from_url("https://github.com/advisories/GHSA-35jh-r3h4-6jhm").as_deref(),
176            Some("GHSA-35jh-r3h4-6jhm")
177        );
178        assert_eq!(
179            ghsa_from_url("https://github.com/advisories/GHSA-35jh-r3h4-6jhm?ref=x").as_deref(),
180            Some("GHSA-35jh-r3h4-6jhm")
181        );
182        assert_eq!(ghsa_from_url("https://example.com/whatever"), None);
183    }
184
185    #[test]
186    fn bulk_request_body_groups_versions_per_name() {
187        let comps = [
188            Component {
189                name: "lodash".into(),
190                version: "4.17.20".into(),
191                purl: String::new(),
192                license: None,
193                resolved: None,
194                integrity: None,
195            },
196            Component {
197                name: "lodash".into(),
198                version: "4.17.21".into(),
199                purl: String::new(),
200                license: None,
201                resolved: None,
202                integrity: None,
203            },
204            Component {
205                name: "ms".into(),
206                version: "2.0.0".into(),
207                purl: String::new(),
208                license: None,
209                resolved: None,
210                integrity: None,
211            },
212        ];
213        let body = bulk_request_body(&comps);
214        assert_eq!(body["lodash"], json!(["4.17.20", "4.17.21"]));
215        assert_eq!(body["ms"], json!(["2.0.0"]));
216    }
217
218    #[test]
219    fn parse_npm_bulk_reads_fields_and_severity() {
220        let body = json!({
221            "lodash": [{
222                "id": 1106913,
223                "url": "https://github.com/advisories/GHSA-35jh-r3h4-6jhm",
224                "title": "Command Injection in lodash",
225                "severity": "high",
226                "vulnerable_versions": "<4.17.21",
227                "cwe": ["CWE-77", "CWE-94"],
228                "cvss": { "score": 7.2, "vectorString": "CVSS:3.1/AV:N/AC:L/PR:H/UI:N/S:U/C:H/I:H/A:H" }
229            }]
230        });
231        let advisories = parse_npm_bulk(&body);
232        assert_eq!(advisories.len(), 1);
233        let a = &advisories[0];
234        assert_eq!(a.source, "npm");
235        assert_eq!(a.id, "GHSA-35jh-r3h4-6jhm");
236        assert_eq!(a.aliases, vec!["GHSA-35jh-r3h4-6jhm"]);
237        assert_eq!(a.package, "lodash");
238        assert_eq!(a.vulnerable_range, "<4.17.21");
239        assert_eq!(a.severity, Some(Severity::High));
240        assert_eq!(a.cwe, vec!["CWE-77", "CWE-94"]);
241        assert_eq!(a.cvss_score, Some(7.2));
242        assert!(a.cvss_vector.is_some());
243    }
244
245    #[test]
246    fn parse_npm_bulk_falls_back_to_cvss_for_severity() {
247        let body = json!({ "x": [{
248            "id": 1, "url": "https://example.com/x", "title": "t",
249            "vulnerable_versions": "<1.0.0", "cvss": { "score": 9.5 }
250        }]});
251        let a = &parse_npm_bulk(&body)[0];
252        assert_eq!(a.severity, Some(Severity::Critical)); // from cvss score; no GHSA in url
253        assert_eq!(a.id, "1"); // numeric id stringified when no GHSA
254    }
255}