use std::collections::HashSet;
use crate::ports::outbound::enriched_package::EnrichedPackage;
use crate::sbom_generation::domain::dependency_graph::DependencyGraph;
use crate::sbom_generation::domain::resolution_guide::{IntroducedBy, ResolutionEntry};
use crate::sbom_generation::domain::vulnerability::PackageVulnerabilities;
pub struct ResolutionAnalyzer;
impl ResolutionAnalyzer {
pub fn analyze(
dependency_graph: &DependencyGraph,
vulnerabilities: &[PackageVulnerabilities],
all_packages: &[EnrichedPackage],
) -> Vec<ResolutionEntry> {
let direct_dep_names: HashSet<&str> = dependency_graph
.direct_dependencies()
.iter()
.map(|p| p.as_str())
.collect();
let mut entries = Vec::new();
for pkg_vuln in vulnerabilities {
if direct_dep_names.contains(pkg_vuln.package_name()) {
continue;
}
let mut introduced_by = Vec::new();
for (direct_dep, trans_deps) in dependency_graph.transitive_dependencies() {
if trans_deps
.iter()
.any(|t| t.as_str() == pkg_vuln.package_name())
{
let version = find_package_version(all_packages, direct_dep.as_str());
introduced_by.push(IntroducedBy::new(direct_dep.as_str().to_string(), version));
}
}
if introduced_by.is_empty() {
continue;
}
introduced_by.sort_by(|a, b| a.package_name().cmp(b.package_name()));
for vuln in pkg_vuln.vulnerabilities() {
entries.push(ResolutionEntry::new(
pkg_vuln.package_name().to_string(),
pkg_vuln.current_version().to_string(),
vuln.fixed_version().map(|v| v.to_string()),
vuln.severity(),
vuln.id().to_string(),
introduced_by.clone(),
));
}
}
entries
}
}
fn find_package_version(all_packages: &[EnrichedPackage], name: &str) -> String {
all_packages
.iter()
.find(|p| p.package.name() == name)
.map(|p| p.package.version().to_string())
.unwrap_or_else(|| "unknown".to_string())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::sbom_generation::domain::dependency_graph::DependencyGraph;
use crate::sbom_generation::domain::package::PackageName;
use crate::sbom_generation::domain::vulnerability::{Severity, Vulnerability};
use crate::sbom_generation::domain::Package;
use std::collections::HashMap;
fn make_vuln(id: &str, severity: Severity, fixed: Option<&str>) -> Vulnerability {
Vulnerability::new(
id.to_string(),
None,
severity,
fixed.map(|v| v.to_string()),
None,
)
.unwrap()
}
fn make_pkg_vulns(
name: &str,
version: &str,
vulns: Vec<Vulnerability>,
) -> PackageVulnerabilities {
PackageVulnerabilities::new(name.to_string(), version.to_string(), vulns)
}
fn make_enriched(name: &str, version: &str) -> EnrichedPackage {
EnrichedPackage::new(
Package::new(name.to_string(), version.to_string()).unwrap(),
None,
None,
)
}
fn make_graph(direct: Vec<&str>, transitive: Vec<(&str, Vec<&str>)>) -> DependencyGraph {
let direct_deps: Vec<PackageName> = direct
.into_iter()
.map(|n| PackageName::new(n.to_string()).unwrap())
.collect();
let trans_deps: HashMap<PackageName, Vec<PackageName>> = transitive
.into_iter()
.map(|(key, vals)| {
let k = PackageName::new(key.to_string()).unwrap();
let v: Vec<PackageName> = vals
.into_iter()
.map(|n| PackageName::new(n.to_string()).unwrap())
.collect();
(k, v)
})
.collect();
DependencyGraph::new(direct_deps, trans_deps)
}
#[test]
fn test_transitive_vulnerability_produces_entry() {
let graph = make_graph(
vec!["requests"],
vec![("requests", vec!["urllib3", "certifi"])],
);
let vulns = vec![make_pkg_vulns(
"urllib3",
"1.26.5",
vec![make_vuln("CVE-2023-43804", Severity::High, Some("1.26.18"))],
)];
let packages = vec![
make_enriched("requests", "2.28.0"),
make_enriched("urllib3", "1.26.5"),
make_enriched("certifi", "2023.7.22"),
];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].vulnerable_package(), "urllib3");
assert_eq!(entries[0].current_version(), "1.26.5");
assert_eq!(entries[0].fixed_version(), Some("1.26.18"));
assert_eq!(entries[0].severity(), Severity::High);
assert_eq!(entries[0].vulnerability_id(), "CVE-2023-43804");
assert_eq!(entries[0].introduced_by().len(), 1);
assert_eq!(entries[0].introduced_by()[0].package_name(), "requests");
assert_eq!(entries[0].introduced_by()[0].version(), "2.28.0");
}
#[test]
fn test_direct_dependency_vulnerability_is_skipped() {
let graph = make_graph(vec!["requests"], vec![("requests", vec!["urllib3"])]);
let vulns = vec![make_pkg_vulns(
"requests",
"2.28.0",
vec![make_vuln("CVE-2024-0001", Severity::Medium, Some("2.29.0"))],
)];
let packages = vec![
make_enriched("requests", "2.28.0"),
make_enriched("urllib3", "1.26.5"),
];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert!(entries.is_empty());
}
#[test]
fn test_multiple_direct_deps_introduce_same_vulnerable_package() {
let graph = make_graph(
vec!["requests", "httpx"],
vec![
("requests", vec!["urllib3"]),
("httpx", vec!["urllib3", "httpcore"]),
],
);
let vulns = vec![make_pkg_vulns(
"urllib3",
"1.26.5",
vec![make_vuln("CVE-2023-43804", Severity::High, Some("1.26.18"))],
)];
let packages = vec![
make_enriched("requests", "2.28.0"),
make_enriched("httpx", "0.23.0"),
make_enriched("urllib3", "1.26.5"),
make_enriched("httpcore", "0.16.0"),
];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].introduced_by().len(), 2);
assert_eq!(entries[0].introduced_by()[0].package_name(), "httpx");
assert_eq!(entries[0].introduced_by()[1].package_name(), "requests");
}
#[test]
fn test_empty_dependency_graph_returns_empty() {
let graph = make_graph(vec![], vec![]);
let vulns = vec![make_pkg_vulns(
"urllib3",
"1.26.5",
vec![make_vuln("CVE-2023-43804", Severity::High, None)],
)];
let packages = vec![make_enriched("urllib3", "1.26.5")];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert!(entries.is_empty());
}
#[test]
fn test_empty_vulnerabilities_returns_empty() {
let graph = make_graph(vec!["requests"], vec![("requests", vec!["urllib3"])]);
let vulns: Vec<PackageVulnerabilities> = vec![];
let packages = vec![
make_enriched("requests", "2.28.0"),
make_enriched("urllib3", "1.26.5"),
];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert!(entries.is_empty());
}
#[test]
fn test_package_not_found_in_transitive_list_is_omitted() {
let graph = make_graph(vec!["requests"], vec![("requests", vec!["urllib3"])]);
let vulns = vec![make_pkg_vulns(
"unknown-pkg",
"0.1.0",
vec![make_vuln("CVE-2024-9999", Severity::Critical, None)],
)];
let packages = vec![
make_enriched("requests", "2.28.0"),
make_enriched("urllib3", "1.26.5"),
make_enriched("unknown-pkg", "0.1.0"),
];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert!(entries.is_empty());
}
#[test]
fn test_multiple_vulnerabilities_in_same_package() {
let graph = make_graph(vec!["requests"], vec![("requests", vec!["urllib3"])]);
let vulns = vec![make_pkg_vulns(
"urllib3",
"1.26.5",
vec![
make_vuln("CVE-2023-43804", Severity::High, Some("1.26.18")),
make_vuln("CVE-2023-45803", Severity::Medium, Some("2.0.7")),
],
)];
let packages = vec![
make_enriched("requests", "2.28.0"),
make_enriched("urllib3", "1.26.5"),
];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].vulnerability_id(), "CVE-2023-43804");
assert_eq!(entries[1].vulnerability_id(), "CVE-2023-45803");
assert_eq!(entries[0].introduced_by()[0].package_name(), "requests");
assert_eq!(entries[1].introduced_by()[0].package_name(), "requests");
}
#[test]
fn test_unknown_version_when_package_not_in_enriched_list() {
let graph = make_graph(vec!["requests"], vec![("requests", vec!["urllib3"])]);
let vulns = vec![make_pkg_vulns(
"urllib3",
"1.26.5",
vec![make_vuln("CVE-2023-43804", Severity::High, None)],
)];
let packages = vec![make_enriched("urllib3", "1.26.5")];
let entries = ResolutionAnalyzer::analyze(&graph, &vulns, &packages);
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].introduced_by()[0].package_name(), "requests");
assert_eq!(entries[0].introduced_by()[0].version(), "unknown");
}
}