Roll findings up per package and image, expandable to their CVEs

The findings table listed every CVE, which is thousands of rows on a real
host. It now shows one row per affected package (host) or image
(containers) with its severity split, how many findings it has and how
many of them have a fix. Clicking a row loads and shows the CVEs of that
group; collapsing keeps them cached.

The rollup is a GROUP BY in SQLite behind a new groups endpoint, so the
page loads a few dozen rows instead of the full finding list, and the
flat list gained a package filter to expand one group.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 20:02:25 +02:00
parent 751296b3b0
commit 278b5e47a3
14 changed files with 635 additions and 59 deletions

View File

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

View File

@ -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<AppState> {
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<String>,
/// `host` (OS, packages, applications) or `container` (images in the cluster).
pub scope: Option<String>,
/// Restrict to one package; used to expand a host group.
pub package: Option<String>,
pub target: Option<String>,
pub status: Option<String>,
#[serde(default)]
pub include_fixed: bool,
}
impl ListQuery {
fn into_filter(self) -> Result<FindingFilter, DomainError> {
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<String>, Query), ("scope" = Option<String>, Query), ("target" = Option<String>, Query), ("status" = Option<String>, Query), ("include_fixed" = Option<bool>, Query)),
params(("min_severity" = Option<String>, Query), ("scope" = Option<String>, Query), ("package" = Option<String>, Query), ("target" = Option<String>, Query), ("status" = Option<String>, Query), ("include_fixed" = Option<bool>, Query)),
responses((status = 200, body = Vec<Object>)))]
async fn list(
State(state): State<AppState>,
_: AuthUser,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<Finding>>, 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<String>, Query), ("min_severity" = Option<String>, Query), ("target" = Option<String>, Query), ("status" = Option<String>, Query)),
responses((status = 200, body = Vec<Object>), (status = 422)))]
async fn groups(
State(state): State<AppState>,
_: AuthUser,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<FindingGroup>>, ApiError> {
Ok(Json(state.vulns.groups(q.into_filter()?).await?))
}
#[derive(Serialize, ToSchema)]