From 751296b3b08f9ae68a9670b1cb4fdaf91b6629f2 Mon Sep 17 00:00:00 2001 From: Dennis Nemec Date: Thu, 3 Sep 2026 19:55:40 +0200 Subject: [PATCH] Failing tests for rolling findings up per package and image Co-Authored-By: Claude Opus 5 --- .../application/src/tests/vuln_tests.rs | 59 ++++++++++ backend/crates/domain/src/ports.rs | 6 +- backend/crates/domain/src/vuln.rs | 23 +++- frontend/e2e/vuln-groups.spec.ts | 35 ++++++ frontend/src/api/types.ts | 11 ++ frontend/src/components/FindingGroups.test.ts | 105 ++++++++++++++++++ 6 files changed, 237 insertions(+), 2 deletions(-) create mode 100644 frontend/e2e/vuln-groups.spec.ts create mode 100644 frontend/src/components/FindingGroups.test.ts diff --git a/backend/crates/application/src/tests/vuln_tests.rs b/backend/crates/application/src/tests/vuln_tests.rs index 2cc85c0..0ad87dc 100644 --- a/backend/crates/application/src/tests/vuln_tests.rs +++ b/backend/crates/application/src/tests/vuln_tests.rs @@ -408,3 +408,62 @@ async fn rescan_refreshes_scanner_details_of_findings_that_are_still_present() { "the user's decision is kept" ); } + +#[tokio::test] +async fn host_findings_are_grouped_per_package_and_containers_per_image() { + let mut go = raw("CVE-9", "stdlib", "1.21", Severity::High, Some("1.22")); + go.source = "gobinary".into(); + let scanner = FakeScanner::default() + .with( + "os", + Ok(vec![ + raw("CVE-1", "openssl", "3.0.1", Severity::Critical, Some("3.0.2")), + raw("CVE-2", "openssl", "3.0.1", Severity::Low, None), + go, + ]), + ) + .with(GITEA, Ok(vec![raw("CVE-3", "git", "2.39", Severity::High, None), raw("CVE-4", "curl", "7.8", Severity::Medium, None)])) + .with(PG, Ok(vec![raw("CVE-5", "libssl3", "3.0", Severity::Low, None)])); + let (_f, svc) = fixture(scanner); + svc.scan(&VecLog::default()).await.unwrap(); + + let host = svc.groups(FindingFilter { target_kind: Some(TargetKind::Os), ..Default::default() }).await.unwrap(); + assert_eq!(host.len(), 2, "one row per package, not per CVE"); + // worst severity first + assert_eq!(host[0].key, "openssl"); + assert_eq!(host[0].counts.critical, 1); + assert_eq!(host[0].counts.low, 1); + assert_eq!(host[0].total, 2); + assert_eq!(host[0].fixable, 1, "only CVE-1 has a fix"); + assert_eq!(host[0].source, "debian"); + assert_eq!(host[0].installed, "3.0.1"); + assert_eq!(host[1].key, "stdlib"); + assert_eq!(host[1].source, "gobinary"); + + let images = svc.groups(FindingFilter { target_kind: Some(TargetKind::Image), ..Default::default() }).await.unwrap(); + assert_eq!(images.iter().map(|g| g.key.as_str()).collect::>(), vec![GITEA, PG]); + assert_eq!(images[0].total, 2); + assert_eq!(images[0].packages, 2, "distinct packages in the image"); + + // the group list honours the other filters + let high = svc + .groups(FindingFilter { target_kind: Some(TargetKind::Os), min_severity: Some(Severity::High), ..Default::default() }) + .await + .unwrap(); + assert_eq!(high.iter().map(|g| g.key.as_str()).collect::>(), vec!["openssl", "stdlib"]); + assert_eq!(high[0].total, 1, "the low finding is filtered out of the counts"); +} + +#[tokio::test] +async fn findings_of_one_group_can_be_listed() { + let scanner = FakeScanner::default() + .with("os", Ok(vec![raw("CVE-1", "openssl", "3.0.1", Severity::Critical, None), raw("CVE-2", "bash", "5.2", Severity::Low, None)])) + .with(GITEA, Ok(vec![raw("CVE-3", "git", "2.39", Severity::High, None)])); + let (_f, svc) = fixture(scanner); + svc.scan(&VecLog::default()).await.unwrap(); + + let pkg = svc.list(FindingFilter { package: Some("openssl".into()), ..Default::default() }).await.unwrap(); + assert_eq!(pkg.iter().map(|f| f.raw.cve_id.as_str()).collect::>(), vec!["CVE-1"]); + let image = svc.list(FindingFilter { target: Some(GITEA.into()), ..Default::default() }).await.unwrap(); + assert_eq!(image.len(), 1); +} diff --git a/backend/crates/domain/src/ports.rs b/backend/crates/domain/src/ports.rs index b1fb214..99525ad 100644 --- a/backend/crates/domain/src/ports.rs +++ b/backend/crates/domain/src/ports.rs @@ -9,7 +9,9 @@ use crate::host::{Inventory, OsInfo, Package}; use crate::jobs::{JobKind, JobRun, JobStatus}; use crate::settings::SmtpSettings; use crate::user::{User, UserUpdate}; -use crate::vuln::{Finding, FindingFilter, FindingStatus, RawFinding, SeverityCounts, TargetKind}; +use crate::vuln::{ + Finding, FindingFilter, FindingGroup, FindingStatus, RawFinding, SeverityCounts, TargetKind, +}; use crate::DomainError; use std::path::{Path, PathBuf}; @@ -150,6 +152,8 @@ pub trait FindingRepository: Send + Sync { async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError>; async fn get(&self, id: Uuid) -> Result, DomainError>; async fn list(&self, filter: &FindingFilter) -> Result, DomainError>; + /// Findings rolled up per package (host) or per image (containers), worst first. + async fn groups(&self, filter: &FindingFilter) -> Result, DomainError>; async fn counts(&self, kind: Option) -> Result; } diff --git a/backend/crates/domain/src/vuln.rs b/backend/crates/domain/src/vuln.rs index b0e76c8..e1b783b 100644 --- a/backend/crates/domain/src/vuln.rs +++ b/backend/crates/domain/src/vuln.rs @@ -160,13 +160,15 @@ pub struct FindingFilter { pub min_severity: Option, /// Restrict to one category (host or containers). pub target_kind: Option, + /// Restrict to one package (used to expand a host group). + pub package: Option, pub target: Option, pub status: Option, /// Include fixed findings (default: only open + acknowledged). pub include_fixed: bool, } -#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)] +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] pub struct SeverityCounts { pub critical: usize, pub high: usize, @@ -190,6 +192,25 @@ impl SeverityCounts { } } +/// Findings rolled up per package (host) or per image (containers): one row per +/// affected thing instead of one row per CVE. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FindingGroup { + /// Package name for host groups, image reference for container groups. + pub key: String, + pub kind: TargetKind, + /// Package source of a host group (`debian`, `gobinary`, …); empty for images. + pub source: String, + /// Installed version of a host group; empty for images. + pub installed: String, + pub counts: SeverityCounts, + pub total: usize, + /// Findings that have a fix version. + pub fixable: usize, + /// Distinct packages inside an image group; 1 for host groups. + pub packages: usize, +} + /// Outcome of one scan run. #[derive(Clone, Debug, Default, PartialEq, Eq)] pub struct ScanReport { diff --git a/frontend/e2e/vuln-groups.spec.ts b/frontend/e2e/vuln-groups.spec.ts new file mode 100644 index 0000000..dd2021e --- /dev/null +++ b/frontend/e2e/vuln-groups.spec.ts @@ -0,0 +1,35 @@ +import { test, expect, type Page } from '@playwright/test' + +async function scan(page: Page) { + await page.goto('/login') + 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 }) +} + +test('packages and images are one row each and expand to their CVEs', async ({ page }) => { + await scan(page) + + // host: a row per package, no CVE ids until a row is opened + const zlib = page.getByRole('row', { name: /^zlib1g/ }) + await expect(zlib).toBeVisible() + await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toHaveCount(0) + + await zlib.click() + await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible() + await expect(zlib).toHaveAttribute('aria-expanded', 'true') + await zlib.click() + await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toHaveCount(0) + + // containers: a row per image that expands to the CVEs of that image + await page.getByTestId('scope-container').click() + const image = page.getByRole('row', { name: /gitea\/gitea/ }).first() + await expect(image).toBeVisible() + await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toHaveCount(0) + await image.click() + await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toBeVisible() +}) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c13f574..09487ff 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -151,6 +151,17 @@ export interface Finding { last_seen: string } +export interface FindingGroup { + key: string + kind: 'os' | 'image' + source: string + installed: string + counts: SeverityCounts + total: number + fixable: number + packages: number +} + export interface TargetSummary { target: string kind: 'os' | 'image' diff --git a/frontend/src/components/FindingGroups.test.ts b/frontend/src/components/FindingGroups.test.ts new file mode 100644 index 0000000..303e0db --- /dev/null +++ b/frontend/src/components/FindingGroups.test.ts @@ -0,0 +1,105 @@ +import { mount, flushPromises } from '@vue/test-utils' +import FindingGroups from './FindingGroups.vue' +import type { Finding, FindingGroup } from '../api/types' + +const group = (over: Partial): FindingGroup => ({ + key: 'openssl', + kind: 'os', + source: 'debian', + installed: '3.0.15', + counts: { critical: 1, high: 2, medium: 0, low: 3, unknown: 0 }, + total: 6, + fixable: 4, + packages: 1, + ...over, +}) + +const finding = (cve: string): Finding => ({ + id: cve, + target_kind: 'os', + target: 'os', + source: 'debian', + cve_id: cve, + severity: 'critical', + package: 'openssl', + installed_version: '3.0.15', + fixed_version: '3.0.16', + title: 'a flaw', + url: 'https://x', + status: 'open', + first_seen: '2026-09-01T00:00:00Z', + last_seen: '2026-09-02T00:00:00Z', +}) + +const groups = [ + group({}), + group({ + key: 'stdlib', + source: 'gobinary', + total: 2, + counts: { critical: 0, high: 2, medium: 0, low: 0, unknown: 0 }, + }), +] + +describe('FindingGroups', () => { + it('shows one row per package with its severity split, not one per CVE', () => { + const w = mount(FindingGroups, { + props: { groups, scope: 'host', canAct: true, load: vi.fn() }, + }) + const rows = w.findAll('tbody tr') + expect(rows).toHaveLength(2) + expect(rows[0].text()).toContain('openssl') + expect(rows[0].text()).toContain('debian') + expect(rows[0].text()).toContain('3.0.15') + expect(rows[0].text()).toContain('6') + expect(rows[0].text()).toContain('4 fixable') + expect(w.text()).not.toContain('CVE-') + }) + + it('shows the image and the number of affected packages for containers', () => { + const images = [ + group({ + key: 'gitea/gitea:1.22', + kind: 'image', + source: '', + installed: '', + packages: 12, + total: 40, + }), + ] + const w = mount(FindingGroups, { + props: { groups: images, scope: 'container', canAct: true, load: vi.fn() }, + }) + const row = w.findAll('tbody tr')[0] + expect(row.text()).toContain('gitea/gitea:1.22') + expect(row.text()).toContain('12 packages') + }) + + it('loads and shows the CVEs of a group when its row is clicked, and collapses again', async () => { + const load = vi.fn().mockResolvedValue([finding('CVE-2024-1'), finding('CVE-2024-2')]) + const w = mount(FindingGroups, { props: { groups, scope: 'host', canAct: true, load } }) + expect(w.text()).not.toContain('CVE-2024-1') + + await w.findAll('tbody tr')[0].trigger('click') + await flushPromises() + expect(load).toHaveBeenCalledWith(groups[0]) + expect(w.text()).toContain('CVE-2024-1') + expect(w.text()).toContain('CVE-2024-2') + expect(w.findAll('tbody tr')[0].attributes('aria-expanded')).toBe('true') + + await w.findAll('tbody tr')[0].trigger('click') + expect(w.text()).not.toContain('CVE-2024-1') + // a second expansion reuses what was loaded + await w.findAll('tbody tr')[0].trigger('click') + await flushPromises() + expect(load).toHaveBeenCalledTimes(1) + }) + + it('reports an empty group instead of an endless spinner', async () => { + const load = vi.fn().mockResolvedValue([]) + const w = mount(FindingGroups, { props: { groups, scope: 'host', canAct: true, load } }) + await w.findAll('tbody tr')[0].trigger('click') + await flushPromises() + expect(w.text()).toContain('No findings') + }) +})