1use crate::ureq_client::UreqClient;
2use crate::util::logger;
3use crate::util::FlagCacheRefresh;
4use crate::util::FlagLog;
5use cvss::Cvss;
6use rayon::prelude::*;
7use serde::Deserialize;
8use serde::Serialize;
9use std::cmp::Ordering;
10use std::collections::HashMap;
11use std::fmt;
12use std::path::Path;
13use std::sync::Arc;
14
15#[derive(Clone, Debug, Deserialize, Serialize)]
17pub struct OSVVulnReference {
18 url: String,
19 r#type: String,
20}
21
22impl fmt::Display for OSVVulnReference {
23 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
24 write!(f, "{}: {}", self.r#type, self.url)
25 }
26}
27
28#[derive(Clone, Debug, Deserialize, Serialize)]
30pub struct OSVReferences(Vec<OSVVulnReference>);
31
32impl OSVReferences {
33 pub fn get_prime(&self) -> String {
35 for s in self.0.iter() {
36 if s.r#type == "ADVISORY" {
37 return s.url.clone();
38 }
39 }
40 self.0[0].url.clone() }
42}
43
44impl fmt::Display for OSVReferences {
45 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46 write!(
48 f,
49 "{}",
50 self.0
51 .iter()
52 .map(|c| c.to_string())
53 .collect::<Vec<_>>()
54 .join(", ")
55 )
56 }
57}
58
59#[derive(Clone, Debug, Deserialize, Serialize, Ord, Eq, PartialEq, PartialOrd)]
62pub struct OSVSeverity {
63 r#type: String,
64 score: String,
65}
66
67impl fmt::Display for OSVSeverity {
68 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
69 write!(f, "{}: {}", self.r#type, self.score)
70 }
71}
72
73#[derive(Clone, Debug, Deserialize, Serialize)]
75pub struct OSVSeverities(Vec<OSVSeverity>);
76
77impl fmt::Display for OSVSeverities {
78 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79 write!(
80 f,
81 "{}",
82 self.0
83 .iter()
84 .map(|c| c.to_string())
85 .collect::<Vec<_>>()
86 .join(", ")
87 )
88 }
89}
90
91#[derive(Clone, Debug, Deserialize, Serialize)]
94pub struct OSVVulnInfo {
95 pub id: String,
96 pub summary: Option<String>,
97 pub references: OSVReferences,
98 pub severity: Option<OSVSeverities>,
99 }
102
103fn query_osv_vuln(
106 client: Arc<dyn UreqClient>,
107 vuln_id: &str,
108 cache_refresh: FlagCacheRefresh,
109 cache_dir: &Path,
110 log: FlagLog,
111) -> Option<OSVVulnInfo> {
112 let cache_fp = cache_dir.join(format!("{vuln_id}.json"));
113
114 if !bool::from(cache_refresh) && cache_fp.exists() {
116 match std::fs::read_to_string(&cache_fp) {
117 Ok(cached_data) => {
118 if let Ok(osv_vuln) = serde_json::from_str(&cached_data) {
119 logger!(log, module_path!(), "Loaded OSV vuln {vuln_id} from cache");
120 return Some(osv_vuln);
121 } else {
122 logger!(
123 log,
124 module_path!(),
125 "Failed to deserialize cached {vuln_id}, refetching"
126 );
127 }
128 }
129 Err(e) => {
130 logger!(
131 log,
132 module_path!(),
133 "Failed to read cache file {cache_fp:?}: {e}, refetching",
134 );
135 }
136 }
137 }
138
139 match client.get(&format!("https://api.osv.dev/v1/vulns/{vuln_id}")) {
141 Ok(body_str) => match serde_json::from_str(&body_str) {
142 Ok(osv_vuln) => {
143 if let Err(e) = std::fs::write(&cache_fp, &body_str) {
144 logger!(
145 log,
146 module_path!(),
147 "Failed to write cache file {cache_fp:?}: {e}"
148 );
149 } else {
150 logger!(
151 log,
152 module_path!(),
153 "Cached OSV vuln response for {vuln_id}"
154 );
155 }
156 Some(osv_vuln)
157 }
158 Err(e) => {
159 logger!(
160 log,
161 module_path!(),
162 "Failed to deserialize OSV vuln {vuln_id}: {e}"
163 );
164 None
165 }
166 },
167 Err(e) => {
168 logger!(
169 log,
170 module_path!(),
171 "HTTP request failed for {vuln_id}: {e}"
172 );
173 None
174 }
175 }
176}
177
178#[derive(Copy, Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Deserialize, Serialize)]
181pub enum CvssVersion {
182 Unknown,
183 V3_0,
184 V3_1,
185 V4_0,
186}
187
188impl CvssVersion {
189 fn from_vector(s: &str) -> Self {
190 if s.starts_with("CVSS:4.0") {
191 Self::V4_0
192 } else if s.starts_with("CVSS:3.1") {
193 Self::V3_1
194 } else if s.starts_with("CVSS:3.0") {
195 Self::V3_0
196 } else {
197 Self::Unknown
198 }
199 }
200}
201
202#[derive(Debug, PartialEq, Clone, Deserialize, Serialize)]
203pub struct CvssDetail {
204 pub version: CvssVersion,
205 pub vector: String,
206 pub score: f64,
207 pub severity: String,
208}
209
210impl CvssDetail {
211 pub fn from_vector(vector: &str) -> Result<Self, String> {
212 let cvss: Cvss = vector
213 .parse()
214 .map_err(|e| format!("Failed to parse CVSS vector: {e}"))?;
215
216 Ok(Self {
217 version: CvssVersion::from_vector(vector),
218 vector: vector.to_string(),
219 score: cvss.score(),
220 severity: cvss.severity().to_string(),
221 })
222 }
223}
224
225impl fmt::Display for CvssDetail {
226 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
227 let mut chars = self.severity.chars();
228 let severity_title = match chars.next() {
229 Some(first) => first.to_uppercase().collect::<String>() + chars.as_str(),
230 None => String::new(),
231 };
232
233 write!(
234 f,
235 "CVSS {:.1} ({}): {}",
236 self.score, severity_title, self.vector
237 )
238 }
239}
240
241#[derive(Clone, Debug, Deserialize, Serialize)]
242pub struct CvssDetails(Vec<CvssDetail>);
243
244impl fmt::Display for CvssDetails {
245 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
246 write!(
247 f,
248 "{}",
249 self.0
250 .iter()
251 .map(|c| c.to_string())
252 .collect::<Vec<_>>()
253 .join(", ")
254 )
255 }
256}
257
258impl CvssDetails {
259 pub fn get_prime(&self) -> String {
261 self.0
262 .iter()
263 .max_by(|a, b| match a.version.cmp(&b.version) {
264 Ordering::Equal => {
265 a.score.partial_cmp(&b.score).unwrap_or(Ordering::Equal)
266 }
267 other => other,
268 })
269 .map(|d| d.to_string())
270 .unwrap_or_default()
271 }
272
273 pub fn get_max_score(&self) -> Option<f64> {
275 self.0.iter().map(|d| d.score).reduce(f64::max)
276 }
277
278 pub fn has_score_gte(&self, threshold: f64) -> bool {
280 self.0.iter().any(|detail| detail.score >= threshold)
281 }
282}
283
284#[derive(Clone, Debug, Deserialize, Serialize)]
287pub struct VulnInfo {
288 pub id: String,
289 pub summary: Option<String>,
290 pub references: OSVReferences,
291 pub cvss_details: Option<CvssDetails>,
292}
293
294impl VulnInfo {
295 pub fn get_url(&self) -> String {
296 format!("https://osv.dev/vulnerability/{}", self.id)
297 }
298}
299
300impl From<OSVVulnInfo> for VulnInfo {
302 fn from(src: OSVVulnInfo) -> Self {
303 let OSVVulnInfo {
304 id,
305 summary,
306 references,
307 severity,
308 } = src;
309 let cvss_details = severity
310 .as_ref()
311 .map(|sevs| {
312 sevs.0
313 .iter()
314 .filter(|s| s.r#type.to_ascii_uppercase().starts_with("CVSS"))
315 .filter_map(|s| CvssDetail::from_vector(&s.score).ok())
317 .collect::<Vec<_>>()
318 })
319 .filter(|v| !v.is_empty())
321 .map(CvssDetails);
322
323 VulnInfo {
324 id,
325 summary: summary.map(|s| s.trim().to_string()),
326 references,
327 cvss_details,
328 }
329 }
330}
331
332pub fn query_osv_vulns(
334 client: Arc<dyn UreqClient>,
335 vuln_ids: &Vec<String>,
336 cache_refresh: FlagCacheRefresh,
337 cache_dir: &Path,
338 log: FlagLog,
339) -> HashMap<String, VulnInfo> {
340 vuln_ids
341 .par_iter()
342 .filter_map(|vuln_id| {
343 query_osv_vuln(client.clone(), vuln_id, cache_refresh, cache_dir, log)
344 .map(|info| (vuln_id.clone(), VulnInfo::from(info)))
345 })
346 .collect() }
348
349#[cfg(test)]
352mod tests {
353 use super::*;
354 use crate::{ureq_client::UreqClientMock, util::path_cache};
355 use cvss::Cvss;
356
357 #[test]
358 fn test_get_prime_prefers_highest_version_and_score() {
359 let d1 = CvssDetail::from_vector("CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L")
361 .unwrap();
362
363 let d2 = CvssDetail::from_vector(
365 "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N",
366 )
367 .unwrap();
368
369 let d3 = CvssDetail::from_vector(
371 "CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H",
372 )
373 .unwrap();
374
375 let details = CvssDetails(vec![d1, d2, d3]);
376
377 let prime_str = details.get_prime();
379 assert!(
380 prime_str.contains("CVSS:4.0"),
381 "Expected CVSS:4.0 in prime string: {}",
382 prime_str
383 );
384 assert!(
385 prime_str.contains("VC:H/VI:H/VA:H"),
386 "Expected high impact scores in prime string: {}",
387 prime_str
388 );
389 assert!(
390 prime_str.starts_with("CVSS"),
391 "Expected formatted display starting with 'CVSS': {}",
392 prime_str
393 );
394 }
395
396 #[test]
397 fn test_cvss_score_a() {
398 let s1 = "CVSS:4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:N/VI:L/VA:N/SC:N/SI:N/SA:N/E:U";
399 let v1: Cvss = s1.parse().unwrap(); assert_eq!(v1.score(), 1.7);
402 assert_eq!(v1.severity().to_string(), "low");
403
404 let s2 = "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L";
405 let v2: Cvss = s2.parse().unwrap(); assert_eq!(v2.score(), 4.3);
407 assert_eq!(v2.severity().to_string(), "medium");
408 }
409
410 #[test]
411 fn test_vuln_a() {
412 let vuln_ids = vec!["GHSA-48cq-79qq-6f7x".to_string()];
413
414 let content = r#"
415 {"id":"GHSA-48cq-79qq-6f7x","summary":"Gradio applications running locally vulnerable to 3rd party websites accessing routes and uploading files","details":" Impact\nThis CVE covers the ability of 3rd party websites to access routes and upload files to users running Gradio applications locally. For example, the malicious owners of [www.dontvisitme.com](http://www.dontvisitme.com/) could put a script on their website that uploads a large file to http://localhost:7860/upload and anyone who visits their website and has a Gradio app will now have that large file uploaded on their computer\n\n### Patches\nYes, the problem has been patched in Gradio version 4.19.2 or higher. We have no knowledge of this exploit being used against users of Gradio applications, but we encourage all users to upgrade to Gradio 4.19.2 or higher.\n\nFixed in: https://github.com/gradio-app/gradio/commit/84802ee6a4806c25287344dce581f9548a99834a\nCVE: https://nvd.nist.gov/vuln/detail/CVE-2024-1727","aliases":["CVE-2024-1727"],"modified":"2024-05-21T15:12:35.101662Z","published":"2024-05-21T14:43:50Z","database_specific":{"github_reviewed_at":"2024-05-21T14:43:50Z","github_reviewed":true,"severity":"MODERATE","cwe_ids":["CWE-352"],"nvd_published_at":null},"references":[{"type":"WEB","url":"https://github.com/gradio-app/gradio/security/advisories/GHSA-48cq-79qq-6f7x"},{"type":"ADVISORY","url":"https://nvd.nist.gov/vuln/detail/CVE-2024-1727"},{"type":"WEB","url":"https://github.com/gradio-app/gradio/pull/7503"},{"type":"WEB","url":"https://github.com/gradio-app/gradio/commit/84802ee6a4806c25287344dce581f9548a99834a"},{"type":"PACKAGE","url":"https://github.com/gradio-app/gradio"},{"type":"WEB","url":"https://huntr.com/bounties/a94d55fb-0770-4cbe-9b20-97a978a2ffff"}],"affected":[{"package":{"name":"gradio","ecosystem":"PyPI","purl":"pkg:pypi/gradio"},"ranges":[{"type":"ECOSYSTEM","events":[{"introduced":"0"},{"fixed":"4.19.2"}]}],"versions":["4.18.0","4.19.0","4.19.1","4.2.0","4.3.0","4.4.0","4.4.1","4.5.0","4.7.0","4.7.1","4.8.0","4.9.0","4.9.1"],"database_specific":{"source":"https://github.com/github/advisory-database/blob/main/advisories/github-reviewed/2024/05/GHSA-48cq-79qq-6f7x/GHSA-48cq-79qq-6f7x.json"}}],"schema_version":"1.6.0","severity":[{"type":"CVSS_V3","score":"CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L"}]}"#;
416
417 let client = Arc::new(UreqClientMock {
418 mock_get: Some(content.to_string()),
419 mock_post: None,
420 });
421
422 let cache_dir = path_cache(true).unwrap();
423 let result_map = query_osv_vulns(
424 client,
425 &vuln_ids,
426 FlagCacheRefresh(true),
427 &cache_dir,
428 FlagLog(false),
429 );
430
431 let mut rm = result_map.iter();
432 let (vuln_id, vuln) = rm.next().unwrap();
433 assert_eq!(vuln_id, "GHSA-48cq-79qq-6f7x");
434 assert_eq!(vuln.summary.as_ref().unwrap(), "Gradio applications running locally vulnerable to 3rd party websites accessing routes and uploading files");
435 assert_eq!(
436 vuln.references.get_prime(),
437 "https://nvd.nist.gov/vuln/detail/CVE-2024-1727"
438 );
439 assert_eq!(
440 vuln.cvss_details.as_ref().unwrap().get_prime(),
441 "CVSS 4.3 (Medium): CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:N/I:N/A:L"
442 );
443 }
444}