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>
333 lines
10 KiB
Rust
333 lines
10 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
|
|
);
|
|
}
|