Skip to content

Commit 5b21dd2

Browse files
authored
feat(domain): implement suggested fix version for packages (#26)
1 parent 679a7bb commit 5b21dd2

File tree

8 files changed

+209
-38
lines changed

8 files changed

+209
-38
lines changed

Cargo.lock

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "sysdig-lsp"
3-
version = "0.7.2"
3+
version = "0.7.3"
44
edition = "2024"
55
authors = [ "Sysdig Inc." ]
66
readme = "README.md"

src/app/markdown/markdown_fixable_package_table.rs

Lines changed: 2 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -57,10 +57,7 @@ impl From<&ScanResult> for FixablePackageTable {
5757
name: p.name().to_string(),
5858
package_type: p.package_type().to_string(),
5959
version: p.version().to_string(),
60-
suggested_fix: p
61-
.vulnerabilities()
62-
.iter()
63-
.find_map(|v| v.fix_version().map(|s| s.to_string())),
60+
suggested_fix: p.suggested_fix_version().map(|v| v.to_string()),
6461
vulnerabilities: vulns,
6562
exploits,
6663
}
@@ -98,10 +95,7 @@ impl From<&Arc<Layer>> for FixablePackageTable {
9895
name: p.name().to_string(),
9996
package_type: p.package_type().to_string(),
10097
version: p.version().to_string(),
101-
suggested_fix: p
102-
.vulnerabilities()
103-
.iter()
104-
.find_map(|v| v.fix_version().map(|s| s.to_string())),
98+
suggested_fix: p.suggested_fix_version().map(|v| v.to_string()),
10599
vulnerabilities: vulns,
106100
exploits,
107101
}

src/domain/scanresult/package.rs

Lines changed: 171 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,19 @@
11
use crate::domain::scanresult::accepted_risk::AcceptedRisk;
22
use crate::domain::scanresult::layer::Layer;
33
use crate::domain::scanresult::package_type::PackageType;
4+
use crate::domain::scanresult::severity::Severity;
45
use crate::domain::scanresult::vulnerability::Vulnerability;
56
use crate::domain::scanresult::weak_hash::WeakHash;
6-
use std::collections::HashSet;
7+
use semver::Version;
8+
use std::collections::{HashMap, HashSet};
79
use std::fmt::Debug;
810
use std::hash::{Hash, Hasher};
911
use std::sync::{Arc, RwLock};
1012

1113
pub struct Package {
1214
package_type: PackageType,
1315
name: String,
14-
version: String,
16+
version: Version,
1517
path: String,
1618
found_in_layer: Arc<Layer>,
1719
vulnerabilities: RwLock<HashSet<WeakHash<Vulnerability>>>,
@@ -34,7 +36,7 @@ impl Package {
3436
pub(in crate::domain::scanresult) fn new(
3537
package_type: PackageType,
3638
name: String,
37-
version: String,
39+
version: Version,
3840
path: String,
3941
found_in_layer: Arc<Layer>,
4042
) -> Self {
@@ -57,7 +59,7 @@ impl Package {
5759
&self.name
5860
}
5961

60-
pub fn version(&self) -> &str {
62+
pub fn version(&self) -> &Version {
6163
&self.version
6264
}
6365

@@ -108,6 +110,69 @@ impl Package {
108110
.filter_map(|r| r.0.upgrade())
109111
.collect()
110112
}
113+
114+
pub fn suggested_fix_version(&self) -> Option<Version> {
115+
let vulnerabilities = self.vulnerabilities();
116+
if vulnerabilities.is_empty() {
117+
return None;
118+
}
119+
120+
let candidate_versions: Vec<Version> = vulnerabilities
121+
.iter()
122+
.filter_map(|vuln| vuln.fix_version().cloned())
123+
.collect::<HashSet<_>>()
124+
.into_iter()
125+
.collect();
126+
127+
if candidate_versions.is_empty() {
128+
return None;
129+
}
130+
131+
let severity_order = [
132+
Severity::Critical,
133+
Severity::High,
134+
Severity::Medium,
135+
Severity::Low,
136+
Severity::Negligible,
137+
Severity::Unknown,
138+
];
139+
140+
let mut scores: HashMap<Version, HashMap<Severity, usize>> = HashMap::new();
141+
142+
for candidate in &candidate_versions {
143+
let mut score: HashMap<Severity, usize> = HashMap::new();
144+
for severity in &severity_order {
145+
score.insert(*severity, 0);
146+
}
147+
for vuln in &vulnerabilities {
148+
if let Some(fix_version) = vuln.fix_version()
149+
&& fix_version == candidate
150+
{
151+
*score.entry(vuln.severity()).or_insert(0) += 1;
152+
}
153+
}
154+
scores.insert(candidate.clone(), score);
155+
}
156+
157+
let mut sorted_candidates = candidate_versions;
158+
sorted_candidates.sort_by(|a, b| {
159+
let score_a = scores.get(a).unwrap();
160+
let score_b = scores.get(b).unwrap();
161+
162+
for severity in &severity_order {
163+
let count_a = score_a.get(severity).unwrap();
164+
let count_b = score_b.get(severity).unwrap();
165+
if count_a != count_b {
166+
return count_b.cmp(count_a); // Higher count is better
167+
}
168+
}
169+
170+
// If scores are identical, lower version is better
171+
a.cmp(b)
172+
});
173+
174+
sorted_candidates.first().cloned()
175+
}
111176
}
112177

113178
impl PartialEq for Package {
@@ -143,3 +208,105 @@ impl Clone for Package {
143208
}
144209
}
145210
}
211+
212+
#[cfg(test)]
213+
mod tests {
214+
use super::*;
215+
use crate::domain::scanresult::layer::Layer;
216+
use crate::domain::scanresult::package_type::PackageType;
217+
use crate::domain::scanresult::severity::Severity;
218+
use crate::domain::scanresult::vulnerability::Vulnerability;
219+
use chrono::NaiveDate;
220+
use rstest::{fixture, rstest};
221+
use semver::Version;
222+
use std::sync::Arc;
223+
224+
#[fixture]
225+
fn layer() -> Arc<Layer> {
226+
Arc::new(Layer::new(
227+
"a_digest".to_string(),
228+
0,
229+
None,
230+
"a_command".to_string(),
231+
))
232+
}
233+
234+
#[fixture]
235+
fn package(#[default("")] version: &str, layer: Arc<Layer>) -> Arc<Package> {
236+
Arc::new(Package::new(
237+
PackageType::Os,
238+
"a_name".to_string(),
239+
Version::parse(version).unwrap(),
240+
"a_path".to_string(),
241+
layer,
242+
))
243+
}
244+
245+
fn a_vulnerability(
246+
cve: &str,
247+
severity: Severity,
248+
fix_version: Option<&str>,
249+
) -> Arc<Vulnerability> {
250+
Arc::new(Vulnerability::new(
251+
cve.to_string(),
252+
severity,
253+
NaiveDate::from_ymd_opt(2023, 1, 1).unwrap(),
254+
None,
255+
false,
256+
fix_version.map(|v| Version::parse(v).unwrap()),
257+
))
258+
}
259+
260+
#[rstest]
261+
#[case("is_none_when_no_vulnerabilities", "1.0.0", vec![], None)]
262+
#[case("is_none_when_no_fixable_vulnerabilities", "1.0.0", vec![a_vulnerability("CVE-1", Severity::High, None)], None)]
263+
#[case("returns_only_available_fix", "1.0.0", vec![a_vulnerability("CVE-1", Severity::High, Some("1.0.1"))], Some("1.0.1"))]
264+
#[case("chooses_version_with_more_critical_fixes", "1.0.0", vec![
265+
a_vulnerability("CVE-1", Severity::Critical, Some("1.0.1")),
266+
a_vulnerability("CVE-2", Severity::Critical, Some("1.0.2")),
267+
a_vulnerability("CVE-3", Severity::High, Some("1.0.2")),
268+
], Some("1.0.2"))]
269+
#[case("chooses_version_with_more_high_fixes_when_criticals_tied", "1.0.0", vec![
270+
a_vulnerability("CVE-1", Severity::Critical, Some("1.0.1")),
271+
a_vulnerability("CVE-5", Severity::Medium, Some("1.0.1")),
272+
a_vulnerability("CVE-2", Severity::Critical, Some("1.0.2")),
273+
a_vulnerability("CVE-3", Severity::High, Some("1.0.2")),
274+
a_vulnerability("CVE-4", Severity::High, Some("1.0.2")),
275+
], Some("1.0.2"))]
276+
#[case("chooses_lower_version_when_counts_are_tied", "1.0.0", vec![
277+
a_vulnerability("CVE-1", Severity::Critical, Some("1.0.1")),
278+
a_vulnerability("CVE-3", Severity::High, Some("1.0.1")),
279+
a_vulnerability("CVE-2", Severity::Critical, Some("1.0.2")),
280+
a_vulnerability("CVE-4", Severity::High, Some("1.0.2")),
281+
], Some("1.0.1"))]
282+
#[case("handles_complex_scenario", "2.8.1", vec![
283+
a_vulnerability("CVE-2022-25857", Severity::High, Some("2.8.2")),
284+
a_vulnerability("CVE-2022-39253", Severity::High, Some("2.8.2")),
285+
a_vulnerability("CVE-2022-0536", Severity::Medium, Some("2.8.2")),
286+
a_vulnerability("CVE-2022-41724", Severity::Medium, Some("2.8.2")),
287+
a_vulnerability("CVE-2022-41725", Severity::Medium, Some("2.8.2")),
288+
289+
a_vulnerability("CVE-2021-33574", Severity::Critical, Some("2.9.0")),
290+
a_vulnerability("CVE-2022-25857", Severity::High, Some("2.9.0")),
291+
a_vulnerability("CVE-2022-39253", Severity::High, Some("2.9.0")),
292+
a_vulnerability("CVE-2022-0536", Severity::Medium, Some("2.9.0")),
293+
a_vulnerability("CVE-2022-41724", Severity::Medium, Some("2.9.0")),
294+
a_vulnerability("CVE-2022-41725", Severity::Medium, Some("2.9.0")),
295+
], Some("2.9.0"))]
296+
fn test_suggested_fix_version(
297+
#[case] _description: &str,
298+
#[case] version: &str,
299+
#[with(version)] package: Arc<Package>,
300+
#[case] vulnerabilities: Vec<Arc<Vulnerability>>,
301+
#[case] expected_fix: Option<&str>,
302+
) {
303+
assert_eq!(package.version(), &Version::parse(version).unwrap());
304+
305+
for vuln in &vulnerabilities {
306+
package.add_vulnerability_found(vuln.clone());
307+
}
308+
309+
let expected = expected_fix.map(|v| Version::parse(v).unwrap());
310+
assert_eq!(package.suggested_fix_version(), expected);
311+
}
312+
}

