Link image findings to their workloads and suggest a fixing image update

Container findings now name the workloads that run the image and link
into the Kubernetes view, which highlights them. An update check asks the
registry for newer tags of the same variant, scans the newest one and
records which of the open findings are gone in it. The image row then
shows the candidate tag, how many findings it fixes and how many remain,
marks those CVEs in the expanded list, and offers to roll every workload
over to it. The check runs as a job, nightly for all running images or on
demand for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 20:19:12 +02:00
parent 278b5e47a3
commit b984cc8c9f
31 changed files with 1674 additions and 29 deletions

View File

@ -330,3 +330,123 @@ async fn findings_are_rolled_up_per_package_and_image() {
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn image_groups_link_to_the_workloads_running_them() {
let app = test_app_with_admin().await;
let token = common::login(&app, ADMIN, PW).await.access;
run_scan(&app, &token).await;
let res = get(
&app,
"/api/vulnerabilities/groups?scope=container&min_severity=unknown",
Some(&token),
)
.await;
let groups = res.json.as_array().unwrap();
let gitea = groups
.iter()
.find(|g| g["key"].as_str().unwrap().contains("gitea"))
.unwrap();
assert_eq!(gitea["running"], true);
let workloads = gitea["workloads"].as_array().unwrap();
assert_eq!(workloads.len(), 1);
assert_eq!(workloads[0]["namespace"], "gitea");
assert_eq!(workloads[0]["kind"], "deployment");
assert_eq!(workloads[0]["name"], "gitea");
// host groups do not carry cluster usage
let host = get(
&app,
"/api/vulnerabilities/groups?scope=host&min_severity=unknown",
Some(&token),
)
.await;
assert!(host
.json
.as_array()
.unwrap()
.iter()
.all(|g| g.get("workloads").is_none()));
}
#[tokio::test]
async fn an_image_update_check_suggests_a_newer_tag_that_fixes_findings() {
let app = test_app_with_admin().await;
let token = common::login(&app, ADMIN, PW).await.access;
run_scan(&app, &token).await;
// nothing is known before a check
let groups = get(
&app,
"/api/vulnerabilities/groups?scope=container&min_severity=unknown",
Some(&token),
)
.await;
let gitea = groups
.json
.as_array()
.unwrap()
.iter()
.find(|g| g["key"].as_str().unwrap().contains("gitea"))
.unwrap()
.clone();
assert!(gitea["update"].is_null());
let run = post(
&app,
"/api/cluster/images/check",
json!({"image": gitea["key"]}),
Some(&token),
)
.await;
assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json);
assert_eq!(run.json["kind"], "image_update_check");
let id = run.json["id"].as_str().unwrap().to_string();
for _ in 0..100 {
let r = get(&app, &format!("/api/jobs/{id}"), Some(&token)).await;
if r.json["status"] != "running" {
assert_eq!(r.json["status"], "success", "{}", r.json["log"]);
break;
}
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
let groups = get(
&app,
"/api/vulnerabilities/groups?scope=container&min_severity=unknown",
Some(&token),
)
.await;
let gitea = groups
.json
.as_array()
.unwrap()
.iter()
.find(|g| g["key"].as_str().unwrap().contains("gitea"))
.unwrap()
.clone();
let update = &gitea["update"];
assert!(
update["candidate"].as_str().unwrap().ends_with(":9.9.9"),
"{update}"
);
assert!(update["fixed"]
.as_array()
.unwrap()
.contains(&json!("CVE-2024-24790")));
assert_eq!(update["fixed_counts"]["critical"], 1);
assert!(update["checked_at"].is_string());
// only admins may start a check
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&token)).await;
let user = common::login(&app, "u@x.de", "user-password-123")
.await
.access;
assert_eq!(
post(&app, "/api/cluster/images/check", json!({}), Some(&user))
.await
.status,
StatusCode::FORBIDDEN
);
}