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 @@