src/domain/scanresult/scan_result.rs

Lines changed: 16 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ use crate::domain::scanresult::severity::Severity;
1414
use crate::domain::scanresult::vulnerability::Vulnerability;
1515
use chrono::{DateTime, NaiveDate, Utc};
1616
use itertools::Itertools;
17+
use semver::Version;
1718
use std::collections::HashMap;
1819
use std::sync::Arc;
1920

@@ -109,14 +110,14 @@ impl ScanResult {
109110
&mut self,
110111
package_type: PackageType,
111112
name: String,
112-
version: String,
113+
version: Version,
113114
path: String,
114115
found_in_layer: Arc<Layer>,
115116
) -> Arc<Package> {
116117
let a_package = Arc::new(Package::new(
117118
package_type,
118119
name.clone(),
119-
version.clone(),
120+
version,
120121
path.clone(),
121122
found_in_layer.clone(),
122123
));
@@ -140,7 +141,7 @@ impl ScanResult {
140141
disclosure_date: NaiveDate,
141142
solution_date: Option<NaiveDate>,
142143
exploitable: bool,
143-
fix_version: Option<String>,
144+
fix_version: Option<Version>,
144145
) -> Arc<Vulnerability> {
145146
self.vulnerabilities
146147
.entry(cve.clone())
@@ -313,7 +314,7 @@ mod tests {
313314
let package = scan_result.add_package(
314315
PackageType::Os,
315316
"musl".to_string(),
316-
"1.2.3".to_string(),
317+
Version::parse("1.2.3").unwrap(),
317318
"/lib/ld-musl-x86_64.so.1".to_string(),
318319
layer.clone(),
319320
);
@@ -336,7 +337,7 @@ mod tests {
336337
Utc::now().naive_utc().date(),
337338
None,
338339
false,
339-
Some("1.2.4".to_string()),
340+
Some(Version::parse("1.2.4").unwrap()),
340341
);
341342

342343
assert_eq!(scan_result.vulnerabilities().len(), 1);
@@ -357,7 +358,7 @@ mod tests {
357358
let package = scan_result.add_package(
358359
PackageType::Os,
359360
"musl".to_string(),
360-
"1.2.3".to_string(),
361+
Version::parse("1.2.3").unwrap(),
361362
"/lib/ld-musl-x86_64.so.1".to_string(),
362363
layer.clone(),
363364
);
@@ -367,7 +368,7 @@ mod tests {
367368
Utc::now().naive_utc().date(),
368369
None,
369370
false,
370-
Some("1.2.4".to_string()),
371+
Some(Version::parse("1.2.4").unwrap()),
371372
);
372373

373374
package.add_vulnerability_found(vuln.clone());
@@ -462,7 +463,7 @@ mod tests {
462463
Utc::now().naive_utc().date(),
463464
None,
464465
false,
465-
Some("1.2.4".to_string()),
466+
Some(Version::parse("1.2.4").unwrap()),
466467
);
467468

468469
vuln.add_accepted_risk(risk.clone());
@@ -488,7 +489,7 @@ mod tests {
488489
let package = scan_result.add_package(
489490
PackageType::Os,
490491
"musl".to_string(),
491-
"1.2.3".to_string(),
492+
Version::parse("1.2.3").unwrap(),
492493
"/lib/ld-musl-x86_64.so.1".to_string(),
493494
layer.clone(),
494495
);
@@ -571,13 +572,13 @@ mod tests {
571572
let package = scan_result.add_package(
572573
PackageType::Os,
573574
"musl".to_string(),
574-
"1.2.3".to_string(),
575+
Version::parse("1.2.3").unwrap(),
575576
"/path".to_string(),
576577
layer.clone(),
577578
);
578579
assert_eq!(package.package_type(), &PackageType::Os);
579580
assert_eq!(package.name(), "musl");
580-
assert_eq!(package.version(), "1.2.3");
581+
assert_eq!(package.version(), &Version::parse("1.2.3").unwrap());
581582
assert_eq!(package.path(), "/path");
582583
assert!(format!("{:?}", package).contains("musl"));
583584
assert_eq!(package.clone(), package);
@@ -589,15 +590,15 @@ mod tests {
589590
now.naive_utc().date(),
590591
Some(now.naive_utc().date()),
591592
true,
592-
Some("1.2.4".to_string()),
593+
Some(Version::parse("1.2.4").unwrap()),
593594
);
594595
assert_eq!(vuln.cve(), "CVE-1");
595596
assert_eq!(vuln.severity(), Severity::High);
596597
assert_eq!(vuln.disclosure_date(), now.naive_utc().date());
597598
assert_eq!(vuln.solution_date(), Some(now.naive_utc().date()));
598599
assert!(vuln.exploitable());
599600
assert!(vuln.fixable());
600-
assert_eq!(vuln.fix_version(), Some("1.2.4"));
601+
assert_eq!(vuln.fix_version(), Some(&Version::parse("1.2.4").unwrap()));
601602
assert!(format!("{:?}", vuln).contains("CVE-1"));
602603

603604
// AcceptedRisk
@@ -672,14 +673,14 @@ mod tests {
672673
let pkg = scan_result.add_package(
673674
PackageType::Os,
674675
"pkg".to_string(),
675-
"1.0".to_string(),
676+
Version::parse("1.0.0").unwrap(),
676677
"/path".to_string(),
677678
layer.clone(),
678679
);
679680
let pkg2 = scan_result.add_package(
680681
PackageType::Os,
681682
"pkg".to_string(),
682-
"1.0".to_string(),
683+
Version::parse("1.0.0").unwrap(),
683684
"/path".to_string(),
684685
layer.clone(),
685686
);

0 commit comments

Comments
 (0)