use forge::signal::compactor;
use once_cell::sync::Lazy;
use regex::Regex;
static PROGRESS_RE: Lazy<Regex> = Lazy::new(|| {
Regex::new(r"(?m)^\s*[✔✘⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏]\s+(?:Loaded|Cataloged|Indexed|Scanned|Tagged)[^\n]*\n?")
.unwrap()
});
static TABLE_SEP_RE: Lazy<Regex> =
Lazy::new(|| Regex::new(r"(?m)^[├╞╘╒╔╠║╚╝╗╬╫╪┼─═╟╞]+[^\n]*\n?").unwrap());
const SEVERITY_ORDER: &[&str] = &["Critical", "High", "Medium", "Low", "Negligible"];
pub fn compress_grype(raw: &str) -> String {
let cleaned = compactor::normalise(raw);
let s = PROGRESS_RE.replace_all(&cleaned, "");
let s = TABLE_SEP_RE.replace_all(&s, "");
let mut critical: Vec<&str> = Vec::new();
let mut high: Vec<&str> = Vec::new();
let mut medium: Vec<&str> = Vec::new();
let mut low_count = 0usize;
let mut negligible_count = 0usize;
let mut summary_line: Option<&str> = None;
let mut header: Option<&str> = None;
for line in s.lines() {
let t = line.trim();
if t.is_empty() {
continue;
}
if t.contains("vulnerabilit") && (t.contains("found") || t.contains("Fixed")) {
summary_line = Some(line);
continue;
}
if t.starts_with("NAME") && t.contains("VULNERABILITY") {
header = Some(line);
continue;
}
let severity = SEVERITY_ORDER
.iter()
.find(|&&s| t.contains(s))
.copied()
.unwrap_or("");
match severity {
"Critical" => critical.push(line),
"High" => high.push(line),
"Medium" => {
if medium.len() < 10 {
medium.push(line);
}
}
"Low" => low_count += 1,
"Negligible" => negligible_count += 1,
_ => {}
}
}
let mut out: Vec<String> = Vec::new();
if let Some(h) = header {
out.push(h.to_string());
}
out.extend(critical.iter().map(|l| l.to_string()));
out.extend(high.iter().map(|l| l.to_string()));
out.extend(medium.iter().map(|l| l.to_string()));
if low_count > 0 || negligible_count > 0 {
out.push(format!(
" … {} Low + {} Negligible vulnerabilities (suppressed)",
low_count, negligible_count
));
}
if let Some(sum) = summary_line {
out.push(String::new());
out.push(sum.to_string());
}
if out.is_empty() {
return compactor::collapse_blanks(&s);
}
out.join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn strips_progress_and_keeps_critical() {
let raw = " ✔ Loaded image\n ✔ Cataloged packages\nNAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY\nlog4j-core 2.14.0 2.17.0 java CVE-2021-44228 Critical\ncommons-io 2.6 2.7 java CVE-2021-29425 Low\n\n2 vulnerabilities found\n";
let out = compress_grype(raw);
assert!(!out.contains("Loaded image"), "{out}");
assert!(out.contains("CVE-2021-44228"), "{out}");
assert!(out.contains("Low") || out.contains("suppressed"), "{out}");
assert!(out.contains("vulnerabilit"), "{out}");
}
#[test]
fn suppresses_low_and_negligible_with_count() {
let mut lines =
vec!["NAME INSTALLED FIXED-IN TYPE VULNERABILITY SEVERITY".to_string()];
for i in 0..5 {
lines.push(format!("pkg-{i} 1.0 1.1 deb CVE-2021-{i:05} Low"));
}
for i in 0..3 {
lines.push(format!(
"pkg2-{i} 1.0 1.1 deb CVE-2022-{i:05} Negligible"
));
}
let out = compress_grype(&lines.join("\n"));
assert!(out.contains("suppressed"), "{out}");
assert!(out.contains('5'), "{out}");
assert!(out.contains('3'), "{out}");
}
#[test]
fn no_vulns_passthrough() {
let raw = " ✔ Loaded image\n ✔ Cataloged packages\nNo vulnerabilities found\n";
let out = compress_grype(raw);
assert!(
out.contains("No vulnerabilities") || out.is_empty(),
"{out}"
);
}
}