use std::collections::{HashMap, HashSet, VecDeque};
use std::path::{Path, PathBuf};
use cargo_metadata::{DependencyKind, Metadata, MetadataCommand, NodeDep, PackageId};
use crate::finding::{EvidenceClass, Finding, Location, OneBasedLine, Origin, Severity};
use crate::ingest::Workspace;
pub const KNOWN_VULNERABILITY_RULE: &str = "known-vulnerability";
pub const KNOWN_VULNERABILITY_RULE_REVISION: u32 = 1;
pub(crate) fn describe_report_io_error(path: &Path, err: &std::io::Error) -> String {
format!("{}: failed to read file: {err}", path.display())
}
pub(crate) fn report_io_error_source(
err: &std::io::Error,
) -> Option<&(dyn std::error::Error + 'static)> {
Some(err)
}
pub(crate) fn read_report_text<E>(
path: &Path,
to_err: impl FnOnce(PathBuf, std::io::Error) -> E,
) -> Result<String, E> {
std::fs::read_to_string(path).map_err(|err| to_err(path.to_path_buf(), err))
}
#[derive(Debug)]
pub enum AuditImportError {
Io(PathBuf, std::io::Error),
}
impl std::fmt::Display for AuditImportError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(path, err) => write!(f, "{}", describe_report_io_error(path, err)),
}
}
}
impl std::error::Error for AuditImportError {
fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
match self {
Self::Io(_, err) => report_io_error_source(err),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AuditVulnerability {
pub advisory_id: String,
pub package_name: String,
pub package_version: String,
pub title: String,
pub url: Option<String>,
}
pub fn parse_audit_report(text: &str) -> Vec<AuditVulnerability> {
let Ok(root) = serde_json::from_str::<serde_json::Value>(text) else {
return Vec::new();
};
let Some(list) = root
.get("vulnerabilities")
.and_then(|v| v.get("list"))
.and_then(|v| v.as_array())
else {
return Vec::new();
};
list.iter()
.filter_map(|entry| {
let advisory = entry.get("advisory")?;
let advisory_id = advisory.get("id")?.as_str()?.to_string();
let title = advisory
.get("title")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
let url = advisory
.get("url")
.and_then(|v| v.as_str())
.map(str::to_string);
let package = entry.get("package")?;
let package_name = package.get("name")?.as_str()?.to_string();
let package_version = package.get("version")?.as_str()?.to_string();
Some(AuditVulnerability {
advisory_id,
package_name,
package_version,
title,
url,
})
})
.collect()
}
pub fn read_audit_report(path: &Path) -> Result<Vec<AuditVulnerability>, AuditImportError> {
let text = read_report_text(path, AuditImportError::Io)?;
Ok(parse_audit_report(&text))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Reachability {
Production,
DevOnly,
Unknown,
}
impl Reachability {
fn label(self) -> &'static str {
match self {
Self::Production => "production",
Self::DevOnly => "dev_only",
Self::Unknown => "unknown",
}
}
}
fn production_reachable_packages(metadata: &Metadata) -> HashSet<PackageId> {
let Some(resolve) = &metadata.resolve else {
return HashSet::new();
};
let adjacency: HashMap<&PackageId, &Vec<NodeDep>> = resolve
.nodes
.iter()
.map(|node| (&node.id, &node.deps))
.collect();
let mut reached: HashSet<PackageId> = metadata.workspace_members.iter().cloned().collect();
let mut queue: VecDeque<PackageId> = metadata.workspace_members.iter().cloned().collect();
while let Some(current) = queue.pop_front() {
let Some(deps) = adjacency.get(¤t) else {
continue;
};
for dep in deps.iter() {
let is_production_edge = dep.dep_kinds.is_empty()
|| dep
.dep_kinds
.iter()
.any(|info| !matches!(info.kind, DependencyKind::Development));
if !is_production_edge {
continue;
}
if reached.insert(dep.pkg.clone()) {
queue.push_back(dep.pkg.clone());
}
}
}
reached
}
#[derive(Debug, Default)]
pub struct AdvisoryReport {
pub findings: Vec<Finding>,
pub errors: Vec<String>,
}
pub fn analyze_vulnerabilities(
workspace: &Workspace,
vulnerabilities: &[AuditVulnerability],
) -> AdvisoryReport {
let mut report = AdvisoryReport::default();
if vulnerabilities.is_empty() {
return report;
}
let manifest_path = workspace.root.join("Cargo.toml");
let metadata = match MetadataCommand::new().manifest_path(&manifest_path).exec() {
Ok(metadata) => metadata,
Err(err) => {
report
.errors
.push(format!("failed to resolve dependency graph: {err}"));
for vuln in vulnerabilities {
report.findings.push(known_vulnerability_finding(
&manifest_path,
vuln,
Reachability::Unknown,
));
}
report
.findings
.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
return report;
}
};
let reachable = production_reachable_packages(&metadata);
for vuln in vulnerabilities {
let reachability = metadata
.packages
.iter()
.find(|package| {
package.name == vuln.package_name
&& package.version.to_string() == vuln.package_version
})
.map_or(Reachability::Unknown, |package| {
if reachable.contains(&package.id) {
Reachability::Production
} else {
Reachability::DevOnly
}
});
report.findings.push(known_vulnerability_finding(
&manifest_path,
vuln,
reachability,
));
}
report
.findings
.sort_by(|a, b| a.id.as_str().cmp(b.id.as_str()));
report
}
fn known_vulnerability_finding(
manifest_path: &Path,
vuln: &AuditVulnerability,
reachability: Reachability,
) -> Finding {
let severity = match reachability {
Reachability::Production => Severity::Fail,
Reachability::DevOnly | Reachability::Unknown => Severity::Warn,
};
Finding::new(
format!(
"{KNOWN_VULNERABILITY_RULE}:{}:{}",
vuln.package_name, vuln.advisory_id
),
KNOWN_VULNERABILITY_RULE,
severity,
Location {
file: manifest_path.to_path_buf(),
line: OneBasedLine::FIRST,
item_path: vuln.package_name.clone(),
},
EvidenceClass::ExternalMeasurement,
Origin::Code,
Some(serde_json::json!({
"advisory_id": vuln.advisory_id,
"package_version": vuln.package_version,
"title": vuln.title,
"url": vuln.url,
"reachability": reachability.label(),
})),
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_util::TempDir;
#[test]
fn parses_a_realistic_vulnerabilities_list() {
let text = r#"{
"vulnerabilities": {
"found": true,
"count": 1,
"list": [
{
"advisory": {
"id": "RUSTSEC-2020-0001",
"title": "Example vulnerability",
"url": "https://rustsec.org/advisories/RUSTSEC-2020-0001"
},
"package": {
"name": "vulnerable-crate",
"version": "1.2.3"
}
}
]
}
}"#;
let vulns = parse_audit_report(text);
assert_eq!(vulns.len(), 1);
assert_eq!(vulns[0].advisory_id, "RUSTSEC-2020-0001");
assert_eq!(vulns[0].package_name, "vulnerable-crate");
assert_eq!(vulns[0].package_version, "1.2.3");
assert_eq!(vulns[0].title, "Example vulnerability");
assert_eq!(
vulns[0].url.as_deref(),
Some("https://rustsec.org/advisories/RUSTSEC-2020-0001")
);
}
#[test]
fn a_report_with_no_vulnerabilities_found_yields_an_empty_list() {
let text = r#"{"vulnerabilities": {"found": false, "count": 0, "list": []}}"#;
assert!(parse_audit_report(text).is_empty());
}
#[test]
fn invalid_json_yields_an_empty_list_rather_than_panicking() {
assert!(parse_audit_report("this is not json").is_empty());
}
#[test]
fn a_missing_vulnerabilities_key_yields_an_empty_list() {
assert!(parse_audit_report("{}").is_empty());
}
#[test]
fn an_entry_missing_a_required_field_is_skipped_not_fatal() {
let text = r#"{
"vulnerabilities": {
"found": true,
"count": 2,
"list": [
{ "advisory": { "id": "RUSTSEC-2020-0001" }, "package": { "name": "no-version" } },
{
"advisory": { "id": "RUSTSEC-2020-0002" },
"package": { "name": "complete-crate", "version": "2.0.0" }
}
]
}
}"#;
let vulns = parse_audit_report(text);
assert_eq!(vulns.len(), 1);
assert_eq!(vulns[0].package_name, "complete-crate");
}
#[test]
fn read_audit_report_errors_clearly_for_a_missing_file() {
let dir = TempDir::new("advisories-missing-file");
let err = read_audit_report(&dir.join("nope.json")).unwrap_err();
assert!(err.to_string().contains("failed to read file"));
}
fn vulnerability(name: &str, version: &str) -> AuditVulnerability {
AuditVulnerability {
advisory_id: "RUSTSEC-2020-0001".to_string(),
package_name: name.to_string(),
package_version: version.to_string(),
title: "Example vulnerability".to_string(),
url: None,
}
}
fn write_vendored_crate(dir: &TempDir, name: &str, version: &str) {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!("[package]\nname = \"{name}\"\nversion = \"{version}\"\nedition = \"2021\"\n"),
)
.unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn noop() {}\n").unwrap();
}
fn write_manifest_with_vendored_deps(
dir: &TempDir,
prod_dep: (&str, &TempDir),
dev_dep: (&str, &TempDir),
) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(
dir.join("Cargo.toml"),
format!(
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n\n[dependencies]\n{} = {{ path = {:?} }}\n\n[dev-dependencies]\n{} = {{ path = {:?} }}\n",
prod_dep.0,
prod_dep.1.to_path_buf(),
dev_dep.0,
dev_dep.1.to_path_buf(),
),
)
.unwrap();
dir.join("Cargo.toml")
}
#[test]
fn a_vulnerability_in_a_production_dependency_is_classified_production_and_fails() {
let prod_vendor = TempDir::new("advisories-prod-vendor");
write_vendored_crate(&prod_vendor, "prod-dep", "1.0.0");
let dev_vendor = TempDir::new("advisories-dev-vendor-a");
write_vendored_crate(&dev_vendor, "dev-dep", "1.0.0");
let dir = TempDir::new("advisories-prod-fixture");
let manifest = write_manifest_with_vendored_deps(
&dir,
("prod-dep", &prod_vendor),
("dev-dep", &dev_vendor),
);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let report = analyze_vulnerabilities(&workspace, &[vulnerability("prod-dep", "1.0.0")]);
assert!(
report.errors.is_empty(),
"unexpected errors: {:?}",
report.errors
);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].severity, Severity::Fail);
assert_eq!(
report.findings[0].evidence.as_ref().unwrap()["reachability"],
"production"
);
}
#[test]
fn known_vulnerability_registry_example_still_triggers_the_rule() {
let example = crate::rule_registry::lookup(KNOWN_VULNERABILITY_RULE)
.expect("known-vulnerability has a registry entry")
.example
.expect("known-vulnerability has a curated example")
.before;
let vulns = parse_audit_report(example);
assert_eq!(
vulns.len(),
1,
"curated example must parse to exactly one vulnerability"
);
let prod_vendor = TempDir::new("advisories-registry-example-prod-vendor");
write_vendored_crate(
&prod_vendor,
&vulns[0].package_name,
&vulns[0].package_version,
);
let dev_vendor = TempDir::new("advisories-registry-example-dev-vendor");
write_vendored_crate(&dev_vendor, "unused-dev-dep", "1.0.0");
let dir = TempDir::new("advisories-registry-example-fixture");
let manifest = write_manifest_with_vendored_deps(
&dir,
(&vulns[0].package_name, &prod_vendor),
("unused-dev-dep", &dev_vendor),
);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let report = analyze_vulnerabilities(&workspace, &vulns);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].rule, KNOWN_VULNERABILITY_RULE);
assert_eq!(
report.findings[0].evidence.as_ref().unwrap()["reachability"],
"production"
);
}
#[test]
fn a_vulnerability_in_a_dev_only_dependency_is_classified_dev_only_and_warns() {
let prod_vendor = TempDir::new("advisories-prod-vendor-b");
write_vendored_crate(&prod_vendor, "prod-dep", "1.0.0");
let dev_vendor = TempDir::new("advisories-dev-vendor-b");
write_vendored_crate(&dev_vendor, "dev-dep", "1.0.0");
let dir = TempDir::new("advisories-dev-fixture");
let manifest = write_manifest_with_vendored_deps(
&dir,
("prod-dep", &prod_vendor),
("dev-dep", &dev_vendor),
);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let report = analyze_vulnerabilities(&workspace, &[vulnerability("dev-dep", "1.0.0")]);
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].severity, Severity::Warn);
assert_eq!(
report.findings[0].evidence.as_ref().unwrap()["reachability"],
"dev_only"
);
}
#[test]
fn a_vulnerability_for_a_package_not_in_this_resolve_is_classified_unknown() {
let dir = TempDir::new("advisories-unknown-fixture");
let manifest = write_manifest(&dir);
let workspace = crate::ingest::load(Some(&manifest)).unwrap();
let report =
analyze_vulnerabilities(&workspace, &[vulnerability("nowhere-to-be-found", "9.9.9")]);
assert!(report.errors.is_empty());
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].severity, Severity::Warn);
assert_eq!(
report.findings[0].evidence.as_ref().unwrap()["reachability"],
"unknown"
);
}
#[test]
fn no_vulnerabilities_produces_no_findings_and_does_not_even_resolve_metadata() {
let dir = TempDir::new("advisories-empty-list");
let workspace = Workspace {
root: dir.to_path_buf(),
crates: Vec::new(),
};
let report = analyze_vulnerabilities(&workspace, &[]);
assert!(report.findings.is_empty());
assert!(report.errors.is_empty());
}
#[test]
fn a_metadata_resolve_failure_still_reports_every_vulnerability_as_unknown() {
let dir = TempDir::new("advisories-resolve-failure");
let workspace = Workspace {
root: dir.to_path_buf(),
crates: Vec::new(),
};
let report = analyze_vulnerabilities(&workspace, &[vulnerability("whatever", "1.0.0")]);
assert_eq!(report.errors.len(), 1);
assert!(report.errors[0].contains("failed to resolve dependency graph"));
assert_eq!(report.findings.len(), 1);
assert_eq!(report.findings[0].severity, Severity::Warn);
assert_eq!(
report.findings[0].evidence.as_ref().unwrap()["reachability"],
"unknown"
);
}
fn write_manifest(dir: &TempDir) -> PathBuf {
std::fs::create_dir_all(dir.join("src")).unwrap();
std::fs::write(dir.join("src/lib.rs"), "pub fn hello() {}\n").unwrap();
std::fs::write(
dir.join("Cargo.toml"),
"[package]\nname = \"fixture\"\nversion = \"0.1.0\"\nedition = \"2021\"\n",
)
.unwrap();
dir.join("Cargo.toml")
}
}