Split the vulnerability view into host and container categories

The findings of the Debian host (OS packages and applications found in the
root filesystem) and of the container images in the cluster were mixed in
one flat table. They are now two prominent categories: a card each with its
own severity split and target count, acting as the primary selector, and a
table that adapts to the selection. Host findings show the package source
reported by the scanner (debian, gobinary, node-pkg …) instead of the
target, container findings show the image.

Backend: findings carry the scanner's package source, the list endpoint
takes ?scope=host|container, and the targets endpoint reports each target
with its category and open count.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 19:47:47 +02:00
parent 21098cadf6
commit aba2c69416
15 changed files with 276 additions and 69 deletions

View File

@ -515,6 +515,7 @@ fn finding_from_row(r: &SqliteRow) -> Finding {
fixed_version: r.get("fixed_version"),
title: r.get("title"),
url: r.get("url"),
source: r.get("source"),
},
status: FindingStatus::parse(r.get::<String, _>("status").as_str())
.unwrap_or(FindingStatus::Open),
@ -523,7 +524,7 @@ fn finding_from_row(r: &SqliteRow) -> Finding {
}
}
const FINDING_COLS: &str = "id, target_kind, target, cve_id, severity, package, installed_version, fixed_version, title, url, status, first_seen, last_seen";
const FINDING_COLS: &str = "id, target_kind, target, cve_id, severity, package, installed_version, fixed_version, title, url, source, status, first_seen, last_seen";
/// Severity ordering for SQL: higher is worse.
fn severity_rank(s: Severity) -> i32 {
@ -552,7 +553,7 @@ impl domain::ports::FindingRepository for SqliteFindings {
}
async fn insert(&self, f: &Finding) -> Result<(), DomainError> {
sqlx::query(&format!(
"INSERT INTO findings ({FINDING_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
"INSERT INTO findings ({FINDING_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
))
.bind(f.id)
.bind(f.target_kind.as_str())
@ -564,6 +565,7 @@ impl domain::ports::FindingRepository for SqliteFindings {
.bind(&f.raw.fixed_version)
.bind(&f.raw.title)
.bind(&f.raw.url)
.bind(&f.raw.source)
.bind(f.status.as_str())
.bind(f.first_seen.to_rfc3339())
.bind(f.last_seen.to_rfc3339())
@ -613,6 +615,9 @@ impl domain::ports::FindingRepository for SqliteFindings {
if filter.target.is_some() {
sql.push_str(" AND target = ?");
}
if filter.target_kind.is_some() {
sql.push_str(" AND target_kind = ?");
}
if filter.min_severity.is_some() {
sql.push_str(&format!(" AND {SEVERITY_RANK_SQL} >= ?"));
}
@ -626,6 +631,9 @@ impl domain::ports::FindingRepository for SqliteFindings {
if let Some(t) = &filter.target {
q = q.bind(t);
}
if let Some(k) = filter.target_kind {
q = q.bind(k.as_str());
}
if let Some(m) = filter.min_severity {
q = q.bind(severity_rank(m));
}

View File

@ -36,6 +36,9 @@ struct Report {
#[derive(serde::Deserialize)]
struct ResultEntry {
/// Package source: `debian` for OS packages, `gobinary`/`node-pkg`/… for applications.
#[serde(rename = "Type", default)]
kind: String,
#[serde(rename = "Vulnerabilities", default)]
vulnerabilities: Option<Vec<Vuln>>,
}
@ -68,8 +71,14 @@ pub fn parse_trivy_json(json: &str) -> Result<Vec<RawFinding>, DomainError> {
.results
.unwrap_or_default()
.into_iter()
.flat_map(|r| r.vulnerabilities.unwrap_or_default());
for v in vulns {
.flat_map(|r| {
let kind = r.kind;
r.vulnerabilities
.unwrap_or_default()
.into_iter()
.map(move |v| (kind.clone(), v))
});
for (source, v) in vulns {
let f = RawFinding {
title: v
.title
@ -83,6 +92,7 @@ pub fn parse_trivy_json(json: &str) -> Result<Vec<RawFinding>, DomainError> {
package: v.pkg,
installed_version: v.installed,
fixed_version: v.fixed.filter(|f| !f.is_empty()),
source,
};
if seen.insert(f.key()) {
out.push(f);
@ -190,6 +200,7 @@ impl VulnerabilityScanner for FakeScanner {
fixed_version: Some("3.0.16-1~deb12u1".into()),
title: "openssl: SSL_select_next_proto buffer overread".into(),
url: "https://avd.aquasec.com/nvd/cve-2024-5535".into(),
source: "debian".into(),
},
RawFinding {
cve_id: "CVE-2023-45853".into(),
@ -199,6 +210,7 @@ impl VulnerabilityScanner for FakeScanner {
fixed_version: None,
title: "zlib: integer overflow in zipOpenNewFileInZip4_64".into(),
url: "https://avd.aquasec.com/nvd/cve-2023-45853".into(),
source: "debian".into(),
},
RawFinding {
cve_id: "CVE-2011-3374".into(),
@ -208,6 +220,7 @@ impl VulnerabilityScanner for FakeScanner {
fixed_version: None,
title: "apt: unsigned repository".into(),
url: "https://avd.aquasec.com/nvd/cve-2011-3374".into(),
source: "debian".into(),
},
// production-sized identifiers: long Go module path, multi-version fix list
RawFinding {
@ -220,6 +233,7 @@ impl VulnerabilityScanner for FakeScanner {
),
title: "otelhttp: unbounded cardinality metrics".into(),
url: "https://github.com/advisories/GHSA-hrxh-6v49-42gf".into(),
source: "gobinary".into(),
},
])
}
@ -238,6 +252,7 @@ impl VulnerabilityScanner for FakeScanner {
fixed_version: Some("1.21.11".into()),
title: "golang: net/netip unexpected behavior".into(),
url: "https://avd.aquasec.com/nvd/cve-2024-24790".into(),
source: "gobinary".into(),
}]
} else if image.contains("postgres") {
vec![RawFinding {
@ -248,6 +263,7 @@ impl VulnerabilityScanner for FakeScanner {
fixed_version: Some("3.0.14".into()),
title: "openssl: use after free".into(),
url: "https://avd.aquasec.com/nvd/cve-2024-4741".into(),
source: "debian".into(),
}]
} else {
vec![]