use crate::errors::RustinelError;
use crate::lockfile::LockfileModel;
use crate::signals::{Evidence, RiskSignal, Severity};
use semver::{BuildMetadata, Comparator, Op, Prerelease, Version, VersionReq};
use serde::Deserialize;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct Advisory {
pub id: String,
pub package: String,
pub title: String,
pub informational: Option<String>,
pub cvss_score: Option<f32>,
pub patched: Vec<String>,
pub unaffected: Vec<String>,
}
#[derive(Debug, Default)]
pub struct AdvisoryDb {
advisories: Vec<Advisory>,
pub missing: bool,
}
#[derive(Debug, Deserialize)]
struct RawAdvisoryFile {
advisory: RawAdvisory,
versions: Option<RawVersions>,
}
#[derive(Debug, Deserialize)]
struct RawAdvisory {
id: String,
package: String,
#[serde(default)]
title: String,
#[serde(default)]
informational: Option<String>,
#[serde(default)]
cvss: Option<String>,
#[serde(default)]
withdrawn: Option<String>,
}
#[derive(Debug, Deserialize)]
struct RawVersions {
#[serde(default)]
patched: Vec<String>,
#[serde(default)]
unaffected: Vec<String>,
}
impl AdvisoryDb {
pub fn empty() -> Self {
Self {
advisories: Vec::new(),
missing: false,
}
}
pub fn len(&self) -> usize {
self.advisories.len()
}
pub fn is_empty(&self) -> bool {
self.advisories.is_empty()
}
pub fn load_from_dir(dir: &Path) -> Result<Self, RustinelError> {
if !dir.exists() {
return Ok(Self {
advisories: Vec::new(),
missing: true,
});
}
let mut advisories: Vec<Advisory> = Vec::new();
let mut stack: Vec<(PathBuf, usize)> = vec![(dir.to_path_buf(), 0)];
let mut visited = 0usize;
while let Some((d, depth)) = stack.pop() {
let entries = std::fs::read_dir(&d).map_err(|e| RustinelError::AdvisoryDb {
path: d.clone(),
message: e.to_string(),
})?;
for entry in entries.flatten() {
if visited >= crate::safety::MAX_DIR_ENTRIES {
advisories.sort_by(|a, b| a.id.cmp(&b.id));
return Ok(Self {
advisories,
missing: false,
});
}
visited += 1;
let Ok(ft) = entry.file_type() else { continue };
if ft.is_symlink() {
continue;
}
let path = entry.path();
if ft.is_dir() {
if path.file_name().and_then(|n| n.to_str()) == Some(".git") {
continue;
}
if depth < crate::safety::MAX_DIR_DEPTH {
stack.push((path, depth + 1));
}
} else if ft.is_file() {
match path.extension().and_then(|e| e.to_str()) {
Some("toml") | Some("md") => {
if let Some(adv) = parse_advisory_file(&path)? {
advisories.push(adv);
}
}
_ => {}
}
}
}
}
advisories.sort_by(|a, b| a.id.cmp(&b.id));
Ok(Self {
advisories,
missing: false,
})
}
pub fn default_cache_dir() -> Option<PathBuf> {
let home = std::env::var_os("CARGO_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cargo")))?;
Some(home.join("advisory-db"))
}
pub fn match_lockfile(&self, lock: &LockfileModel) -> Vec<RiskSignal> {
let mut signals = Vec::new();
for package in lock.registry_packages() {
if !package.id.is_crates_io() {
continue;
}
let Ok(version) = Version::parse(&package.id.version) else {
continue;
};
for advisory in &self.advisories {
if advisory.package != package.id.name {
continue;
}
if advisory.affects(&version) {
signals.push(advisory.to_signal(&package.id.to_string()));
}
}
}
signals
}
}
impl Advisory {
pub fn affects(&self, version: &Version) -> bool {
if matches_any(&self.patched, version) {
return false;
}
if matches_any(&self.unaffected, version) {
return false;
}
true
}
pub fn severity(&self) -> Severity {
if let Some(kind) = &self.informational {
return match kind.as_str() {
"unsound" => Severity::Medium,
_ => Severity::Low,
};
}
match self.cvss_score {
Some(s) if s >= 9.0 => Severity::Critical,
Some(s) if s >= 7.0 => Severity::High,
Some(s) if s >= 4.0 => Severity::Medium,
Some(_) => Severity::Low,
None => Severity::High,
}
}
fn weight(&self) -> u8 {
match self.severity() {
Severity::Critical => 60,
Severity::High => 30,
Severity::Medium => 15,
Severity::Low => 6,
Severity::Info => 0,
}
}
fn to_signal(&self, package: &str) -> RiskSignal {
let summary = if self.title.is_empty() {
format!("{} advisory affects this version", self.id)
} else {
format!("{}: {}", self.id, self.title)
};
let recommendation = if self.patched.is_empty() {
"No patched version is published. Evaluate removing or replacing this dependency."
.into()
} else {
format!("Update to a patched version: {}", self.patched.join(", "))
};
RiskSignal {
id: format!("advisory_{}", self.id),
package: package.to_string(),
severity: self.severity(),
weight: self.weight(),
confidence: 1.0,
evidence: vec![Evidence::new("advisory", summary)],
recommendation,
}
}
}
fn matches_any(reqs: &[String], version: &Version) -> bool {
reqs.iter().any(|raw| req_matches(raw, version))
}
fn req_matches(raw: &str, version: &Version) -> bool {
let Ok(req) = VersionReq::parse(raw) else {
return false;
};
if version.pre.is_empty() {
return req.matches(version);
}
req.comparators
.iter()
.all(|c| comparator_matches_bare(c, version))
}
fn comparator_matches_bare(c: &Comparator, v: &Version) -> bool {
let base = Version {
major: c.major,
minor: c.minor.unwrap_or(0),
patch: c.patch.unwrap_or(0),
pre: c.pre.clone(),
build: BuildMetadata::EMPTY,
};
match c.op {
Op::Greater => *v > base,
Op::GreaterEq => *v >= base,
Op::Less => *v < base,
Op::LessEq => *v <= base,
Op::Exact => {
if v.major != c.major {
return false;
}
let Some(minor) = c.minor else {
return true;
};
if v.minor != minor {
return false;
}
let Some(patch) = c.patch else {
return true;
};
v.patch == patch && v.pre == c.pre
}
Op::Caret => *v >= base && *v < caret_upper(c),
_ => VersionReq {
comparators: vec![c.clone()],
}
.matches(v),
}
}
fn caret_upper(c: &Comparator) -> Version {
let (major, minor, patch);
if c.major > 0 {
(major, minor, patch) = (c.major + 1, 0, 0);
} else if c.minor.unwrap_or(0) > 0 {
(major, minor, patch) = (0, c.minor.unwrap_or(0) + 1, 0);
} else if c.minor.is_some() && c.patch.is_some() {
(major, minor, patch) = (0, 0, c.patch.unwrap_or(0) + 1);
} else if c.minor.is_some() {
(major, minor, patch) = (0, 1, 0);
} else {
(major, minor, patch) = (1, 0, 0);
}
Version {
major,
minor,
patch,
pre: Prerelease::new("0").unwrap_or(Prerelease::EMPTY),
build: BuildMetadata::EMPTY,
}
}
fn parse_advisory_file(path: &Path) -> Result<Option<Advisory>, RustinelError> {
let content =
match crate::safety::read_file_capped(path, crate::safety::MAX_ADVISORY_FILE_BYTES) {
Some(c) => c,
None => return Ok(None),
};
let toml_src = match extract_toml(&content) {
Some(src) => src,
None => return Ok(None),
};
let raw: RawAdvisoryFile = match toml::from_str(&toml_src) {
Ok(r) => r,
Err(_) => return Ok(None),
};
if raw.advisory.withdrawn.is_some() {
return Ok(None);
}
let versions = raw.versions.unwrap_or(RawVersions {
patched: vec![],
unaffected: vec![],
});
let title = if raw.advisory.title.is_empty() {
extract_md_title(&content).unwrap_or_default()
} else {
raw.advisory.title
};
Ok(Some(Advisory {
id: raw.advisory.id,
package: raw.advisory.package,
title,
informational: raw.advisory.informational,
cvss_score: raw.advisory.cvss.as_deref().and_then(parse_cvss_base_score),
patched: versions.patched,
unaffected: versions.unaffected,
}))
}
pub(crate) fn extract_toml(content: &str) -> Option<String> {
let trimmed = content.trim_start();
if let Some(start) = content.find("```toml") {
let after = &content[start + "```toml".len()..];
if let Some(end) = after.find("```") {
return Some(after[..end].trim().to_string());
}
}
if let Some(rest) = trimmed.strip_prefix("+++") {
if let Some(end) = rest.find("+++") {
return Some(rest[..end].trim().to_string());
}
}
if content.contains("[advisory]") {
return Some(content.to_string());
}
None
}
fn extract_md_title(content: &str) -> Option<String> {
let body = match content.find("```toml").and_then(|s| {
content[s + 7..]
.find("```")
.map(|e| &content[s + 7 + e + 3..])
}) {
Some(after_fence) => after_fence,
None => content,
};
for line in body.lines() {
let line = line.trim();
if let Some(title) = line.strip_prefix("# ") {
return Some(title.trim().to_string());
}
}
None
}
fn parse_cvss_base_score(cvss: &str) -> Option<f32> {
cvss.trim().parse::<f32>().ok()
}
#[cfg(test)]
mod tests {
use super::*;
fn adv(patched: &[&str], unaffected: &[&str]) -> Advisory {
Advisory {
id: "RUSTSEC-2099-0001".into(),
package: "vuln".into(),
title: "test".into(),
informational: None,
cvss_score: Some(7.5),
patched: patched.iter().map(|s| s.to_string()).collect(),
unaffected: unaffected.iter().map(|s| s.to_string()).collect(),
}
}
#[test]
fn extract_toml_from_markdown_fence() {
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";
let toml_src = extract_toml(md).expect("toml extracted");
let raw: RawAdvisoryFile = toml::from_str(&toml_src).unwrap();
assert_eq!(raw.advisory.id, "RUSTSEC-2020-0105");
assert_eq!(raw.advisory.package, "abi_stable");
}
#[test]
fn extract_toml_from_bare_toml() {
let src = "[advisory]\nid = \"X\"\npackage = \"p\"\n";
assert!(extract_toml(src).is_some());
}
#[test]
fn extract_toml_rejects_plain_markdown() {
assert!(extract_toml("# Just a readme\n\nNo advisory here.\n").is_none());
}
#[test]
fn affected_below_patch() {
let a = adv(&[">= 1.2.4"], &[]);
assert!(a.affects(&Version::parse("1.2.3").unwrap()));
assert!(!a.affects(&Version::parse("1.2.4").unwrap()));
assert!(!a.affects(&Version::parse("2.0.0").unwrap()));
}
#[test]
fn unaffected_range_excluded() {
let a = adv(&[">= 1.2.4"], &["< 1.0.0"]);
assert!(!a.affects(&Version::parse("0.9.0").unwrap()));
assert!(a.affects(&Version::parse("1.1.0").unwrap()));
}
#[test]
fn severity_from_cvss() {
let mut a = adv(&[], &[]);
a.cvss_score = Some(9.5);
assert_eq!(a.severity(), Severity::Critical);
a.cvss_score = Some(5.0);
assert_eq!(a.severity(), Severity::Medium);
a.cvss_score = None;
assert_eq!(a.severity(), Severity::High);
}
#[test]
fn informational_is_lower_severity() {
let mut a = adv(&[], &[]);
a.informational = Some("unmaintained".into());
assert_eq!(a.severity(), Severity::Low);
a.informational = Some("unsound".into());
assert_eq!(a.severity(), Severity::Medium);
}
#[test]
fn missing_dir_is_not_an_error() {
let db = AdvisoryDb::load_from_dir(Path::new("/nonexistent/rustinel/db")).unwrap();
assert!(db.missing);
assert!(db.is_empty());
}
#[test]
fn withdrawn_advisories_are_suppressed() {
let dir = std::env::temp_dir().join("rustinel_withdrawn_db_test");
let _ = std::fs::remove_dir_all(&dir);
std::fs::create_dir_all(&dir).unwrap();
let withdrawn = "[advisory]\nid = \"RUSTSEC-2025-0007\"\npackage = \"ring\"\n\
informational = \"unmaintained\"\nwithdrawn = \"2025-02-22\"\n";
let active = "[advisory]\nid = \"RUSTSEC-2099-0001\"\npackage = \"vuln-crate\"\n\
cvss = \"7.5\"\n\n[versions]\npatched = [\">= 1.0.2\"]\n";
std::fs::write(dir.join("withdrawn.toml"), withdrawn).unwrap();
std::fs::write(dir.join("active.toml"), active).unwrap();
let db = AdvisoryDb::load_from_dir(&dir).unwrap();
let _ = std::fs::remove_dir_all(&dir);
assert_eq!(db.len(), 1, "withdrawn advisory must be dropped at load");
assert!(
db.advisories.iter().all(|a| a.id != "RUSTSEC-2025-0007"),
"withdrawn advisory must not be present"
);
assert!(db.advisories.iter().any(|a| a.id == "RUSTSEC-2099-0001"));
}
#[test]
fn multi_range_patched_real_shape() {
let a = adv(
&[">= 0.103.12, < 0.104.0-alpha.1", ">= 0.104.0-alpha.6"],
&[],
);
assert!(
a.affects(&Version::parse("0.103.10").unwrap()),
"below patch"
);
assert!(
!a.affects(&Version::parse("0.103.12").unwrap()),
"first patch"
);
assert!(
!a.affects(&Version::parse("0.104.0-alpha.6").unwrap()),
"second patch"
);
}
#[test]
fn prerelease_in_gap_is_affected() {
let a = adv(
&[">= 0.103.12, < 0.104.0-alpha.1", ">= 0.104.0-alpha.6"],
&[],
);
assert!(a.affects(&Version::parse("0.104.0-alpha.3").unwrap()));
}
#[test]
fn no_version_ranges_affects_all() {
let a = adv(&[], &[]);
assert!(a.affects(&Version::parse("0.1.0").unwrap()));
assert!(a.affects(&Version::parse("99.0.0").unwrap()));
}
#[test]
fn malformed_version_req_does_not_panic() {
let a = adv(&["not a semver req"], &["also <<>> bad"]);
assert!(a.affects(&Version::parse("1.0.0").unwrap()));
}
#[test]
fn prerelease_excluded_by_unaffected_bound() {
let a = adv(&[], &["< 1.0.0"]);
assert!(!a.affects(&Version::parse("1.0.0-rc.1").unwrap()));
let b = adv(&[">= 0.3.6"], &["< 0.3.6"]);
assert!(!b.affects(&Version::parse("0.2.23-rc.1").unwrap()));
}
#[test]
fn partial_exact_bound_covers_prereleases() {
let a = adv(&[], &["= 1.2"]);
assert!(
!a.affects(&Version::parse("1.2.5-rc.1").unwrap()),
"=1.2 must cover 1.2.5-rc.1"
);
assert!(
a.affects(&Version::parse("1.3.0-rc.1").unwrap()),
"=1.2 must not cover 1.3.0-rc.1"
);
let b = adv(&[], &["= 1"]);
assert!(!b.affects(&Version::parse("1.7.0-rc.1").unwrap()));
assert!(b.affects(&Version::parse("2.0.0-rc.1").unwrap()));
let c = adv(&[], &["= 1.2.3"]);
assert!(c.affects(&Version::parse("1.2.3-rc.1").unwrap()));
}
#[test]
fn prerelease_in_affected_range_still_flags() {
let a = adv(&[">= 0.3.0"], &["< 0.1.0"]);
assert!(a.affects(&Version::parse("0.2.0-rc.1").unwrap()));
}
#[test]
fn prerelease_against_caret_patched_bound() {
let a = adv(&["^0.6.4", ">= 0.7.1"], &[]);
assert!(!a.affects(&Version::parse("0.6.5-rc.1").unwrap()));
assert!(a.affects(&Version::parse("0.6.4-rc.1").unwrap()));
assert!(a.affects(&Version::parse("0.7.0-rc.1").unwrap()));
}
#[test]
fn advisories_only_match_crates_io_source() {
use crate::lockfile::{LockfileModel, Package, PackageId};
let mk = |source: Option<&str>| Package {
id: PackageId {
name: "vuln".into(),
version: "1.0.0".into(),
source: source.map(|s| s.to_string()),
},
checksum: None,
dependencies: vec![],
};
let lock = LockfileModel {
path: "Cargo.lock".into(),
version: Some(3),
packages: vec![
mk(Some(crate::lockfile::CRATES_IO_REGISTRY)),
mk(Some("git+https://github.com/attacker/notvuln#abc")),
mk(Some("registry+https://internal.corp/private-index")),
],
};
let mut db = AdvisoryDb::empty();
db.advisories.push(adv(&[], &[]));
let signals = db.match_lockfile(&lock);
assert_eq!(signals.len(), 1, "only crates.io source should match");
}
}