lean_ctx/core/patterns/
trivy.rs1use crate::core::compressor::strip_ansi;
11
12const MAX_ROWS: usize = 30;
15
16pub fn compress(_cmd: &str, output: &str) -> Option<String> {
17 let trimmed = output.trim();
18 if trimmed.is_empty() {
19 return Some("trivy: ok".to_string());
20 }
21
22 let lines: Vec<String> = trimmed
23 .lines()
24 .map(|l| strip_ansi(l).trim().to_string())
25 .filter(|l| !l.is_empty())
26 .collect();
27
28 let mut parts: Vec<String> = Vec::new();
29 let mut last_target: Option<String> = None;
30 let mut emitted_target: Option<String> = None;
31 let mut serious = 0usize;
32
33 for line in &lines {
34 if line.starts_with("Total:") {
35 emit_target(&mut parts, last_target.as_ref(), &mut emitted_target);
36 parts.push(format!(" {line}"));
37 } else if is_target_header(line) {
38 last_target = Some(line.clone());
39 } else if let Some((sev, row)) = parse_vuln_row(line)
40 && sev_is_serious(&sev)
41 {
42 emit_target(&mut parts, last_target.as_ref(), &mut emitted_target);
43 if serious < MAX_ROWS {
44 parts.push(format!(" {row}"));
45 }
46 serious += 1;
47 }
48 }
49
50 if serious > MAX_ROWS {
51 parts.push(format!(
52 " ... +{} more HIGH/CRITICAL",
53 serious - MAX_ROWS
54 ));
55 }
56
57 if parts.is_empty() {
58 if trimmed.contains("Total: 0") || trimmed.to_lowercase().contains("no vulnerabilities") {
59 return Some("trivy: no vulnerabilities".to_string());
60 }
61 return Some(fallback(&lines));
62 }
63 Some(format!("trivy:\n{}", parts.join("\n")))
64}
65
66fn emit_target(
68 parts: &mut Vec<String>,
69 last_target: Option<&String>,
70 emitted_target: &mut Option<String>,
71) {
72 if let Some(t) = last_target
73 && emitted_target.as_ref() != Some(t)
74 {
75 parts.push(t.clone());
76 *emitted_target = Some(t.clone());
77 }
78}
79
80fn is_target_header(line: &str) -> bool {
83 line.contains('(')
84 && line.ends_with(')')
85 && !is_log(line)
86 && !is_border(line)
87 && !line.starts_with("Total:")
88}
89
90fn parse_vuln_row(line: &str) -> Option<(String, String)> {
96 let cells: Vec<String> = if line.contains('│') || line.starts_with('|') || line.contains(" | ")
97 {
98 line.split(['│', '|'])
99 .map(str::trim)
100 .filter(|s| !s.is_empty())
101 .map(String::from)
102 .collect()
103 } else {
104 line.split(" ")
105 .map(str::trim)
106 .filter(|s| !s.is_empty())
107 .map(String::from)
108 .collect()
109 };
110 if cells.len() < 2 {
111 return None;
112 }
113 let upper = cells.join(" ").to_ascii_uppercase();
114 if upper.contains("SEVERITY") || upper.contains("VULNERABILITY ID") {
115 return None; }
117 let sev = cells.iter().find(|c| is_severity(c))?.clone();
118 Some((sev, cells.join(" ")))
119}
120
121fn is_severity(s: &str) -> bool {
122 matches!(
123 s.to_ascii_uppercase().as_str(),
124 "CRITICAL" | "HIGH" | "MEDIUM" | "LOW" | "UNKNOWN"
125 )
126}
127
128fn sev_is_serious(s: &str) -> bool {
129 matches!(s.to_ascii_uppercase().as_str(), "CRITICAL" | "HIGH")
130}
131
132fn is_log(line: &str) -> bool {
133 let first = line.split_whitespace().next().unwrap_or("");
135 first.contains('T') && first.ends_with('Z') && first.contains('-')
136}
137
138fn is_border(line: &str) -> bool {
139 line.starts_with('┌')
140 || line.starts_with('├')
141 || line.starts_with('└')
142 || line.starts_with('│')
143 || line.starts_with('=')
144 || line.starts_with('+')
145}
146
147fn fallback(lines: &[String]) -> String {
148 let n = lines.len().min(8);
149 let mut s = lines[..n].join("\n");
150 if lines.len() > n {
151 s.push_str(&format!("\n... (+{} lines)", lines.len() - n));
152 }
153 s
154}
155
156#[cfg(test)]
157mod tests {
158 use super::*;
159
160 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";
161
162 #[test]
163 fn keeps_target_total_and_serious_rows() {
164 let r = compress("trivy image nginx", SCAN).unwrap();
165 assert!(r.contains("nginx:latest (debian 12.1)"), "{r}");
166 assert!(r.contains("Total: 45"), "{r}");
167 assert!(r.contains("CRITICAL: 1"), "{r}");
168 assert!(r.contains("CVE-2023-0001"), "keeps critical row: {r}");
170 assert!(r.contains("openssl"), "keeps critical lib: {r}");
171 assert!(r.contains("CVE-2023-1234"), "keeps high row: {r}");
172 assert!(r.contains("1.1.1w"), "keeps fixed version: {r}");
173 assert!(!r.contains("INFO"), "drops logs: {r}");
175 assert!(!r.contains("CVE-2021-9999"), "drops low row detail: {r}");
176 assert!(
177 !r.contains('┌') && !r.contains('│'),
178 "drops table chrome: {r}"
179 );
180 }
181
182 #[test]
183 fn ascii_table_rows_are_parsed() {
184 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";
185 let r = compress("trivy image app", scan).unwrap();
186 assert!(r.contains("CVE-2024-0001"), "keeps ascii high row: {r}");
187 assert!(!r.contains("CVE-2024-0009"), "drops ascii low row: {r}");
188 assert!(!r.contains("LIBRARY"), "drops ascii header: {r}");
189 }
190
191 #[test]
192 fn shorter_than_input() {
193 let r = compress("trivy image nginx", SCAN).unwrap();
194 assert!(r.len() < SCAN.len());
195 }
196
197 #[test]
198 fn empty_is_ok() {
199 assert_eq!(compress("trivy image x", "").unwrap(), "trivy: ok");
200 }
201}