1use crate::errors::RustinelError;
16use crate::lockfile::LockfileModel;
17use crate::signals::{Evidence, RiskSignal, Severity};
18use semver::{BuildMetadata, Comparator, Op, Prerelease, Version, VersionReq};
19use serde::Deserialize;
20use std::path::{Path, PathBuf};
21
22#[derive(Debug, Clone)]
23pub struct Advisory {
24 pub id: String,
25 pub package: String,
26 pub title: String,
27 pub informational: Option<String>,
28 pub cvss_score: Option<f32>,
29 pub patched: Vec<String>,
30 pub unaffected: Vec<String>,
31}
32
33#[derive(Debug, Default)]
34pub struct AdvisoryDb {
35 advisories: Vec<Advisory>,
36 pub missing: bool,
39}
40
41#[derive(Debug, Deserialize)]
42struct RawAdvisoryFile {
43 advisory: RawAdvisory,
44 versions: Option<RawVersions>,
45}
46
47#[derive(Debug, Deserialize)]
48struct RawAdvisory {
49 id: String,
50 package: String,
51 #[serde(default)]
52 title: String,
53 #[serde(default)]
54 informational: Option<String>,
55 #[serde(default)]
56 cvss: Option<String>,
57 #[serde(default)]
61 withdrawn: Option<String>,
62}
63
64#[derive(Debug, Deserialize)]
65struct RawVersions {
66 #[serde(default)]
67 patched: Vec<String>,
68 #[serde(default)]
69 unaffected: Vec<String>,
70}
71
72impl AdvisoryDb {
73 pub fn empty() -> Self {
74 Self {
75 advisories: Vec::new(),
76 missing: false,
77 }
78 }
79
80 pub fn len(&self) -> usize {
81 self.advisories.len()
82 }
83
84 pub fn is_empty(&self) -> bool {
85 self.advisories.is_empty()
86 }
87
88 pub fn load_from_dir(dir: &Path) -> Result<Self, RustinelError> {
93 if !dir.exists() {
94 return Ok(Self {
95 advisories: Vec::new(),
96 missing: true,
97 });
98 }
99 let mut advisories: Vec<Advisory> = Vec::new();
100 let mut stack: Vec<(PathBuf, usize)> = vec![(dir.to_path_buf(), 0)];
103 let mut visited = 0usize;
104 while let Some((d, depth)) = stack.pop() {
105 let entries = std::fs::read_dir(&d).map_err(|e| RustinelError::AdvisoryDb {
106 path: d.clone(),
107 message: e.to_string(),
108 })?;
109 for entry in entries.flatten() {
110 if visited >= crate::safety::MAX_DIR_ENTRIES {
111 advisories.sort_by(|a, b| a.id.cmp(&b.id));
112 return Ok(Self {
113 advisories,
114 missing: false,
115 });
116 }
117 visited += 1;
118 let Ok(ft) = entry.file_type() else { continue };
119 if ft.is_symlink() {
120 continue;
121 }
122 let path = entry.path();
123 if ft.is_dir() {
124 if path.file_name().and_then(|n| n.to_str()) == Some(".git") {
126 continue;
127 }
128 if depth < crate::safety::MAX_DIR_DEPTH {
129 stack.push((path, depth + 1));
130 }
131 } else if ft.is_file() {
132 match path.extension().and_then(|e| e.to_str()) {
136 Some("toml") | Some("md") => {
137 if let Some(adv) = parse_advisory_file(&path)? {
138 advisories.push(adv);
139 }
140 }
141 _ => {}
142 }
143 }
144 }
145 }
146 advisories.sort_by(|a, b| a.id.cmp(&b.id));
147 Ok(Self {
148 advisories,
149 missing: false,
150 })
151 }
152
153 pub fn default_cache_dir() -> Option<PathBuf> {
156 let home = std::env::var_os("CARGO_HOME")
157 .map(PathBuf::from)
158 .or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))?;
159 Some(home.join("advisory-db"))
160 }
161
162 pub fn match_lockfile(&self, lock: &LockfileModel) -> Vec<RiskSignal> {
164 let mut signals = Vec::new();
165 for package in lock.registry_packages() {
166 if !package.id.is_crates_io() {
170 continue;
171 }
172 let Ok(version) = Version::parse(&package.id.version) else {
173 continue;
174 };
175 for advisory in &self.advisories {
176 if advisory.package != package.id.name {
177 continue;
178 }
179 if advisory.affects(&version) {
180 signals.push(advisory.to_signal(&package.id.to_string()));
181 }
182 }
183 }
184 signals
185 }
186}
187
188impl Advisory {
189 pub fn affects(&self, version: &Version) -> bool {
192 if matches_any(&self.patched, version) {
193 return false;
194 }
195 if matches_any(&self.unaffected, version) {
196 return false;
197 }
198 true
199 }
200
201 pub fn severity(&self) -> Severity {
202 if let Some(kind) = &self.informational {
203 return match kind.as_str() {
205 "unsound" => Severity::Medium,
206 _ => Severity::Low,
207 };
208 }
209 match self.cvss_score {
210 Some(s) if s >= 9.0 => Severity::Critical,
211 Some(s) if s >= 7.0 => Severity::High,
212 Some(s) if s >= 4.0 => Severity::Medium,
213 Some(_) => Severity::Low,
214 None => Severity::High,
216 }
217 }
218
219 fn weight(&self) -> u8 {
220 match self.severity() {
221 Severity::Critical => 60,
222 Severity::High => 30,
223 Severity::Medium => 15,
224 Severity::Low => 6,
225 Severity::Info => 0,
226 }
227 }
228
229 fn to_signal(&self, package: &str) -> RiskSignal {
230 let summary = if self.title.is_empty() {
231 format!("{} advisory affects this version", self.id)
232 } else {
233 format!("{}: {}", self.id, self.title)
234 };
235 let recommendation = if self.patched.is_empty() {
236 "No patched version is published. Evaluate removing or replacing this dependency."
237 .into()
238 } else {
239 format!("Update to a patched version: {}", self.patched.join(", "))
240 };
241 RiskSignal {
242 id: format!("advisory_{}", self.id),
243 package: package.to_string(),
244 severity: self.severity(),
245 weight: self.weight(),
246 confidence: 1.0,
247 evidence: vec![Evidence::new("advisory", summary)],
248 recommendation,
249 }
250 }
251}
252
253fn matches_any(reqs: &[String], version: &Version) -> bool {
254 reqs.iter().any(|raw| req_matches(raw, version))
255}
256
257fn req_matches(raw: &str, version: &Version) -> bool {
268 let Ok(req) = VersionReq::parse(raw) else {
269 return false;
270 };
271 if version.pre.is_empty() {
272 return req.matches(version);
273 }
274 req.comparators
275 .iter()
276 .all(|c| comparator_matches_bare(c, version))
277}
278
279fn comparator_matches_bare(c: &Comparator, v: &Version) -> bool {
282 let base = Version {
283 major: c.major,
284 minor: c.minor.unwrap_or(0),
285 patch: c.patch.unwrap_or(0),
286 pre: c.pre.clone(),
287 build: BuildMetadata::EMPTY,
288 };
289 match c.op {
290 Op::Greater => *v > base,
291 Op::GreaterEq => *v >= base,
292 Op::Less => *v < base,
293 Op::LessEq => *v <= base,
294 Op::Exact => {
299 if v.major != c.major {
300 return false;
301 }
302 let Some(minor) = c.minor else {
303 return true;
304 };
305 if v.minor != minor {
306 return false;
307 }
308 let Some(patch) = c.patch else {
309 return true;
310 };
311 v.patch == patch && v.pre == c.pre
312 }
313 Op::Caret => *v >= base && *v < caret_upper(c),
316 _ => VersionReq {
319 comparators: vec![c.clone()],
320 }
321 .matches(v),
322 }
323}
324
325fn caret_upper(c: &Comparator) -> Version {
333 let (major, minor, patch);
334 if c.major > 0 {
335 (major, minor, patch) = (c.major + 1, 0, 0);
336 } else if c.minor.unwrap_or(0) > 0 {
337 (major, minor, patch) = (0, c.minor.unwrap_or(0) + 1, 0);
338 } else if c.minor.is_some() && c.patch.is_some() {
339 (major, minor, patch) = (0, 0, c.patch.unwrap_or(0) + 1);
340 } else if c.minor.is_some() {
341 (major, minor, patch) = (0, 1, 0);
342 } else {
343 (major, minor, patch) = (1, 0, 0);
344 }
345 Version {
346 major,
347 minor,
348 patch,
349 pre: Prerelease::new("0").unwrap_or(Prerelease::EMPTY),
351 build: BuildMetadata::EMPTY,
352 }
353}
354
355fn parse_advisory_file(path: &Path) -> Result<Option<Advisory>, RustinelError> {
356 let content =
358 match crate::safety::read_file_capped(path, crate::safety::MAX_ADVISORY_FILE_BYTES) {
359 Some(c) => c,
360 None => return Ok(None),
361 };
362 let toml_src = match extract_toml(&content) {
365 Some(src) => src,
366 None => return Ok(None),
367 };
368 let raw: RawAdvisoryFile = match toml::from_str(&toml_src) {
369 Ok(r) => r,
370 Err(_) => return Ok(None),
372 };
373 if raw.advisory.withdrawn.is_some() {
376 return Ok(None);
377 }
378 let versions = raw.versions.unwrap_or(RawVersions {
379 patched: vec![],
380 unaffected: vec![],
381 });
382 let title = if raw.advisory.title.is_empty() {
385 extract_md_title(&content).unwrap_or_default()
386 } else {
387 raw.advisory.title
388 };
389 Ok(Some(Advisory {
390 id: raw.advisory.id,
391 package: raw.advisory.package,
392 title,
393 informational: raw.advisory.informational,
394 cvss_score: raw.advisory.cvss.as_deref().and_then(parse_cvss_base_score),
395 patched: versions.patched,
396 unaffected: versions.unaffected,
397 }))
398}
399
400pub(crate) fn extract_toml(content: &str) -> Option<String> {
406 let trimmed = content.trim_start();
407
408 if let Some(start) = content.find("```toml") {
410 let after = &content[start + "```toml".len()..];
411 if let Some(end) = after.find("```") {
412 return Some(after[..end].trim().to_string());
413 }
414 }
415
416 if let Some(rest) = trimmed.strip_prefix("+++") {
418 if let Some(end) = rest.find("+++") {
419 return Some(rest[..end].trim().to_string());
420 }
421 }
422
423 if content.contains("[advisory]") {
425 return Some(content.to_string());
426 }
427
428 None
429}
430
431fn extract_md_title(content: &str) -> Option<String> {
434 let body = match content.find("```toml").and_then(|s| {
436 content[s + 7..]
437 .find("```")
438 .map(|e| &content[s + 7 + e + 3..])
439 }) {
440 Some(after_fence) => after_fence,
441 None => content,
442 };
443 for line in body.lines() {
444 let line = line.trim();
445 if let Some(title) = line.strip_prefix("# ") {
446 return Some(title.trim().to_string());
447 }
448 }
449 None
450}
451
452fn parse_cvss_base_score(cvss: &str) -> Option<f32> {
456 cvss.trim().parse::<f32>().ok()
457}
458
459#[cfg(test)]
460mod tests {
461 use super::*;
462
463 fn adv(patched: &[&str], unaffected: &[&str]) -> Advisory {
464 Advisory {
465 id: "RUSTSEC-2099-0001".into(),
466 package: "vuln".into(),
467 title: "test".into(),
468 informational: None,
469 cvss_score: Some(7.5),
470 patched: patched.iter().map(|s| s.to_string()).collect(),
471 unaffected: unaffected.iter().map(|s| s.to_string()).collect(),
472 }
473 }
474
475 #[test]
476 fn extract_toml_from_markdown_fence() {
477 let md = "```toml\n[advisory]\nid = \"RUSTSEC-2020-0105\"\npackage = \"abi_stable\"\ncvss = \"CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H\"\n\n[versions]\npatched = [\">= 0.9.1\"]\n```\n\n# Title\n\nDescription text.\n";
478 let toml_src = extract_toml(md).expect("toml extracted");
479 let raw: RawAdvisoryFile = toml::from_str(&toml_src).unwrap();
480 assert_eq!(raw.advisory.id, "RUSTSEC-2020-0105");
481 assert_eq!(raw.advisory.package, "abi_stable");
482 }
483
484 #[test]
485 fn extract_toml_from_bare_toml() {
486 let src = "[advisory]\nid = \"X\"\npackage = \"p\"\n";
487 assert!(extract_toml(src).is_some());
488 }
489
490 #[test]
491 fn extract_toml_rejects_plain_markdown() {
492 assert!(extract_toml("# Just a readme\n\nNo advisory here.\n").is_none());
493 }
494
495 #[test]
496 fn affected_below_patch() {
497 let a = adv(&[">= 1.2.4"], &[]);
498 assert!(a.affects(&Version::parse("1.2.3").unwrap()));
499 assert!(!a.affects(&Version::parse("1.2.4").unwrap()));
500 assert!(!a.affects(&Version::parse("2.0.0").unwrap()));
501 }
502
503 #[test]
504 fn unaffected_range_excluded() {
505 let a = adv(&[">= 1.2.4"], &["< 1.0.0"]);
506 assert!(!a.affects(&Version::parse("0.9.0").unwrap()));
507 assert!(a.affects(&Version::parse("1.1.0").unwrap()));
508 }
509
510 #[test]
511 fn severity_from_cvss() {
512 let mut a = adv(&[], &[]);
513 a.cvss_score = Some(9.5);
514 assert_eq!(a.severity(), Severity::Critical);
515 a.cvss_score = Some(5.0);
516 assert_eq!(a.severity(), Severity::Medium);
517 a.cvss_score = None;
518 assert_eq!(a.severity(), Severity::High);
519 }
520
521 #[test]
522 fn informational_is_lower_severity() {
523 let mut a = adv(&[], &[]);
524 a.informational = Some("unmaintained".into());
525 assert_eq!(a.severity(), Severity::Low);
526 a.informational = Some("unsound".into());
527 assert_eq!(a.severity(), Severity::Medium);
528 }
529
530 #[test]
531 fn missing_dir_is_not_an_error() {
532 let db = AdvisoryDb::load_from_dir(Path::new("/nonexistent/rustinel/db")).unwrap();
533 assert!(db.missing);
534 assert!(db.is_empty());
535 }
536
537 #[test]
538 fn withdrawn_advisories_are_suppressed() {
539 let dir = std::env::temp_dir().join("rustinel_withdrawn_db_test");
542 let _ = std::fs::remove_dir_all(&dir);
543 std::fs::create_dir_all(&dir).unwrap();
544 let withdrawn = "[advisory]\nid = \"RUSTSEC-2025-0007\"\npackage = \"ring\"\n\
545 informational = \"unmaintained\"\nwithdrawn = \"2025-02-22\"\n";
546 let active = "[advisory]\nid = \"RUSTSEC-2099-0001\"\npackage = \"vuln-crate\"\n\
547 cvss = \"7.5\"\n\n[versions]\npatched = [\">= 1.0.2\"]\n";
548 std::fs::write(dir.join("withdrawn.toml"), withdrawn).unwrap();
549 std::fs::write(dir.join("active.toml"), active).unwrap();
550
551 let db = AdvisoryDb::load_from_dir(&dir).unwrap();
552 let _ = std::fs::remove_dir_all(&dir);
553
554 assert_eq!(db.len(), 1, "withdrawn advisory must be dropped at load");
556 assert!(
557 db.advisories.iter().all(|a| a.id != "RUSTSEC-2025-0007"),
558 "withdrawn advisory must not be present"
559 );
560 assert!(db.advisories.iter().any(|a| a.id == "RUSTSEC-2099-0001"));
561 }
562
563 #[test]
564 fn multi_range_patched_real_shape() {
565 let a = adv(
569 &[">= 0.103.12, < 0.104.0-alpha.1", ">= 0.104.0-alpha.6"],
570 &[],
571 );
572 assert!(
573 a.affects(&Version::parse("0.103.10").unwrap()),
574 "below patch"
575 );
576 assert!(
577 !a.affects(&Version::parse("0.103.12").unwrap()),
578 "first patch"
579 );
580 assert!(
581 !a.affects(&Version::parse("0.104.0-alpha.6").unwrap()),
582 "second patch"
583 );
584 }
585
586 #[test]
587 fn prerelease_in_gap_is_affected() {
588 let a = adv(
590 &[">= 0.103.12, < 0.104.0-alpha.1", ">= 0.104.0-alpha.6"],
591 &[],
592 );
593 assert!(a.affects(&Version::parse("0.104.0-alpha.3").unwrap()));
594 }
595
596 #[test]
597 fn no_version_ranges_affects_all() {
598 let a = adv(&[], &[]);
601 assert!(a.affects(&Version::parse("0.1.0").unwrap()));
602 assert!(a.affects(&Version::parse("99.0.0").unwrap()));
603 }
604
605 #[test]
606 fn malformed_version_req_does_not_panic() {
607 let a = adv(&["not a semver req"], &["also <<>> bad"]);
610 assert!(a.affects(&Version::parse("1.0.0").unwrap()));
612 }
613
614 #[test]
615 fn prerelease_excluded_by_unaffected_bound() {
616 let a = adv(&[], &["< 1.0.0"]);
620 assert!(!a.affects(&Version::parse("1.0.0-rc.1").unwrap()));
621 let b = adv(&[">= 0.3.6"], &["< 0.3.6"]);
623 assert!(!b.affects(&Version::parse("0.2.23-rc.1").unwrap()));
624 }
625
626 #[test]
627 fn partial_exact_bound_covers_prereleases() {
628 let a = adv(&[], &["= 1.2"]);
631 assert!(
632 !a.affects(&Version::parse("1.2.5-rc.1").unwrap()),
633 "=1.2 must cover 1.2.5-rc.1"
634 );
635 assert!(
636 a.affects(&Version::parse("1.3.0-rc.1").unwrap()),
637 "=1.2 must not cover 1.3.0-rc.1"
638 );
639 let b = adv(&[], &["= 1"]);
641 assert!(!b.affects(&Version::parse("1.7.0-rc.1").unwrap()));
642 assert!(b.affects(&Version::parse("2.0.0-rc.1").unwrap()));
643 let c = adv(&[], &["= 1.2.3"]);
645 assert!(c.affects(&Version::parse("1.2.3-rc.1").unwrap()));
646 }
647
648 #[test]
649 fn prerelease_in_affected_range_still_flags() {
650 let a = adv(&[">= 0.3.0"], &["< 0.1.0"]);
653 assert!(a.affects(&Version::parse("0.2.0-rc.1").unwrap()));
654 }
655
656 #[test]
657 fn prerelease_against_caret_patched_bound() {
658 let a = adv(&["^0.6.4", ">= 0.7.1"], &[]);
662 assert!(!a.affects(&Version::parse("0.6.5-rc.1").unwrap()));
663 assert!(a.affects(&Version::parse("0.6.4-rc.1").unwrap()));
664 assert!(a.affects(&Version::parse("0.7.0-rc.1").unwrap()));
668 }
669
670 #[test]
671 fn advisories_only_match_crates_io_source() {
672 use crate::lockfile::{LockfileModel, Package, PackageId};
675
676 let mk = |source: Option<&str>| Package {
677 id: PackageId {
678 name: "vuln".into(),
679 version: "1.0.0".into(),
680 source: source.map(|s| s.to_string()),
681 },
682 checksum: None,
683 dependencies: vec![],
684 };
685 let lock = LockfileModel {
686 path: "Cargo.lock".into(),
687 version: Some(3),
688 packages: vec![
689 mk(Some(crate::lockfile::CRATES_IO_REGISTRY)),
690 mk(Some("git+https://github.com/attacker/notvuln#abc")),
691 mk(Some("registry+https://internal.corp/private-index")),
692 ],
693 };
694
695 let mut db = AdvisoryDb::empty();
696 db.advisories.push(adv(&[], &[])); let signals = db.match_lockfile(&lock);
699 assert_eq!(signals.len(), 1, "only crates.io source should match");
701 }
702}