Files
Dennis Nemec b984cc8c9f 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>
2026-09-03 20:19:12 +02:00

453 lines
14 KiB
Rust

//! WP-20/21: /api/vulnerabilities and notification settings.
mod common;
use axum::http::StatusCode;
use common::{get, post, send_json, test_app_with_admin};
use serde_json::json;
const ADMIN: &str = "admin@example.com";
const PW: &str = "admin-password-123";
async fn run_scan(app: &axum::Router, token: &str) -> serde_json::Value {
let run = post(
app,
"/api/jobs/run",
json!({"kind": "vulnerability_scan"}),
Some(token),
)
.await;
assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json);
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" {
return r.json;
}
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
}
panic!("scan did not finish");
}
#[tokio::test]
async fn scan_populates_findings_summary_and_filters() {
let app = test_app_with_admin().await;
let token = common::login(&app, ADMIN, PW).await.access;
let empty = get(&app, "/api/vulnerabilities/summary", Some(&token)).await;
assert_eq!(empty.status, StatusCode::OK);
assert_eq!(empty.json["total"]["critical"], 0);
assert!(empty.json["last_scan"].is_null());
assert!(empty.json["scanner"].as_str().unwrap().contains("fake"));
let run = run_scan(&app, &token).await;
assert_eq!(run["status"], "success", "{}", run["log"]);
assert!(run["log"].as_str().unwrap().contains("new"));
let s = get(&app, "/api/vulnerabilities/summary", Some(&token)).await;
assert!(s.json["total"]["critical"].as_u64().unwrap() >= 2);
assert!(s.json["os"]["high"].as_u64().unwrap() >= 1);
assert!(s.json["last_scan"].is_string());
let all = get(&app, "/api/vulnerabilities", Some(&token)).await;
let list = all.json.as_array().unwrap();
assert!(list.len() >= 5);
assert_eq!(list[0]["severity"], "critical", "sorted by severity");
let crit = get(
&app,
"/api/vulnerabilities?min_severity=critical",
Some(&token),
)
.await;
assert!(crit
.json
.as_array()
.unwrap()
.iter()
.all(|f| f["severity"] == "critical"));
let os = get(&app, "/api/vulnerabilities?target=os", Some(&token)).await;
assert!(os
.json
.as_array()
.unwrap()
.iter()
.all(|f| f["target"] == "os"));
let targets = get(&app, "/api/vulnerabilities/targets", Some(&token)).await;
assert!(targets
.json
.as_array()
.unwrap()
.iter()
.any(|t| t["target"] == "os" && t["kind"] == "os"));
// acknowledge one
let id = list[0]["id"].as_str().unwrap();
let res = post(
&app,
&format!("/api/vulnerabilities/{id}/status"),
json!({"status": "acknowledged"}),
Some(&token),
)
.await;
assert_eq!(res.status, StatusCode::OK, "{}", res.json);
assert_eq!(res.json["status"], "acknowledged");
assert_eq!(
post(
&app,
&format!("/api/vulnerabilities/{id}/status"),
json!({"status": "fixed"}),
Some(&token)
)
.await
.status,
StatusCode::UNPROCESSABLE_ENTITY
);
// second scan reports nothing new
let run = run_scan(&app, &token).await;
assert!(
run["log"].as_str().unwrap().contains("0 new"),
"{}",
run["log"]
);
}
#[tokio::test]
async fn notification_threshold_setting_and_permissions() {
let app = test_app_with_admin().await;
let admin = common::login(&app, ADMIN, PW).await.access;
let res = get(&app, "/api/settings/notifications", Some(&admin)).await;
assert_eq!(res.json["min_severity"], "high");
assert_eq!(
send_json(
&app,
"PUT",
"/api/settings/notifications",
json!({"min_severity": "medium"}),
Some(&admin)
)
.await
.status,
StatusCode::NO_CONTENT
);
assert_eq!(
get(&app, "/api/settings/notifications", Some(&admin))
.await
.json["min_severity"],
"medium"
);
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&admin)).await;
let user = common::login(&app, "u@x.de", "user-password-123")
.await
.access;
assert_eq!(
get(&app, "/api/vulnerabilities", Some(&user)).await.status,
StatusCode::OK
);
assert_eq!(
send_json(
&app,
"PUT",
"/api/settings/notifications",
json!({"min_severity": "low"}),
Some(&user)
)
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
post(
&app,
"/api/vulnerabilities/00000000-0000-0000-0000-000000000000/status",
json!({"status": "acknowledged"}),
Some(&user)
)
.await
.status,
StatusCode::FORBIDDEN
);
assert_eq!(
get(&app, "/api/vulnerabilities", None).await.status,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn findings_are_scoped_into_host_and_containers() {
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?scope=host&min_severity=unknown",
Some(&token),
)
.await;
assert_eq!(host.status, StatusCode::OK, "{}", host.json);
let host_list = host.json.as_array().unwrap();
assert!(!host_list.is_empty());
assert!(host_list
.iter()
.all(|f| f["target_kind"] == "os" && f["target"] == "os"));
assert!(
host_list.iter().any(|f| f["source"] == "debian"),
"host findings name their package source"
);
let containers = get(
&app,
"/api/vulnerabilities?scope=container&min_severity=unknown",
Some(&token),
)
.await;
let container_list = containers.json.as_array().unwrap();
assert!(!container_list.is_empty());
assert!(container_list.iter().all(|f| f["target_kind"] == "image"));
assert!(container_list
.iter()
.any(|f| f["target"].as_str().unwrap().contains("gitea")));
let all = get(
&app,
"/api/vulnerabilities?min_severity=unknown",
Some(&token),
)
.await;
assert_eq!(
all.json.as_array().unwrap().len(),
host_list.len() + container_list.len()
);
assert_eq!(
get(&app, "/api/vulnerabilities?scope=nope", Some(&token))
.await
.status,
StatusCode::UNPROCESSABLE_ENTITY
);
// the summary splits the same way
let s = get(&app, "/api/vulnerabilities/summary", Some(&token)).await;
let sum = |v: &serde_json::Value| {
v["critical"].as_u64().unwrap()
+ v["high"].as_u64().unwrap()
+ v["medium"].as_u64().unwrap()
+ v["low"].as_u64().unwrap()
+ v["unknown"].as_u64().unwrap()
};
assert_eq!(sum(&s.json["os"]) as usize, host_list.len());
assert_eq!(sum(&s.json["images"]) as usize, container_list.len());
}
#[tokio::test]
async fn targets_carry_their_scope_and_open_count() {
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/targets", Some(&token)).await;
let targets = res.json.as_array().unwrap();
assert_eq!(targets[0]["kind"], "os", "the host comes first");
assert_eq!(targets[0]["target"], "os");
assert!(targets[0]["open"].as_u64().unwrap() >= 3);
let image = targets
.iter()
.find(|t| t["target"].as_str().unwrap().contains("gitea"))
.unwrap();
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
);
}
#[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
);
}