Skip to main content

lean_ctx_patterns/
grype.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Grype vulnerability scanner output compression.
4//!
5//! Grype prints `✔` progress lines then an aligned table
6//! (`NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY`). We replace the
7//! table with a severity histogram and keep only the Critical/High rows
8//! (NAME · VULNERABILITY · SEVERITY).
9
10use crate::ansi::strip_ansi;
11
12pub fn compress(_cmd: &str, output: &str) -> Option<String> {
13    let trimmed = output.trim();
14    if trimmed.is_empty() {
15        return Some("grype: ok".to_string());
16    }
17    if trimmed.contains("No vulnerabilities found") {
18        return Some("grype: no vulnerabilities".to_string());
19    }
20
21    let lines: Vec<String> = trimmed
22        .lines()
23        .map(|l| strip_ansi(l).trim_end().to_string())
24        .collect();
25
26    let header = lines.iter().position(|l| {
27        let u = l.to_ascii_uppercase();
28        u.contains("VULNERABILITY") && u.contains("SEVERITY")
29    })?;
30
31    // severity order high→low for stable histogram output
32    let order = ["Critical", "High", "Medium", "Low", "Negligible", "Unknown"];
33    let mut counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
34    let mut serious: Vec<String> = Vec::new();
35    let mut total = 0usize;
36
37    for line in &lines[header + 1..] {
38        let cols = split_cols(line);
39        if cols.len() < 3 {
40            continue;
41        }
42        let severity = cols[cols.len() - 1].clone();
43        let vuln = cols[cols.len() - 2].clone();
44        let name = cols[0].clone();
45        total += 1;
46        *counts.entry(severity.clone()).or_default() += 1;
47        if severity.eq_ignore_ascii_case("Critical") || severity.eq_ignore_ascii_case("High") {
48            serious.push(format!("  {name} {vuln} {severity}"));
49        }
50    }
51
52    if total == 0 {
53        return Some("grype: no vulnerabilities".to_string());
54    }
55
56    let hist: Vec<String> = order
57        .iter()
58        .filter_map(|sev| {
59            counts
60                .iter()
61                .find(|(k, _)| k.eq_ignore_ascii_case(sev))
62                .map(|(_, n)| format!("{sev}: {n}"))
63        })
64        .collect();
65
66    let mut parts = vec![format!("grype: {total} vulns ({})", hist.join(", "))];
67    parts.extend(serious.into_iter().take(15));
68    Some(parts.join("\n"))
69}
70
71/// Split an aligned table row on runs of 2+ spaces.
72fn split_cols(line: &str) -> Vec<String> {
73    line.split("  ")
74        .map(str::trim)
75        .filter(|s| !s.is_empty())
76        .map(str::to_string)
77        .collect()
78}
79
80#[cfg(test)]
81mod tests {
82    use super::*;
83
84    const SCAN: &str = " ✔ Vulnerability DB        [updated]\n ✔ Scanned for vulnerabilities     [45 vulnerabilities]\nNAME       INSTALLED  FIXED-IN  TYPE  VULNERABILITY   SEVERITY\nlibssl1.1  1.1.1n     1.1.1w    deb   CVE-2023-1234   Critical\nzlib1g     1.2.11     1.2.13    deb   CVE-2022-5678   High\ncurl       7.74.0     7.88.0    deb   CVE-2021-1111   Low\n";
85
86    #[test]
87    fn histogram_and_serious_rows() {
88        let r = compress("grype nginx", SCAN).unwrap();
89        assert!(r.contains("grype: 3 vulns"), "{r}");
90        assert!(r.contains("Critical: 1"), "{r}");
91        assert!(r.contains("High: 1"), "{r}");
92        assert!(r.contains("Low: 1"), "{r}");
93        assert!(r.contains("CVE-2023-1234"), "keeps critical row: {r}");
94        assert!(!r.contains("CVE-2021-1111"), "drops low row detail: {r}");
95        assert!(!r.contains("✔"), "drops progress: {r}");
96    }
97
98    #[test]
99    fn no_vulns() {
100        let r = compress("grype nginx", " ✔ Scanned\nNo vulnerabilities found").unwrap();
101        assert_eq!(r, "grype: no vulnerabilities");
102    }
103
104    #[test]
105    fn empty_is_ok() {
106        assert_eq!(compress("grype nginx", "").unwrap(), "grype: ok");
107    }
108}