Skip to main content

lean_ctx_patterns/
trivy.rs

1// SPDX-License-Identifier: Apache-2.0
2
3//! Trivy vulnerability scanner output compression.
4//!
5//! Trivy prefixes ISO-timestamped INFO logs and renders a large ASCII table
6//! per target. We drop the logs and table chrome but KEEP the actionable
7//! signal: each target header, its `Total: N (LOW.. CRITICAL..)` summary, and
8//! every `HIGH`/`CRITICAL` row (library · CVE · severity · installed · fixed).
9//! Lower-severity rows are counted into the `Total` but their detail is dropped
10//! — an agent fixes the criticals first and the summary still reports the rest.
11
12use crate::ansi::strip_ansi;
13
14/// Cap on kept HIGH/CRITICAL detail rows (per scan) to bound output on images
15/// with pathological vuln counts; the `Total:` line still reports the true sum.
16const MAX_ROWS: usize = 30;
17
18pub fn compress(_cmd: &str, output: &str) -> Option<String> {
19    let trimmed = output.trim();
20    if trimmed.is_empty() {
21        return Some("trivy: ok".to_string());
22    }
23
24    let lines: Vec<String> = trimmed
25        .lines()
26        .map(|l| strip_ansi(l).trim().to_string())
27        .filter(|l| !l.is_empty())
28        .collect();
29
30    let mut parts: Vec<String> = Vec::new();
31    let mut last_target: Option<String> = None;
32    let mut emitted_target: Option<String> = None;
33    let mut serious = 0usize;
34
35    for line in &lines {
36        if line.starts_with("Total:") {
37            emit_target(&mut parts, last_target.as_ref(), &mut emitted_target);
38            parts.push(format!("  {line}"));
39        } else if is_target_header(line) {
40            last_target = Some(line.clone());
41        } else if let Some((sev, row)) = parse_vuln_row(line)
42            && sev_is_serious(&sev)
43        {
44            emit_target(&mut parts, last_target.as_ref(), &mut emitted_target);
45            if serious < MAX_ROWS {
46                parts.push(format!("    {row}"));
47            }
48            serious += 1;
49        }
50    }
51
52    if serious > MAX_ROWS {
53        parts.push(format!(
54            "    ... +{} more HIGH/CRITICAL",
55            serious - MAX_ROWS
56        ));
57    }
58
59    if parts.is_empty() {
60        if trimmed.contains("Total: 0") || trimmed.to_lowercase().contains("no vulnerabilities") {
61            return Some("trivy: no vulnerabilities".to_string());
62        }
63        return Some(fallback(&lines));
64    }
65    Some(format!("trivy:\n{}", parts.join("\n")))
66}
67
68/// Emit the pending target header once, before its first kept child line.
69fn emit_target(
70    parts: &mut Vec<String>,
71    last_target: Option<&String>,
72    emitted_target: &mut Option<String>,
73) {
74    if let Some(t) = last_target
75        && emitted_target.as_ref() != Some(t)
76    {
77        parts.push(t.clone());
78        *emitted_target = Some(t.clone());
79    }
80}
81
82/// A target header like `nginx:latest (debian 12.1)` — has parens, isn't a log
83/// line, isn't a table border.
84fn is_target_header(line: &str) -> bool {
85    line.contains('(')
86        && line.ends_with(')')
87        && !is_log(line)
88        && !is_border(line)
89        && !line.starts_with("Total:")
90}
91
92/// Parse a vulnerability table row into `(severity, compact_row)`.
93///
94/// Handles both the Unicode (`│ … │`) and ASCII (`| … |`) bordered tables as
95/// well as whitespace-aligned output. Returns `None` for borders, the header
96/// row, and any line without a recognizable severity cell.
97fn parse_vuln_row(line: &str) -> Option<(String, String)> {
98    let cells: Vec<String> = if line.contains('│') || line.starts_with('|') || line.contains(" | ")
99    {
100        line.split(['│', '|'])
101            .map(str::trim)
102            .filter(|s| !s.is_empty())
103            .map(String::from)
104            .collect()
105    } else {
106        line.split("  ")
107            .map(str::trim)
108            .filter(|s| !s.is_empty())
109            .map(String::from)
110            .collect()
111    };
112    if cells.len() < 2 {
113        return None;
114    }
115    let upper = cells.join(" ").to_ascii_uppercase();
116    if upper.contains("SEVERITY") || upper.contains("VULNERABILITY ID") {
117        return None; // header row
118    }
119    let sev = cells.iter().find(|c| is_severity(c))?.clone();
120    Some((sev, cells.join("  ")))
121}
122
123fn is_severity(s: &str) -> bool {
124    matches!(
125        s.to_ascii_uppercase().as_str(),
126        "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "UNKNOWN"
127    )
128}
129
130fn sev_is_serious(s: &str) -> bool {
131    matches!(s.to_ascii_uppercase().as_str(), "CRITICAL" | "HIGH")
132}
133
134fn is_log(line: &str) -> bool {
135    // 2024-01-01T12:00:00.000Z INFO ...
136    let first = line.split_whitespace().next().unwrap_or("");
137    first.contains('T') && first.ends_with('Z') && first.contains('-')
138}
139
140fn is_border(line: &str) -> bool {
141    line.starts_with('┌')
142        || line.starts_with('├')
143        || line.starts_with('└')
144        || line.starts_with('│')
145        || line.starts_with('=')
146        || line.starts_with('+')
147}
148
149fn fallback(lines: &[String]) -> String {
150    let n = lines.len().min(8);
151    let mut s = lines[..n].join("\n");
152    if lines.len() > n {
153        s.push_str(&format!("\n... (+{} lines)", lines.len() - n));
154    }
155    s
156}
157
158#[cfg(test)]
159mod tests {
160    use super::*;
161
162    const SCAN: &str = "2024-01-01T12:00:00.000Z\tINFO\tVulnerability scanning is enabled\n2024-01-01T12:00:01.000Z\tINFO\tNeed to update DB\nnginx:latest (debian 12.1)\n==================================\nTotal: 45 (UNKNOWN: 0, LOW: 20, MEDIUM: 15, HIGH: 1, CRITICAL: 1)\n\n┌────────────┬───────────────┬──────────┬───────────┬───────────┐\n│  Library   │ Vulnerability │ Severity │ Installed │ Fixed     │\n├────────────┼───────────────┼──────────┼───────────┼───────────┤\n│ openssl    │ CVE-2023-0001 │ CRITICAL │ 3.0.1     │ 3.0.2     │\n│ libssl1.1  │ CVE-2023-1234 │ HIGH     │ 1.1.1n    │ 1.1.1w    │\n│ zlib1g     │ CVE-2021-9999 │ LOW      │ 1.2.11    │ 1.2.13    │\n└────────────┴───────────────┴──────────┴───────────┴───────────┘\n";
163
164    #[test]
165    fn keeps_target_total_and_serious_rows() {
166        let r = compress("trivy image nginx", SCAN).unwrap();
167        assert!(r.contains("nginx:latest (debian 12.1)"), "{r}");
168        assert!(r.contains("Total: 45"), "{r}");
169        assert!(r.contains("CRITICAL: 1"), "{r}");
170        // actionable CVEs survive — the whole point of a vuln scanner
171        assert!(r.contains("CVE-2023-0001"), "keeps critical row: {r}");
172        assert!(r.contains("openssl"), "keeps critical lib: {r}");
173        assert!(r.contains("CVE-2023-1234"), "keeps high row: {r}");
174        assert!(r.contains("1.1.1w"), "keeps fixed version: {r}");
175        // noise + low-severity detail dropped
176        assert!(!r.contains("INFO"), "drops logs: {r}");
177        assert!(!r.contains("CVE-2021-9999"), "drops low row detail: {r}");
178        assert!(
179            !r.contains('┌') && !r.contains('│'),
180            "drops table chrome: {r}"
181        );
182    }
183
184    #[test]
185    fn ascii_table_rows_are_parsed() {
186        let scan = "app (alpine 3.19)\nTotal: 2 (HIGH: 1, LOW: 1)\n+-----------+---------------+----------+-----------+-----------+\n| LIBRARY   | VULNERABILITY | SEVERITY | INSTALLED | FIXED     |\n+-----------+---------------+----------+-----------+-----------+\n| libcrypto | CVE-2024-0001 | HIGH     | 3.1.4-r0  | 3.1.4-r1  |\n| busybox   | CVE-2024-0009 | LOW      | 1.36-r0   | 1.36-r2   |\n+-----------+---------------+----------+-----------+-----------+\n";
187        let r = compress("trivy image app", scan).unwrap();
188        assert!(r.contains("CVE-2024-0001"), "keeps ascii high row: {r}");
189        assert!(!r.contains("CVE-2024-0009"), "drops ascii low row: {r}");
190        assert!(!r.contains("LIBRARY"), "drops ascii header: {r}");
191    }
192
193    #[test]
194    fn shorter_than_input() {
195        let r = compress("trivy image nginx", SCAN).unwrap();
196        assert!(r.len() < SCAN.len());
197    }
198
199    #[test]
200    fn empty_is_ok() {
201        assert_eq!(compress("trivy image x", "").unwrap(), "trivy: ok");
202    }
203}