diff --git a/backend/crates/api/tests/vulnerabilities.rs b/backend/crates/api/tests/vulnerabilities.rs index e8606db..c0b0985 100644 --- a/backend/crates/api/tests/vulnerabilities.rs +++ b/backend/crates/api/tests/vulnerabilities.rs @@ -167,3 +167,88 @@ async fn notification_threshold_setting_and_permissions() { StatusCode::UNAUTHORIZED ); } + +#[tokio::test] +async fn findings_are_scoped_into_host_and_containers() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + run_scan(&app, &token).await; + + let host = get( + &app, + "/api/vulnerabilities?scope=host&min_severity=unknown", + Some(&token), + ) + .await; + assert_eq!(host.status, StatusCode::OK, "{}", host.json); + let host_list = host.json.as_array().unwrap(); + assert!(!host_list.is_empty()); + assert!(host_list + .iter() + .all(|f| f["target_kind"] == "os" && f["target"] == "os")); + assert!( + host_list.iter().any(|f| f["source"] == "debian"), + "host findings name their package source" + ); + + let containers = get( + &app, + "/api/vulnerabilities?scope=container&min_severity=unknown", + Some(&token), + ) + .await; + let container_list = containers.json.as_array().unwrap(); + assert!(!container_list.is_empty()); + assert!(container_list.iter().all(|f| f["target_kind"] == "image")); + assert!(container_list + .iter() + .any(|f| f["target"].as_str().unwrap().contains("gitea"))); + + let all = get( + &app, + "/api/vulnerabilities?min_severity=unknown", + Some(&token), + ) + .await; + assert_eq!( + all.json.as_array().unwrap().len(), + host_list.len() + container_list.len() + ); + assert_eq!( + get(&app, "/api/vulnerabilities?scope=nope", Some(&token)) + .await + .status, + StatusCode::UNPROCESSABLE_ENTITY + ); + + // the summary splits the same way + let s = get(&app, "/api/vulnerabilities/summary", Some(&token)).await; + let sum = |v: &serde_json::Value| { + v["critical"].as_u64().unwrap() + + v["high"].as_u64().unwrap() + + v["medium"].as_u64().unwrap() + + v["low"].as_u64().unwrap() + + v["unknown"].as_u64().unwrap() + }; + assert_eq!(sum(&s.json["os"]) as usize, host_list.len()); + assert_eq!(sum(&s.json["images"]) as usize, container_list.len()); +} + +#[tokio::test] +async fn targets_carry_their_scope_and_open_count() { + let app = test_app_with_admin().await; + let token = common::login(&app, ADMIN, PW).await.access; + run_scan(&app, &token).await; + + let res = get(&app, "/api/vulnerabilities/targets", Some(&token)).await; + let targets = res.json.as_array().unwrap(); + assert_eq!(targets[0]["kind"], "os", "the host comes first"); + assert_eq!(targets[0]["target"], "os"); + assert!(targets[0]["open"].as_u64().unwrap() >= 3); + let image = targets + .iter() + .find(|t| t["target"].as_str().unwrap().contains("gitea")) + .unwrap(); + assert_eq!(image["kind"], "image"); + assert!(image["open"].as_u64().unwrap() >= 1); +} diff --git a/backend/crates/application/src/tests/vuln_tests.rs b/backend/crates/application/src/tests/vuln_tests.rs index 6ae1663..d44746c 100644 --- a/backend/crates/application/src/tests/vuln_tests.rs +++ b/backend/crates/application/src/tests/vuln_tests.rs @@ -277,3 +277,89 @@ async fn list_summary_and_status_changes() { .unwrap(); assert_eq!(ack.len(), 1); } + +#[tokio::test] +async fn findings_are_separated_into_host_and_container_scopes() { + 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)]), + ) + .with( + PG, + Ok(vec![raw("CVE-4", "libssl3", "3.0", Severity::Medium, None)]), + ); + let (_f, svc) = fixture(scanner); + svc.scan(&VecLog::default()).await.unwrap(); + + let host = svc + .list(FindingFilter { + target_kind: Some(TargetKind::Os), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!( + host.iter() + .map(|f| f.raw.cve_id.as_str()) + .collect::>(), + vec!["CVE-1", "CVE-2"] + ); + let containers = svc + .list(FindingFilter { + target_kind: Some(TargetKind::Image), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(containers.len(), 2); + assert!(containers + .iter() + .all(|f| f.target_kind == TargetKind::Image)); + // a scope combines with the other filters + let critical_host = svc + .list(FindingFilter { + target_kind: Some(TargetKind::Os), + min_severity: Some(Severity::High), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(critical_host.len(), 1); +} + +#[tokio::test] +async fn targets_are_reported_with_their_scope_and_open_count() { + let scanner = FakeScanner::default() + .with( + "os", + Ok(vec![ + raw("CVE-1", "a", "1", Severity::High, None), + raw("CVE-2", "b", "1", Severity::Low, None), + ]), + ) + .with( + GITEA, + Ok(vec![raw("CVE-3", "c", "1", Severity::High, None)]), + ); + let (_f, svc) = fixture(scanner); + svc.scan(&VecLog::default()).await.unwrap(); + + let targets = svc.targets().await.unwrap(); + // host first, then images sorted by name + assert_eq!(targets[0].kind, TargetKind::Os); + assert_eq!(targets[0].target, "os"); + assert_eq!(targets[0].open, 2); + let image = targets.iter().find(|t| t.target == GITEA).unwrap(); + assert_eq!(image.kind, TargetKind::Image); + assert_eq!(image.open, 1); + // an image without findings is not listed + assert!(targets.iter().all(|t| t.target != PG)); +} diff --git a/backend/crates/infrastructure/src/sqlite.rs b/backend/crates/infrastructure/src/sqlite.rs index bb096ff..5f04498 100644 --- a/backend/crates/infrastructure/src/sqlite.rs +++ b/backend/crates/infrastructure/src/sqlite.rs @@ -677,6 +677,7 @@ mod finding_tests { fixed_version: None, title: "t".into(), url: "u".into(), + source: "debian".into(), }, status: FindingStatus::Open, first_seen: Utc::now(), @@ -751,6 +752,33 @@ mod finding_tests { .unwrap_err(), DomainError::NotFound ); + + let host = repo + .list(&FindingFilter { + target_kind: Some(TargetKind::Os), + include_fixed: true, + ..Default::default() + }) + .await + .unwrap(); + assert_eq!( + host.iter() + .map(|f| f.raw.cve_id.as_str()) + .collect::>(), + vec!["CVE-A", "CVE-B"] + ); + let images = repo + .list(&FindingFilter { + target_kind: Some(TargetKind::Image), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(images.len(), 1); + assert_eq!( + images[0].raw.source, "debian", + "source survives the roundtrip" + ); } } diff --git a/backend/crates/infrastructure/src/trivy.rs b/backend/crates/infrastructure/src/trivy.rs index f06558b..4ead062 100644 --- a/backend/crates/infrastructure/src/trivy.rs +++ b/backend/crates/infrastructure/src/trivy.rs @@ -276,6 +276,24 @@ mod tests { "Vulnerabilities": [{"VulnerabilityID": "GHSA-1", "PkgName": "stdlib", "InstalledVersion": "1.21.5", "Severity": "WEIRD"}]} ]}"#; + #[test] + fn keeps_the_trivy_package_type_as_the_finding_source() { + let f = parse_trivy_json(SAMPLE).unwrap(); + let ssl = f.iter().find(|x| x.package == "openssl").unwrap(); + assert_eq!(ssl.source, "debian", "os package"); + let go = f.iter().find(|x| x.cve_id == "GHSA-1").unwrap(); + assert_eq!(go.source, "gobinary", "application binary on the host"); + // unknown type falls back to an empty source rather than failing + assert_eq!( + parse_trivy_json( + r#"{"Results":[{"Target":"t","Vulnerabilities":[{"VulnerabilityID":"X"}]}]}"# + ) + .unwrap()[0] + .source, + "" + ); + } + #[test] fn parses_results_merges_targets_and_dedups() { let f = parse_trivy_json(SAMPLE).unwrap(); diff --git a/frontend/e2e/vuln-scopes.spec.ts b/frontend/e2e/vuln-scopes.spec.ts new file mode 100644 index 0000000..cd2a317 --- /dev/null +++ b/frontend/e2e/vuln-scopes.spec.ts @@ -0,0 +1,39 @@ +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 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('host and container findings are separated into two categories', async ({ page }) => { + await scan(page) + + // both categories are visible with their own numbers + await expect(page.getByTestId('scope-host')).toContainText('OS, packages and applications') + await expect(page.getByTestId('scope-container')).toContainText('images') + await expect(page.getByTestId('scope-container-critical')).not.toHaveText('0') + + // host is selected first and shows only host findings, with the package source + await expect(page.getByTestId('scope-host')).toHaveAttribute('aria-pressed', 'true') + await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible() + await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toHaveCount(0) + await expect(page.getByTestId('findings-scroll')).toContainText('Source') + + // switching to containers swaps the table + await page.getByTestId('scope-container').click() + await expect(page.getByTestId('scope-container')).toHaveAttribute('aria-pressed', 'true') + 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() + + // 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') +}) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index 28f8d3d..c13f574 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -132,10 +132,13 @@ export interface ClusterOverview { export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'unknown' export type FindingStatus = 'open' | 'acknowledged' | 'fixed' +export type FindingScope = 'host' | 'container' + export interface Finding { id: string target_kind: 'os' | 'image' target: string + source: string cve_id: string severity: Severity package: string @@ -148,6 +151,12 @@ export interface Finding { last_seen: string } +export interface TargetSummary { + target: string + kind: 'os' | 'image' + open: number +} + export interface SeverityCounts { critical: number high: number diff --git a/frontend/src/components/FindingTable.test.ts b/frontend/src/components/FindingTable.test.ts index 0fbc464..8b14257 100644 --- a/frontend/src/components/FindingTable.test.ts +++ b/frontend/src/components/FindingTable.test.ts @@ -13,6 +13,7 @@ const f = (over: Partial): Finding => ({ fixed_version: null, title: 'title', url: 'https://x', + source: 'debian', status: 'open', first_seen: '2026-09-01T00:00:00Z', last_seen: '2026-09-02T00:00:00Z', @@ -32,7 +33,7 @@ const findings = [ describe('FindingTable', () => { it('renders severity, target, fix version and status', () => { - const w = mount(FindingTable, { props: { findings, canAct: true } }) + const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'host' } }) const rows = w.findAll('tbody tr') expect(rows).toHaveLength(2) expect(rows[0].text()).toContain('critical') @@ -42,7 +43,7 @@ describe('FindingTable', () => { }) it('emits acknowledge/reopen and opens details', async () => { - const w = mount(FindingTable, { props: { findings, canAct: true } }) + const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'host' } }) await w.findAll('button[name=ack]')[0].trigger('click') expect(w.emitted('status')![0]).toEqual([findings[0], 'acknowledged']) await w.findAll('button[name=ack]')[1].trigger('click') @@ -52,14 +53,14 @@ describe('FindingTable', () => { }) it('hides actions for viewers', () => { - const w = mount(FindingTable, { props: { findings, canAct: false } }) + const w = mount(FindingTable, { props: { findings, canAct: false, scope: 'host' } }) expect(w.findAll('button[name=ack]')).toHaveLength(0) }) }) describe('FindingTable layout', () => { it('keeps the table in a horizontal scroll container and the CVE on one line', () => { - const w = mount(FindingTable, { props: { findings, canAct: true } }) + const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'host' } }) const wrapper = w.find('[data-testid=findings-scroll]') expect(wrapper.exists()).toBe(true) expect(wrapper.classes()).toContain('overflow-x-auto') @@ -68,3 +69,29 @@ describe('FindingTable layout', () => { expect(cve.classes()).toContain('whitespace-nowrap') }) }) + +describe('FindingTable per scope', () => { + it('shows the package source instead of the target for host findings', () => { + const hostFindings = [ + f({ cve_id: 'CVE-1', severity: 'high', source: 'debian' }), + f({ cve_id: 'CVE-2', source: 'gobinary' }), + ] + const w = mount(FindingTable, { + props: { findings: hostFindings, canAct: true, scope: 'host' }, + }) + expect(w.find('thead').text()).toContain('Source') + expect(w.find('thead').text()).not.toContain('Target') + const row = w.findAll('tbody tr')[0] + expect(row.text()).toContain('debian') + expect(w.findAll('tbody tr')[1].text()).toContain('gobinary') + }) + + it('shows the image for container findings', () => { + const images = [ + f({ cve_id: 'CVE-9', target_kind: 'image', target: 'gitea/gitea:1.22', source: 'gobinary' }), + ] + const w = mount(FindingTable, { props: { findings: images, canAct: true, scope: 'container' } }) + expect(w.find('thead').text()).toContain('Image') + expect(w.findAll('tbody tr')[0].text()).toContain('gitea/gitea:1.22') + }) +}) diff --git a/frontend/src/components/ScopeTabs.test.ts b/frontend/src/components/ScopeTabs.test.ts new file mode 100644 index 0000000..43d76a7 --- /dev/null +++ b/frontend/src/components/ScopeTabs.test.ts @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils' +import ScopeTabs from './ScopeTabs.vue' +import type { SeverityCounts } from '../api/types' + +const host: SeverityCounts = { critical: 3, high: 12, medium: 20, low: 5, unknown: 1 } +const container: SeverityCounts = { critical: 7, high: 30, medium: 40, low: 2, unknown: 0 } + +const props = { modelValue: 'host' as const, host, container, hostTargets: 1, containerTargets: 12 } + +describe('ScopeTabs', () => { + it('names both categories and what they cover', () => { + const w = mount(ScopeTabs, { props }) + const text = w.text() + expect(text).toContain('Host') + expect(text).toContain('OS, packages and applications') + expect(text).toContain('Containers') + expect(text).toContain('images') + }) + + it('shows the severity split and the number of scanned targets per category', () => { + const w = mount(ScopeTabs, { props }) + const hostCard = w.get('[data-testid=scope-host]') + expect(hostCard.get('[data-testid=scope-host-critical]').text()).toBe('3') + expect(hostCard.text()).toContain('12') + expect(hostCard.text()).toContain('41 open') + const containerCard = w.get('[data-testid=scope-container]') + expect(containerCard.get('[data-testid=scope-container-critical]').text()).toBe('7') + expect(containerCard.text()).toContain('12 images') + }) + + it('marks the selected category and emits the other one on click', async () => { + const w = mount(ScopeTabs, { props }) + expect(w.get('[data-testid=scope-host]').attributes('aria-pressed')).toBe('true') + expect(w.get('[data-testid=scope-container]').attributes('aria-pressed')).toBe('false') + await w.get('[data-testid=scope-container]').trigger('click') + expect(w.emitted('update:modelValue')![0]).toEqual(['container']) + }) +})