Skip to main content

lean_ctx/core/patterns/
grype.rs

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