The findings of the Debian host (OS packages and applications found in the root filesystem) and of the container images in the cluster were mixed in one flat table. They are now two prominent categories: a card each with its own severity split and target count, acting as the primary selector, and a table that adapts to the selection. Host findings show the package source reported by the scanner (debian, gobinary, node-pkg …) instead of the target, container findings show the image. Backend: findings carry the scanner's package source, the list endpoint takes ?scope=host|container, and the targets endpoint reports each target with its category and open count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
260 lines
7.9 KiB
Rust
260 lines
7.9 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);
|
|
}
|