fetter/
osv_vulns.rs

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//------------------------------------------------------------------------------
16#[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//------------------------------------------------------------------------------
29#[derive(Clone, Debug, Deserialize, Serialize)]
30pub struct OSVReferences(Vec<OSVVulnReference>);
31
32impl OSVReferences {
33    /// Return a primary value for this collection.
34    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() // just get the first
41    }
42}
43
44impl fmt::Display for OSVReferences {
45    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
46        // NOTE: might only show ADVISORY if defined
47        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//------------------------------------------------------------------------------
60/// If this is a CVSS_V3 or V4, the "score" is the vector, not the score
61#[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//------------------------------------------------------------------------------
74#[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//------------------------------------------------------------------------------
92/// This is a query object designed to match the response from the API.
93#[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    // details: String,
100    // affected: Vec<OSVAffected>,
101}
102
103//------------------------------------------------------------------------------
104
105fn 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    // Try reading from cache
115    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    // Fetch from API
140    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//------------------------------------------------------------------------------
179
180#[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    /// For the max version of CVSS, get the max score with full display formatting
260    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    /// Get the maximum CVSS score among all details
274    pub fn get_max_score(&self) -> Option<f64> {
275        self.0.iter().map(|d| d.score).reduce(f64::max)
276    }
277
278    /// Check if any CVSS detail has a score >= threshold
279    pub fn has_score_gte(&self, threshold: f64) -> bool {
280        self.0.iter().any(|detail| detail.score >= threshold)
281    }
282}
283
284//--------------------------------------------------------------------------
285
286#[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
300// NOTE: Keep only severity entries that look like CVSS (defensive); drop entries that fail to parse.
301impl 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                    // parse each vector into CvssDetail; drop failures
316                    .filter_map(|s| CvssDetail::from_vector(&s.score).ok())
317                    .collect::<Vec<_>>()
318            })
319            // turn empty vecs into None
320            .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
332//--------------------------------------------------------------------------
333pub 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() // directly collect to HashMap
347}
348
349//--------------------------------------------------------------------------
350
351#[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        // v3.1 example, score ~4.3 (Medium)
360        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        // v4.0 example, lower score (~1.7 Low)
364        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        // v4.0 example, higher score (should be chosen as prime)
370        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        // Ensure string formatting looks right and it picked the higher-scoring v4.0 vector
378        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(); // calls FromStr automatically
400
401        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(); // calls FromStr automatically
406        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}