1use std::collections::{BTreeMap, BTreeSet};
14
15use semver::{Op, Version, VersionReq};
16use serde::{Deserialize, Serialize};
17
18use crate::{Ecosystem, FleetReport, Occurrence, ReachVerdict, Severity, VulnFinding};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
26#[serde(rename_all = "snake_case")]
27pub enum ReachTier {
28 Reachable,
30 Unknown,
32 NotReachable,
34}
35
36impl ReachTier {
37 pub fn is_actionable(self) -> bool {
40 self != ReachTier::NotReachable
41 }
42}
43
44#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
46#[serde(tag = "type", rename_all = "snake_case")]
47pub enum Action {
48 Upgrade { to: Version, breaking: bool },
52 NoFixAvailable,
55}
56
57#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
62pub struct RemediationItem {
63 pub package: String,
65 pub ecosystem: Ecosystem,
66 pub current: Vec<Version>,
68 pub advisories: Vec<String>,
70 pub action: Action,
71 pub reach: ReachTier,
73 pub repos: usize,
75 pub occurrences: usize,
77 pub max_severity: Severity,
79 pub kev: bool,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub max_epss: Option<f32>,
84 #[serde(default, skip_serializing_if = "Option::is_none")]
86 pub max_cvss: Option<f32>,
87}
88
89pub fn remediations(report: &FleetReport) -> Vec<RemediationItem> {
98 let mut groups: BTreeMap<(Ecosystem, String), Vec<&VulnFinding>> = BTreeMap::new();
101 for v in &report.vulnerabilities {
102 if vuln_occ_count(v) == 0 {
104 continue;
105 }
106 if let Some(package) = finding_package(v) {
107 groups.entry((v.ecosystem, package)).or_default().push(v);
108 }
109 }
110
111 let mut items = Vec::new();
112 for ((ecosystem, package), findings) in groups {
113 let (fixable, nofix): (Vec<&VulnFinding>, Vec<&VulnFinding>) = findings
114 .into_iter()
115 .partition(|f| finding_floor(&finding_patched(f)).is_some());
116
117 if !nofix.is_empty() {
118 items.push(build_item(
119 ecosystem,
120 &package,
121 &nofix,
122 Action::NoFixAvailable,
123 ));
124 }
125 if fixable.is_empty() {
126 continue;
127 }
128
129 let group_lb = fixable
136 .iter()
137 .flat_map(|f| installed_versions(f))
138 .max()
139 .unwrap_or(Version::new(0, 0, 0));
140 let candidate = match fixable
141 .iter()
142 .filter_map(|f| finding_target(&finding_patched(f), &group_lb))
143 .max()
144 {
145 Some(c) => c,
146 None => continue, };
148 let compatible = fixable
149 .iter()
150 .all(|f| satisfied_by(&finding_patched(f), &candidate));
151
152 if compatible {
153 let action = upgrade_action(&fixable, &candidate);
154 items.push(build_item(ecosystem, &package, &fixable, action));
155 } else {
156 for &f in &fixable {
157 let lb = installed_versions(f)
158 .into_iter()
159 .max()
160 .unwrap_or(Version::new(0, 0, 0));
161 if let Some(target) = finding_target(&finding_patched(f), &lb) {
162 let action = upgrade_action(&[f], &target);
163 items.push(build_item(ecosystem, &package, &[f], action));
164 }
165 }
166 }
167 }
168
169 items.sort_by(|a, b| {
170 a.package
171 .cmp(&b.package)
172 .then_with(|| a.ecosystem.cmp(&b.ecosystem))
173 .then_with(|| a.advisories.cmp(&b.advisories))
174 });
175 items
176}
177
178fn build_item(
181 ecosystem: Ecosystem,
182 package: &str,
183 subset: &[&VulnFinding],
184 action: Action,
185) -> RemediationItem {
186 let mut advisories: Vec<String> = subset.iter().map(|f| f.advisory_id.clone()).collect();
187 advisories.sort();
188 advisories.dedup();
189
190 let mut current: Vec<Version> = subset.iter().flat_map(|f| installed_versions(f)).collect();
191 current.sort();
192 current.dedup();
193
194 let repos: BTreeSet<&str> = subset.iter().flat_map(|f| repo_ids(f)).collect();
195 let occurrences = subset.iter().map(|f| vuln_occ_count(f)).sum();
196 let max_severity = subset.iter().map(|f| f.severity).max().unwrap_or_default();
197 let kev = subset.iter().any(|f| f.exploit.kev);
198 let max_epss = subset
199 .iter()
200 .filter_map(|f| f.exploit.epss)
201 .reduce(f32::max);
202 let max_cvss = subset.iter().filter_map(|f| f.cvss_score).reduce(f32::max);
203
204 RemediationItem {
205 package: package.to_string(),
206 ecosystem,
207 current,
208 advisories,
209 action,
210 reach: collapse_reach(subset.iter().copied()),
211 repos: repos.len(),
212 occurrences,
213 max_severity,
214 kev,
215 max_epss,
216 max_cvss,
217 }
218}
219
220fn upgrade_action(subset: &[&VulnFinding], to: &Version) -> Action {
223 let current_max = subset.iter().flat_map(|f| installed_versions(f)).max();
224 let breaking = match ¤t_max {
225 Some(c) => to.major != c.major || (to.major == 0 && to.minor != c.minor),
227 None => false,
228 };
229 Action::Upgrade {
230 to: to.clone(),
231 breaking,
232 }
233}
234
235fn collapse_reach<'a>(findings: impl Iterator<Item = &'a VulnFinding>) -> ReachTier {
239 let mut tier = ReachTier::NotReachable;
240 for f in findings {
241 match finding_reach(f) {
242 ReachTier::Reachable => return ReachTier::Reachable,
243 ReachTier::Unknown => tier = ReachTier::Unknown,
244 ReachTier::NotReachable => {}
245 }
246 }
247 tier
248}
249
250fn finding_reach(f: &VulnFinding) -> ReachTier {
251 match f.reachability.as_ref().map(|r| &r.verdict) {
252 Some(ReachVerdict::Reachable { .. }) => ReachTier::Reachable,
253 Some(ReachVerdict::NotReachable) => ReachTier::NotReachable,
254 Some(ReachVerdict::Unknown { .. }) | None => ReachTier::Unknown,
255 }
256}
257
258fn req_floor(req: &VersionReq) -> Option<Version> {
261 req.comparators.iter().find_map(|c| match c.op {
262 Op::Exact | Op::Greater | Op::GreaterEq | Op::Tilde | Op::Caret => Some(Version::new(
263 c.major,
264 c.minor.unwrap_or(0),
265 c.patch.unwrap_or(0),
266 )),
267 _ => None,
268 })
269}
270
271fn finding_floor(patched: &[VersionReq]) -> Option<Version> {
276 patched.iter().filter_map(req_floor).min()
277}
278
279fn finding_target(patched: &[VersionReq], lb: &Version) -> Option<Version> {
285 let mut floors: Vec<Version> = patched.iter().filter_map(req_floor).collect();
286 floors.sort();
287 floors
288 .iter()
289 .find(|v| *v >= lb)
290 .cloned()
291 .or_else(|| floors.last().cloned())
292}
293
294fn satisfied_by(patched: &[VersionReq], v: &Version) -> bool {
296 patched.iter().any(|r| r.matches(v))
297}
298
299fn finding_package(f: &VulnFinding) -> Option<String> {
301 f.occurrences.first().map(|o| match o {
302 Occurrence::InRepo { package, .. } => package.clone(),
303 Occurrence::Toolchain { channel, .. } => channel.clone(),
304 })
305}
306
307fn finding_patched(f: &VulnFinding) -> Vec<VersionReq> {
310 let mut reqs: Vec<VersionReq> = f
311 .occurrences
312 .iter()
313 .flat_map(|o| match o {
314 Occurrence::InRepo { patched, .. } => patched.clone(),
315 Occurrence::Toolchain { patched, .. } => patched.clone(),
316 })
317 .collect();
318 reqs.sort_by_key(|r| r.to_string());
319 reqs.dedup_by(|a, b| a.to_string() == b.to_string());
320 reqs
321}
322
323fn installed_versions(f: &VulnFinding) -> Vec<Version> {
324 f.occurrences
325 .iter()
326 .filter(|o| o.is_vulnerable())
327 .filter_map(|o| match o {
328 Occurrence::InRepo { installed, .. } => Some(installed.clone()),
329 Occurrence::Toolchain { installed, .. } => installed.clone(),
330 })
331 .collect()
332}
333
334fn repo_ids(f: &VulnFinding) -> Vec<&str> {
335 f.occurrences
336 .iter()
337 .filter(|o| o.is_vulnerable())
338 .filter_map(|o| match o {
339 Occurrence::InRepo { repo, .. } => Some(repo.0.as_str()),
340 Occurrence::Toolchain { .. } => None,
341 })
342 .collect()
343}
344
345fn vuln_occ_count(f: &VulnFinding) -> usize {
346 f.occurrences.iter().filter(|o| o.is_vulnerable()).count()
347}
348
349#[cfg(test)]
350mod tests {
351 #![allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
352 use super::*;
353 use crate::{DependencyKind, Provenance, Reachability, RepoId, Summary, SCHEMA_VERSION};
354
355 fn in_repo(repo: &str, pkg: &str, installed: &str, patched: &[&str]) -> Occurrence {
356 Occurrence::InRepo {
357 repo: RepoId(repo.into()),
358 package: pkg.into(),
359 installed: Version::parse(installed).unwrap(),
360 patched: patched
361 .iter()
362 .map(|p| VersionReq::parse(p).unwrap())
363 .collect(),
364 dependency_kind: DependencyKind::Transitive,
365 dependency_path: vec![],
366 active: None,
367 source: Default::default(),
368 }
369 }
370
371 fn vuln(id: &str, sev: Severity, occ: Vec<Occurrence>) -> VulnFinding {
372 VulnFinding {
373 advisory_id: id.into(),
374 aliases: vec![],
375 ecosystem: Ecosystem::Cargo,
376 title: id.into(),
377 severity: sev,
378 cvss_score: None,
379 url: None,
380 occurrences: occ,
381 affected_functions: vec![],
382 reachable: None,
383 reachability: None,
384 exploit: Default::default(),
385 }
386 }
387
388 fn with_reach(mut f: VulnFinding, verdict: ReachVerdict) -> VulnFinding {
389 f.reachability = Some(Reachability {
390 verdict,
391 config: "cfg".into(),
392 engine: "test".into(),
393 targets: vec![],
394 witness: None,
395 });
396 f
397 }
398
399 fn report_of(vulns: Vec<VulnFinding>) -> FleetReport {
400 FleetReport {
401 schema_version: SCHEMA_VERSION,
402 provenance: Provenance {
403 tool_version: "t".into(),
404 rustsec_crate_version: "t".into(),
405 db_commit: None,
406 db_timestamp: None,
407 host_os: "t".into(),
408 host_arch: "t".into(),
409 generated_at: "t".into(),
410 },
411 summary: Summary {
412 repos_scanned: 0,
413 repos_errored: 0,
414 vuln_count: vulns.len(),
415 warn_count: 0,
416 max_severity: Severity::Unknown,
417 stale_ignores: vec![],
418 },
419 vulnerabilities: vulns,
420 warnings: vec![],
421 outcomes: vec![],
422 }
423 }
424
425 #[test]
426 fn single_fixable_finding_yields_one_upgrade() {
427 let r = report_of(vec![vuln(
428 "RUSTSEC-1",
429 Severity::High,
430 vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
431 )]);
432 let items = remediations(&r);
433 assert_eq!(items.len(), 1);
434 let it = &items[0];
435 assert_eq!(it.package, "foo");
436 assert_eq!(it.advisories, ["RUSTSEC-1"]);
437 assert_eq!(
438 it.action,
439 Action::Upgrade {
440 to: Version::new(1, 2, 0),
441 breaking: false,
442 }
443 );
444 assert_eq!(it.reach, ReachTier::Unknown);
445 assert_eq!(it.repos, 1);
446 assert_eq!(it.occurrences, 1);
447 assert_eq!(it.current, [Version::new(1, 0, 0)]);
448 }
449
450 #[test]
451 fn compatible_advisories_batch_into_one_bump() {
452 let r = report_of(vec![
455 vuln(
456 "RUSTSEC-A",
457 Severity::Medium,
458 vec![in_repo("app1", "foo", "1.0.0", &[">=1.2.0"])],
459 ),
460 vuln(
461 "RUSTSEC-B",
462 Severity::High,
463 vec![in_repo("app2", "foo", "1.1.0", &[">=1.5.0"])],
464 ),
465 ]);
466 let items = remediations(&r);
467 assert_eq!(items.len(), 1);
468 let it = &items[0];
469 assert_eq!(it.advisories, ["RUSTSEC-A", "RUSTSEC-B"]);
470 assert_eq!(
471 it.action,
472 Action::Upgrade {
473 to: Version::new(1, 5, 0),
474 breaking: false,
475 }
476 );
477 assert_eq!(it.repos, 2);
478 assert_eq!(it.max_severity, Severity::High);
480 }
481
482 #[test]
483 fn incompatible_ranges_split_per_advisory() {
484 let r = report_of(vec![
487 vuln(
488 "RUSTSEC-A",
489 Severity::High,
490 vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0, <2.0.0"])],
491 ),
492 vuln(
493 "RUSTSEC-B",
494 Severity::High,
495 vec![in_repo("app", "foo", "1.0.0", &[">=2.1.0"])],
496 ),
497 ]);
498 let items = remediations(&r);
499 assert_eq!(items.len(), 2);
500 let tos: Vec<&Action> = items.iter().map(|i| &i.action).collect();
501 assert!(tos.contains(&&Action::Upgrade {
502 to: Version::new(1, 2, 0),
503 breaking: false,
504 }));
505 assert!(tos.contains(&&Action::Upgrade {
506 to: Version::new(2, 1, 0),
507 breaking: true,
508 }));
509 }
510
511 #[test]
512 fn distinct_ecosystems_never_batch() {
513 let mut go = vuln(
515 "GO-2024-0001",
516 Severity::High,
517 vec![in_repo("r", "foo", "1.0.0", &[">=1.2.0"])],
518 );
519 go.ecosystem = Ecosystem::Go;
520 let cargo = vuln(
521 "RUSTSEC-2024-0001",
522 Severity::High,
523 vec![in_repo("r", "foo", "1.0.0", &[">=1.2.0"])],
524 );
525 let items = remediations(&report_of(vec![go, cargo]));
526 assert_eq!(
527 items.len(),
528 2,
529 "same name, different ecosystem must not batch"
530 );
531 let ecos: Vec<Ecosystem> = items.iter().map(|i| i.ecosystem).collect();
532 assert!(ecos.contains(&Ecosystem::Cargo) && ecos.contains(&Ecosystem::Go));
533 }
534
535 #[test]
536 fn never_recommends_a_downgrade() {
537 let r = report_of(vec![vuln(
540 "RUSTSEC-2021-0003",
541 Severity::Critical,
542 vec![in_repo(
543 "app",
544 "smallvec",
545 "1.6.0",
546 &[">=0.6.14, <1.0.0", ">=1.6.1"],
547 )],
548 )]);
549 let items = remediations(&r);
550 assert_eq!(
551 items[0].action,
552 Action::Upgrade {
553 to: Version::new(1, 6, 1),
554 breaking: false, }
556 );
557 }
558
559 #[test]
560 fn no_published_fix_is_honest() {
561 let r = report_of(vec![vuln(
562 "RUSTSEC-1",
563 Severity::Critical,
564 vec![in_repo("app", "foo", "1.0.0", &[])],
565 )]);
566 let items = remediations(&r);
567 assert_eq!(items.len(), 1);
568 assert_eq!(items[0].action, Action::NoFixAvailable);
569 assert_eq!(items[0].max_severity, Severity::Critical);
570 }
571
572 #[test]
573 fn major_bump_is_breaking() {
574 let r = report_of(vec![vuln(
575 "RUSTSEC-1",
576 Severity::High,
577 vec![in_repo("app", "foo", "1.4.0", &[">=2.0.0"])],
578 )]);
579 let items = remediations(&r);
580 assert_eq!(
581 items[0].action,
582 Action::Upgrade {
583 to: Version::new(2, 0, 0),
584 breaking: true,
585 }
586 );
587 }
588
589 #[test]
590 fn zerover_minor_bump_is_breaking() {
591 let r = report_of(vec![vuln(
592 "RUSTSEC-1",
593 Severity::Low,
594 vec![in_repo("app", "foo", "0.4.0", &[">=0.5.0"])],
595 )]);
596 let items = remediations(&r);
597 assert_eq!(
598 items[0].action,
599 Action::Upgrade {
600 to: Version::new(0, 5, 0),
601 breaking: true,
602 }
603 );
604 }
605
606 #[test]
607 fn not_reachable_demotes_to_informational() {
608 let r = report_of(vec![with_reach(
609 vuln(
610 "RUSTSEC-1",
611 Severity::High,
612 vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
613 ),
614 ReachVerdict::NotReachable,
615 )]);
616 let items = remediations(&r);
617 assert_eq!(items[0].reach, ReachTier::NotReachable);
618 assert!(!items[0].reach.is_actionable());
619 }
620
621 #[test]
622 fn reachable_stays_actionable() {
623 let r = report_of(vec![with_reach(
624 vuln(
625 "RUSTSEC-1",
626 Severity::High,
627 vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
628 ),
629 ReachVerdict::Reachable { witness: vec![] },
630 )]);
631 let items = remediations(&r);
632 assert_eq!(items[0].reach, ReachTier::Reachable);
633 assert!(items[0].reach.is_actionable());
634 }
635
636 #[test]
637 fn any_reachable_in_a_batch_keeps_it_active() {
638 let r = report_of(vec![
641 with_reach(
642 vuln(
643 "RUSTSEC-A",
644 Severity::Medium,
645 vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
646 ),
647 ReachVerdict::NotReachable,
648 ),
649 with_reach(
650 vuln(
651 "RUSTSEC-B",
652 Severity::High,
653 vec![in_repo("app", "foo", "1.0.0", &[">=1.2.0"])],
654 ),
655 ReachVerdict::Reachable { witness: vec![] },
656 ),
657 ]);
658 let items = remediations(&r);
659 assert_eq!(items.len(), 1);
660 assert_eq!(items[0].reach, ReachTier::Reachable);
661 }
662
663 #[test]
664 fn fully_patched_finding_is_skipped() {
665 let r = report_of(vec![vuln(
668 "RUSTSEC-1",
669 Severity::High,
670 vec![in_repo("app", "foo", "1.2.0", &[">=1.2.0"])],
671 )]);
672 assert!(remediations(&r).is_empty());
673 }
674}