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>
453 lines
14 KiB
Rust
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
|
|
);
|
|
}
|