Skip to main content

gem_audit/advisory/
model.rs

1use std::fmt;
2use std::path::Path;
3use thiserror::Error;
4
5use serde::Deserialize;
6
7use crate::version::{Requirement, Version};
8
9/// Distinguishes gem advisories from Ruby interpreter advisories.
10#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AdvisoryKind {
12    /// Advisory for a RubyGem (loaded from `gems/` directory).
13    Gem,
14    /// Advisory for a Ruby interpreter (loaded from `rubies/` directory).
15    Ruby,
16}
17
18/// The criticality level of a vulnerability.
19#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
20pub enum Criticality {
21    None,
22    Low,
23    Medium,
24    High,
25    Critical,
26}
27
28impl fmt::Display for Criticality {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        match self {
31            Criticality::None => write!(f, "none"),
32            Criticality::Low => write!(f, "low"),
33            Criticality::Medium => write!(f, "medium"),
34            Criticality::High => write!(f, "high"),
35            Criticality::Critical => write!(f, "critical"),
36        }
37    }
38}
39
40/// Raw YAML structure for deserialization.
41#[derive(Debug, Deserialize)]
42struct AdvisoryYaml {
43    #[serde(default)]
44    gem: Option<String>,
45    #[serde(default)]
46    engine: Option<String>,
47    #[serde(default)]
48    cve: Option<String>,
49    #[serde(default)]
50    osvdb: Option<String>,
51    #[serde(default)]
52    ghsa: Option<String>,
53    #[serde(default)]
54    url: Option<String>,
55    #[serde(default)]
56    title: Option<String>,
57    #[serde(default)]
58    date: Option<String>,
59    #[serde(default)]
60    description: Option<String>,
61    #[serde(default)]
62    cvss_v2: Option<f64>,
63    #[serde(default)]
64    cvss_v3: Option<f64>,
65    #[serde(default)]
66    framework: Option<String>,
67    #[serde(default)]
68    patched_versions: Option<Vec<String>>,
69    #[serde(default)]
70    unaffected_versions: Option<Vec<String>>,
71    // We intentionally skip `related` — it's metadata only, not used for auditing.
72}
73
74/// A security advisory loaded from the ruby-advisory-db.
75#[derive(Debug, Clone)]
76pub struct Advisory {
77    /// The advisory identifier (filename without .yml).
78    pub id: String,
79    /// The affected gem or Ruby engine name.
80    pub name: String,
81    /// Whether this advisory is for a gem or a Ruby interpreter.
82    pub kind: AdvisoryKind,
83    /// CVE identifier (e.g., "2020-1234").
84    pub cve: Option<String>,
85    /// OSVDB identifier.
86    pub osvdb: Option<String>,
87    /// GitHub Security Advisory identifier (e.g., "aaaa-bbbb-cccc").
88    pub ghsa: Option<String>,
89    /// URL with vulnerability details.
90    pub url: Option<String>,
91    /// Vulnerability title.
92    pub title: Option<String>,
93    /// Discovery/publication date.
94    pub date: Option<String>,
95    /// Full vulnerability description.
96    pub description: Option<String>,
97    /// CVSS v2 score (0.0-10.0).
98    pub cvss_v2: Option<f64>,
99    /// CVSS v3 score (0.0-10.0).
100    pub cvss_v3: Option<f64>,
101    /// Framework (e.g., "rails").
102    pub framework: Option<String>,
103    /// Version requirements for patched versions.
104    pub patched_versions: Vec<Requirement>,
105    /// Version requirements for unaffected versions.
106    pub unaffected_versions: Vec<Requirement>,
107}
108
109#[derive(Debug, Error)]
110pub enum AdvisoryError {
111    #[error("IO error: {0}")]
112    Io(#[from] std::io::Error),
113    #[error("YAML parse error: {0}")]
114    Yaml(#[from] serde_yaml::Error),
115    #[error("invalid requirement '{version_str}': {error}")]
116    InvalidRequirement { version_str: String, error: String },
117    #[error("advisory {path} is missing both 'gem' and 'engine' fields")]
118    MissingField { path: String },
119}
120
121impl Advisory {
122    /// Load an advisory from a YAML file.
123    pub fn load(path: &Path) -> Result<Self, AdvisoryError> {
124        let content = std::fs::read_to_string(path)?;
125        Self::from_yaml(&content, path)
126    }
127
128    /// Parse an advisory from a YAML string with a path for ID extraction.
129    pub fn from_yaml(yaml: &str, path: &Path) -> Result<Self, AdvisoryError> {
130        let id = path
131            .file_stem()
132            .and_then(|s| s.to_str())
133            .unwrap_or("unknown")
134            .to_string();
135
136        let raw: AdvisoryYaml = serde_yaml::from_str(yaml)?;
137
138        let (name, kind) = match (raw.gem, raw.engine) {
139            (Some(gem), _) => (gem, AdvisoryKind::Gem),
140            (None, Some(engine)) => (engine, AdvisoryKind::Ruby),
141            (None, None) => {
142                return Err(AdvisoryError::MissingField {
143                    path: path.display().to_string(),
144                });
145            }
146        };
147
148        let patched_versions =
149            parse_version_requirements(raw.patched_versions.as_deref().unwrap_or(&[]))?;
150        let unaffected_versions =
151            parse_version_requirements(raw.unaffected_versions.as_deref().unwrap_or(&[]))?;
152
153        Ok(Advisory {
154            id,
155            name,
156            kind,
157            cve: raw.cve,
158            osvdb: raw.osvdb,
159            ghsa: raw.ghsa,
160            url: raw.url,
161            title: raw.title,
162            date: raw.date,
163            description: raw.description,
164            cvss_v2: raw.cvss_v2,
165            cvss_v3: raw.cvss_v3,
166            framework: raw.framework,
167            patched_versions,
168            unaffected_versions,
169        })
170    }
171
172    /// Check if the given version is patched against this advisory.
173    pub fn patched(&self, version: &Version) -> bool {
174        self.patched_versions
175            .iter()
176            .any(|req| req.satisfied_by(version))
177    }
178
179    /// Check if the given version is unaffected by this advisory.
180    pub fn unaffected(&self, version: &Version) -> bool {
181        self.unaffected_versions
182            .iter()
183            .any(|req| req.satisfied_by(version))
184    }
185
186    /// Check if the given version is vulnerable to this advisory.
187    ///
188    /// A version is vulnerable if it is neither patched nor unaffected.
189    pub fn vulnerable(&self, version: &Version) -> bool {
190        !self.patched(version) && !self.unaffected(version)
191    }
192
193    /// The CVE identifier string (e.g., "CVE-2020-1234").
194    pub fn cve_id(&self) -> Option<String> {
195        self.cve.as_ref().map(|cve| format!("CVE-{}", cve))
196    }
197
198    /// The OSVDB identifier string (e.g., "OSVDB-91452").
199    pub fn osvdb_id(&self) -> Option<String> {
200        self.osvdb.as_ref().map(|id| format!("OSVDB-{}", id))
201    }
202
203    /// The GHSA identifier string (e.g., "GHSA-aaaa-bbbb-cccc").
204    pub fn ghsa_id(&self) -> Option<String> {
205        self.ghsa.as_ref().map(|id| format!("GHSA-{}", id))
206    }
207
208    /// All identifiers (CVE, OSVDB, GHSA) as a list.
209    pub fn identifiers(&self) -> Vec<String> {
210        [self.cve_id(), self.osvdb_id(), self.ghsa_id()]
211            .into_iter()
212            .flatten()
213            .collect()
214    }
215
216    /// Determine the criticality based on CVSS scores.
217    ///
218    /// CVSS v3 is preferred over v2. Scoring follows NIST/NVD guidelines.
219    pub fn criticality(&self) -> Option<Criticality> {
220        if let Some(score) = self.cvss_v3 {
221            Some(match score {
222                0.0 => Criticality::None,
223                s if s < 4.0 => Criticality::Low,
224                s if s < 7.0 => Criticality::Medium,
225                s if s < 9.0 => Criticality::High,
226                _ => Criticality::Critical,
227            })
228        } else {
229            self.cvss_v2.map(|score| match score {
230                s if s < 4.0 => Criticality::Low,
231                s if s < 7.0 => Criticality::Medium,
232                _ => Criticality::High,
233            })
234        }
235    }
236}
237
238impl fmt::Display for Advisory {
239    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
240        write!(f, "{}", self.id)
241    }
242}
243
244/// Parse version requirement strings (as they appear in advisory YAML)
245/// into `Requirement` objects.
246///
247/// Each string like `"~> 0.1.42"` or `">= 1.0, < 2.0"` becomes a `Requirement`.
248fn parse_version_requirements(versions: &[String]) -> Result<Vec<Requirement>, AdvisoryError> {
249    versions
250        .iter()
251        .map(|v| {
252            // Ruby splits on ", " and passes as multiple args to Gem::Requirement.new
253            let parts: Vec<&str> = v.split(", ").collect();
254            Requirement::parse_multiple(&parts).map_err(|e| AdvisoryError::InvalidRequirement {
255                version_str: v.clone(),
256                error: e.to_string(),
257            })
258        })
259        .collect()
260}
261
262#[cfg(test)]
263mod tests {
264    use super::*;
265    use std::path::PathBuf;
266
267    fn fixture_path() -> PathBuf {
268        PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/advisory/CVE-2020-1234.yml")
269    }
270
271    fn load_fixture() -> Advisory {
272        Advisory::load(&fixture_path()).unwrap()
273    }
274
275    // ========== Loading ==========
276
277    #[test]
278    fn load_advisory_from_yaml() {
279        let adv = load_fixture();
280        assert_eq!(adv.id, "CVE-2020-1234");
281        assert_eq!(adv.name, "test");
282        assert_eq!(adv.kind, AdvisoryKind::Gem);
283        assert_eq!(adv.cve, Some("2020-1234".to_string()));
284        assert_eq!(adv.ghsa, Some("aaaa-bbbb-cccc".to_string()));
285        assert_eq!(adv.url, Some("https://example.com/".to_string()));
286        assert_eq!(adv.title, Some("Test advisory".to_string()));
287        assert_eq!(adv.cvss_v2, Some(10.0));
288        assert_eq!(adv.cvss_v3, Some(9.8));
289    }
290
291    #[test]
292    fn load_patched_versions() {
293        let adv = load_fixture();
294        assert_eq!(adv.patched_versions.len(), 3);
295        // ~> 0.1.42, ~> 0.2.42, >= 1.0.0
296    }
297
298    #[test]
299    fn load_unaffected_versions() {
300        let adv = load_fixture();
301        assert_eq!(adv.unaffected_versions.len(), 1);
302        // < 0.1.0
303    }
304
305    // ========== Identifiers ==========
306
307    #[test]
308    fn cve_id() {
309        let adv = load_fixture();
310        assert_eq!(adv.cve_id(), Some("CVE-2020-1234".to_string()));
311    }
312
313    #[test]
314    fn ghsa_id() {
315        let adv = load_fixture();
316        assert_eq!(adv.ghsa_id(), Some("GHSA-aaaa-bbbb-cccc".to_string()));
317    }
318
319    #[test]
320    fn identifiers_list() {
321        let adv = load_fixture();
322        let ids = adv.identifiers();
323        assert_eq!(ids.len(), 2); // CVE + GHSA, no OSVDB
324        assert!(ids.contains(&"CVE-2020-1234".to_string()));
325        assert!(ids.contains(&"GHSA-aaaa-bbbb-cccc".to_string()));
326    }
327
328    // ========== Criticality ==========
329
330    #[test]
331    fn criticality_uses_cvss_v3() {
332        let adv = load_fixture();
333        // cvss_v3 = 9.8 -> Critical
334        assert_eq!(adv.criticality(), Some(Criticality::Critical));
335    }
336
337    #[test]
338    fn criticality_cvss_v3_ranges() {
339        let test = |v3: f64, expected: Criticality| {
340            let yaml = format!(
341                "---\ngem: test\ncvss_v3: {}\npatched_versions:\n  - \">= 1.0\"\n",
342                v3
343            );
344            let adv = Advisory::from_yaml(&yaml, Path::new("test.yml")).unwrap();
345            assert_eq!(adv.criticality(), Some(expected), "cvss_v3={}", v3);
346        };
347
348        test(0.0, Criticality::None);
349        test(1.0, Criticality::Low);
350        test(3.9, Criticality::Low);
351        test(4.0, Criticality::Medium);
352        test(6.9, Criticality::Medium);
353        test(7.0, Criticality::High);
354        test(8.9, Criticality::High);
355        test(9.0, Criticality::Critical);
356        test(10.0, Criticality::Critical);
357    }
358
359    #[test]
360    fn criticality_falls_back_to_cvss_v2() {
361        let yaml = "---\ngem: test\ncvss_v2: 7.5\npatched_versions:\n  - \">= 1.0\"\n";
362        let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
363        assert_eq!(adv.criticality(), Some(Criticality::High));
364    }
365
366    #[test]
367    fn criticality_none_when_no_cvss() {
368        let yaml = "---\ngem: test\npatched_versions:\n  - \">= 1.0\"\n";
369        let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
370        assert_eq!(adv.criticality(), None);
371    }
372
373    // ========== Vulnerability Checking ==========
374
375    #[test]
376    fn vulnerable_version() {
377        let adv = load_fixture();
378        // 0.1.0 is not patched and not unaffected -> vulnerable
379        assert!(adv.vulnerable(&Version::parse("0.1.0").unwrap()));
380        assert!(adv.vulnerable(&Version::parse("0.1.41").unwrap()));
381        assert!(adv.vulnerable(&Version::parse("0.2.0").unwrap()));
382        assert!(adv.vulnerable(&Version::parse("0.2.41").unwrap()));
383    }
384
385    #[test]
386    fn patched_version() {
387        let adv = load_fixture();
388        // Patched by ~> 0.1.42
389        assert!(!adv.vulnerable(&Version::parse("0.1.42").unwrap()));
390        assert!(!adv.vulnerable(&Version::parse("0.1.50").unwrap()));
391        // Patched by ~> 0.2.42
392        assert!(!adv.vulnerable(&Version::parse("0.2.42").unwrap()));
393        // Patched by >= 1.0.0
394        assert!(!adv.vulnerable(&Version::parse("1.0.0").unwrap()));
395        assert!(!adv.vulnerable(&Version::parse("2.0.0").unwrap()));
396    }
397
398    #[test]
399    fn unaffected_version() {
400        let adv = load_fixture();
401        // Unaffected by < 0.1.0
402        assert!(!adv.vulnerable(&Version::parse("0.0.9").unwrap()));
403        assert!(!adv.vulnerable(&Version::parse("0.0.1").unwrap()));
404    }
405
406    // ========== Edge Cases ==========
407
408    #[test]
409    fn advisory_without_optional_fields() {
410        let yaml = "---\ngem: minimal\npatched_versions:\n  - \">= 1.0\"\n";
411        let adv = Advisory::from_yaml(yaml, Path::new("GHSA-test.yml")).unwrap();
412        assert_eq!(adv.id, "GHSA-test");
413        assert_eq!(adv.name, "minimal");
414        assert!(adv.cve.is_none());
415        assert!(adv.ghsa.is_none());
416        assert!(adv.osvdb.is_none());
417        assert!(adv.url.is_none());
418        assert!(adv.cvss_v2.is_none());
419        assert!(adv.cvss_v3.is_none());
420        assert!(adv.unaffected_versions.is_empty());
421    }
422
423    #[test]
424    fn advisory_with_framework() {
425        let yaml = "---\ngem: actionpack\nframework: rails\ncve: 2011-0446\npatched_versions:\n  - \"~> 2.3.11\"\n  - \">= 3.0.4\"\n";
426        let adv = Advisory::from_yaml(yaml, Path::new("CVE-2011-0446.yml")).unwrap();
427        assert_eq!(adv.framework, Some("rails".to_string()));
428        assert_eq!(adv.patched_versions.len(), 2);
429    }
430
431    #[test]
432    fn display_shows_id() {
433        let adv = load_fixture();
434        assert_eq!(adv.to_string(), "CVE-2020-1234");
435    }
436
437    // ========== OSVDB ID ==========
438
439    #[test]
440    fn osvdb_id_with_value() {
441        let yaml = "---\ngem: test\nosvdb: 91452\npatched_versions:\n  - \">= 1.0\"\n";
442        let adv = Advisory::from_yaml(yaml, Path::new("OSVDB-91452.yml")).unwrap();
443        assert_eq!(adv.osvdb_id(), Some("OSVDB-91452".to_string()));
444    }
445
446    // ========== Criticality Display ==========
447
448    #[test]
449    fn criticality_display_all_variants() {
450        assert_eq!(Criticality::None.to_string(), "none");
451        assert_eq!(Criticality::Low.to_string(), "low");
452        assert_eq!(Criticality::Medium.to_string(), "medium");
453        assert_eq!(Criticality::High.to_string(), "high");
454        assert_eq!(Criticality::Critical.to_string(), "critical");
455    }
456
457    // ========== CVSS v2 Ranges ==========
458
459    #[test]
460    fn criticality_cvss_v2_low() {
461        let yaml = "---\ngem: test\ncvss_v2: 2.0\npatched_versions:\n  - \">= 1.0\"\n";
462        let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
463        assert_eq!(adv.criticality(), Some(Criticality::Low));
464    }
465
466    #[test]
467    fn criticality_cvss_v2_medium() {
468        let yaml = "---\ngem: test\ncvss_v2: 5.0\npatched_versions:\n  - \">= 1.0\"\n";
469        let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
470        assert_eq!(adv.criticality(), Some(Criticality::Medium));
471    }
472
473    // ========== AdvisoryError Display ==========
474
475    #[test]
476    fn advisory_error_invalid_requirement_display() {
477        let err = AdvisoryError::InvalidRequirement {
478            version_str: "bad".to_string(),
479            error: "parse error".to_string(),
480        };
481        let msg = err.to_string();
482        assert!(msg.contains("bad"));
483        assert!(msg.contains("parse error"));
484    }
485
486    #[test]
487    fn advisory_with_engine_field() {
488        let yaml = "---\nengine: ruby\ncve: 2021-31810\npatched_versions:\n  - \">= 2.6.7\"\n";
489        let adv = Advisory::from_yaml(yaml, Path::new("CVE-2021-31810.yml")).unwrap();
490        assert_eq!(adv.name, "ruby");
491        assert_eq!(adv.kind, AdvisoryKind::Ruby);
492    }
493
494    #[test]
495    fn advisory_missing_gem_and_engine() {
496        let yaml = "---\ncve: 2020-9999\npatched_versions:\n  - \">= 1.0\"\n";
497        let result = Advisory::from_yaml(yaml, Path::new("CVE-2020-9999.yml"));
498        assert!(result.is_err());
499        let err = result.unwrap_err();
500        assert!(err.to_string().contains("missing both"));
501    }
502
503    #[test]
504    fn advisory_error_missing_field_display() {
505        let err = AdvisoryError::MissingField {
506            path: "test.yml".to_string(),
507        };
508        assert!(err.to_string().contains("missing both"));
509        assert!(err.to_string().contains("test.yml"));
510    }
511
512    #[test]
513    fn advisory_error_yaml_display() {
514        let yaml_err = serde_yaml::from_str::<AdvisoryYaml>("not valid yaml {{{{").unwrap_err();
515        let err = AdvisoryError::Yaml(yaml_err);
516        assert!(err.to_string().contains("YAML parse error"));
517    }
518}