From 29affcc79b5c5dbe56b48b979bff19f0cd7427d3 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Tue, 28 Oct 2025 15:44:20 +0100 Subject: [PATCH 1/4] feat(domain): use semver for package and vulnerability versions --- src/domain/scanresult/package.rs | 7 +++-- src/domain/scanresult/scan_result.rs | 31 ++++++++++--------- src/domain/scanresult/vulnerability.rs | 9 +++--- ...ysdig_image_scanner_json_scan_result_v1.rs | 11 +++++-- tests/general.rs | 7 +++-- 5 files changed, 38 insertions(+), 27 deletions(-) diff --git a/src/domain/scanresult/package.rs b/src/domain/scanresult/package.rs index 5fcba36..6d8ef13 100644 --- a/src/domain/scanresult/package.rs +++ b/src/domain/scanresult/package.rs @@ -3,6 +3,7 @@ use crate::domain::scanresult::layer::Layer; use crate::domain::scanresult::package_type::PackageType; use crate::domain::scanresult::vulnerability::Vulnerability; use crate::domain::scanresult::weak_hash::WeakHash; +use semver::Version; use std::collections::HashSet; use std::fmt::Debug; use std::hash::{Hash, Hasher}; @@ -11,7 +12,7 @@ use std::sync::{Arc, RwLock}; pub struct Package { package_type: PackageType, name: String, - version: String, + version: Version, path: String, found_in_layer: Arc, vulnerabilities: RwLock>>, @@ -34,7 +35,7 @@ impl Package { pub(in crate::domain::scanresult) fn new( package_type: PackageType, name: String, - version: String, + version: Version, path: String, found_in_layer: Arc, ) -> Self { @@ -57,7 +58,7 @@ impl Package { &self.name } - pub fn version(&self) -> &str { + pub fn version(&self) -> &Version { &self.version } diff --git a/src/domain/scanresult/scan_result.rs b/src/domain/scanresult/scan_result.rs index 2221061..51f4d14 100644 --- a/src/domain/scanresult/scan_result.rs +++ b/src/domain/scanresult/scan_result.rs @@ -14,6 +14,7 @@ use crate::domain::scanresult::severity::Severity; use crate::domain::scanresult::vulnerability::Vulnerability; use chrono::{DateTime, NaiveDate, Utc}; use itertools::Itertools; +use semver::Version; use std::collections::HashMap; use std::sync::Arc; @@ -109,14 +110,14 @@ impl ScanResult { &mut self, package_type: PackageType, name: String, - version: String, + version: Version, path: String, found_in_layer: Arc, ) -> Arc { let a_package = Arc::new(Package::new( package_type, name.clone(), - version.clone(), + version, path.clone(), found_in_layer.clone(), )); @@ -140,7 +141,7 @@ impl ScanResult { disclosure_date: NaiveDate, solution_date: Option, exploitable: bool, - fix_version: Option, + fix_version: Option, ) -> Arc { self.vulnerabilities .entry(cve.clone()) @@ -313,7 +314,7 @@ mod tests { let package = scan_result.add_package( PackageType::Os, "musl".to_string(), - "1.2.3".to_string(), + Version::parse("1.2.3").unwrap(), "/lib/ld-musl-x86_64.so.1".to_string(), layer.clone(), ); @@ -336,7 +337,7 @@ mod tests { Utc::now().naive_utc().date(), None, false, - Some("1.2.4".to_string()), + Some(Version::parse("1.2.4").unwrap()), ); assert_eq!(scan_result.vulnerabilities().len(), 1); @@ -357,7 +358,7 @@ mod tests { let package = scan_result.add_package( PackageType::Os, "musl".to_string(), - "1.2.3".to_string(), + Version::parse("1.2.3").unwrap(), "/lib/ld-musl-x86_64.so.1".to_string(), layer.clone(), ); @@ -367,7 +368,7 @@ mod tests { Utc::now().naive_utc().date(), None, false, - Some("1.2.4".to_string()), + Some(Version::parse("1.2.4").unwrap()), ); package.add_vulnerability_found(vuln.clone()); @@ -462,7 +463,7 @@ mod tests { Utc::now().naive_utc().date(), None, false, - Some("1.2.4".to_string()), + Some(Version::parse("1.2.4").unwrap()), ); vuln.add_accepted_risk(risk.clone()); @@ -488,7 +489,7 @@ mod tests { let package = scan_result.add_package( PackageType::Os, "musl".to_string(), - "1.2.3".to_string(), + Version::parse("1.2.3").unwrap(), "/lib/ld-musl-x86_64.so.1".to_string(), layer.clone(), ); @@ -571,13 +572,13 @@ mod tests { let package = scan_result.add_package( PackageType::Os, "musl".to_string(), - "1.2.3".to_string(), + Version::parse("1.2.3").unwrap(), "/path".to_string(), layer.clone(), ); assert_eq!(package.package_type(), &PackageType::Os); assert_eq!(package.name(), "musl"); - assert_eq!(package.version(), "1.2.3"); + assert_eq!(package.version(), &Version::parse("1.2.3").unwrap()); assert_eq!(package.path(), "/path"); assert!(format!("{:?}", package).contains("musl")); assert_eq!(package.clone(), package); @@ -589,7 +590,7 @@ mod tests { now.naive_utc().date(), Some(now.naive_utc().date()), true, - Some("1.2.4".to_string()), + Some(Version::parse("1.2.4").unwrap()), ); assert_eq!(vuln.cve(), "CVE-1"); assert_eq!(vuln.severity(), Severity::High); @@ -597,7 +598,7 @@ mod tests { assert_eq!(vuln.solution_date(), Some(now.naive_utc().date())); assert!(vuln.exploitable()); assert!(vuln.fixable()); - assert_eq!(vuln.fix_version(), Some("1.2.4")); + assert_eq!(vuln.fix_version(), Some(&Version::parse("1.2.4").unwrap())); assert!(format!("{:?}", vuln).contains("CVE-1")); // AcceptedRisk @@ -672,14 +673,14 @@ mod tests { let pkg = scan_result.add_package( PackageType::Os, "pkg".to_string(), - "1.0".to_string(), + Version::parse("1.0.0").unwrap(), "/path".to_string(), layer.clone(), ); let pkg2 = scan_result.add_package( PackageType::Os, "pkg".to_string(), - "1.0".to_string(), + Version::parse("1.0.0").unwrap(), "/path".to_string(), layer.clone(), ); diff --git a/src/domain/scanresult/vulnerability.rs b/src/domain/scanresult/vulnerability.rs index 14df4ea..79b83d8 100644 --- a/src/domain/scanresult/vulnerability.rs +++ b/src/domain/scanresult/vulnerability.rs @@ -4,6 +4,7 @@ use crate::domain::scanresult::package::Package; use crate::domain::scanresult::severity::Severity; use crate::domain::scanresult::weak_hash::WeakHash; use chrono::NaiveDate; +use semver::Version; use std::collections::HashSet; use std::fmt::Debug; use std::hash::{Hash, Hasher}; @@ -15,7 +16,7 @@ pub struct Vulnerability { disclosure_date: NaiveDate, solution_date: Option, exploitable: bool, - fix_version: Option, + fix_version: Option, found_in_packages: RwLock>>, accepted_risks: RwLock>>, } @@ -40,7 +41,7 @@ impl Vulnerability { disclosure_date: NaiveDate, solution_date: Option, exploitable: bool, - fix_version: Option, + fix_version: Option, ) -> Self { Self { cve, @@ -78,8 +79,8 @@ impl Vulnerability { self.fix_version.is_some() } - pub fn fix_version(&self) -> Option<&str> { - self.fix_version.as_deref() + pub fn fix_version(&self) -> Option<&Version> { + self.fix_version.as_ref() } pub(in crate::domain::scanresult) fn add_found_in_package( diff --git a/src/infra/sysdig_image_scanner_json_scan_result_v1.rs b/src/infra/sysdig_image_scanner_json_scan_result_v1.rs index bd96f9f..5302072 100644 --- a/src/infra/sysdig_image_scanner_json_scan_result_v1.rs +++ b/src/infra/sysdig_image_scanner_json_scan_result_v1.rs @@ -13,6 +13,7 @@ use crate::domain::scanresult::{ scan_type::ScanType, severity::Severity, }; +use semver::Version; impl From for ScanResult { fn from(report: JsonScanResultV1) -> Self { @@ -55,13 +56,15 @@ fn add_risk_accepts(result: &JsonResult, scan_result: &mut ScanResult) { fn add_vulnerabilities(result: &JsonResult, scan_result: &mut ScanResult) { for v in result.vulnerabilities.values() { + let fix_version = v.fix_version.as_ref().and_then(|s| Version::parse(s).ok()); + let vuln = scan_result.add_vulnerability( v.name.clone(), v.severity.clone().into(), v.disclosure_date, v.solution_date, v.exploitable, - v.fix_version.clone(), + fix_version, ); v.risk_accept_refs @@ -86,10 +89,14 @@ fn add_packages(result: &JsonResult, scan_result: &mut ScanResult) { continue; }; + let Ok(version) = Version::parse(&json_pkg.version) else { + continue; + }; + let pkg = scan_result.add_package( json_pkg.package_type.clone().into(), json_pkg.name.clone(), - json_pkg.version.clone(), + version, json_pkg.path.clone(), layer_where_this_package_is_found, ); diff --git a/tests/general.rs b/tests/general.rs index a5fd65b..0a3f33e 100644 --- a/tests/general.rs +++ b/tests/general.rs @@ -2,6 +2,7 @@ mod common; use common::TestSetup; use rstest::{fixture, rstest}; +use semver::Version; use serde_json::json; use std::collections::HashMap; use sysdig_lsp::domain::scanresult::architecture::Architecture; @@ -134,7 +135,7 @@ fn scan_result() -> ScanResult { let package1 = result.add_package( PackageType::Os, "package1".to_string(), - "1.0.0".to_string(), + Version::parse("1.0.0").unwrap(), "/usr/lib/package1".to_string(), layer.clone(), ); @@ -142,7 +143,7 @@ fn scan_result() -> ScanResult { result.add_package( PackageType::Os, "package2".to_string(), - "2.0.0".to_string(), + Version::parse("2.0.0").unwrap(), "/usr/lib/package2".to_string(), layer, ); @@ -153,7 +154,7 @@ fn scan_result() -> ScanResult { chrono::NaiveDate::from_ymd_opt(2021, 1, 1).unwrap(), None, false, - Some("1.0.1".to_string()), + Some(Version::parse("1.0.1").unwrap()), ); package1.add_vulnerability_found(vulnerability); From 99a3e26ffb1ee7a92e403b5f3f6697b782ced7ec Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Tue, 28 Oct 2025 16:56:48 +0100 Subject: [PATCH 2/4] feat(domain): implement suggested fix version for packages --- src/domain/scanresult/package.rs | 168 ++++++++++++++++++++++++++++++- 1 file changed, 167 insertions(+), 1 deletion(-) diff --git a/src/domain/scanresult/package.rs b/src/domain/scanresult/package.rs index 6d8ef13..dc8df95 100644 --- a/src/domain/scanresult/package.rs +++ b/src/domain/scanresult/package.rs @@ -1,10 +1,11 @@ use crate::domain::scanresult::accepted_risk::AcceptedRisk; use crate::domain::scanresult::layer::Layer; use crate::domain::scanresult::package_type::PackageType; +use crate::domain::scanresult::severity::Severity; use crate::domain::scanresult::vulnerability::Vulnerability; use crate::domain::scanresult::weak_hash::WeakHash; use semver::Version; -use std::collections::HashSet; +use std::collections::{HashMap, HashSet}; use std::fmt::Debug; use std::hash::{Hash, Hasher}; use std::sync::{Arc, RwLock}; @@ -109,6 +110,69 @@ impl Package { .filter_map(|r| r.0.upgrade()) .collect() } + + pub fn suggested_fix_version(&self) -> Option { + let vulnerabilities = self.vulnerabilities(); + if vulnerabilities.is_empty() { + return None; + } + + let candidate_versions: Vec = vulnerabilities + .iter() + .filter_map(|vuln| vuln.fix_version().cloned()) + .collect::>() + .into_iter() + .collect(); + + if candidate_versions.is_empty() { + return None; + } + + let severity_order = [ + Severity::Critical, + Severity::High, + Severity::Medium, + Severity::Low, + Severity::Negligible, + Severity::Unknown, + ]; + + let mut scores: HashMap> = HashMap::new(); + + for candidate in &candidate_versions { + let mut score: HashMap = HashMap::new(); + for severity in &severity_order { + score.insert(*severity, 0); + } + for vuln in &vulnerabilities { + if let Some(fix_version) = vuln.fix_version() + && fix_version == candidate + { + *score.entry(vuln.severity()).or_insert(0) += 1; + } + } + scores.insert(candidate.clone(), score); + } + + let mut sorted_candidates = candidate_versions; + sorted_candidates.sort_by(|a, b| { + let score_a = scores.get(a).unwrap(); + let score_b = scores.get(b).unwrap(); + + for severity in &severity_order { + let count_a = score_a.get(severity).unwrap(); + let count_b = score_b.get(severity).unwrap(); + if count_a != count_b { + return count_b.cmp(count_a); // Higher count is better + } + } + + // If scores are identical, lower version is better + a.cmp(b) + }); + + sorted_candidates.first().cloned() + } } impl PartialEq for Package { @@ -144,3 +208,105 @@ impl Clone for Package { } } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::domain::scanresult::layer::Layer; + use crate::domain::scanresult::package_type::PackageType; + use crate::domain::scanresult::severity::Severity; + use crate::domain::scanresult::vulnerability::Vulnerability; + use chrono::NaiveDate; + use rstest::{fixture, rstest}; + use semver::Version; + use std::sync::Arc; + + #[fixture] + fn layer() -> Arc { + Arc::new(Layer::new( + "a_digest".to_string(), + 0, + None, + "a_command".to_string(), + )) + } + + #[fixture] + fn package(#[default("")] version: &str, layer: Arc) -> Arc { + Arc::new(Package::new( + PackageType::Os, + "a_name".to_string(), + Version::parse(version).unwrap(), + "a_path".to_string(), + layer, + )) + } + + fn a_vulnerability( + cve: &str, + severity: Severity, + fix_version: Option<&str>, + ) -> Arc { + Arc::new(Vulnerability::new( + cve.to_string(), + severity, + NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(), + None, + false, + fix_version.map(|v| Version::parse(v).unwrap()), + )) + } + + #[rstest] + #[case("is_none_when_no_vulnerabilities", "1.0.0", vec![], None)] + #[case("is_none_when_no_fixable_vulnerabilities", "1.0.0", vec![a_vulnerability("CVE-1", Severity::High, None)], None)] + #[case("returns_only_available_fix", "1.0.0", vec![a_vulnerability("CVE-1", Severity::High, Some("1.0.1"))], Some("1.0.1"))] + #[case("chooses_version_with_more_critical_fixes", "1.0.0", vec![ + a_vulnerability("CVE-1", Severity::Critical, Some("1.0.1")), + a_vulnerability("CVE-2", Severity::Critical, Some("1.0.2")), + a_vulnerability("CVE-3", Severity::High, Some("1.0.2")), + ], Some("1.0.2"))] + #[case("chooses_version_with_more_high_fixes_when_criticals_tied", "1.0.0", vec![ + a_vulnerability("CVE-1", Severity::Critical, Some("1.0.1")), + a_vulnerability("CVE-5", Severity::Medium, Some("1.0.1")), + a_vulnerability("CVE-2", Severity::Critical, Some("1.0.2")), + a_vulnerability("CVE-3", Severity::High, Some("1.0.2")), + a_vulnerability("CVE-4", Severity::High, Some("1.0.2")), + ], Some("1.0.2"))] + #[case("chooses_lower_version_when_counts_are_tied", "1.0.0", vec![ + a_vulnerability("CVE-1", Severity::Critical, Some("1.0.1")), + a_vulnerability("CVE-3", Severity::High, Some("1.0.1")), + a_vulnerability("CVE-2", Severity::Critical, Some("1.0.2")), + a_vulnerability("CVE-4", Severity::High, Some("1.0.2")), + ], Some("1.0.1"))] + #[case("handles_complex_scenario", "2.8.1", vec![ + a_vulnerability("CVE-2022-25857", Severity::High, Some("2.8.2")), + a_vulnerability("CVE-2022-39253", Severity::High, Some("2.8.2")), + a_vulnerability("CVE-2022-0536", Severity::Medium, Some("2.8.2")), + a_vulnerability("CVE-2022-41724", Severity::Medium, Some("2.8.2")), + a_vulnerability("CVE-2022-41725", Severity::Medium, Some("2.8.2")), + + a_vulnerability("CVE-2021-33574", Severity::Critical, Some("2.9.0")), + a_vulnerability("CVE-2022-25857", Severity::High, Some("2.9.0")), + a_vulnerability("CVE-2022-39253", Severity::High, Some("2.9.0")), + a_vulnerability("CVE-2022-0536", Severity::Medium, Some("2.9.0")), + a_vulnerability("CVE-2022-41724", Severity::Medium, Some("2.9.0")), + a_vulnerability("CVE-2022-41725", Severity::Medium, Some("2.9.0")), + ], Some("2.9.0"))] + fn test_suggested_fix_version( + #[case] _description: &str, + #[case] version: &str, + #[with(version)] package: Arc, + #[case] vulnerabilities: Vec>, + #[case] expected_fix: Option<&str>, + ) { + assert_eq!(package.version(), &Version::parse(version).unwrap()); + + for vuln in &vulnerabilities { + package.add_vulnerability_found(vuln.clone()); + } + + let expected = expected_fix.map(|v| Version::parse(v).unwrap()); + assert_eq!(package.suggested_fix_version(), expected); + } +} From 06f91e7a173f904193a4128c6c8dd30fac98c2a7 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Tue, 28 Oct 2025 17:00:07 +0100 Subject: [PATCH 3/4] feat(markdown): use suggested_fix_version for better fix suggestions --- src/app/markdown/markdown_fixable_package_table.rs | 10 ++-------- 1 file changed, 2 insertions(+), 8 deletions(-) diff --git a/src/app/markdown/markdown_fixable_package_table.rs b/src/app/markdown/markdown_fixable_package_table.rs index eddb69e..f2e9aa5 100644 --- a/src/app/markdown/markdown_fixable_package_table.rs +++ b/src/app/markdown/markdown_fixable_package_table.rs @@ -57,10 +57,7 @@ impl From<&ScanResult> for FixablePackageTable { name: p.name().to_string(), package_type: p.package_type().to_string(), version: p.version().to_string(), - suggested_fix: p - .vulnerabilities() - .iter() - .find_map(|v| v.fix_version().map(|s| s.to_string())), + suggested_fix: p.suggested_fix_version().map(|v| v.to_string()), vulnerabilities: vulns, exploits, } @@ -98,10 +95,7 @@ impl From<&Arc> for FixablePackageTable { name: p.name().to_string(), package_type: p.package_type().to_string(), version: p.version().to_string(), - suggested_fix: p - .vulnerabilities() - .iter() - .find_map(|v| v.fix_version().map(|s| s.to_string())), + suggested_fix: p.suggested_fix_version().map(|v| v.to_string()), vulnerabilities: vulns, exploits, } From 5442e2273c0e0256bb6a2c617e7a78cb532c7183 Mon Sep 17 00:00:00 2001 From: Fede Barcelona Date: Tue, 28 Oct 2025 17:01:44 +0100 Subject: [PATCH 4/4] chore: bump version to 0.7.3 --- Cargo.lock | 2 +- Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 7105123..43e75c3 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2035,7 +2035,7 @@ dependencies = [ [[package]] name = "sysdig-lsp" -version = "0.7.2" +version = "0.7.3" dependencies = [ "async-trait", "bollard", diff --git a/Cargo.toml b/Cargo.toml index 6cfc324..7b963b2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "sysdig-lsp" -version = "0.7.2" +version = "0.7.3" edition = "2024" authors = [ "Sysdig Inc." ] readme = "README.md"