From aba2c69416da6867621b31bd776be084f6ed0559 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 3 Sep 2026 19:47:47 +0200 Subject: [PATCH] Split the vulnerability view into host and container categories MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/crates/api/src/vulnerabilities.rs | 22 ++++- backend/crates/api/tests/vulnerabilities.rs | 7 +- backend/crates/application/src/test_fakes.rs | 2 + .../crates/application/src/vuln_service.rs | 35 +++++-- backend/crates/domain/src/vuln.rs | 32 ++++++- .../migrations/0006_finding_source.sql | 1 + backend/crates/infrastructure/src/sqlite.rs | 12 ++- backend/crates/infrastructure/src/trivy.rs | 20 +++- frontend/e2e/layout.spec.ts | 4 +- frontend/e2e/vuln-scopes.spec.ts | 16 +++- frontend/e2e/vulnerabilities.spec.ts | 2 +- frontend/src/components/FindingTable.test.ts | 2 +- frontend/src/components/FindingTable.vue | 19 +++- frontend/src/components/ScopeTabs.vue | 75 +++++++++++++++ frontend/src/pages/VulnerabilitiesPage.vue | 96 ++++++++++++------- 15 files changed, 276 insertions(+), 69 deletions(-) create mode 100644 backend/crates/infrastructure/migrations/0006_finding_source.sql create mode 100644 frontend/src/components/ScopeTabs.vue diff --git a/backend/crates/api/src/vulnerabilities.rs b/backend/crates/api/src/vulnerabilities.rs index c6ac9f1..9fd7fd6 100644 --- a/backend/crates/api/src/vulnerabilities.rs +++ b/backend/crates/api/src/vulnerabilities.rs @@ -1,9 +1,10 @@ //! /api/vulnerabilities: findings, summary, status changes. -use application::vuln_service::Summary; +use application::vuln_service::{Summary, TargetSummary}; use axum::extract::{Path, Query, State}; use axum::routing::{get, post}; use axum::{Json, Router}; -use domain::vuln::{Finding, FindingFilter, FindingStatus, Severity}; +use domain::vuln::{Finding, FindingFilter, FindingStatus, Severity, TargetKind}; +use domain::DomainError; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use uuid::Uuid; @@ -23,6 +24,8 @@ pub fn router() -> Router { #[derive(Deserialize)] pub struct ListQuery { pub min_severity: Option, + /// `host` (OS, packages, applications) or `container` (images in the cluster). + pub scope: Option, pub target: Option, pub status: Option, #[serde(default)] @@ -30,15 +33,24 @@ pub struct ListQuery { } #[utoipa::path(get, path = "/api/vulnerabilities", tag = "vulnerabilities", security(("bearer" = [])), - params(("min_severity" = Option, Query), ("target" = Option, Query), ("status" = Option, Query), ("include_fixed" = Option, Query)), + params(("min_severity" = Option, Query), ("scope" = Option, Query), ("target" = Option, Query), ("status" = Option, Query), ("include_fixed" = Option, Query)), responses((status = 200, body = Vec)))] async fn list( State(state): State, _: AuthUser, Query(q): Query, ) -> Result>, ApiError> { + let target_kind = match q.scope.as_deref() { + None => None, + Some(s) => Some(TargetKind::parse_scope(s).ok_or_else(|| { + DomainError::Validation(format!( + "unknown scope '{s}', expected 'host' or 'container'" + )) + })?), + }; let filter = FindingFilter { min_severity: q.min_severity.as_deref().map(Severity::parse), + target_kind, target: q.target, status: q.status.as_deref().and_then(FindingStatus::parse), include_fixed: q.include_fixed, @@ -70,11 +82,11 @@ async fn summary( })) } -#[utoipa::path(get, path = "/api/vulnerabilities/targets", tag = "vulnerabilities", security(("bearer" = [])), responses((status = 200, body = Vec)))] +#[utoipa::path(get, path = "/api/vulnerabilities/targets", tag = "vulnerabilities", security(("bearer" = [])), responses((status = 200, body = Vec)))] async fn targets( State(state): State, _: AuthUser, -) -> Result>, ApiError> { +) -> Result>, ApiError> { Ok(Json(state.vulns.targets().await?)) } diff --git a/backend/crates/api/tests/vulnerabilities.rs b/backend/crates/api/tests/vulnerabilities.rs index c0b0985..b8f262e 100644 --- a/backend/crates/api/tests/vulnerabilities.rs +++ b/backend/crates/api/tests/vulnerabilities.rs @@ -72,7 +72,12 @@ async fn scan_populates_findings_summary_and_filters() { .iter() .all(|f| f["target"] == "os")); let targets = get(&app, "/api/vulnerabilities/targets", Some(&token)).await; - assert!(targets.json.as_array().unwrap().iter().any(|t| t == "os")); + assert!(targets + .json + .as_array() + .unwrap() + .iter() + .any(|t| t["target"] == "os" && t["kind"] == "os")); // acknowledge one let id = list[0]["id"].as_str().unwrap(); diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs index f069f69..3c69d41 100644 --- a/backend/crates/application/src/test_fakes.rs +++ b/backend/crates/application/src/test_fakes.rs @@ -525,6 +525,7 @@ pub fn raw( fixed_version: fixed.map(String::from), title: format!("{cve} in {pkg}"), url: format!("https://nvd.nist.gov/vuln/detail/{cve}"), + source: "debian".into(), } } @@ -627,6 +628,7 @@ impl FindingRepository for MemFindings { .iter() .filter(|f| filter.include_fixed || f.status != FindingStatus::Fixed) .filter(|f| filter.min_severity.is_none_or(|m| f.raw.severity >= m)) + .filter(|f| filter.target_kind.is_none_or(|k| f.target_kind == k)) .filter(|f| filter.target.as_ref().is_none_or(|t| &f.target == t)) .filter(|f| filter.status.is_none_or(|s| f.status == s)) .cloned() diff --git a/backend/crates/application/src/vuln_service.rs b/backend/crates/application/src/vuln_service.rs index 35a078f..4f35898 100644 --- a/backend/crates/application/src/vuln_service.rs +++ b/backend/crates/application/src/vuln_service.rs @@ -27,6 +27,19 @@ pub struct VulnerabilityService { pub const KEY_NOTIFY_MIN_SEVERITY: &str = "vuln.notify_min_severity"; pub const DEFAULT_NOTIFY_MIN_SEVERITY: Severity = Severity::High; +/// One scanned target with its number of open findings. +#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)] +pub struct TargetSummary { + #[serde(serialize_with = "serialize_kind")] + pub kind: TargetKind, + pub target: String, + pub open: usize, +} + +fn serialize_kind(k: &TargetKind, s: S) -> Result { + s.serialize_str(k.as_str()) +} + #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] pub struct Summary { pub total: SeverityCounts, @@ -221,17 +234,21 @@ impl VulnerabilityService { self.findings.list(&filter).await } - pub async fn targets(&self) -> Result, DomainError> { - let mut t: Vec = self - .findings - .list(&FindingFilter::default()) - .await? + /// Scanned targets that still have open findings, host first. + pub async fn targets(&self) -> Result, DomainError> { + let mut by_target: std::collections::HashMap<(TargetKind, String), usize> = + Default::default(); + for f in self.findings.list(&FindingFilter::default()).await? { + *by_target.entry((f.target_kind, f.target)).or_default() += 1; + } + let mut targets: Vec = by_target .into_iter() - .map(|f| f.target) + .map(|((kind, target), open)| TargetSummary { kind, target, open }) .collect(); - t.sort(); - t.dedup(); - Ok(t) + targets.sort_by(|a, b| { + (a.kind != TargetKind::Os, &a.target).cmp(&(b.kind != TargetKind::Os, &b.target)) + }); + Ok(targets) } pub async fn summary(&self) -> Result { diff --git a/backend/crates/domain/src/vuln.rs b/backend/crates/domain/src/vuln.rs index 9108518..b0e76c8 100644 --- a/backend/crates/domain/src/vuln.rs +++ b/backend/crates/domain/src/vuln.rs @@ -43,7 +43,7 @@ impl Severity { } } -#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum TargetKind { Os, @@ -51,6 +51,30 @@ pub enum TargetKind { } impl TargetKind { + /// Category shown to the user: the Debian host, or the container images in the cluster. + pub fn label(self) -> &'static str { + match self { + TargetKind::Os => "Host", + TargetKind::Image => "Containers", + } + } + + /// Scope name used by the API (`?scope=`). + pub fn scope(self) -> &'static str { + match self { + TargetKind::Os => "host", + TargetKind::Image => "container", + } + } + + pub fn parse_scope(s: &str) -> Option { + match s { + "host" => Some(TargetKind::Os), + "container" => Some(TargetKind::Image), + _ => None, + } + } + pub fn as_str(self) -> &'static str { match self { TargetKind::Os => "os", @@ -102,6 +126,10 @@ pub struct RawFinding { pub fixed_version: Option, pub title: String, pub url: String, + /// Package source reported by the scanner: `debian` for OS packages, `gobinary`, + /// `node-pkg`, `python-pkg` … for applications and libraries found on the target. + #[serde(default)] + pub source: String, } impl RawFinding { @@ -130,6 +158,8 @@ pub struct Finding { #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct FindingFilter { pub min_severity: Option, + /// Restrict to one category (host or containers). + pub target_kind: Option, pub target: Option, pub status: Option, /// Include fixed findings (default: only open + acknowledged). diff --git a/backend/crates/infrastructure/migrations/0006_finding_source.sql b/backend/crates/infrastructure/migrations/0006_finding_source.sql new file mode 100644 index 0000000..cd6b3a4 --- /dev/null +++ b/backend/crates/infrastructure/migrations/0006_finding_source.sql @@ -0,0 +1 @@ +ALTER TABLE findings ADD COLUMN source TEXT NOT NULL DEFAULT ''; diff --git a/backend/crates/infrastructure/src/sqlite.rs b/backend/crates/infrastructure/src/sqlite.rs index 5f04498..cb3cca1 100644 --- a/backend/crates/infrastructure/src/sqlite.rs +++ b/backend/crates/infrastructure/src/sqlite.rs @@ -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::("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)); } diff --git a/backend/crates/infrastructure/src/trivy.rs b/backend/crates/infrastructure/src/trivy.rs index 4ead062..8ce98b0 100644 --- a/backend/crates/infrastructure/src/trivy.rs +++ b/backend/crates/infrastructure/src/trivy.rs @@ -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>, } @@ -68,8 +71,14 @@ pub fn parse_trivy_json(json: &str) -> Result, 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, 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![] diff --git a/frontend/e2e/layout.spec.ts b/frontend/e2e/layout.spec.ts index 5950714..9e8f006 100644 --- a/frontend/e2e/layout.spec.ts +++ b/frontend/e2e/layout.spec.ts @@ -34,7 +34,7 @@ test('no page scrolls horizontally, with data and on a narrow window', async ({ await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 }) await page.goto('/vulnerabilities') await page.getByRole('button', { name: 'Scan now' }).click() - await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 }) + await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 }) for (const width of [1440, 1280, 1024, 900]) { await page.setViewportSize({ width, height: 800 }) @@ -51,7 +51,7 @@ test('the findings table scrolls inside its own container', async ({ page }) => await login(page) await page.goto('/vulnerabilities') await page.getByRole('button', { name: 'Scan now' }).click() - await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 }) + await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 }) expect(await pageOverflow(page)).toBe(0) const scrollable = await page diff --git a/frontend/e2e/vuln-scopes.spec.ts b/frontend/e2e/vuln-scopes.spec.ts index cd2a317..817638a 100644 --- a/frontend/e2e/vuln-scopes.spec.ts +++ b/frontend/e2e/vuln-scopes.spec.ts @@ -5,6 +5,7 @@ async function scan(page: Page) { await page.getByLabel('Email').fill('admin@example.com') await page.getByLabel('Password').fill('admin-password-123') await page.getByRole('button', { name: 'Sign in' }).click() + await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible() await page.goto('/vulnerabilities') await page.getByRole('button', { name: 'Scan now' }).click() await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 }) @@ -30,10 +31,15 @@ test('host and container findings are separated into two categories', async ({ p await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toBeVisible() await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toHaveCount(0) await expect(page.getByTestId('findings-scroll')).toContainText('Image') - await expect(page.getByText('gitea')).toBeVisible() + await expect(page.getByRole('row', { name: /gitea\/gitea/ }).first()).toBeVisible() - // the target filter only offers targets of the selected category - const options = await page.getByLabel('Target').locator('option').allTextContents() - expect(options.some((o) => o.includes('gitea'))).toBe(true) - expect(options).not.toContain('os') + // the filter is labelled per category and only offers targets of that category + const images = await page.getByLabel('Image', { exact: true }).locator('option').allTextContents() + expect(images.some((o) => o.includes('gitea'))).toBe(true) + expect(images.some((o) => o.startsWith('os'))).toBe(false) + + await page.getByTestId('scope-host').click() + const hosts = await page.getByLabel('Target', { exact: true }).locator('option').allTextContents() + expect(hosts.some((o) => o.startsWith('os'))).toBe(true) + expect(hosts.some((o) => o.includes('gitea'))).toBe(false) }) diff --git a/frontend/e2e/vulnerabilities.spec.ts b/frontend/e2e/vulnerabilities.spec.ts index 4fc9f30..312f557 100644 --- a/frontend/e2e/vulnerabilities.spec.ts +++ b/frontend/e2e/vulnerabilities.spec.ts @@ -8,7 +8,7 @@ test('admin runs a scan, filters findings and acknowledges one', async ({ page } await page.getByRole('navigation').getByRole('link', { name: 'Vulnerabilities' }).click() await page.getByRole('button', { name: 'Scan now' }).click() - await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 }) + await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 }) await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible() await page.getByLabel('Minimum severity').selectOption('critical') diff --git a/frontend/src/components/FindingTable.test.ts b/frontend/src/components/FindingTable.test.ts index 8b14257..c6cc789 100644 --- a/frontend/src/components/FindingTable.test.ts +++ b/frontend/src/components/FindingTable.test.ts @@ -33,7 +33,7 @@ const findings = [ describe('FindingTable', () => { it('renders severity, target, fix version and status', () => { - const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'host' } }) + const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'container' } }) const rows = w.findAll('tbody tr') expect(rows).toHaveLength(2) expect(rows[0].text()).toContain('critical') diff --git a/frontend/src/components/FindingTable.vue b/frontend/src/components/FindingTable.vue index 1269a12..b750656 100644 --- a/frontend/src/components/FindingTable.vue +++ b/frontend/src/components/FindingTable.vue @@ -1,7 +1,7 @@ + + diff --git a/frontend/src/pages/VulnerabilitiesPage.vue b/frontend/src/pages/VulnerabilitiesPage.vue index d263678..60d96b8 100644 --- a/frontend/src/pages/VulnerabilitiesPage.vue +++ b/frontend/src/pages/VulnerabilitiesPage.vue @@ -1,16 +1,25 @@