1use std::fmt;
2use std::path::Path;
3use thiserror::Error;
4
5use serde::Deserialize;
6
7use crate::version::{Requirement, Version};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq)]
11pub enum AdvisoryKind {
12 Gem,
14 Ruby,
16}
17
18#[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#[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 }
73
74#[derive(Debug, Clone)]
76pub struct Advisory {
77 pub id: String,
79 pub name: String,
81 pub kind: AdvisoryKind,
83 pub cve: Option<String>,
85 pub osvdb: Option<String>,
87 pub ghsa: Option<String>,
89 pub url: Option<String>,
91 pub title: Option<String>,
93 pub date: Option<String>,
95 pub description: Option<String>,
97 pub cvss_v2: Option<f64>,
99 pub cvss_v3: Option<f64>,
101 pub framework: Option<String>,
103 pub patched_versions: Vec<Requirement>,
105 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 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 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 pub fn patched(&self, version: &Version) -> bool {
174 self.patched_versions
175 .iter()
176 .any(|req| req.satisfied_by(version))
177 }
178
179 pub fn unaffected(&self, version: &Version) -> bool {
181 self.unaffected_versions
182 .iter()
183 .any(|req| req.satisfied_by(version))
184 }
185
186 pub fn vulnerable(&self, version: &Version) -> bool {
190 !self.patched(version) && !self.unaffected(version)
191 }
192
193 pub fn cve_id(&self) -> Option<String> {
195 self.cve.as_ref().map(|cve| format!("CVE-{}", cve))
196 }
197
198 pub fn osvdb_id(&self) -> Option<String> {
200 self.osvdb.as_ref().map(|id| format!("OSVDB-{}", id))
201 }
202
203 pub fn ghsa_id(&self) -> Option<String> {
205 self.ghsa.as_ref().map(|id| format!("GHSA-{}", id))
206 }
207
208 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 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
244fn parse_version_requirements(versions: &[String]) -> Result<Vec<Requirement>, AdvisoryError> {
249 versions
250 .iter()
251 .map(|v| {
252 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 #[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 }
297
298 #[test]
299 fn load_unaffected_versions() {
300 let adv = load_fixture();
301 assert_eq!(adv.unaffected_versions.len(), 1);
302 }
304
305 #[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); assert!(ids.contains(&"CVE-2020-1234".to_string()));
325 assert!(ids.contains(&"GHSA-aaaa-bbbb-cccc".to_string()));
326 }
327
328 #[test]
331 fn criticality_uses_cvss_v3() {
332 let adv = load_fixture();
333 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 #[test]
376 fn vulnerable_version() {
377 let adv = load_fixture();
378 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 assert!(!adv.vulnerable(&Version::parse("0.1.42").unwrap()));
390 assert!(!adv.vulnerable(&Version::parse("0.1.50").unwrap()));
391 assert!(!adv.vulnerable(&Version::parse("0.2.42").unwrap()));
393 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 assert!(!adv.vulnerable(&Version::parse("0.0.9").unwrap()));
403 assert!(!adv.vulnerable(&Version::parse("0.0.1").unwrap()));
404 }
405
406 #[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 #[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 #[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 #[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 #[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}