diff --git a/backend/crates/api/src/openapi.rs b/backend/crates/api/src/openapi.rs index e936b5e..46972e3 100644 --- a/backend/crates/api/src/openapi.rs +++ b/backend/crates/api/src/openapi.rs @@ -28,7 +28,7 @@ impl Modify for BearerAuth { crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run, crate::system::inventory, crate::system::upgrade, crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image, - crate::vulnerabilities::list, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status, + crate::vulnerabilities::list, crate::vulnerabilities::groups, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status, crate::settings::get_notifications, crate::settings::put_notifications, crate::backups::list_targets, crate::backups::get_target, crate::backups::create_target, crate::backups::update_target, crate::backups::delete_target, crate::backups::test_target, crate::backups::list_strategies, crate::backups::get_strategy, diff --git a/backend/crates/api/src/vulnerabilities.rs b/backend/crates/api/src/vulnerabilities.rs index 9fd7fd6..1b35969 100644 --- a/backend/crates/api/src/vulnerabilities.rs +++ b/backend/crates/api/src/vulnerabilities.rs @@ -3,7 +3,7 @@ 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, TargetKind}; +use domain::vuln::{Finding, FindingFilter, FindingGroup, FindingStatus, Severity, TargetKind}; use domain::DomainError; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; @@ -16,6 +16,7 @@ use crate::AppState; pub fn router() -> Router { Router::new() .route("/", get(list)) + .route("/groups", get(groups)) .route("/summary", get(summary)) .route("/targets", get(targets)) .route("/{id}/status", post(set_status)) @@ -26,36 +27,55 @@ pub struct ListQuery { pub min_severity: Option, /// `host` (OS, packages, applications) or `container` (images in the cluster). pub scope: Option, + /// Restrict to one package; used to expand a host group. + pub package: Option, pub target: Option, pub status: Option, #[serde(default)] pub include_fixed: bool, } +impl ListQuery { + fn into_filter(self) -> Result { + let target_kind = match self.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'" + )) + })?), + }; + Ok(FindingFilter { + min_severity: self.min_severity.as_deref().map(Severity::parse), + target_kind, + target: self.target, + package: self.package, + status: self.status.as_deref().and_then(FindingStatus::parse), + include_fixed: self.include_fixed, + }) + } +} + #[utoipa::path(get, path = "/api/vulnerabilities", tag = "vulnerabilities", security(("bearer" = [])), - params(("min_severity" = Option, Query), ("scope" = Option, Query), ("target" = Option, Query), ("status" = Option, Query), ("include_fixed" = Option, Query)), + params(("min_severity" = Option, Query), ("scope" = Option, Query), ("package" = 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, - }; - Ok(Json(state.vulns.list(filter).await?)) + Ok(Json(state.vulns.list(q.into_filter()?).await?)) +} + +#[utoipa::path(get, path = "/api/vulnerabilities/groups", tag = "vulnerabilities", security(("bearer" = [])), + params(("scope" = Option, Query), ("min_severity" = Option, Query), ("target" = Option, Query), ("status" = Option, Query)), + responses((status = 200, body = Vec), (status = 422)))] +async fn groups( + State(state): State, + _: AuthUser, + Query(q): Query, +) -> Result>, ApiError> { + Ok(Json(state.vulns.groups(q.into_filter()?).await?)) } #[derive(Serialize, ToSchema)] diff --git a/backend/crates/api/tests/vulnerabilities.rs b/backend/crates/api/tests/vulnerabilities.rs index b8f262e..ce78f8d 100644 --- a/backend/crates/api/tests/vulnerabilities.rs +++ b/backend/crates/api/tests/vulnerabilities.rs @@ -257,3 +257,76 @@ async fn targets_carry_their_scope_and_open_count() { assert_eq!(image["kind"], "image"); assert!(image["open"].as_u64().unwrap() >= 1); } + +#[tokio::test] +async fn findings_are_rolled_up_per_package_and_image() { + 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/groups?scope=host&min_severity=unknown", + Some(&token), + ) + .await; + assert_eq!(host.status, StatusCode::OK, "{}", host.json); + let groups = host.json.as_array().unwrap(); + let flat = get( + &app, + "/api/vulnerabilities?scope=host&min_severity=unknown", + Some(&token), + ) + .await; + let flat_len = flat.json.as_array().unwrap().len(); + assert!(groups.len() < flat_len, "fewer rows than findings"); + assert_eq!( + groups + .iter() + .map(|g| g["total"].as_u64().unwrap()) + .sum::() as usize, + flat_len + ); + let zlib = groups.iter().find(|g| g["key"] == "zlib1g").unwrap(); + assert_eq!(zlib["kind"], "os"); + assert_eq!(zlib["source"], "debian"); + assert_eq!(zlib["counts"]["critical"], 1); + assert!(zlib["installed"].as_str().unwrap().starts_with("1:1.2.13")); + + let images = get( + &app, + "/api/vulnerabilities/groups?scope=container&min_severity=unknown", + Some(&token), + ) + .await; + let images = images.json.as_array().unwrap(); + let gitea = images + .iter() + .find(|g| g["key"].as_str().unwrap().contains("gitea")) + .unwrap(); + assert_eq!(gitea["kind"], "image"); + assert!(gitea["total"].as_u64().unwrap() >= 1); + assert!(gitea["packages"].as_u64().unwrap() >= 1); + + // a group is expanded by filtering the flat list + let pkg = get( + &app, + "/api/vulnerabilities?scope=host&package=zlib1g&min_severity=unknown", + Some(&token), + ) + .await; + let pkg = pkg.json.as_array().unwrap(); + assert_eq!(pkg.len(), zlib["total"].as_u64().unwrap() as usize); + assert!(pkg.iter().all(|f| f["package"] == "zlib1g")); + + assert_eq!( + get(&app, "/api/vulnerabilities/groups?scope=nope", Some(&token)) + .await + .status, + StatusCode::UNPROCESSABLE_ENTITY + ); + assert_eq!( + get(&app, "/api/vulnerabilities/groups", None).await.status, + StatusCode::UNAUTHORIZED + ); +} diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs index 8bb58c7..a661da2 100644 --- a/backend/crates/application/src/test_fakes.rs +++ b/backend/crates/application/src/test_fakes.rs @@ -507,7 +507,8 @@ impl ClusterGateway for MemCluster { use domain::ports::{FindingRepository, VulnerabilityScanner}; use domain::vuln::{ - Finding, FindingFilter, FindingStatus, RawFinding, Severity, SeverityCounts, TargetKind, + Finding, FindingFilter, FindingGroup, FindingStatus, RawFinding, Severity, SeverityCounts, + TargetKind, }; pub fn raw( @@ -630,6 +631,7 @@ impl FindingRepository for MemFindings { .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.package.as_ref().is_none_or(|p| &f.raw.package == p)) .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() @@ -642,6 +644,71 @@ impl FindingRepository for MemFindings { }); Ok(v) } + async fn groups(&self, filter: &FindingFilter) -> Result, DomainError> { + let per_image = filter.target_kind == Some(TargetKind::Image); + let mut by_key: HashMap = HashMap::new(); + let mut packages: HashMap> = HashMap::new(); + for f in self.list(filter).await? { + let key = if per_image { + f.target.clone() + } else { + f.raw.package.clone() + }; + packages + .entry(key.clone()) + .or_default() + .insert(f.raw.package.clone()); + let g = by_key.entry(key.clone()).or_insert_with(|| FindingGroup { + key, + kind: f.target_kind, + source: if per_image { + String::new() + } else { + f.raw.source.clone() + }, + installed: if per_image { + String::new() + } else { + f.raw.installed_version.clone() + }, + counts: SeverityCounts::default(), + total: 0, + fixable: 0, + packages: 0, + }); + g.counts.add(f.raw.severity); + g.total += 1; + if f.raw.fixed_version.is_some() { + g.fixable += 1; + } + } + let mut groups: Vec = by_key + .into_values() + .map(|mut g| { + g.packages = packages.get(&g.key).map(|p| p.len()).unwrap_or(1); + g + }) + .collect(); + let worst = |g: &FindingGroup| { + Severity::ALL + .iter() + .position(|s| match s { + Severity::Critical => g.counts.critical > 0, + Severity::High => g.counts.high > 0, + Severity::Medium => g.counts.medium > 0, + Severity::Low => g.counts.low > 0, + Severity::Unknown => g.counts.unknown > 0, + }) + .unwrap_or(usize::MAX) + }; + groups.sort_by(|a, b| { + worst(a) + .cmp(&worst(b)) + .then(b.total.cmp(&a.total)) + .then(a.key.cmp(&b.key)) + }); + Ok(groups) + } async fn counts(&self, kind: Option) -> Result { let mut c = SeverityCounts::default(); for f in diff --git a/backend/crates/application/src/tests/vuln_tests.rs b/backend/crates/application/src/tests/vuln_tests.rs index 0ad87dc..d6ca817 100644 --- a/backend/crates/application/src/tests/vuln_tests.rs +++ b/backend/crates/application/src/tests/vuln_tests.rs @@ -417,17 +417,38 @@ async fn host_findings_are_grouped_per_package_and_containers_per_image() { .with( "os", Ok(vec![ - raw("CVE-1", "openssl", "3.0.1", Severity::Critical, Some("3.0.2")), + 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)])); + .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(); + 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"); @@ -440,30 +461,75 @@ async fn host_findings_are_grouped_per_package_and_containers_per_image() { 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]); + 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() }) + .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"); + 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)])); + .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(); + 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/application/src/vuln_service.rs b/backend/crates/application/src/vuln_service.rs index 97bf1ac..9e76cea 100644 --- a/backend/crates/application/src/vuln_service.rs +++ b/backend/crates/application/src/vuln_service.rs @@ -7,8 +7,8 @@ use async_trait::async_trait; use chrono::{DateTime, Utc}; use domain::ports::{ClusterGateway, FindingRepository, LineSink, VulnerabilityScanner}; use domain::vuln::{ - Finding, FindingFilter, FindingStatus, RawFinding, ScanReport, Severity, SeverityCounts, - TargetKind, + Finding, FindingFilter, FindingGroup, FindingStatus, RawFinding, ScanReport, Severity, + SeverityCounts, TargetKind, }; use domain::DomainError; use uuid::Uuid; @@ -237,6 +237,11 @@ impl VulnerabilityService { self.findings.list(&filter).await } + /// Findings rolled up per package (host) or per image (containers). + pub async fn groups(&self, filter: FindingFilter) -> Result, DomainError> { + self.findings.groups(&filter).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> = diff --git a/backend/crates/infrastructure/src/sqlite.rs b/backend/crates/infrastructure/src/sqlite.rs index 1557da4..ddfa138 100644 --- a/backend/crates/infrastructure/src/sqlite.rs +++ b/backend/crates/infrastructure/src/sqlite.rs @@ -496,7 +496,8 @@ impl domain::ports::InventoryRepository for SqliteInventory { } use domain::vuln::{ - Finding, FindingFilter, FindingStatus, RawFinding, Severity, SeverityCounts, TargetKind, + Finding, FindingFilter, FindingGroup, FindingStatus, RawFinding, Severity, SeverityCounts, + TargetKind, }; pub struct SqliteFindings(pub DbPool); @@ -629,6 +630,9 @@ impl domain::ports::FindingRepository for SqliteFindings { if filter.target_kind.is_some() { sql.push_str(" AND target_kind = ?"); } + if filter.package.is_some() { + sql.push_str(" AND package = ?"); + } if filter.min_severity.is_some() { sql.push_str(&format!(" AND {SEVERITY_RANK_SQL} >= ?")); } @@ -645,6 +649,9 @@ impl domain::ports::FindingRepository for SqliteFindings { if let Some(k) = filter.target_kind { q = q.bind(k.as_str()); } + if let Some(p) = &filter.package { + q = q.bind(p); + } if let Some(m) = filter.min_severity { q = q.bind(severity_rank(m)); } @@ -653,6 +660,94 @@ impl domain::ports::FindingRepository for SqliteFindings { .map(|rows| rows.iter().map(finding_from_row).collect()) .map_err(storage) } + async fn groups(&self, filter: &FindingFilter) -> Result, DomainError> { + // Host findings roll up per package, container findings per image. + let per_image = filter.target_kind == Some(TargetKind::Image); + let key = if per_image { "target" } else { "package" }; + let mut sql = format!( + "SELECT {key} AS key, target_kind, \ + COALESCE(MAX(source), '') AS source, COALESCE(MAX(installed_version), '') AS installed, \ + COUNT(*) AS total, \ + SUM(fixed_version IS NOT NULL AND fixed_version != '') AS fixable, \ + COUNT(DISTINCT package) AS packages, \ + SUM(severity = 'critical') AS critical, SUM(severity = 'high') AS high, \ + SUM(severity = 'medium') AS medium, SUM(severity = 'low') AS low, \ + SUM(severity NOT IN ('critical', 'high', 'medium', 'low')) AS unknown \ + FROM findings WHERE 1=1" + ); + if !filter.include_fixed { + sql.push_str(" AND status != 'fixed'"); + } + if filter.status.is_some() { + sql.push_str(" AND status = ?"); + } + if filter.target.is_some() { + sql.push_str(" AND target = ?"); + } + if filter.target_kind.is_some() { + sql.push_str(" AND target_kind = ?"); + } + if filter.package.is_some() { + sql.push_str(" AND package = ?"); + } + if filter.min_severity.is_some() { + sql.push_str(&format!(" AND {SEVERITY_RANK_SQL} >= ?")); + } + // worst severity first, then most findings, then by name + sql.push_str(&format!( + " GROUP BY {key}, target_kind ORDER BY MAX({SEVERITY_RANK_SQL}) DESC, total DESC, key" + )); + let mut q = sqlx::query(&sql); + if let Some(st) = filter.status { + q = q.bind(st.as_str()); + } + 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(p) = &filter.package { + q = q.bind(p); + } + if let Some(m) = filter.min_severity { + q = q.bind(severity_rank(m)); + } + let rows = q.fetch_all(&self.0).await.map_err(storage)?; + Ok(rows + .iter() + .map(|r| { + let n = |c: &str| r.get::(c) as usize; + let kind = TargetKind::parse(r.get::("target_kind").as_str()) + .unwrap_or(TargetKind::Os); + FindingGroup { + key: r.get("key"), + kind, + source: if kind == TargetKind::Image { + String::new() + } else { + r.get("source") + }, + installed: if kind == TargetKind::Image { + String::new() + } else { + r.get("installed") + }, + counts: SeverityCounts { + critical: n("critical"), + high: n("high"), + medium: n("medium"), + low: n("low"), + unknown: n("unknown"), + }, + total: n("total"), + fixable: n("fixable"), + packages: n("packages"), + } + }) + .collect()) + } + async fn counts(&self, kind: Option) -> Result { let sql = match kind { Some(_) => "SELECT severity, COUNT(*) FROM findings WHERE status != 'fixed' AND target_kind = ? GROUP BY severity", @@ -807,6 +902,77 @@ mod finding_tests { "source survives the roundtrip" ); } + + #[tokio::test] + async fn groups_roll_up_per_package_and_per_image() { + let pool = crate::connect("sqlite::memory:").await.unwrap(); + let repo = SqliteFindings(pool); + let mut a = finding("os", TargetKind::Os, "CVE-A", Severity::Critical); + a.raw.package = "openssl".into(); + a.raw.fixed_version = Some("3.0.2".into()); + let mut b = finding("os", TargetKind::Os, "CVE-B", Severity::Low); + b.raw.package = "openssl".into(); + let mut c = finding("os", TargetKind::Os, "CVE-C", Severity::High); + c.raw.package = "stdlib".into(); + c.raw.source = "gobinary".into(); + let mut d = finding("img:1", TargetKind::Image, "CVE-D", Severity::Medium); + d.raw.package = "curl".into(); + let mut e = finding("img:1", TargetKind::Image, "CVE-E", Severity::High); + e.raw.package = "git".into(); + for f in [&a, &b, &c, &d, &e] { + repo.insert(f).await.unwrap(); + } + + let host = repo + .groups(&FindingFilter { + target_kind: Some(TargetKind::Os), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!( + host.iter().map(|g| g.key.as_str()).collect::>(), + vec!["openssl", "stdlib"] + ); + 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); + assert_eq!(host[0].installed, "1"); + assert_eq!(host[1].source, "gobinary"); + + let images = repo + .groups(&FindingFilter { + target_kind: Some(TargetKind::Image), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(images.len(), 1); + assert_eq!(images[0].key, "img:1"); + assert_eq!(images[0].total, 2); + assert_eq!(images[0].packages, 2); + + // fixed findings stay out unless asked for, and the package filter drills in + repo.set_status(b.id, FindingStatus::Fixed).await.unwrap(); + let host = repo + .groups(&FindingFilter { + target_kind: Some(TargetKind::Os), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(host[0].total, 1); + let drilled = repo + .list(&FindingFilter { + package: Some("stdlib".into()), + ..Default::default() + }) + .await + .unwrap(); + assert_eq!(drilled.len(), 1); + assert_eq!(drilled[0].raw.cve_id, "CVE-C"); + } } use domain::backup::{BackupRecord, BackupSource, BackupStrategy, BackupTarget, StorageKind}; diff --git a/backend/crates/infrastructure/src/trivy.rs b/backend/crates/infrastructure/src/trivy.rs index 8ce98b0..9816ec0 100644 --- a/backend/crates/infrastructure/src/trivy.rs +++ b/backend/crates/infrastructure/src/trivy.rs @@ -202,6 +202,17 @@ impl VulnerabilityScanner for FakeScanner { url: "https://avd.aquasec.com/nvd/cve-2024-5535".into(), source: "debian".into(), }, + // a second flaw in the same package: the grouped view collapses both into one row + RawFinding { + cve_id: "CVE-2024-2511".into(), + severity: Severity::Medium, + package: "openssl".into(), + installed_version: "3.0.15-1~deb12u1".into(), + fixed_version: Some("3.0.16-1~deb12u1".into()), + title: "openssl: unbounded memory growth in the session cache".into(), + url: "https://avd.aquasec.com/nvd/cve-2024-2511".into(), + source: "debian".into(), + }, RawFinding { cve_id: "CVE-2023-45853".into(), severity: Severity::Critical, diff --git a/frontend/e2e/vuln-groups.spec.ts b/frontend/e2e/vuln-groups.spec.ts index dd2021e..70608a3 100644 --- a/frontend/e2e/vuln-groups.spec.ts +++ b/frontend/e2e/vuln-groups.spec.ts @@ -15,7 +15,7 @@ test('packages and images are one row each and expand to their CVEs', async ({ p await scan(page) // host: a row per package, no CVE ids until a row is opened - const zlib = page.getByRole('row', { name: /^zlib1g/ }) + const zlib = page.getByRole('row', { name: /zlib1g/ }) await expect(zlib).toBeVisible() await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toHaveCount(0) diff --git a/frontend/e2e/vuln-scopes.spec.ts b/frontend/e2e/vuln-scopes.spec.ts index 817638a..e0b9dda 100644 --- a/frontend/e2e/vuln-scopes.spec.ts +++ b/frontend/e2e/vuln-scopes.spec.ts @@ -19,19 +19,18 @@ test('host and container findings are separated into two categories', async ({ p 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 + // host is selected first and lists host packages with their 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.getByRole('row', { name: /zlib1g/ })).toBeVisible() + await expect(page.getByRole('row', { name: /gitea\/gitea/ })).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.getByRole('row', { name: /gitea\/gitea/ }).first()).toBeVisible() + await expect(page.getByRole('row', { name: /zlib1g/ })).toHaveCount(0) + await expect(page.getByTestId('findings-scroll')).toContainText('Image') // the filter is labelled per category and only offers targets of that category const images = await page.getByLabel('Image', { exact: true }).locator('option').allTextContents() diff --git a/frontend/e2e/vulnerabilities.spec.ts b/frontend/e2e/vulnerabilities.spec.ts index 312f557..efa38c4 100644 --- a/frontend/e2e/vulnerabilities.spec.ts +++ b/frontend/e2e/vulnerabilities.spec.ts @@ -9,13 +9,15 @@ test('admin runs a scan, filters findings and acknowledges one', async ({ page } await page.getByRole('button', { name: 'Scan now' }).click() await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 }) + await page.getByRole('row', { name: /zlib1g/ }).click() await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible() await page.getByLabel('Minimum severity').selectOption('critical') - await expect(page.getByRole('row', { name: /CVE-2011-3374/ })).toHaveCount(0) + await expect(page.getByRole('row', { name: /apt/ })).toHaveCount(0) await page.getByLabel('Minimum severity').selectOption('low') - await expect(page.getByRole('row', { name: /CVE-2011-3374/ })).toBeVisible() + await expect(page.getByRole('row', { name: /apt/ })).toBeVisible() + await page.getByRole('row', { name: /zlib1g/ }).click() const row = page.getByRole('row', { name: /CVE-2023-45853/ }) await row.getByRole('button', { name: 'Acknowledge' }).click() await expect(row).toContainText('acknowledged') diff --git a/frontend/src/components/FindingGroups.vue b/frontend/src/components/FindingGroups.vue new file mode 100644 index 0000000..9705a1d --- /dev/null +++ b/frontend/src/components/FindingGroups.vue @@ -0,0 +1,135 @@ + + + diff --git a/frontend/src/components/FindingTable.vue b/frontend/src/components/FindingTable.vue index b750656..1ee68f4 100644 --- a/frontend/src/components/FindingTable.vue +++ b/frontend/src/components/FindingTable.vue @@ -1,7 +1,15 @@