use std::collections::HashMap;
use std::path::Path;
use semver::{Version, VersionReq};
use serde::{Deserialize, Serialize};
use walkdir::WalkDir;
use crate::pep440;
use crate::scanner::{
read_file_with_limit, CryptoFinding, Ecosystem, VersionSource, MAX_MANIFEST_SIZE,
};
#[derive(Debug, Clone)]
pub enum ParsedVersion {
Semver(Version),
Pep440(pep440::Version),
}
impl ParsedVersion {
pub fn parse(ecosystem: &Ecosystem, version: &str) -> Option<ParsedVersion> {
match ecosystem {
Ecosystem::PyPI => pep440::Version::parse(version).map(ParsedVersion::Pep440),
_ => Version::parse(version).ok().map(ParsedVersion::Semver),
}
}
fn cmp_str(&self, other: &str) -> Option<std::cmp::Ordering> {
match self {
ParsedVersion::Semver(v) => Version::parse(other).ok().map(|o| v.cmp(&o)),
ParsedVersion::Pep440(v) => pep440::Version::parse(other).map(|o| v.cmp(&o)),
}
}
fn satisfies(&self, req: &str) -> bool {
match self {
ParsedVersion::Semver(v) => VersionReq::parse(req)
.map(|r| r.matches(v))
.unwrap_or(false),
ParsedVersion::Pep440(v) => pep440::Requirement::parse(req)
.map(|r| r.matches(v))
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
pub enum Severity {
None,
Low,
Medium,
High,
Critical,
}
impl Severity {
pub fn from_score(score: f32) -> Severity {
if score <= 0.0 {
Severity::None
} else if score < 4.0 {
Severity::Low
} else if score < 7.0 {
Severity::Medium
} else if score < 9.0 {
Severity::High
} else {
Severity::Critical
}
}
pub fn parse(s: &str) -> Option<Severity> {
match s.trim().to_lowercase().as_str() {
"none" => Some(Severity::None),
"low" => Some(Severity::Low),
"medium" | "moderate" => Some(Severity::Medium),
"high" => Some(Severity::High),
"critical" => Some(Severity::Critical),
_ => None,
}
}
pub fn as_str(&self) -> &'static str {
match self {
Severity::None => "none",
Severity::Low => "low",
Severity::Medium => "medium",
Severity::High => "high",
Severity::Critical => "critical",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Interval {
introduced: Option<String>,
end: Option<String>,
end_inclusive: bool,
}
impl Interval {
fn contains(&self, v: &ParsedVersion) -> bool {
use std::cmp::Ordering::*;
if let Some(lo) = &self.introduced {
match v.cmp_str(lo) {
Some(Less) | None => return false,
_ => {}
}
}
if let Some(hi) = &self.end {
match v.cmp_str(hi) {
Some(Less) => {}
Some(Equal) if self.end_inclusive => {}
_ => return false,
}
}
true
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum Affected {
Requirements {
patched: Vec<String>,
unaffected: Vec<String>,
},
Ranges {
intervals: Vec<Interval>,
versions: Vec<String>,
},
AllVersions,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Advisory {
pub id: String,
pub ecosystem: Ecosystem,
pub package: String,
pub title: String,
pub severity: Severity,
pub cvss: Option<f32>,
pub affected: Affected,
}
impl Advisory {
pub fn affects(&self, version: &ParsedVersion, raw: &str) -> bool {
match &self.affected {
Affected::AllVersions => true,
Affected::Requirements {
patched,
unaffected,
} => {
let satisfies = |reqs: &[String]| reqs.iter().any(|r| version.satisfies(r));
!satisfies(patched) && !satisfies(unaffected)
}
Affected::Ranges {
intervals,
versions,
} => {
if intervals.iter().any(|i| i.contains(version)) {
return true;
}
versions.iter().any(|v| {
matches!(version.cmp_str(v), Some(std::cmp::Ordering::Equal)) || raw == v
})
}
}
}
#[cfg(test)]
pub(crate) fn for_test(
id: &str,
severity: Severity,
cvss: Option<f32>,
title: &str,
) -> Advisory {
Advisory {
id: id.to_string(),
ecosystem: Ecosystem::Cargo,
package: id.to_string(),
title: title.to_string(),
severity,
cvss,
affected: Affected::Requirements {
patched: Vec::new(),
unaffected: Vec::new(),
},
}
}
}
#[derive(Debug, Default)]
pub struct AdvisoryDb {
by_package: HashMap<(Ecosystem, String), Vec<Advisory>>,
}
impl AdvisoryDb {
pub fn load_from_dir(path: &Path) -> std::io::Result<AdvisoryDb> {
let mut db = AdvisoryDb::default();
for entry in WalkDir::new(path)
.follow_links(false)
.into_iter()
.filter_map(|e| e.ok())
{
let p = entry.path();
if !p.is_file() {
continue;
}
match p.extension().and_then(|e| e.to_str()) {
Some("toml") | Some("md") => {
if let Some(a) = parse_rustsec_advisory(p) {
db.insert(a);
}
}
Some("json") => {
for a in parse_osv_json(p) {
db.insert(a);
}
}
_ => {}
}
}
Ok(db)
}
fn insert(&mut self, a: Advisory) {
self.by_package
.entry((a.ecosystem.clone(), a.package.clone()))
.or_default()
.push(a);
}
pub fn matches(&self, ecosystem: &Ecosystem, name: &str, version: &str) -> Vec<&Advisory> {
let parsed = match ParsedVersion::parse(ecosystem, version) {
Some(v) => v,
None => return Vec::new(),
};
self.by_package
.get(&(ecosystem.clone(), name.to_string()))
.map(|advs| {
advs.iter()
.filter(|a| a.affects(&parsed, version))
.collect()
})
.unwrap_or_default()
}
pub fn len(&self) -> usize {
self.by_package.values().map(|v| v.len()).sum()
}
pub fn is_empty(&self) -> bool {
self.by_package.is_empty()
}
}
#[derive(Debug, Clone, Serialize)]
pub struct FindingMatch {
pub name: String,
pub version: String,
pub path: String,
pub advisories: Vec<Advisory>,
}
#[derive(Debug, Default)]
pub struct CorrelationResult {
pub matches: Vec<FindingMatch>,
pub skipped_unresolved: usize,
}
impl CorrelationResult {
pub fn worst_severity(&self) -> Option<Severity> {
self.matches
.iter()
.flat_map(|m| m.advisories.iter())
.map(|a| a.severity)
.max()
}
pub fn total_advisories(&self) -> usize {
self.matches.iter().map(|m| m.advisories.len()).sum()
}
pub fn counts_by_severity(&self) -> Vec<(Severity, usize)> {
let mut counts: HashMap<Severity, usize> = HashMap::new();
for adv in self.matches.iter().flat_map(|m| m.advisories.iter()) {
*counts.entry(adv.severity).or_insert(0) += 1;
}
let mut out: Vec<_> = counts.into_iter().collect();
out.sort_by_key(|&(severity, _)| std::cmp::Reverse(severity));
out
}
}
pub fn correlate(db: &AdvisoryDb, findings: &[CryptoFinding]) -> CorrelationResult {
let mut result = CorrelationResult::default();
let mut seen: std::collections::HashSet<(Ecosystem, String, String)> =
std::collections::HashSet::new();
for finding in findings {
let version = match &finding.version {
Some(v) => v,
None => continue,
};
match finding.version_source {
VersionSource::Locked => {
if !seen.insert((
finding.ecosystem.clone(),
finding.name.clone(),
version.clone(),
)) {
continue;
}
let advisories: Vec<Advisory> = db
.matches(&finding.ecosystem, &finding.name, version)
.into_iter()
.cloned()
.collect();
if !advisories.is_empty() {
result.matches.push(FindingMatch {
name: finding.name.clone(),
version: version.clone(),
path: finding.path.clone(),
advisories,
});
}
}
VersionSource::Declared => {
result.skipped_unresolved += 1;
}
}
}
result
}
fn split_advisory_frontmatter(content: &str) -> Option<(&str, &str)> {
let trimmed = content.trim_start();
if !trimmed.starts_with("```") {
return Some((content, ""));
}
let after_open = trimmed.find('\n').map(|i| &trimmed[i + 1..])?;
let mut offset = 0usize;
for line in after_open.split_inclusive('\n') {
if line.trim_start().starts_with("```") {
return Some((&after_open[..offset], &after_open[offset + line.len()..]));
}
offset += line.len();
}
None
}
fn markdown_title(body: &str) -> Option<String> {
body.lines()
.map(str::trim)
.find(|l| l.starts_with("# "))
.map(|l| l.trim_start_matches("# ").trim().to_string())
.filter(|t| !t.is_empty())
}
fn parse_rustsec_advisory(path: &Path) -> Option<Advisory> {
let content = read_file_with_limit(path, MAX_MANIFEST_SIZE).ok()?;
let (toml_src, body) = split_advisory_frontmatter(&content)?;
let val: toml::Value = toml::from_str(toml_src).ok()?;
let adv = val.get("advisory")?;
if adv.get("informational").is_some() {
return None;
}
let id = adv.get("id")?.as_str()?.to_string();
let package = adv.get("package")?.as_str()?.to_string();
let title = adv
.get("title")
.and_then(|v| v.as_str())
.map(str::to_string)
.or_else(|| markdown_title(body))
.unwrap_or_default();
let cvss = adv
.get("cvss")
.and_then(|v| v.as_str())
.and_then(cvss_base_score);
let severity = cvss.map(Severity::from_score).unwrap_or(Severity::Medium);
let versions = val.get("versions");
let patched = versions
.and_then(|v| v.get("patched"))
.and_then(toml_str_array)
.unwrap_or_default();
let unaffected = versions
.and_then(|v| v.get("unaffected"))
.and_then(toml_str_array)
.unwrap_or_default();
Some(Advisory {
id,
ecosystem: Ecosystem::Cargo, package,
title,
severity,
cvss,
affected: Affected::Requirements {
patched,
unaffected,
},
})
}
fn toml_str_array(v: &toml::Value) -> Option<Vec<String>> {
v.as_array().map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
}
fn osv_ecosystem(s: &str) -> Option<Ecosystem> {
let base = s.split(':').next().unwrap_or(s);
if base.eq_ignore_ascii_case("crates.io") {
Some(Ecosystem::Cargo)
} else if base.eq_ignore_ascii_case("npm") {
Some(Ecosystem::Npm)
} else if base.eq_ignore_ascii_case("PyPI") {
Some(Ecosystem::PyPI)
} else {
None
}
}
fn parse_osv_json(path: &Path) -> Vec<Advisory> {
let content = match read_file_with_limit(path, MAX_MANIFEST_SIZE) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
let val: serde_json::Value = match serde_json::from_str(&content) {
Ok(v) => v,
Err(_) => return Vec::new(),
};
let id = val
.get("id")
.and_then(|v| v.as_str())
.unwrap_or("")
.to_string();
let title = val
.get("summary")
.and_then(|v| v.as_str())
.or_else(|| val.get("details").and_then(|v| v.as_str()))
.unwrap_or("")
.to_string();
let cvss = val
.get("severity")
.and_then(|v| v.as_array())
.and_then(|arr| {
arr.iter().find_map(|s| {
let ty = s.get("type").and_then(|t| t.as_str()).unwrap_or("");
if ty.starts_with("CVSS_V3") {
s.get("score")
.and_then(|sc| sc.as_str())
.and_then(cvss_base_score)
} else {
None
}
})
});
let severity = cvss.map(Severity::from_score).unwrap_or(Severity::Medium);
let mut out = Vec::new();
let affected = match val.get("affected").and_then(|v| v.as_array()) {
Some(a) => a,
None => return out,
};
for aff in affected {
let pkg = aff.get("package");
let ecosystem = match pkg
.and_then(|p| p.get("ecosystem"))
.and_then(|e| e.as_str())
.and_then(osv_ecosystem)
{
Some(e) => e,
None => continue, };
let raw_name = match pkg.and_then(|p| p.get("name")).and_then(|n| n.as_str()) {
Some(n) => n,
None => continue,
};
let name = if ecosystem == Ecosystem::PyPI {
crate::scanner::normalize_pypi_name(raw_name)
} else {
raw_name.to_string()
};
let mut intervals: Vec<Interval> = Vec::new();
let mut has_git_range = false;
if let Some(ranges) = aff.get("ranges").and_then(|r| r.as_array()) {
for range in ranges {
if range.get("type").and_then(|t| t.as_str()) == Some("GIT") {
has_git_range = true;
continue;
}
let events = match range.get("events").and_then(|e| e.as_array()) {
Some(e) => e,
None => continue,
};
let mut open: Option<Interval> = None;
for ev in events {
if let Some(introduced) = ev.get("introduced").and_then(|v| v.as_str()) {
if let Some(prev) = open.take() {
intervals.push(prev);
}
let lo = matches!(introduced, "0" | "0.0.0" | "0.0.0-0")
.then(|| None)
.unwrap_or_else(|| Some(introduced.to_string()));
open = Some(Interval {
introduced: lo,
end: None,
end_inclusive: false,
});
}
let close = ev
.get("fixed")
.and_then(|v| v.as_str())
.map(|f| (f, false))
.or_else(|| {
ev.get("last_affected")
.and_then(|v| v.as_str())
.map(|l| (l, true))
});
if let Some((bound, inclusive)) = close {
let mut iv = open.take().unwrap_or(Interval {
introduced: None,
end: None,
end_inclusive: false,
});
iv.end = Some(bound.to_string());
iv.end_inclusive = inclusive;
intervals.push(iv);
}
}
if let Some(iv) = open.take() {
intervals.push(iv);
}
}
}
let versions: Vec<String> = aff
.get("versions")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(String::from))
.collect()
})
.unwrap_or_default();
let affected = if intervals.is_empty() && versions.is_empty() {
if has_git_range {
Affected::Ranges {
intervals,
versions,
}
} else {
Affected::AllVersions
}
} else {
Affected::Ranges {
intervals,
versions,
}
};
out.push(Advisory {
id: id.clone(),
ecosystem,
package: name,
title: title.clone(),
severity,
cvss,
affected,
});
}
out
}
pub fn cvss_base_score(vector: &str) -> Option<f32> {
let mut m: HashMap<&str, &str> = HashMap::new();
for part in vector.split('/') {
if let Some((k, v)) = part.split_once(':') {
m.insert(k, v);
}
}
if !m.get("CVSS").map(|v| v.starts_with("3.")).unwrap_or(false) {
return None;
}
let av = match *m.get("AV")? {
"N" => 0.85,
"A" => 0.62,
"L" => 0.55,
"P" => 0.20,
_ => return None,
};
let ac = match *m.get("AC")? {
"L" => 0.77,
"H" => 0.44,
_ => return None,
};
let ui = match *m.get("UI")? {
"N" => 0.85,
"R" => 0.62,
_ => return None,
};
let scope_changed = match *m.get("S")? {
"U" => false,
"C" => true,
_ => return None,
};
let pr = match *m.get("PR")? {
"N" => 0.85,
"L" => {
if scope_changed {
0.68
} else {
0.62
}
}
"H" => {
if scope_changed {
0.50
} else {
0.27
}
}
_ => return None,
};
let impact_metric = |code: &str| -> Option<f64> {
match m.get(code).copied()? {
"H" => Some(0.56),
"L" => Some(0.22),
"N" => Some(0.0),
_ => None,
}
};
let c = impact_metric("C")?;
let i = impact_metric("I")?;
let a = impact_metric("A")?;
let isc_base = 1.0 - ((1.0 - c) * (1.0 - i) * (1.0 - a));
let impact = if scope_changed {
7.52 * (isc_base - 0.029) - 3.25 * (isc_base - 0.02).powf(15.0)
} else {
6.42 * isc_base
};
let exploitability = 8.22 * av * ac * pr * ui;
let base = if impact <= 0.0 {
0.0
} else if scope_changed {
roundup(f64::min(1.08 * (impact + exploitability), 10.0))
} else {
roundup(f64::min(impact + exploitability, 10.0))
};
Some(base as f32)
}
fn roundup(x: f64) -> f64 {
(x * 10.0).ceil() / 10.0
}
#[cfg(test)]
mod tests {
use super::*;
use crate::scanner::{CryptoType, Ecosystem, FindingType};
fn advisory(pkg: &str, sev: Severity, patched: &[&str], unaffected: &[&str]) -> Advisory {
advisory_in(Ecosystem::Cargo, pkg, sev, patched, unaffected)
}
fn advisory_in(
ecosystem: Ecosystem,
pkg: &str,
sev: Severity,
patched: &[&str],
unaffected: &[&str],
) -> Advisory {
Advisory {
id: format!("TEST-{}", pkg),
ecosystem,
package: pkg.to_string(),
title: "test advisory".to_string(),
severity: sev,
cvss: None,
affected: Affected::Requirements {
patched: patched.iter().map(|s| s.to_string()).collect(),
unaffected: unaffected.iter().map(|s| s.to_string()).collect(),
},
}
}
fn locked_finding(name: &str, version: &str) -> CryptoFinding {
locked_finding_in(Ecosystem::Cargo, name, version)
}
fn locked_finding_in(ecosystem: Ecosystem, name: &str, version: &str) -> CryptoFinding {
CryptoFinding {
path: "/p/manifest".to_string(),
name: name.to_string(),
finding_type: FindingType::TransitiveDependency,
crypto_type: CryptoType::GeneralCrypto,
ecosystem,
version: Some(version.to_string()),
version_source: VersionSource::Locked,
details: String::new(),
}
}
fn sv(version: &str) -> ParsedVersion {
ParsedVersion::Semver(Version::parse(version).unwrap())
}
#[test]
fn vulnerable_version_matches_patched_boundary() {
let adv = advisory("foo", Severity::High, &[">= 1.2.0"], &[]);
assert!(adv.affects(&sv("1.1.9"), "1.1.9"), "below fix is affected");
assert!(!adv.affects(&sv("1.2.0"), "1.2.0"), "at fix is patched");
assert!(!adv.affects(&sv("1.3.0"), "1.3.0"), "above fix is patched");
}
#[test]
fn unaffected_range_excludes_old_versions() {
let adv = advisory("foo", Severity::Medium, &[">= 1.2.0"], &["< 1.0.0"]);
assert!(!adv.affects(&sv("0.9.0"), "0.9.0"), "pre-introduction safe");
assert!(adv.affects(&sv("1.1.0"), "1.1.0"), "in-window affected");
assert!(!adv.affects(&sv("1.2.0"), "1.2.0"), "fixed safe");
}
#[test]
fn correlate_skips_declared_and_dedups() {
let mut db = AdvisoryDb::default();
db.insert(advisory("foo", Severity::Critical, &[">= 2.0.0"], &[]));
let mut declared = locked_finding("foo", "1.0.0");
declared.version_source = VersionSource::Declared;
let findings = vec![
locked_finding("foo", "1.0.0"),
locked_finding("foo", "1.0.0"), declared, locked_finding("safe", "1.0.0"),
];
let res = correlate(&db, &findings);
assert_eq!(res.matches.len(), 1, "one vulnerable crate after dedup");
assert_eq!(res.total_advisories(), 1);
assert_eq!(res.worst_severity(), Some(Severity::Critical));
assert_eq!(res.skipped_unresolved, 1, "declared version is skipped");
}
#[test]
fn ecosystem_keying_prevents_cross_ecosystem_match() {
let mut db = AdvisoryDb::default();
db.insert(advisory_in(
Ecosystem::Npm,
"shared",
Severity::High,
&[">= 2.0.0"],
&[],
));
let findings = vec![
locked_finding_in(Ecosystem::Npm, "shared", "1.0.0"), locked_finding_in(Ecosystem::PyPI, "shared", "1.0.0"), locked_finding_in(Ecosystem::Cargo, "shared", "1.0.0"), ];
let res = correlate(&db, &findings);
assert_eq!(res.matches.len(), 1, "only the npm package matches");
assert_eq!(res.matches[0].name, "shared");
assert_eq!(res.worst_severity(), Some(Severity::High));
assert_eq!(db.matches(&Ecosystem::Npm, "shared", "1.0.0").len(), 1);
assert!(db.matches(&Ecosystem::PyPI, "shared", "1.0.0").is_empty());
assert!(db.matches(&Ecosystem::Cargo, "shared", "1.0.0").is_empty());
}
#[test]
fn osv_ecosystem_maps_supported_only() {
assert_eq!(osv_ecosystem("crates.io"), Some(Ecosystem::Cargo));
assert_eq!(osv_ecosystem("npm"), Some(Ecosystem::Npm));
assert_eq!(osv_ecosystem("PyPI"), Some(Ecosystem::PyPI));
assert_eq!(osv_ecosystem("Debian:11"), None);
assert_eq!(osv_ecosystem("Go"), None);
}
#[test]
fn pypi_correlation_matches_normalized_name() {
let mut db = AdvisoryDb::default();
db.insert(advisory_in(
Ecosystem::PyPI,
"pynacl",
Severity::High,
&[">= 1.5.0"],
&[],
));
let findings = vec![locked_finding_in(Ecosystem::PyPI, "pynacl", "1.4.0")];
let res = correlate(&db, &findings);
assert_eq!(res.matches.len(), 1, "vulnerable PyPI package matches");
assert_eq!(res.worst_severity(), Some(Severity::High));
let safe = vec![locked_finding_in(Ecosystem::PyPI, "pynacl", "1.5.0")];
assert!(correlate(&db, &safe).matches.is_empty());
}
#[test]
fn pypi_versions_compare_under_pep440() {
let mut db = AdvisoryDb::default();
db.insert(advisory_in(
Ecosystem::PyPI,
"cryptography",
Severity::High,
&[">= 39.0.1"],
&["< 1.8"],
));
assert_eq!(
db.matches(&Ecosystem::PyPI, "cryptography", "3.1").len(),
1,
"short release segment is affected"
);
assert_eq!(
db.matches(&Ecosystem::PyPI, "cryptography", "39.0.1rc1")
.len(),
1,
"pre-release of the fix predates the fix"
);
assert!(
db.matches(&Ecosystem::PyPI, "cryptography", "39.0.1")
.is_empty(),
"fixed version is clean"
);
assert!(
db.matches(&Ecosystem::PyPI, "cryptography", "1.7.2")
.is_empty(),
"pre-introduction version is clean"
);
assert!(
db.matches(&Ecosystem::PyPI, "cryptography", "not-a-version")
.is_empty(),
"unparseable version matches nothing"
);
}
#[test]
fn version_scheme_is_ecosystem_dependent() {
let mut db = AdvisoryDb::default();
for eco in [Ecosystem::PyPI, Ecosystem::Cargo, Ecosystem::Npm] {
db.insert(advisory_in(eco, "pkg", Severity::High, &[">= 4.0.0"], &[]));
}
assert_eq!(db.matches(&Ecosystem::PyPI, "pkg", "3.1").len(), 1);
assert!(db.matches(&Ecosystem::Cargo, "pkg", "3.1").is_empty());
assert!(db.matches(&Ecosystem::Npm, "pkg", "3.1").is_empty());
assert_eq!(db.matches(&Ecosystem::Cargo, "pkg", "3.1.0").len(), 1);
assert_eq!(db.matches(&Ecosystem::Npm, "pkg", "3.1.0").len(), 1);
}
#[test]
fn severity_ordering_supports_gating() {
assert!(Severity::Critical > Severity::High);
assert!(Severity::High > Severity::Medium);
assert!(Severity::Low > Severity::None);
assert_eq!(Severity::parse("HIGH"), Some(Severity::High));
assert_eq!(Severity::parse("moderate"), Some(Severity::Medium));
}
#[test]
fn cvss_v31_known_vector_scores_correctly() {
let v = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:C/C:H/I:H/A:H";
let score = cvss_base_score(v).expect("valid vector");
assert!((score - 10.0).abs() < 0.05, "expected ~10.0, got {}", score);
assert_eq!(Severity::from_score(score), Severity::Critical);
let low = "CVSS:3.1/AV:L/AC:H/PR:H/UI:R/S:U/C:L/I:N/A:N";
let low_score = cvss_base_score(low).expect("valid vector");
assert!(low_score < 4.0, "expected Low band, got {}", low_score);
}
#[test]
fn non_cvss3_vector_returns_none() {
assert!(cvss_base_score("not a vector").is_none());
assert!(cvss_base_score("CVSS:2.0/AV:N/AC:L").is_none());
}
const REAL_ADVISORY_MD: &str = r#"```toml
[advisory]
id = "RUSTSEC-2020-0048"
package = "actix-http"
aliases = ["CVE-2020-35901", "GHSA-v3j6-xf77-8r9c"]
cvss = "CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H"
categories = ["memory-corruption"]
date = "2020-01-24"
url = "https://github.com/actix/actix-web/issues/1321"
[versions]
patched = [">= 2.0.0-alpha.1"]
```
# Use-after-free in BodyStream due to lack of pinning
Affected versions of this crate did not require the buffer wrapped in
`BodyStream` to be pinned.
"#;
fn write_tmp(name: &str, contents: &str) -> std::path::PathBuf {
let dir = std::env::temp_dir().join(format!("lens3329-adv-{}", std::process::id()));
std::fs::create_dir_all(&dir).unwrap();
let path = dir.join(name);
std::fs::write(&path, contents).unwrap();
path
}
#[test]
fn parses_real_rustsec_markdown_advisory() {
let path = write_tmp("RUSTSEC-2020-0048.md", REAL_ADVISORY_MD);
let adv = parse_rustsec_advisory(&path).expect("real .md advisory should parse");
assert_eq!(adv.id, "RUSTSEC-2020-0048");
assert_eq!(adv.package, "actix-http");
assert_eq!(adv.ecosystem, Ecosystem::Cargo);
assert_eq!(
adv.title,
"Use-after-free in BodyStream due to lack of pinning"
);
match &adv.affected {
Affected::Requirements { patched, .. } => {
assert_eq!(patched, &vec![">= 2.0.0-alpha.1".to_string()]);
}
other => panic!("RustSec advisories use requirement semantics, got {other:?}"),
}
assert_eq!(adv.severity, Severity::High);
assert!((adv.cvss.unwrap() - 7.5).abs() < 0.05);
}
#[test]
fn real_markdown_advisory_correlates_against_a_locked_finding() {
let path = write_tmp("RUSTSEC-2020-0048-corr.md", REAL_ADVISORY_MD);
let mut db = AdvisoryDb::default();
db.insert(parse_rustsec_advisory(&path).unwrap());
let vulnerable = locked_finding("actix-http", "1.0.0");
assert_eq!(
db.matches(&Ecosystem::Cargo, "actix-http", "1.0.0").len(),
1
);
assert!(!correlate(&db, std::slice::from_ref(&vulnerable))
.matches
.is_empty());
assert!(db
.matches(&Ecosystem::Cargo, "actix-http", "2.0.0")
.is_empty());
}
#[test]
fn informational_markdown_advisory_is_skipped() {
let path = write_tmp(
"RUSTSEC-2025-0123.md",
"```toml\n[advisory]\nid = \"RUSTSEC-2025-0123\"\npackage = \"opentelemetry-jaeger\"\n\
informational = \"unmaintained\"\n\n[versions]\npatched = []\n```\n\n# unmaintained\n",
);
assert!(parse_rustsec_advisory(&path).is_none());
}
#[test]
fn non_advisory_markdown_is_skipped() {
let path = write_tmp("README.md", "# RustSec Advisory Database\n\nProse only.\n");
assert!(parse_rustsec_advisory(&path).is_none());
}
#[test]
fn bare_toml_advisory_still_parses() {
let path = write_tmp(
"bare.toml",
"[advisory]\nid = \"RUSTSEC-2099-0001\"\npackage = \"ring\"\n\
title = \"Explicit title\"\n\n[versions]\npatched = [\">= 0.18.0\"]\n",
);
let adv = parse_rustsec_advisory(&path).expect("bare TOML should still parse");
assert_eq!(adv.package, "ring");
assert_eq!(adv.title, "Explicit title");
}
#[test]
fn unterminated_fence_is_declined() {
assert!(split_advisory_frontmatter("```toml\n[advisory]\nid = \"x\"\n").is_none());
}
#[test]
fn empty_db_is_reported_as_empty() {
assert!(AdvisoryDb::default().is_empty());
assert_eq!(AdvisoryDb::default().len(), 0);
}
}