use std::fmt;
use std::path::Path;
use thiserror::Error;
use serde::Deserialize;
use crate::version::{Requirement, Version};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AdvisoryKind {
Gem,
Ruby,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, clap::ValueEnum)]
pub enum Criticality {
None,
Low,
Medium,
High,
Critical,
}
impl fmt::Display for Criticality {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Criticality::None => write!(f, "none"),
Criticality::Low => write!(f, "low"),
Criticality::Medium => write!(f, "medium"),
Criticality::High => write!(f, "high"),
Criticality::Critical => write!(f, "critical"),
}
}
}
#[derive(Debug, Deserialize)]
struct AdvisoryYaml {
#[serde(default)]
gem: Option<String>,
#[serde(default)]
engine: Option<String>,
#[serde(default)]
cve: Option<String>,
#[serde(default)]
osvdb: Option<String>,
#[serde(default)]
ghsa: Option<String>,
#[serde(default)]
url: Option<String>,
#[serde(default)]
title: Option<String>,
#[serde(default)]
date: Option<String>,
#[serde(default)]
description: Option<String>,
#[serde(default)]
cvss_v2: Option<f64>,
#[serde(default)]
cvss_v3: Option<f64>,
#[serde(default)]
framework: Option<String>,
#[serde(default)]
patched_versions: Option<Vec<String>>,
#[serde(default)]
unaffected_versions: Option<Vec<String>>,
}
#[derive(Debug, Clone)]
pub struct Advisory {
pub id: String,
pub name: String,
pub kind: AdvisoryKind,
pub cve: Option<String>,
pub osvdb: Option<String>,
pub ghsa: Option<String>,
pub url: Option<String>,
pub title: Option<String>,
pub date: Option<String>,
pub description: Option<String>,
pub cvss_v2: Option<f64>,
pub cvss_v3: Option<f64>,
pub framework: Option<String>,
pub patched_versions: Vec<Requirement>,
pub unaffected_versions: Vec<Requirement>,
}
#[derive(Debug, Error)]
pub enum AdvisoryError {
#[error("IO error: {0}")]
Io(#[from] std::io::Error),
#[error("YAML parse error: {0}")]
Yaml(#[from] serde_yml::Error),
#[error("invalid requirement '{version_str}': {error}")]
InvalidRequirement { version_str: String, error: String },
#[error("advisory {path} is missing both 'gem' and 'engine' fields")]
MissingField { path: String },
}
impl Advisory {
pub fn load(path: &Path) -> Result<Self, AdvisoryError> {
let content = std::fs::read_to_string(path)?;
Self::from_yaml(&content, path)
}
pub fn from_yaml(yaml: &str, path: &Path) -> Result<Self, AdvisoryError> {
let id = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
let raw: AdvisoryYaml = serde_yml::from_str(yaml)?;
let (name, kind) = match (raw.gem, raw.engine) {
(Some(gem), _) => (gem, AdvisoryKind::Gem),
(None, Some(engine)) => (engine, AdvisoryKind::Ruby),
(None, None) => {
return Err(AdvisoryError::MissingField {
path: path.display().to_string(),
});
}
};
let patched_versions =
parse_version_requirements(raw.patched_versions.as_deref().unwrap_or(&[]))?;
let unaffected_versions =
parse_version_requirements(raw.unaffected_versions.as_deref().unwrap_or(&[]))?;
Ok(Advisory {
id,
name,
kind,
cve: raw.cve,
osvdb: raw.osvdb,
ghsa: raw.ghsa,
url: raw.url,
title: raw.title,
date: raw.date,
description: raw.description,
cvss_v2: raw.cvss_v2,
cvss_v3: raw.cvss_v3,
framework: raw.framework,
patched_versions,
unaffected_versions,
})
}
pub fn patched(&self, version: &Version) -> bool {
self.patched_versions
.iter()
.any(|req| req.satisfied_by(version))
}
pub fn unaffected(&self, version: &Version) -> bool {
self.unaffected_versions
.iter()
.any(|req| req.satisfied_by(version))
}
pub fn vulnerable(&self, version: &Version) -> bool {
!self.patched(version) && !self.unaffected(version)
}
pub fn cve_id(&self) -> Option<String> {
self.cve.as_ref().map(|cve| format!("CVE-{}", cve))
}
pub fn osvdb_id(&self) -> Option<String> {
self.osvdb.as_ref().map(|id| format!("OSVDB-{}", id))
}
pub fn ghsa_id(&self) -> Option<String> {
self.ghsa.as_ref().map(|id| format!("GHSA-{}", id))
}
pub fn identifiers(&self) -> Vec<String> {
[self.cve_id(), self.osvdb_id(), self.ghsa_id()]
.into_iter()
.flatten()
.collect()
}
pub fn criticality(&self) -> Option<Criticality> {
if let Some(score) = self.cvss_v3 {
Some(match score {
0.0 => Criticality::None,
s if s < 4.0 => Criticality::Low,
s if s < 7.0 => Criticality::Medium,
s if s < 9.0 => Criticality::High,
_ => Criticality::Critical,
})
} else {
self.cvss_v2.map(|score| match score {
s if s < 4.0 => Criticality::Low,
s if s < 7.0 => Criticality::Medium,
_ => Criticality::High,
})
}
}
}
impl fmt::Display for Advisory {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.id)
}
}
fn parse_version_requirements(versions: &[String]) -> Result<Vec<Requirement>, AdvisoryError> {
versions
.iter()
.map(|v| {
let parts: Vec<&str> = v.split(", ").collect();
Requirement::parse_multiple(&parts).map_err(|e| AdvisoryError::InvalidRequirement {
version_str: v.clone(),
error: e.to_string(),
})
})
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn fixture_path() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/advisory/CVE-2020-1234.yml")
}
fn load_fixture() -> Advisory {
Advisory::load(&fixture_path()).unwrap()
}
#[test]
fn load_advisory_from_yaml() {
let adv = load_fixture();
assert_eq!(adv.id, "CVE-2020-1234");
assert_eq!(adv.name, "test");
assert_eq!(adv.kind, AdvisoryKind::Gem);
assert_eq!(adv.cve, Some("2020-1234".to_string()));
assert_eq!(adv.ghsa, Some("aaaa-bbbb-cccc".to_string()));
assert_eq!(adv.url, Some("https://example.com/".to_string()));
assert_eq!(adv.title, Some("Test advisory".to_string()));
assert_eq!(adv.cvss_v2, Some(10.0));
assert_eq!(adv.cvss_v3, Some(9.8));
}
#[test]
fn load_patched_versions() {
let adv = load_fixture();
assert_eq!(adv.patched_versions.len(), 3);
}
#[test]
fn load_unaffected_versions() {
let adv = load_fixture();
assert_eq!(adv.unaffected_versions.len(), 1);
}
#[test]
fn cve_id() {
let adv = load_fixture();
assert_eq!(adv.cve_id(), Some("CVE-2020-1234".to_string()));
}
#[test]
fn ghsa_id() {
let adv = load_fixture();
assert_eq!(adv.ghsa_id(), Some("GHSA-aaaa-bbbb-cccc".to_string()));
}
#[test]
fn identifiers_list() {
let adv = load_fixture();
let ids = adv.identifiers();
assert_eq!(ids.len(), 2); assert!(ids.contains(&"CVE-2020-1234".to_string()));
assert!(ids.contains(&"GHSA-aaaa-bbbb-cccc".to_string()));
}
#[test]
fn criticality_uses_cvss_v3() {
let adv = load_fixture();
assert_eq!(adv.criticality(), Some(Criticality::Critical));
}
#[test]
fn criticality_cvss_v3_ranges() {
let test = |v3: f64, expected: Criticality| {
let yaml = format!(
"---\ngem: test\ncvss_v3: {}\npatched_versions:\n - \">= 1.0\"\n",
v3
);
let adv = Advisory::from_yaml(&yaml, Path::new("test.yml")).unwrap();
assert_eq!(adv.criticality(), Some(expected), "cvss_v3={}", v3);
};
test(0.0, Criticality::None);
test(1.0, Criticality::Low);
test(3.9, Criticality::Low);
test(4.0, Criticality::Medium);
test(6.9, Criticality::Medium);
test(7.0, Criticality::High);
test(8.9, Criticality::High);
test(9.0, Criticality::Critical);
test(10.0, Criticality::Critical);
}
#[test]
fn criticality_falls_back_to_cvss_v2() {
let yaml = "---\ngem: test\ncvss_v2: 7.5\npatched_versions:\n - \">= 1.0\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
assert_eq!(adv.criticality(), Some(Criticality::High));
}
#[test]
fn criticality_none_when_no_cvss() {
let yaml = "---\ngem: test\npatched_versions:\n - \">= 1.0\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
assert_eq!(adv.criticality(), None);
}
#[test]
fn vulnerable_version() {
let adv = load_fixture();
assert!(adv.vulnerable(&Version::parse("0.1.0").unwrap()));
assert!(adv.vulnerable(&Version::parse("0.1.41").unwrap()));
assert!(adv.vulnerable(&Version::parse("0.2.0").unwrap()));
assert!(adv.vulnerable(&Version::parse("0.2.41").unwrap()));
}
#[test]
fn patched_version() {
let adv = load_fixture();
assert!(!adv.vulnerable(&Version::parse("0.1.42").unwrap()));
assert!(!adv.vulnerable(&Version::parse("0.1.50").unwrap()));
assert!(!adv.vulnerable(&Version::parse("0.2.42").unwrap()));
assert!(!adv.vulnerable(&Version::parse("1.0.0").unwrap()));
assert!(!adv.vulnerable(&Version::parse("2.0.0").unwrap()));
}
#[test]
fn unaffected_version() {
let adv = load_fixture();
assert!(!adv.vulnerable(&Version::parse("0.0.9").unwrap()));
assert!(!adv.vulnerable(&Version::parse("0.0.1").unwrap()));
}
#[test]
fn advisory_without_optional_fields() {
let yaml = "---\ngem: minimal\npatched_versions:\n - \">= 1.0\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("GHSA-test.yml")).unwrap();
assert_eq!(adv.id, "GHSA-test");
assert_eq!(adv.name, "minimal");
assert!(adv.cve.is_none());
assert!(adv.ghsa.is_none());
assert!(adv.osvdb.is_none());
assert!(adv.url.is_none());
assert!(adv.cvss_v2.is_none());
assert!(adv.cvss_v3.is_none());
assert!(adv.unaffected_versions.is_empty());
}
#[test]
fn advisory_with_framework() {
let yaml = "---\ngem: actionpack\nframework: rails\ncve: 2011-0446\npatched_versions:\n - \"~> 2.3.11\"\n - \">= 3.0.4\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("CVE-2011-0446.yml")).unwrap();
assert_eq!(adv.framework, Some("rails".to_string()));
assert_eq!(adv.patched_versions.len(), 2);
}
#[test]
fn display_shows_id() {
let adv = load_fixture();
assert_eq!(adv.to_string(), "CVE-2020-1234");
}
#[test]
fn osvdb_id_with_value() {
let yaml = "---\ngem: test\nosvdb: 91452\npatched_versions:\n - \">= 1.0\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("OSVDB-91452.yml")).unwrap();
assert_eq!(adv.osvdb_id(), Some("OSVDB-91452".to_string()));
}
#[test]
fn criticality_display_all_variants() {
assert_eq!(Criticality::None.to_string(), "none");
assert_eq!(Criticality::Low.to_string(), "low");
assert_eq!(Criticality::Medium.to_string(), "medium");
assert_eq!(Criticality::High.to_string(), "high");
assert_eq!(Criticality::Critical.to_string(), "critical");
}
#[test]
fn criticality_cvss_v2_low() {
let yaml = "---\ngem: test\ncvss_v2: 2.0\npatched_versions:\n - \">= 1.0\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
assert_eq!(adv.criticality(), Some(Criticality::Low));
}
#[test]
fn criticality_cvss_v2_medium() {
let yaml = "---\ngem: test\ncvss_v2: 5.0\npatched_versions:\n - \">= 1.0\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("test.yml")).unwrap();
assert_eq!(adv.criticality(), Some(Criticality::Medium));
}
#[test]
fn advisory_error_invalid_requirement_display() {
let err = AdvisoryError::InvalidRequirement {
version_str: "bad".to_string(),
error: "parse error".to_string(),
};
let msg = err.to_string();
assert!(msg.contains("bad"));
assert!(msg.contains("parse error"));
}
#[test]
fn advisory_with_engine_field() {
let yaml = "---\nengine: ruby\ncve: 2021-31810\npatched_versions:\n - \">= 2.6.7\"\n";
let adv = Advisory::from_yaml(yaml, Path::new("CVE-2021-31810.yml")).unwrap();
assert_eq!(adv.name, "ruby");
assert_eq!(adv.kind, AdvisoryKind::Ruby);
}
#[test]
fn advisory_missing_gem_and_engine() {
let yaml = "---\ncve: 2020-9999\npatched_versions:\n - \">= 1.0\"\n";
let result = Advisory::from_yaml(yaml, Path::new("CVE-2020-9999.yml"));
assert!(result.is_err());
let err = result.unwrap_err();
assert!(err.to_string().contains("missing both"));
}
#[test]
fn advisory_error_missing_field_display() {
let err = AdvisoryError::MissingField {
path: "test.yml".to_string(),
};
assert!(err.to_string().contains("missing both"));
assert!(err.to_string().contains("test.yml"));
}
#[test]
fn advisory_error_yaml_display() {
let yaml_err = serde_yml::from_str::<AdvisoryYaml>("not valid yaml {{{{").unwrap_err();
let err = AdvisoryError::Yaml(yaml_err);
assert!(err.to_string().contains("YAML parse error"));
}
}