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

@ -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
);
}