pr_01m47d15m3e54sn21z27rpy5n9/crates/scan/src/osv.rs

349 lines13,932 bytesCodeBlame
1//! Asking OSV (api.osv.dev) which packages have known vulnerabilities,
2//! and reading its answers: how severe each is and which version fixes it.
3//!
4//! Only the requests and the reading of answers live here; the service
5//! that calls OSV does the fetching.
6
7use serde_json::{Value, json};
8
9use crate::lockfiles::Package;
10use crate::version;
11
12pub const QUERY_BATCH_URL: &str = "https://api.osv.dev/v1/querybatch";
13/// OSV takes at most this many queries in one batch.
14pub const MAX_BATCH: usize = 1000;
15
16pub fn vuln_url(id: &str) -> String {
17 format!("https://api.osv.dev/v1/vulns/{id}")
18}
19
20pub fn page_url(id: &str) -> String {
21 format!("https://osv.dev/vulnerability/{id}")
22}
23
24/// The bodies to POST to [`QUERY_BATCH_URL`], [`MAX_BATCH`] packages each.
25pub fn batch_bodies(packages: &[Package]) -> Vec<Value> {
26 packages
27 .chunks(MAX_BATCH)
28 .map(|chunk| {
29 json!({
30 "queries": chunk.iter().map(|package| json!({
31 "package": { "name": package.name, "ecosystem": package.ecosystem.osv() },
32 "version": package.version,
33 })).collect::<Vec<_>>()
34 })
35 })
36 .collect()
37}
38
39/// The ids of the vulnerabilities affecting each query of one batch, in
40/// the order the queries were sent, and the queries with more to fetch
41/// (index, page token).
42pub fn read_batch(answer: &Value, sent: usize) -> (Vec<Vec<String>>, Vec<(usize, String)>) {
43 let results = answer.get("results").and_then(Value::as_array).cloned().unwrap_or_default();
44 let mut ids = vec![Vec::new(); sent];
45 let mut more = Vec::new();
46 for (index, result) in results.into_iter().enumerate().take(sent) {
47 if let Some(vulns) = result.get("vulns").and_then(Value::as_array) {
48 ids[index] = vulns
49 .iter()
50 .filter_map(|vuln| vuln.get("id").and_then(Value::as_str).map(str::to_owned))
51 .collect();
52 }
53 if let Some(token) = result.get("next_page_token").and_then(Value::as_str) {
54 more.push((index, token.to_owned()));
55 }
56 }
57 (ids, more)
58}
59
60/// How bad a vulnerability is.
61#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)]
62pub enum Severity {
63 Unknown,
64 Low,
65 Medium,
66 High,
67 Critical,
68}
69
70impl Severity {
71 pub const ALL: [Severity; 5] = [Severity::Critical, Severity::High, Severity::Medium, Severity::Low, Severity::Unknown];
72
73 pub fn as_str(self) -> &'static str {
74 match self {
75 Severity::Critical => "critical",
76 Severity::High => "high",
77 Severity::Medium => "medium",
78 Severity::Low => "low",
79 Severity::Unknown => "unknown",
80 }
81 }
82
83 pub fn parse(text: &str) -> Severity {
84 match text.to_ascii_lowercase().as_str() {
85 "critical" => Severity::Critical,
86 "high" => Severity::High,
87 "moderate" | "medium" => Severity::Medium,
88 "low" => Severity::Low,
89 _ => Severity::Unknown,
90 }
91 }
92
93 pub fn from_score(score: f64) -> Severity {
94 match score {
95 s if s >= 9.0 => Severity::Critical,
96 s if s >= 7.0 => Severity::High,
97 s if s >= 4.0 => Severity::Medium,
98 s if s > 0.0 => Severity::Low,
99 _ => Severity::Unknown,
100 }
101 }
102}
103
104/// The base score of a CVSS 3.0 or 3.1 vector, such as
105/// `CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H` (9.8).
106pub fn cvss3_score(vector: &str) -> Option<f64> {
107 if !vector.starts_with("CVSS:3") {
108 return None;
109 }
110 let metric = |name: &str| {
111 vector
112 .split('/')
113 .find_map(|part| part.strip_prefix(name).and_then(|rest| rest.strip_prefix(':')))
114 };
115 let changed = metric("S")? == "C";
116 let av = match metric("AV")? { "N" => 0.85, "A" => 0.62, "L" => 0.55, "P" => 0.2, _ => return None };
117 let ac = match metric("AC")? { "L" => 0.77, "H" => 0.44, _ => return None };
118 let pr = match (metric("PR")?, changed) {
119 ("N", _) => 0.85,
120 ("L", false) => 0.62,
121 ("L", true) => 0.68,
122 ("H", false) => 0.27,
123 ("H", true) => 0.5,
124 _ => return None,
125 };
126 let ui = match metric("UI")? { "N" => 0.85, "R" => 0.62, _ => return None };
127 let cia = |name: &str| match metric(name) { Some("H") => Some(0.56), Some("L") => Some(0.22), Some("N") => Some(0.0), _ => None };
128 let (c, i, a) = (cia("C")?, cia("I")?, cia("A")?);
129 let iss: f64 = 1.0 - (1.0 - c) * (1.0 - i) * (1.0 - a);
130 let impact = if changed { 7.52 * (iss - 0.029) - 3.25 * (iss - 0.02).powi(15) } else { 6.42 * iss };
131 if impact <= 0.0 {
132 return Some(0.0);
133 }
134 let exploitability = 8.22 * av * ac * pr * ui;
135 let total = if changed { 1.08 * (impact + exploitability) } else { impact + exploitability };
136 // CVSS rounds up to one decimal, ignoring floating-point dust.
137 let tenths = (total.min(10.0) * 100_000.0).round() as i64;
138 Some(if tenths % 10_000 == 0 { tenths as f64 / 100_000.0 } else { ((tenths / 10_000) + 1) as f64 / 10.0 })
139}
140
141/// What g1t keeps of an advisory for one package.
142#[derive(Clone, Debug, PartialEq)]
143pub struct Advisory {
144 /// OSV's id.
145 pub id: String,
146 /// The id people know it by: its GHSA id when it has one.
147 pub display_id: String,
148 pub aliases: Vec<String>,
149 pub summary: String,
150 pub severity: Severity,
151 /// The lowest version above the one in use that is not affected.
152 pub fixed: Option<String>,
153}
154
155fn severity_of(vuln: &Value) -> Severity {
156 let named = |value: Option<&Value>| value.and_then(Value::as_str).map(Severity::parse);
157 let mut found = named(vuln.pointer("/database_specific/severity"));
158 if found.is_none_or(|severity| severity == Severity::Unknown)
159 && let Some(affected) = vuln.get("affected").and_then(Value::as_array)
160 {
161 found = affected
162 .iter()
163 .filter_map(|entry| {
164 named(entry.pointer("/database_specific/severity")).or_else(|| named(entry.pointer("/ecosystem_specific/severity")))
165 })
166 .max();
167 }
168 if let Some(severity) = found.filter(|severity| *severity != Severity::Unknown) {
169 return severity;
170 }
171 vuln.get("severity")
172 .and_then(Value::as_array)
173 .into_iter()
174 .flatten()
175 .filter_map(|entry| entry.get("score").and_then(Value::as_str).and_then(cvss3_score))
176 .map(Severity::from_score)
177 .max()
178 .unwrap_or(Severity::Unknown)
179}
180
181/// The fixed version for `package`: the end of the affected range it is
182/// in, or failing that the lowest fix above its version.
183fn fixed_for(vuln: &Value, package: &Package) -> Option<String> {
184 let mut candidates = Vec::new();
185 for entry in vuln.get("affected").and_then(Value::as_array).into_iter().flatten() {
186 let name = entry.pointer("/package/name").and_then(Value::as_str).unwrap_or_default();
187 let ecosystem = entry.pointer("/package/ecosystem").and_then(Value::as_str).unwrap_or_default();
188 if ecosystem != package.ecosystem.osv() || package.ecosystem.normalize(name) != package.name {
189 continue;
190 }
191 for range in entry.get("ranges").and_then(Value::as_array).into_iter().flatten() {
192 if range.get("type").and_then(Value::as_str) == Some("GIT") {
193 continue;
194 }
195 for event in range.get("events").and_then(Value::as_array).into_iter().flatten() {
196 if let Some(fixed) = event.get("fixed").and_then(Value::as_str)
197 && version::compare(fixed, &package.version).is_gt()
198 {
199 candidates.push(fixed.to_owned());
200 }
201 }
202 }
203 }
204 candidates.into_iter().min_by(|a, b| version::compare(a, b))
205}
206
207/// Reads OSV's record of a vulnerability (`GET /v1/vulns/<id>`) as it
208/// concerns `package`.
209pub fn read_vuln(vuln: &Value, package: &Package) -> Option<Advisory> {
210 let id = vuln.get("id").and_then(Value::as_str)?.to_owned();
211 let aliases: Vec<String> = vuln
212 .get("aliases")
213 .and_then(Value::as_array)
214 .into_iter()
215 .flatten()
216 .filter_map(|alias| alias.as_str().map(str::to_owned))
217 .collect();
218 let display_id = if id.starts_with("GHSA-") {
219 id.clone()
220 } else {
221 aliases
222 .iter()
223 .find(|alias| alias.starts_with("GHSA-"))
224 .or_else(|| aliases.iter().find(|alias| alias.starts_with("CVE-")))
225 .cloned()
226 .unwrap_or_else(|| id.clone())
227 };
228 let summary = vuln
229 .get("summary")
230 .and_then(Value::as_str)
231 .or_else(|| vuln.get("details").and_then(Value::as_str).and_then(|details| details.lines().next()))
232 .unwrap_or_default()
233 .chars()
234 .take(300)
235 .collect();
236 Some(Advisory {
237 severity: severity_of(vuln),
238 fixed: fixed_for(vuln, package),
239 id,
240 display_id,
241 aliases,
242 summary,
243 })
244}
245
246/// The version to move a package to so that every advisory with a fix is
247/// fixed: the highest of their fixed versions.
248pub fn upgrade_target<'a>(fixed: impl IntoIterator<Item = &'a str>) -> Option<String> {
249 fixed.into_iter().max_by(|a, b| version::compare(a, b)).map(str::to_owned)
250}
251
252#[cfg(test)]
253mod tests {
254 use super::*;
255 use crate::lockfiles::Ecosystem;
256
257 fn lodash(version: &str) -> Package {
258 Package { ecosystem: Ecosystem::Npm, name: "lodash".into(), version: version.into() }
259 }
260
261 #[test]
262 fn packages_go_in_batches_of_a_thousand() {
263 let packages: Vec<Package> = (0..2500).map(|n| lodash(&format!("1.0.{n}"))).collect();
264 let bodies = batch_bodies(&packages);
265 assert_eq!(bodies.len(), 3);
266 assert_eq!(bodies[0]["queries"].as_array().unwrap().len(), 1000);
267 assert_eq!(bodies[2]["queries"].as_array().unwrap().len(), 500);
268 assert_eq!(bodies[0]["queries"][0], json!({"package": {"name": "lodash", "ecosystem": "npm"}, "version": "1.0.0"}));
269 }
270
271 #[test]
272 fn a_batch_answer_is_read_in_order() {
273 let answer = json!({"results": [
274 {"vulns": [{"id": "GHSA-a", "modified": "x"}, {"id": "GHSA-b"}]},
275 {},
276 {"vulns": [{"id": "PYSEC-1"}], "next_page_token": "t"}
277 ]});
278 let (ids, more) = read_batch(&answer, 3);
279 assert_eq!(ids, vec![vec!["GHSA-a".to_owned(), "GHSA-b".to_owned()], vec![], vec!["PYSEC-1".to_owned()]]);
280 assert_eq!(more, vec![(2, "t".to_owned())]);
281 // A short answer leaves the rest empty rather than failing.
282 assert_eq!(read_batch(&json!({}), 2).0, vec![Vec::<String>::new(), vec![]]);
283 }
284
285 #[test]
286 fn cvss_scores() {
287 assert_eq!(cvss3_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H"), Some(9.8));
288 assert_eq!(cvss3_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:C/C:L/I:L/A:N"), Some(6.1));
289 assert_eq!(cvss3_score("CVSS:3.0/AV:L/AC:H/PR:H/UI:R/S:U/C:N/I:N/A:L"), Some(1.8));
290 assert_eq!(cvss3_score("CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:N"), Some(0.0));
291 assert_eq!(cvss3_score("CVSS:4.0/AV:N"), None);
292 }
293
294 #[test]
295 fn an_advisory_says_how_bad_and_what_fixes_it() {
296 let vuln = json!({
297 "id": "GHSA-35jh-r3h4-6jhm",
298 "aliases": ["CVE-2021-23337"],
299 "summary": "Command Injection in lodash",
300 "database_specific": {"severity": "HIGH"},
301 "affected": [{
302 "package": {"ecosystem": "npm", "name": "lodash"},
303 "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "4.17.21"}]}]
304 }]
305 });
306 let advisory = read_vuln(&vuln, &lodash("4.17.20")).unwrap();
307 assert_eq!(advisory.display_id, "GHSA-35jh-r3h4-6jhm");
308 assert_eq!(advisory.severity, Severity::High);
309 assert_eq!(advisory.fixed.as_deref(), Some("4.17.21"));
310 }
311
312 #[test]
313 fn the_fix_is_the_one_for_the_version_in_use() {
314 let vuln = json!({
315 "id": "RUSTSEC-2020-0071",
316 "aliases": ["CVE-2020-26235", "GHSA-wcg3-cvx6-7396"],
317 "severity": [{"type": "CVSS_V3", "score": "CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:N/A:H"}],
318 "affected": [{
319 "package": {"ecosystem": "crates.io", "name": "time"},
320 "ranges": [{"type": "SEMVER", "events": [
321 {"introduced": "0.0.0"}, {"fixed": "0.2.23"},
322 {"introduced": "0.3.0"}, {"fixed": "0.3.1"}
323 ]}]
324 }, {
325 "package": {"ecosystem": "crates.io", "name": "other"},
326 "ranges": [{"type": "SEMVER", "events": [{"introduced": "0"}, {"fixed": "0.1.44"}]}]
327 }]
328 });
329 let time = Package { ecosystem: Ecosystem::Cargo, name: "time".into(), version: "0.1.43".into() };
330 let advisory = read_vuln(&vuln, &time).unwrap();
331 assert_eq!(advisory.display_id, "GHSA-wcg3-cvx6-7396");
332 assert_eq!(advisory.severity, Severity::Medium);
333 assert_eq!(advisory.fixed.as_deref(), Some("0.2.23"));
334 assert_eq!(upgrade_target(["0.2.23", "0.3.1", "0.2.9"]).as_deref(), Some("0.3.1"));
335 }
336
337 #[test]
338 fn an_advisory_without_a_fix_says_so() {
339 let vuln = json!({"id": "PYSEC-2024-1", "details": "Bad thing.\nMore.", "affected": [{
340 "package": {"ecosystem": "PyPI", "name": "Some_Package"},
341 "ranges": [{"type": "ECOSYSTEM", "events": [{"introduced": "0"}, {"last_affected": "2.0"}]}]
342 }]});
343 let package = Package { ecosystem: Ecosystem::PyPI, name: "some-package".into(), version: "1.0".into() };
344 let advisory = read_vuln(&vuln, &package).unwrap();
345 assert_eq!(advisory.fixed, None);
346 assert_eq!(advisory.summary, "Bad thing.");
347 assert_eq!(advisory.severity, Severity::Unknown);
348 }
349}