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:
@ -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,
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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::<u64>() 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
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user