1use 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
19pub 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 None => Err("npm advisory endpoint unreachable or returned no usable data".into()),
52 }
53 }
54}
55
56fn 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
78pub 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 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
138pub 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)); assert_eq!(a.id, "1"); }
255}