WP-20/21: contract and failing tests for vulnerability management
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
169
backend/crates/api/tests/vulnerabilities.rs
Normal file
169
backend/crates/api/tests/vulnerabilities.rs
Normal file
@ -0,0 +1,169 @@
|
|||||||
|
//! 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 == "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
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -7,6 +7,7 @@ pub mod scheduler;
|
|||||||
pub mod settings_service;
|
pub mod settings_service;
|
||||||
pub mod upgrade_service;
|
pub mod upgrade_service;
|
||||||
pub mod user_service;
|
pub mod user_service;
|
||||||
|
pub mod vuln_service;
|
||||||
|
|
||||||
pub use auth_service::AuthService;
|
pub use auth_service::AuthService;
|
||||||
pub use cluster_service::ClusterService;
|
pub use cluster_service::ClusterService;
|
||||||
@ -15,6 +16,7 @@ pub use jobs::{JobHandler, JobLog, JobRunner};
|
|||||||
pub use settings_service::SettingsService;
|
pub use settings_service::SettingsService;
|
||||||
pub use upgrade_service::{PackageUpgradeJob, UpgradeParams};
|
pub use upgrade_service::{PackageUpgradeJob, UpgradeParams};
|
||||||
pub use user_service::UserService;
|
pub use user_service::UserService;
|
||||||
|
pub use vuln_service::{VulnerabilityScanJob, VulnerabilityService};
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
pub(crate) mod test_fakes;
|
pub(crate) mod test_fakes;
|
||||||
|
|||||||
@ -6,6 +6,8 @@ use domain::settings::{SmtpSettings, KEY_SMTP, SECRET_KEYS};
|
|||||||
use domain::DomainError;
|
use domain::DomainError;
|
||||||
|
|
||||||
use crate::scheduler::validate_cron;
|
use crate::scheduler::validate_cron;
|
||||||
|
use crate::vuln_service::{DEFAULT_NOTIFY_MIN_SEVERITY, KEY_NOTIFY_MIN_SEVERITY};
|
||||||
|
use domain::vuln::Severity;
|
||||||
|
|
||||||
pub struct SettingsService {
|
pub struct SettingsService {
|
||||||
repo: Arc<dyn SettingsRepository>,
|
repo: Arc<dyn SettingsRepository>,
|
||||||
@ -104,6 +106,20 @@ impl SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
impl SettingsService {
|
||||||
|
pub async fn notify_min_severity(&self) -> Result<Severity, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.get(KEY_NOTIFY_MIN_SEVERITY)
|
||||||
|
.await?
|
||||||
|
.map(|s| Severity::parse(&s))
|
||||||
|
.unwrap_or(DEFAULT_NOTIFY_MIN_SEVERITY))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn set_notify_min_severity(&self, severity: Severity) -> Result<(), DomainError> {
|
||||||
|
self.set(KEY_NOTIFY_MIN_SEVERITY, severity.as_str()).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn schedule_key(kind: JobKind) -> String {
|
fn schedule_key(kind: JobKind) -> String {
|
||||||
format!("schedule.{}", kind.as_str())
|
format!("schedule.{}", kind.as_str())
|
||||||
}
|
}
|
||||||
|
|||||||
@ -494,3 +494,150 @@ impl ClusterGateway for MemCluster {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use domain::ports::{FindingRepository, VulnerabilityScanner};
|
||||||
|
use domain::vuln::{
|
||||||
|
Finding, FindingFilter, FindingStatus, RawFinding, Severity, SeverityCounts, TargetKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn raw(
|
||||||
|
cve: &str,
|
||||||
|
pkg: &str,
|
||||||
|
installed: &str,
|
||||||
|
sev: Severity,
|
||||||
|
fixed: Option<&str>,
|
||||||
|
) -> RawFinding {
|
||||||
|
RawFinding {
|
||||||
|
cve_id: cve.into(),
|
||||||
|
severity: sev,
|
||||||
|
package: pkg.into(),
|
||||||
|
installed_version: installed.into(),
|
||||||
|
fixed_version: fixed.map(String::from),
|
||||||
|
title: format!("{cve} in {pkg}"),
|
||||||
|
url: format!("https://nvd.nist.gov/vuln/detail/{cve}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub type ScanResults = Arc<Mutex<HashMap<String, Result<Vec<RawFinding>, String>>>>;
|
||||||
|
|
||||||
|
/// Scanner returning configurable results per target ("os" or image ref).
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct FakeScanner {
|
||||||
|
pub results: ScanResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FakeScanner {
|
||||||
|
pub fn with(self, target: &str, r: Result<Vec<RawFinding>, &str>) -> Self {
|
||||||
|
self.results
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert(target.into(), r.map_err(String::from));
|
||||||
|
self
|
||||||
|
}
|
||||||
|
fn get(&self, target: &str) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
match self.results.lock().unwrap().get(target) {
|
||||||
|
Some(Ok(v)) => Ok(v.clone()),
|
||||||
|
Some(Err(e)) => Err(DomainError::Unavailable(e.clone())),
|
||||||
|
None => Ok(vec![]),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VulnerabilityScanner for FakeScanner {
|
||||||
|
async fn version(&self) -> Result<String, DomainError> {
|
||||||
|
Ok("fake 0.1".into())
|
||||||
|
}
|
||||||
|
async fn scan_os(
|
||||||
|
&self,
|
||||||
|
out: &dyn domain::ports::LineSink,
|
||||||
|
) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
out.line("scanning os");
|
||||||
|
self.get("os")
|
||||||
|
}
|
||||||
|
async fn scan_image(
|
||||||
|
&self,
|
||||||
|
image: &str,
|
||||||
|
out: &dyn domain::ports::LineSink,
|
||||||
|
) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
out.line(&format!("scanning {image}"));
|
||||||
|
self.get(image)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct MemFindings(pub Mutex<Vec<Finding>>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl FindingRepository for MemFindings {
|
||||||
|
async fn active_by_target(&self, target: &str) -> Result<Vec<Finding>, DomainError> {
|
||||||
|
Ok(self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|f| f.target == target && f.status != FindingStatus::Fixed)
|
||||||
|
.cloned()
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
async fn insert(&self, finding: &Finding) -> Result<(), DomainError> {
|
||||||
|
self.0.lock().unwrap().push(finding.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
ids: &[Uuid],
|
||||||
|
last_seen: chrono::DateTime<Utc>,
|
||||||
|
) -> Result<(), DomainError> {
|
||||||
|
self.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter_mut()
|
||||||
|
.filter(|f| ids.contains(&f.id))
|
||||||
|
.for_each(|f| f.last_seen = last_seen);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError> {
|
||||||
|
let mut v = self.0.lock().unwrap();
|
||||||
|
let f = v
|
||||||
|
.iter_mut()
|
||||||
|
.find(|f| f.id == id)
|
||||||
|
.ok_or(DomainError::NotFound)?;
|
||||||
|
f.status = status;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn get(&self, id: Uuid) -> Result<Option<Finding>, DomainError> {
|
||||||
|
Ok(self.0.lock().unwrap().iter().find(|f| f.id == id).cloned())
|
||||||
|
}
|
||||||
|
async fn list(&self, filter: &FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||||
|
let mut v: Vec<Finding> = self
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|f| filter.include_fixed || f.status != FindingStatus::Fixed)
|
||||||
|
.filter(|f| filter.min_severity.is_none_or(|m| f.raw.severity >= m))
|
||||||
|
.filter(|f| filter.target.as_ref().is_none_or(|t| &f.target == t))
|
||||||
|
.filter(|f| filter.status.is_none_or(|s| f.status == s))
|
||||||
|
.cloned()
|
||||||
|
.collect();
|
||||||
|
v.sort_by(|a, b| {
|
||||||
|
b.raw
|
||||||
|
.severity
|
||||||
|
.cmp(&a.raw.severity)
|
||||||
|
.then(a.raw.cve_id.cmp(&b.raw.cve_id))
|
||||||
|
});
|
||||||
|
Ok(v)
|
||||||
|
}
|
||||||
|
async fn counts(&self, kind: Option<TargetKind>) -> Result<SeverityCounts, DomainError> {
|
||||||
|
let mut c = SeverityCounts::default();
|
||||||
|
for f in
|
||||||
|
self.0.lock().unwrap().iter().filter(|f| {
|
||||||
|
f.status != FindingStatus::Fixed && kind.is_none_or(|k| f.target_kind == k)
|
||||||
|
})
|
||||||
|
{
|
||||||
|
c.add(f.raw.severity);
|
||||||
|
}
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -6,3 +6,4 @@ mod scheduler_tests;
|
|||||||
mod settings_tests;
|
mod settings_tests;
|
||||||
mod upgrade_tests;
|
mod upgrade_tests;
|
||||||
mod user_service_tests;
|
mod user_service_tests;
|
||||||
|
mod vuln_tests;
|
||||||
|
|||||||
278
backend/crates/application/src/tests/vuln_tests.rs
Normal file
278
backend/crates/application/src/tests/vuln_tests.rs
Normal file
@ -0,0 +1,278 @@
|
|||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::vuln::{FindingFilter, FindingStatus, Severity, TargetKind};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
|
use crate::jobs::JobLog;
|
||||||
|
use crate::test_fakes::{
|
||||||
|
raw, smtp, FakeCipher, FakeScanner, MemCluster, MemFindings, MemMailer, MemSettings,
|
||||||
|
ScanResults,
|
||||||
|
};
|
||||||
|
use crate::{SettingsService, VulnerabilityService};
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
struct VecLog(Mutex<Vec<String>>);
|
||||||
|
#[async_trait]
|
||||||
|
impl JobLog for VecLog {
|
||||||
|
async fn line(&self, text: &str) {
|
||||||
|
self.0.lock().unwrap().push(text.into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct F {
|
||||||
|
findings: Arc<MemFindings>,
|
||||||
|
mailer: Arc<MemMailer>,
|
||||||
|
settings: Arc<SettingsService>,
|
||||||
|
results: ScanResults,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(scanner: FakeScanner) -> (F, VulnerabilityService) {
|
||||||
|
let findings = Arc::new(MemFindings::default());
|
||||||
|
let mailer = Arc::new(MemMailer::default());
|
||||||
|
let results = scanner.results.clone();
|
||||||
|
let settings = Arc::new(SettingsService::new(
|
||||||
|
Arc::new(MemSettings::default()),
|
||||||
|
Arc::new(FakeCipher),
|
||||||
|
mailer.clone(),
|
||||||
|
));
|
||||||
|
let svc = VulnerabilityService::new(
|
||||||
|
Arc::new(scanner),
|
||||||
|
findings.clone(),
|
||||||
|
Arc::new(MemCluster::default()),
|
||||||
|
settings.clone(),
|
||||||
|
);
|
||||||
|
(
|
||||||
|
F {
|
||||||
|
findings,
|
||||||
|
mailer,
|
||||||
|
settings,
|
||||||
|
results,
|
||||||
|
},
|
||||||
|
svc,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const GITEA: &str = "gitea/gitea:1.22.3";
|
||||||
|
const PG: &str = "bitnami/postgresql:16.4.0";
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn first_scan_inserts_findings_for_os_and_all_cluster_images() {
|
||||||
|
let scanner = FakeScanner::default()
|
||||||
|
.with(
|
||||||
|
"os",
|
||||||
|
Ok(vec![raw(
|
||||||
|
"CVE-1",
|
||||||
|
"openssl",
|
||||||
|
"3.0.1",
|
||||||
|
Severity::High,
|
||||||
|
Some("3.0.2"),
|
||||||
|
)]),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
GITEA,
|
||||||
|
Ok(vec![
|
||||||
|
raw("CVE-2", "git", "2.39", Severity::Critical, None),
|
||||||
|
raw("CVE-3", "curl", "7.88", Severity::Low, None),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let (f, svc) = fixture(scanner);
|
||||||
|
let log = VecLog::default();
|
||||||
|
let report = svc.scan(&log).await.unwrap();
|
||||||
|
assert_eq!(report.targets, vec!["os", PG, GITEA]);
|
||||||
|
assert_eq!(report.new_findings.len(), 3);
|
||||||
|
assert_eq!(report.total_open, 3);
|
||||||
|
assert_eq!(report.fixed, 0);
|
||||||
|
let all = f.findings.0.lock().unwrap();
|
||||||
|
let os = all.iter().find(|x| x.target == "os").unwrap();
|
||||||
|
assert_eq!(os.target_kind, TargetKind::Os);
|
||||||
|
assert_eq!(os.status, FindingStatus::Open);
|
||||||
|
assert_eq!(os.raw.fixed_version.as_deref(), Some("3.0.2"));
|
||||||
|
assert!(all
|
||||||
|
.iter()
|
||||||
|
.any(|x| x.target == GITEA && x.target_kind == TargetKind::Image));
|
||||||
|
let lines = log.0.lock().unwrap().join("\n");
|
||||||
|
assert!(lines.contains("scanning os"), "{lines}");
|
||||||
|
assert!(lines.contains(GITEA), "{lines}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn rescan_keeps_existing_marks_fixed_and_reports_only_new() {
|
||||||
|
let scanner = FakeScanner::default().with(
|
||||||
|
"os",
|
||||||
|
Ok(vec![
|
||||||
|
raw("CVE-1", "openssl", "3.0.1", Severity::High, None),
|
||||||
|
raw("CVE-9", "bash", "5.2", Severity::Low, None),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let (f, svc) = fixture(scanner);
|
||||||
|
let first = svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
assert_eq!(first.new_findings.len(), 2);
|
||||||
|
let first_seen = f.findings.0.lock().unwrap()[0].first_seen;
|
||||||
|
|
||||||
|
// CVE-9 disappears (fixed), CVE-1 stays, CVE-2 is new
|
||||||
|
*f.results.lock().unwrap() = std::collections::HashMap::from([(
|
||||||
|
"os".to_string(),
|
||||||
|
Ok(vec![
|
||||||
|
raw("CVE-1", "openssl", "3.0.1", Severity::High, None),
|
||||||
|
raw("CVE-2", "openssl", "3.0.1", Severity::Critical, None),
|
||||||
|
]),
|
||||||
|
)]);
|
||||||
|
let second = svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
second
|
||||||
|
.new_findings
|
||||||
|
.iter()
|
||||||
|
.map(|f| f.raw.cve_id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["CVE-2"]
|
||||||
|
);
|
||||||
|
assert_eq!(second.fixed, 1);
|
||||||
|
assert_eq!(second.total_open, 2);
|
||||||
|
let all = f.findings.0.lock().unwrap();
|
||||||
|
let cve1 = all.iter().find(|x| x.raw.cve_id == "CVE-1").unwrap();
|
||||||
|
assert_eq!(cve1.first_seen, first_seen, "first_seen is preserved");
|
||||||
|
assert!(cve1.last_seen > first_seen);
|
||||||
|
assert_eq!(
|
||||||
|
all.iter().find(|x| x.raw.cve_id == "CVE-9").unwrap().status,
|
||||||
|
FindingStatus::Fixed
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn failed_target_is_skipped_and_its_findings_are_kept() {
|
||||||
|
let scanner = FakeScanner::default()
|
||||||
|
.with("os", Ok(vec![raw("CVE-1", "a", "1", Severity::High, None)]))
|
||||||
|
.with(GITEA, Err("pull failed"));
|
||||||
|
let (f, svc) = fixture(scanner);
|
||||||
|
svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
*f.results.lock().unwrap() =
|
||||||
|
std::collections::HashMap::from([("os".to_string(), Err("trivy crashed".to_string()))]);
|
||||||
|
let log = VecLog::default();
|
||||||
|
let report = svc.scan(&log).await.unwrap();
|
||||||
|
assert_eq!(report.failed_targets, vec!["os"]);
|
||||||
|
assert_eq!(
|
||||||
|
f.findings
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.filter(|x| x.status == FindingStatus::Open)
|
||||||
|
.count(),
|
||||||
|
1,
|
||||||
|
"not marked fixed on failure"
|
||||||
|
);
|
||||||
|
assert!(log
|
||||||
|
.0
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|l| l.contains("trivy crashed")));
|
||||||
|
// scanner not installed at all -> hard error
|
||||||
|
*f.results.lock().unwrap() = std::collections::HashMap::new();
|
||||||
|
svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn new_findings_at_or_above_threshold_trigger_one_mail() {
|
||||||
|
let scanner = FakeScanner::default().with(
|
||||||
|
"os",
|
||||||
|
Ok(vec![
|
||||||
|
raw("CVE-1", "a", "1", Severity::Critical, Some("2")),
|
||||||
|
raw("CVE-2", "b", "1", Severity::Medium, None),
|
||||||
|
]),
|
||||||
|
);
|
||||||
|
let (f, svc) = fixture(scanner);
|
||||||
|
// no smtp configured -> no mail, no error
|
||||||
|
svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
assert!(f.mailer.0.lock().unwrap().is_empty());
|
||||||
|
|
||||||
|
f.settings.set_smtp(smtp()).await.unwrap();
|
||||||
|
*f.results.lock().unwrap() = std::collections::HashMap::from([(
|
||||||
|
"os".to_string(),
|
||||||
|
Ok(vec![
|
||||||
|
raw("CVE-3", "c", "1", Severity::High, None),
|
||||||
|
raw("CVE-4", "d", "1", Severity::Low, None),
|
||||||
|
]),
|
||||||
|
)]);
|
||||||
|
svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
let sent = f.mailer.0.lock().unwrap();
|
||||||
|
assert_eq!(sent.len(), 1);
|
||||||
|
assert!(sent[0].1.contains("1 new"), "{}", sent[0].1);
|
||||||
|
assert!(sent[0].2.contains("CVE-3"));
|
||||||
|
assert!(!sent[0].2.contains("CVE-4"), "below threshold");
|
||||||
|
drop(sent);
|
||||||
|
|
||||||
|
f.settings
|
||||||
|
.set_notify_min_severity(Severity::Low)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
*f.results.lock().unwrap() = std::collections::HashMap::from([(
|
||||||
|
"os".to_string(),
|
||||||
|
Ok(vec![raw("CVE-5", "e", "1", Severity::Low, None)]),
|
||||||
|
)]);
|
||||||
|
svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
assert_eq!(f.mailer.0.lock().unwrap().len(), 2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn list_summary_and_status_changes() {
|
||||||
|
let scanner = FakeScanner::default()
|
||||||
|
.with(
|
||||||
|
"os",
|
||||||
|
Ok(vec![
|
||||||
|
raw("CVE-1", "a", "1", Severity::Critical, None),
|
||||||
|
raw("CVE-2", "b", "1", Severity::Low, None),
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.with(
|
||||||
|
GITEA,
|
||||||
|
Ok(vec![raw("CVE-3", "c", "1", Severity::High, None)]),
|
||||||
|
);
|
||||||
|
let (_f, svc) = fixture(scanner);
|
||||||
|
svc.scan(&VecLog::default()).await.unwrap();
|
||||||
|
|
||||||
|
let high = svc
|
||||||
|
.list(FindingFilter {
|
||||||
|
min_severity: Some(Severity::High),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
high.iter()
|
||||||
|
.map(|f| f.raw.cve_id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["CVE-1", "CVE-3"]
|
||||||
|
);
|
||||||
|
let s = svc.summary().await.unwrap();
|
||||||
|
assert_eq!(s.total.total(), 3);
|
||||||
|
assert_eq!(s.os.critical, 1);
|
||||||
|
assert_eq!(s.images.high, 1);
|
||||||
|
assert!(s.last_scan.is_some());
|
||||||
|
|
||||||
|
let id = high[0].id;
|
||||||
|
let f = svc
|
||||||
|
.set_status(id, FindingStatus::Acknowledged)
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(f.status, FindingStatus::Acknowledged);
|
||||||
|
assert!(matches!(
|
||||||
|
svc.set_status(id, FindingStatus::Fixed).await.unwrap_err(),
|
||||||
|
DomainError::Validation(_)
|
||||||
|
));
|
||||||
|
assert_eq!(
|
||||||
|
svc.set_status(uuid::Uuid::new_v4(), FindingStatus::Open)
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
DomainError::NotFound
|
||||||
|
);
|
||||||
|
let ack = svc
|
||||||
|
.list(FindingFilter {
|
||||||
|
status: Some(FindingStatus::Acknowledged),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ack.len(), 1);
|
||||||
|
}
|
||||||
84
backend/crates/application/src/vuln_service.rs
Normal file
84
backend/crates/application/src/vuln_service.rs
Normal file
@ -0,0 +1,84 @@
|
|||||||
|
//! Vulnerability scanning: diffs scanner results against stored findings,
|
||||||
|
//! notifies about new ones by mail.
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ports::{ClusterGateway, FindingRepository, VulnerabilityScanner};
|
||||||
|
use domain::vuln::{
|
||||||
|
Finding, FindingFilter, FindingStatus, ScanReport, Severity, SeverityCounts, TargetKind,
|
||||||
|
};
|
||||||
|
use domain::DomainError;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::jobs::{JobHandler, JobLog};
|
||||||
|
use crate::SettingsService;
|
||||||
|
|
||||||
|
pub struct VulnerabilityService {
|
||||||
|
pub(crate) scanner: Arc<dyn VulnerabilityScanner>,
|
||||||
|
pub(crate) findings: Arc<dyn FindingRepository>,
|
||||||
|
pub(crate) cluster: Arc<dyn ClusterGateway>,
|
||||||
|
pub(crate) settings: Arc<SettingsService>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl VulnerabilityService {
|
||||||
|
pub fn new(
|
||||||
|
scanner: Arc<dyn VulnerabilityScanner>,
|
||||||
|
findings: Arc<dyn FindingRepository>,
|
||||||
|
cluster: Arc<dyn ClusterGateway>,
|
||||||
|
settings: Arc<SettingsService>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
scanner,
|
||||||
|
findings,
|
||||||
|
cluster,
|
||||||
|
settings,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scan the OS and all cluster images, persist the diff, notify about new findings.
|
||||||
|
pub async fn scan(&self, _log: &dyn JobLog) -> Result<ScanReport, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn list(&self, _filter: FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn summary(&self) -> Result<Summary, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Only open <-> acknowledged transitions are allowed by users.
|
||||||
|
pub async fn set_status(
|
||||||
|
&self,
|
||||||
|
_id: Uuid,
|
||||||
|
_status: FindingStatus,
|
||||||
|
) -> Result<Finding, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn scanner_version(&self) -> Result<String, DomainError> {
|
||||||
|
self.scanner.version().await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub struct Summary {
|
||||||
|
pub total: SeverityCounts,
|
||||||
|
pub os: SeverityCounts,
|
||||||
|
pub images: SeverityCounts,
|
||||||
|
pub last_scan: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct VulnerabilityScanJob(pub Arc<VulnerabilityService>);
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl JobHandler for VulnerabilityScanJob {
|
||||||
|
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Key of the notification threshold setting.
|
||||||
|
pub const KEY_NOTIFY_MIN_SEVERITY: &str = "vuln.notify_min_severity";
|
||||||
|
pub const DEFAULT_NOTIFY_MIN_SEVERITY: Severity = Severity::High;
|
||||||
@ -8,5 +8,6 @@ pub mod jobs;
|
|||||||
pub mod ports;
|
pub mod ports;
|
||||||
pub mod settings;
|
pub mod settings;
|
||||||
pub mod user;
|
pub mod user;
|
||||||
|
pub mod vuln;
|
||||||
|
|
||||||
pub use error::DomainError;
|
pub use error::DomainError;
|
||||||
|
|||||||
@ -8,6 +8,7 @@ use crate::host::{Inventory, OsInfo, Package};
|
|||||||
use crate::jobs::{JobKind, JobRun, JobStatus};
|
use crate::jobs::{JobKind, JobRun, JobStatus};
|
||||||
use crate::settings::SmtpSettings;
|
use crate::settings::SmtpSettings;
|
||||||
use crate::user::{User, UserUpdate};
|
use crate::user::{User, UserUpdate};
|
||||||
|
use crate::vuln::{Finding, FindingFilter, FindingStatus, RawFinding, SeverityCounts, TargetKind};
|
||||||
use crate::DomainError;
|
use crate::DomainError;
|
||||||
|
|
||||||
#[async_trait]
|
#[async_trait]
|
||||||
@ -117,3 +118,31 @@ pub trait ClusterGateway: Send + Sync {
|
|||||||
image: &str,
|
image: &str,
|
||||||
) -> Result<(), DomainError>;
|
) -> Result<(), DomainError>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait VulnerabilityScanner: Send + Sync {
|
||||||
|
/// Scanner version, or an error if it is not installed.
|
||||||
|
async fn version(&self) -> Result<String, DomainError>;
|
||||||
|
async fn scan_os(&self, out: &dyn LineSink) -> Result<Vec<RawFinding>, DomainError>;
|
||||||
|
async fn scan_image(
|
||||||
|
&self,
|
||||||
|
image: &str,
|
||||||
|
out: &dyn LineSink,
|
||||||
|
) -> Result<Vec<RawFinding>, DomainError>;
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
pub trait FindingRepository: Send + Sync {
|
||||||
|
/// Open and acknowledged findings of one target.
|
||||||
|
async fn active_by_target(&self, target: &str) -> Result<Vec<Finding>, DomainError>;
|
||||||
|
async fn insert(&self, finding: &Finding) -> Result<(), DomainError>;
|
||||||
|
async fn touch(
|
||||||
|
&self,
|
||||||
|
ids: &[Uuid],
|
||||||
|
last_seen: chrono::DateTime<chrono::Utc>,
|
||||||
|
) -> Result<(), DomainError>;
|
||||||
|
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError>;
|
||||||
|
async fn get(&self, id: Uuid) -> Result<Option<Finding>, DomainError>;
|
||||||
|
async fn list(&self, filter: &FindingFilter) -> Result<Vec<Finding>, DomainError>;
|
||||||
|
async fn counts(&self, kind: Option<TargetKind>) -> Result<SeverityCounts, DomainError>;
|
||||||
|
}
|
||||||
|
|||||||
171
backend/crates/domain/src/vuln.rs
Normal file
171
backend/crates/domain/src/vuln.rs
Normal file
@ -0,0 +1,171 @@
|
|||||||
|
//! Vulnerability findings from scans of the OS and container images.
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum Severity {
|
||||||
|
Unknown,
|
||||||
|
Low,
|
||||||
|
Medium,
|
||||||
|
High,
|
||||||
|
Critical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Severity {
|
||||||
|
pub const ALL: [Severity; 5] = [
|
||||||
|
Severity::Critical,
|
||||||
|
Severity::High,
|
||||||
|
Severity::Medium,
|
||||||
|
Severity::Low,
|
||||||
|
Severity::Unknown,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Severity::Critical => "critical",
|
||||||
|
Severity::High => "high",
|
||||||
|
Severity::Medium => "medium",
|
||||||
|
Severity::Low => "low",
|
||||||
|
Severity::Unknown => "unknown",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse(s: &str) -> Severity {
|
||||||
|
match s.to_ascii_lowercase().as_str() {
|
||||||
|
"critical" => Severity::Critical,
|
||||||
|
"high" => Severity::High,
|
||||||
|
"medium" => Severity::Medium,
|
||||||
|
"low" => Severity::Low,
|
||||||
|
_ => Severity::Unknown,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum TargetKind {
|
||||||
|
Os,
|
||||||
|
Image,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TargetKind {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TargetKind::Os => "os",
|
||||||
|
TargetKind::Image => "image",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn parse(s: &str) -> Option<TargetKind> {
|
||||||
|
match s {
|
||||||
|
"os" => Some(TargetKind::Os),
|
||||||
|
"image" => Some(TargetKind::Image),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
#[serde(rename_all = "lowercase")]
|
||||||
|
pub enum FindingStatus {
|
||||||
|
Open,
|
||||||
|
Acknowledged,
|
||||||
|
Fixed,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FindingStatus {
|
||||||
|
pub fn as_str(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
FindingStatus::Open => "open",
|
||||||
|
FindingStatus::Acknowledged => "acknowledged",
|
||||||
|
FindingStatus::Fixed => "fixed",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn parse(s: &str) -> Option<FindingStatus> {
|
||||||
|
match s {
|
||||||
|
"open" => Some(FindingStatus::Open),
|
||||||
|
"acknowledged" => Some(FindingStatus::Acknowledged),
|
||||||
|
"fixed" => Some(FindingStatus::Fixed),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One vulnerability as reported by the scanner, without lifecycle data.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct RawFinding {
|
||||||
|
pub cve_id: String,
|
||||||
|
pub severity: Severity,
|
||||||
|
pub package: String,
|
||||||
|
pub installed_version: String,
|
||||||
|
pub fixed_version: Option<String>,
|
||||||
|
pub title: String,
|
||||||
|
pub url: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl RawFinding {
|
||||||
|
/// Identity of a finding within a target.
|
||||||
|
pub fn key(&self) -> String {
|
||||||
|
format!(
|
||||||
|
"{}|{}|{}",
|
||||||
|
self.cve_id, self.package, self.installed_version
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
|
pub struct Finding {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub target_kind: TargetKind,
|
||||||
|
/// "os" for the host, otherwise the image reference.
|
||||||
|
pub target: String,
|
||||||
|
#[serde(flatten)]
|
||||||
|
pub raw: RawFinding,
|
||||||
|
pub status: FindingStatus,
|
||||||
|
pub first_seen: DateTime<Utc>,
|
||||||
|
pub last_seen: DateTime<Utc>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct FindingFilter {
|
||||||
|
pub min_severity: Option<Severity>,
|
||||||
|
pub target: Option<String>,
|
||||||
|
pub status: Option<FindingStatus>,
|
||||||
|
/// Include fixed findings (default: only open + acknowledged).
|
||||||
|
pub include_fixed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize)]
|
||||||
|
pub struct SeverityCounts {
|
||||||
|
pub critical: usize,
|
||||||
|
pub high: usize,
|
||||||
|
pub medium: usize,
|
||||||
|
pub low: usize,
|
||||||
|
pub unknown: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SeverityCounts {
|
||||||
|
pub fn add(&mut self, s: Severity) {
|
||||||
|
match s {
|
||||||
|
Severity::Critical => self.critical += 1,
|
||||||
|
Severity::High => self.high += 1,
|
||||||
|
Severity::Medium => self.medium += 1,
|
||||||
|
Severity::Low => self.low += 1,
|
||||||
|
Severity::Unknown => self.unknown += 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
pub fn total(&self) -> usize {
|
||||||
|
self.critical + self.high + self.medium + self.low + self.unknown
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Outcome of one scan run.
|
||||||
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
|
pub struct ScanReport {
|
||||||
|
pub targets: Vec<String>,
|
||||||
|
pub failed_targets: Vec<String>,
|
||||||
|
pub total_open: usize,
|
||||||
|
pub new_findings: Vec<Finding>,
|
||||||
|
pub fixed: usize,
|
||||||
|
}
|
||||||
17
backend/crates/infrastructure/migrations/0004_findings.sql
Normal file
17
backend/crates/infrastructure/migrations/0004_findings.sql
Normal file
@ -0,0 +1,17 @@
|
|||||||
|
CREATE TABLE findings (
|
||||||
|
id TEXT PRIMARY KEY,
|
||||||
|
target_kind TEXT NOT NULL CHECK (target_kind IN ('os', 'image')),
|
||||||
|
target TEXT NOT NULL,
|
||||||
|
cve_id TEXT NOT NULL,
|
||||||
|
severity TEXT NOT NULL,
|
||||||
|
package TEXT NOT NULL,
|
||||||
|
installed_version TEXT NOT NULL,
|
||||||
|
fixed_version TEXT,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
url TEXT NOT NULL,
|
||||||
|
status TEXT NOT NULL CHECK (status IN ('open', 'acknowledged', 'fixed')),
|
||||||
|
first_seen TEXT NOT NULL,
|
||||||
|
last_seen TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE INDEX findings_target_status ON findings(target, status);
|
||||||
|
CREATE INDEX findings_status_severity ON findings(status, severity);
|
||||||
@ -7,6 +7,7 @@ pub mod mail;
|
|||||||
pub mod password;
|
pub mod password;
|
||||||
pub mod sqlite;
|
pub mod sqlite;
|
||||||
pub mod token;
|
pub mod token;
|
||||||
|
pub mod trivy;
|
||||||
|
|
||||||
pub use cipher::AesGcmCipher;
|
pub use cipher::AesGcmCipher;
|
||||||
pub use db::{connect, DbPool};
|
pub use db::{connect, DbPool};
|
||||||
@ -16,6 +17,7 @@ pub use host::{
|
|||||||
pub use k8s::{FakeClusterGateway, KubeGateway};
|
pub use k8s::{FakeClusterGateway, KubeGateway};
|
||||||
pub use mail::LettreMailer;
|
pub use mail::LettreMailer;
|
||||||
pub use password::Argon2Hasher;
|
pub use password::Argon2Hasher;
|
||||||
pub use sqlite::SqliteInventory;
|
|
||||||
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
|
pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers};
|
||||||
|
pub use sqlite::{SqliteFindings, SqliteInventory};
|
||||||
pub use token::JwtIssuer;
|
pub use token::JwtIssuer;
|
||||||
|
pub use trivy::{FakeScanner, TrivyScanner};
|
||||||
|
|||||||
@ -485,3 +485,262 @@ impl domain::ports::InventoryRepository for SqliteInventory {
|
|||||||
.transpose()
|
.transpose()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
use domain::vuln::{
|
||||||
|
Finding, FindingFilter, FindingStatus, RawFinding, Severity, SeverityCounts, TargetKind,
|
||||||
|
};
|
||||||
|
|
||||||
|
pub struct SqliteFindings(pub DbPool);
|
||||||
|
|
||||||
|
fn finding_from_row(r: &SqliteRow) -> Finding {
|
||||||
|
Finding {
|
||||||
|
id: r.get("id"),
|
||||||
|
target_kind: TargetKind::parse(r.get::<String, _>("target_kind").as_str())
|
||||||
|
.unwrap_or(TargetKind::Os),
|
||||||
|
target: r.get("target"),
|
||||||
|
raw: RawFinding {
|
||||||
|
cve_id: r.get("cve_id"),
|
||||||
|
severity: Severity::parse(r.get::<String, _>("severity").as_str()),
|
||||||
|
package: r.get("package"),
|
||||||
|
installed_version: r.get("installed_version"),
|
||||||
|
fixed_version: r.get("fixed_version"),
|
||||||
|
title: r.get("title"),
|
||||||
|
url: r.get("url"),
|
||||||
|
},
|
||||||
|
status: FindingStatus::parse(r.get::<String, _>("status").as_str())
|
||||||
|
.unwrap_or(FindingStatus::Open),
|
||||||
|
first_seen: parse_ts(r.get::<String, _>("first_seen").as_str()),
|
||||||
|
last_seen: parse_ts(r.get::<String, _>("last_seen").as_str()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FINDING_COLS: &str = "id, target_kind, target, cve_id, severity, package, installed_version, fixed_version, title, url, status, first_seen, last_seen";
|
||||||
|
|
||||||
|
/// Severity ordering for SQL: higher is worse.
|
||||||
|
fn severity_rank(s: Severity) -> i32 {
|
||||||
|
match s {
|
||||||
|
Severity::Critical => 4,
|
||||||
|
Severity::High => 3,
|
||||||
|
Severity::Medium => 2,
|
||||||
|
Severity::Low => 1,
|
||||||
|
Severity::Unknown => 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const SEVERITY_RANK_SQL: &str = "CASE severity WHEN 'critical' THEN 4 WHEN 'high' THEN 3 WHEN 'medium' THEN 2 WHEN 'low' THEN 1 ELSE 0 END";
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl domain::ports::FindingRepository for SqliteFindings {
|
||||||
|
async fn active_by_target(&self, target: &str) -> Result<Vec<Finding>, DomainError> {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"SELECT {FINDING_COLS} FROM findings WHERE target = ? AND status != 'fixed'"
|
||||||
|
))
|
||||||
|
.bind(target)
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().map(finding_from_row).collect())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn insert(&self, f: &Finding) -> Result<(), DomainError> {
|
||||||
|
sqlx::query(&format!(
|
||||||
|
"INSERT INTO findings ({FINDING_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
|
))
|
||||||
|
.bind(f.id)
|
||||||
|
.bind(f.target_kind.as_str())
|
||||||
|
.bind(&f.target)
|
||||||
|
.bind(&f.raw.cve_id)
|
||||||
|
.bind(f.raw.severity.as_str())
|
||||||
|
.bind(&f.raw.package)
|
||||||
|
.bind(&f.raw.installed_version)
|
||||||
|
.bind(&f.raw.fixed_version)
|
||||||
|
.bind(&f.raw.title)
|
||||||
|
.bind(&f.raw.url)
|
||||||
|
.bind(f.status.as_str())
|
||||||
|
.bind(f.first_seen.to_rfc3339())
|
||||||
|
.bind(f.last_seen.to_rfc3339())
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn touch(&self, ids: &[Uuid], last_seen: DateTime<Utc>) -> Result<(), DomainError> {
|
||||||
|
for id in ids {
|
||||||
|
sqlx::query("UPDATE findings SET last_seen = ? WHERE id = ?")
|
||||||
|
.bind(last_seen.to_rfc3339())
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError> {
|
||||||
|
let res = sqlx::query("UPDATE findings SET status = ? WHERE id = ?")
|
||||||
|
.bind(status.as_str())
|
||||||
|
.bind(id)
|
||||||
|
.execute(&self.0)
|
||||||
|
.await
|
||||||
|
.map_err(storage)?;
|
||||||
|
(res.rows_affected() > 0)
|
||||||
|
.then_some(())
|
||||||
|
.ok_or(DomainError::NotFound)
|
||||||
|
}
|
||||||
|
async fn get(&self, id: Uuid) -> Result<Option<Finding>, DomainError> {
|
||||||
|
sqlx::query(&format!("SELECT {FINDING_COLS} FROM findings WHERE id = ?"))
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|r| r.as_ref().map(finding_from_row))
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn list(&self, filter: &FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||||
|
let mut sql = format!("SELECT {FINDING_COLS} FROM findings WHERE 1=1");
|
||||||
|
if !filter.include_fixed {
|
||||||
|
sql.push_str(" AND status != 'fixed'");
|
||||||
|
}
|
||||||
|
if filter.status.is_some() {
|
||||||
|
sql.push_str(" AND status = ?");
|
||||||
|
}
|
||||||
|
if filter.target.is_some() {
|
||||||
|
sql.push_str(" AND target = ?");
|
||||||
|
}
|
||||||
|
if filter.min_severity.is_some() {
|
||||||
|
sql.push_str(&format!(" AND {SEVERITY_RANK_SQL} >= ?"));
|
||||||
|
}
|
||||||
|
sql.push_str(&format!(
|
||||||
|
" ORDER BY {SEVERITY_RANK_SQL} DESC, target, cve_id"
|
||||||
|
));
|
||||||
|
let mut q = sqlx::query(&sql);
|
||||||
|
if let Some(s) = filter.status {
|
||||||
|
q = q.bind(s.as_str());
|
||||||
|
}
|
||||||
|
if let Some(t) = &filter.target {
|
||||||
|
q = q.bind(t);
|
||||||
|
}
|
||||||
|
if let Some(m) = filter.min_severity {
|
||||||
|
q = q.bind(severity_rank(m));
|
||||||
|
}
|
||||||
|
q.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
.map(|rows| rows.iter().map(finding_from_row).collect())
|
||||||
|
.map_err(storage)
|
||||||
|
}
|
||||||
|
async fn counts(&self, kind: Option<TargetKind>) -> Result<SeverityCounts, DomainError> {
|
||||||
|
let sql = match kind {
|
||||||
|
Some(_) => "SELECT severity, COUNT(*) FROM findings WHERE status != 'fixed' AND target_kind = ? GROUP BY severity",
|
||||||
|
None => "SELECT severity, COUNT(*) FROM findings WHERE status != 'fixed' AND ? = ? GROUP BY severity",
|
||||||
|
};
|
||||||
|
let rows: Vec<(String, i64)> = match kind {
|
||||||
|
Some(k) => {
|
||||||
|
sqlx::query_as(sql)
|
||||||
|
.bind(k.as_str())
|
||||||
|
.fetch_all(&self.0)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
None => sqlx::query_as(sql).bind(1).bind(1).fetch_all(&self.0).await,
|
||||||
|
}
|
||||||
|
.map_err(storage)?;
|
||||||
|
let mut c = SeverityCounts::default();
|
||||||
|
for (sev, n) in rows {
|
||||||
|
for _ in 0..n {
|
||||||
|
c.add(Severity::parse(&sev));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod finding_tests {
|
||||||
|
use super::*;
|
||||||
|
use domain::ports::FindingRepository;
|
||||||
|
|
||||||
|
fn finding(target: &str, kind: TargetKind, cve: &str, sev: Severity) -> Finding {
|
||||||
|
Finding {
|
||||||
|
id: Uuid::new_v4(),
|
||||||
|
target_kind: kind,
|
||||||
|
target: target.into(),
|
||||||
|
raw: RawFinding {
|
||||||
|
cve_id: cve.into(),
|
||||||
|
severity: sev,
|
||||||
|
package: "p".into(),
|
||||||
|
installed_version: "1".into(),
|
||||||
|
fixed_version: None,
|
||||||
|
title: "t".into(),
|
||||||
|
url: "u".into(),
|
||||||
|
},
|
||||||
|
status: FindingStatus::Open,
|
||||||
|
first_seen: Utc::now(),
|
||||||
|
last_seen: Utc::now(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn insert_list_filter_counts_and_status() {
|
||||||
|
let pool = crate::connect("sqlite::memory:").await.unwrap();
|
||||||
|
let repo = SqliteFindings(pool);
|
||||||
|
let a = finding("os", TargetKind::Os, "CVE-A", Severity::Critical);
|
||||||
|
let b = finding("os", TargetKind::Os, "CVE-B", Severity::Low);
|
||||||
|
let c = finding("img:1", TargetKind::Image, "CVE-C", Severity::High);
|
||||||
|
for f in [&a, &b, &c] {
|
||||||
|
repo.insert(f).await.unwrap();
|
||||||
|
}
|
||||||
|
assert_eq!(repo.active_by_target("os").await.unwrap().len(), 2);
|
||||||
|
let high = repo
|
||||||
|
.list(&FindingFilter {
|
||||||
|
min_severity: Some(Severity::High),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(
|
||||||
|
high.iter()
|
||||||
|
.map(|f| f.raw.cve_id.as_str())
|
||||||
|
.collect::<Vec<_>>(),
|
||||||
|
vec!["CVE-A", "CVE-C"]
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
repo.list(&FindingFilter {
|
||||||
|
target: Some("img:1".into()),
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
1
|
||||||
|
);
|
||||||
|
|
||||||
|
repo.set_status(b.id, FindingStatus::Fixed).await.unwrap();
|
||||||
|
assert_eq!(repo.active_by_target("os").await.unwrap().len(), 1);
|
||||||
|
assert_eq!(repo.list(&FindingFilter::default()).await.unwrap().len(), 2);
|
||||||
|
assert_eq!(
|
||||||
|
repo.list(&FindingFilter {
|
||||||
|
include_fixed: true,
|
||||||
|
..Default::default()
|
||||||
|
})
|
||||||
|
.await
|
||||||
|
.unwrap()
|
||||||
|
.len(),
|
||||||
|
3
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
repo.counts(None).await.unwrap(),
|
||||||
|
SeverityCounts {
|
||||||
|
critical: 1,
|
||||||
|
high: 1,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
);
|
||||||
|
assert_eq!(repo.counts(Some(TargetKind::Os)).await.unwrap().critical, 1);
|
||||||
|
|
||||||
|
let later = Utc::now() + chrono::Duration::hours(1);
|
||||||
|
repo.touch(&[a.id], later).await.unwrap();
|
||||||
|
assert!(repo.get(a.id).await.unwrap().unwrap().last_seen > a.last_seen);
|
||||||
|
assert_eq!(
|
||||||
|
repo.set_status(Uuid::new_v4(), FindingStatus::Open)
|
||||||
|
.await
|
||||||
|
.unwrap_err(),
|
||||||
|
DomainError::NotFound
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
224
backend/crates/infrastructure/src/trivy.rs
Normal file
224
backend/crates/infrastructure/src/trivy.rs
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
//! Trivy CLI scanner (JSON output) and a fake for development.
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
use async_trait::async_trait;
|
||||||
|
use domain::ports::{LineSink, VulnerabilityScanner};
|
||||||
|
use domain::vuln::{RawFinding, Severity};
|
||||||
|
use domain::DomainError;
|
||||||
|
|
||||||
|
use crate::host::CommandRunner;
|
||||||
|
|
||||||
|
pub struct TrivyScanner {
|
||||||
|
runner: Arc<dyn CommandRunner>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl TrivyScanner {
|
||||||
|
pub fn new(runner: Arc<dyn CommandRunner>) -> Self {
|
||||||
|
Self { runner }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse `trivy ... --format json` output into findings (all results merged, deduplicated).
|
||||||
|
pub fn parse_trivy_json(_json: &str) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VulnerabilityScanner for TrivyScanner {
|
||||||
|
async fn version(&self) -> Result<String, DomainError> {
|
||||||
|
let _ = &self.runner;
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
async fn scan_os(&self, _out: &dyn LineSink) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
async fn scan_image(
|
||||||
|
&self,
|
||||||
|
_image: &str,
|
||||||
|
_out: &dyn LineSink,
|
||||||
|
) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
todo!()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sample findings for development (FAKE_HOST=true).
|
||||||
|
pub struct FakeScanner;
|
||||||
|
|
||||||
|
#[async_trait]
|
||||||
|
impl VulnerabilityScanner for FakeScanner {
|
||||||
|
async fn version(&self) -> Result<String, DomainError> {
|
||||||
|
Ok("fake-trivy 0.0".into())
|
||||||
|
}
|
||||||
|
async fn scan_os(&self, out: &dyn LineSink) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
out.line("fake: scanning rootfs");
|
||||||
|
Ok(vec![
|
||||||
|
RawFinding {
|
||||||
|
cve_id: "CVE-2024-5535".into(),
|
||||||
|
severity: Severity::High,
|
||||||
|
package: "openssl".into(),
|
||||||
|
installed_version: "3.0.15-1~deb12u1".into(),
|
||||||
|
fixed_version: Some("3.0.16-1~deb12u1".into()),
|
||||||
|
title: "openssl: SSL_select_next_proto buffer overread".into(),
|
||||||
|
url: "https://avd.aquasec.com/nvd/cve-2024-5535".into(),
|
||||||
|
},
|
||||||
|
RawFinding {
|
||||||
|
cve_id: "CVE-2023-45853".into(),
|
||||||
|
severity: Severity::Critical,
|
||||||
|
package: "zlib1g".into(),
|
||||||
|
installed_version: "1:1.2.13.dfsg-1".into(),
|
||||||
|
fixed_version: None,
|
||||||
|
title: "zlib: integer overflow in zipOpenNewFileInZip4_64".into(),
|
||||||
|
url: "https://avd.aquasec.com/nvd/cve-2023-45853".into(),
|
||||||
|
},
|
||||||
|
RawFinding {
|
||||||
|
cve_id: "CVE-2011-3374".into(),
|
||||||
|
severity: Severity::Low,
|
||||||
|
package: "apt".into(),
|
||||||
|
installed_version: "2.6.1".into(),
|
||||||
|
fixed_version: None,
|
||||||
|
title: "apt: unsigned repository".into(),
|
||||||
|
url: "https://avd.aquasec.com/nvd/cve-2011-3374".into(),
|
||||||
|
},
|
||||||
|
])
|
||||||
|
}
|
||||||
|
async fn scan_image(
|
||||||
|
&self,
|
||||||
|
image: &str,
|
||||||
|
out: &dyn LineSink,
|
||||||
|
) -> Result<Vec<RawFinding>, DomainError> {
|
||||||
|
out.line(&format!("fake: scanning image {image}"));
|
||||||
|
Ok(if image.contains("gitea") {
|
||||||
|
vec![RawFinding {
|
||||||
|
cve_id: "CVE-2024-24790".into(),
|
||||||
|
severity: Severity::Critical,
|
||||||
|
package: "stdlib".into(),
|
||||||
|
installed_version: "1.21.5".into(),
|
||||||
|
fixed_version: Some("1.21.11".into()),
|
||||||
|
title: "golang: net/netip unexpected behavior".into(),
|
||||||
|
url: "https://avd.aquasec.com/nvd/cve-2024-24790".into(),
|
||||||
|
}]
|
||||||
|
} else if image.contains("postgres") {
|
||||||
|
vec![RawFinding {
|
||||||
|
cve_id: "CVE-2024-4741".into(),
|
||||||
|
severity: Severity::Medium,
|
||||||
|
package: "libssl3".into(),
|
||||||
|
installed_version: "3.0.13".into(),
|
||||||
|
fixed_version: Some("3.0.14".into()),
|
||||||
|
title: "openssl: use after free".into(),
|
||||||
|
url: "https://avd.aquasec.com/nvd/cve-2024-4741".into(),
|
||||||
|
}]
|
||||||
|
} else {
|
||||||
|
vec![]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
const SAMPLE: &str = r#"{
|
||||||
|
"SchemaVersion": 2,
|
||||||
|
"Results": [
|
||||||
|
{"Target": "debian 12", "Class": "os-pkgs", "Type": "debian",
|
||||||
|
"Vulnerabilities": [
|
||||||
|
{"VulnerabilityID": "CVE-2024-5535", "PkgName": "openssl", "InstalledVersion": "3.0.15-1~deb12u1", "FixedVersion": "3.0.16-1~deb12u1",
|
||||||
|
"Severity": "HIGH", "Title": "openssl: buffer overread", "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5535"},
|
||||||
|
{"VulnerabilityID": "CVE-2024-5535", "PkgName": "libssl3", "InstalledVersion": "3.0.15-1~deb12u1", "FixedVersion": "3.0.16-1~deb12u1",
|
||||||
|
"Severity": "HIGH", "Title": "openssl: buffer overread", "PrimaryURL": "https://avd.aquasec.com/nvd/cve-2024-5535"},
|
||||||
|
{"VulnerabilityID": "CVE-2011-3374", "PkgName": "apt", "InstalledVersion": "2.6.1", "Severity": "LOW", "PrimaryURL": "https://x"},
|
||||||
|
{"VulnerabilityID": "CVE-2011-3374", "PkgName": "apt", "InstalledVersion": "2.6.1", "Severity": "LOW", "PrimaryURL": "https://x"}
|
||||||
|
]},
|
||||||
|
{"Target": "Node.js", "Class": "lang-pkgs", "Type": "node-pkg"},
|
||||||
|
{"Target": "usr/bin/x", "Class": "lang-pkgs", "Type": "gobinary",
|
||||||
|
"Vulnerabilities": [{"VulnerabilityID": "GHSA-1", "PkgName": "stdlib", "InstalledVersion": "1.21.5", "Severity": "WEIRD"}]}
|
||||||
|
]}"#;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_results_merges_targets_and_dedups() {
|
||||||
|
let f = parse_trivy_json(SAMPLE).unwrap();
|
||||||
|
assert_eq!(f.len(), 4, "{f:?}");
|
||||||
|
let ssl = f
|
||||||
|
.iter()
|
||||||
|
.find(|x| x.cve_id == "CVE-2024-5535" && x.package == "openssl")
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(ssl.severity, Severity::High);
|
||||||
|
assert_eq!(ssl.fixed_version.as_deref(), Some("3.0.16-1~deb12u1"));
|
||||||
|
assert_eq!(ssl.title, "openssl: buffer overread");
|
||||||
|
let apt = f.iter().filter(|x| x.cve_id == "CVE-2011-3374").count();
|
||||||
|
assert_eq!(apt, 1, "duplicates removed");
|
||||||
|
let ghsa = f.iter().find(|x| x.cve_id == "GHSA-1").unwrap();
|
||||||
|
assert_eq!(ghsa.severity, Severity::Unknown);
|
||||||
|
assert_eq!(ghsa.fixed_version, None);
|
||||||
|
assert!(ghsa.title.contains("GHSA-1"), "title falls back to id");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_and_invalid_json() {
|
||||||
|
assert_eq!(parse_trivy_json(r#"{"Results": null}"#).unwrap(), vec![]);
|
||||||
|
assert_eq!(parse_trivy_json(r#"{}"#).unwrap(), vec![]);
|
||||||
|
assert!(matches!(
|
||||||
|
parse_trivy_json("nope").unwrap_err(),
|
||||||
|
DomainError::Unavailable(_)
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Canned;
|
||||||
|
#[async_trait]
|
||||||
|
impl CommandRunner for Canned {
|
||||||
|
async fn run(
|
||||||
|
&self,
|
||||||
|
program: &str,
|
||||||
|
args: &[&str],
|
||||||
|
) -> Result<crate::host::Output, DomainError> {
|
||||||
|
assert_eq!(program, "trivy");
|
||||||
|
Ok(match args[0] {
|
||||||
|
"--version" => crate::host::Output {
|
||||||
|
stdout: "Version: 0.58.1\n".into(),
|
||||||
|
success: true,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
"rootfs" | "image" => {
|
||||||
|
assert!(args.contains(&"--format") && args.contains(&"json"));
|
||||||
|
crate::host::Output {
|
||||||
|
stdout: SAMPLE.into(),
|
||||||
|
success: true,
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => crate::host::Output {
|
||||||
|
success: false,
|
||||||
|
stderr: "bad".into(),
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
async fn read_file(&self, _: &str) -> Result<Option<String>, DomainError> {
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
async fn run_streaming(
|
||||||
|
&self,
|
||||||
|
_: &str,
|
||||||
|
_: &[&str],
|
||||||
|
_: &dyn LineSink,
|
||||||
|
) -> Result<bool, DomainError> {
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Sink;
|
||||||
|
impl LineSink for Sink {
|
||||||
|
fn line(&self, _: &str) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn scanner_invokes_trivy_and_parses() {
|
||||||
|
let s = TrivyScanner::new(Arc::new(Canned));
|
||||||
|
assert_eq!(s.version().await.unwrap(), "0.58.1");
|
||||||
|
assert_eq!(s.scan_os(&Sink).await.unwrap().len(), 4);
|
||||||
|
assert_eq!(
|
||||||
|
s.scan_image("gitea/gitea:1.22", &Sink).await.unwrap().len(),
|
||||||
|
4
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/e2e/vulnerabilities.spec.ts
Normal file
36
frontend/e2e/vulnerabilities.spec.ts
Normal file
@ -0,0 +1,36 @@
|
|||||||
|
import { test, expect } from '@playwright/test'
|
||||||
|
|
||||||
|
test('admin runs a scan, filters findings and acknowledges one', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.getByLabel('Email').fill('admin@example.com')
|
||||||
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
|
await page.getByRole('link', { name: 'Vulnerabilities' }).click()
|
||||||
|
|
||||||
|
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||||
|
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||||
|
await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible()
|
||||||
|
|
||||||
|
await page.getByLabel('Minimum severity').selectOption('critical')
|
||||||
|
await expect(page.getByRole('row', { name: /CVE-2011-3374/ })).toHaveCount(0)
|
||||||
|
await page.getByLabel('Minimum severity').selectOption('low')
|
||||||
|
await expect(page.getByRole('row', { name: /CVE-2011-3374/ })).toBeVisible()
|
||||||
|
|
||||||
|
const row = page.getByRole('row', { name: /CVE-2023-45853/ })
|
||||||
|
await row.getByRole('button', { name: 'Acknowledge' }).click()
|
||||||
|
await expect(row).toContainText('acknowledged')
|
||||||
|
|
||||||
|
await row.getByRole('button', { name: 'Details' }).click()
|
||||||
|
await expect(page.getByRole('dialog')).toContainText('zlib')
|
||||||
|
})
|
||||||
|
|
||||||
|
test('notification threshold can be changed in settings', async ({ page }) => {
|
||||||
|
await page.goto('/login')
|
||||||
|
await page.getByLabel('Email').fill('admin@example.com')
|
||||||
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
|
await page.getByRole('link', { name: 'Settings' }).click()
|
||||||
|
await page.getByLabel('Notify from severity').selectOption('medium')
|
||||||
|
await page.getByRole('button', { name: 'Save notifications' }).click()
|
||||||
|
await expect(page.getByRole('status')).toContainText('Notification settings saved')
|
||||||
|
})
|
||||||
@ -128,3 +128,38 @@ export interface ClusterOverview {
|
|||||||
images: string[]
|
images: string[]
|
||||||
fetched_at: string
|
fetched_at: string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type Severity = 'critical' | 'high' | 'medium' | 'low' | 'unknown'
|
||||||
|
export type FindingStatus = 'open' | 'acknowledged' | 'fixed'
|
||||||
|
|
||||||
|
export interface Finding {
|
||||||
|
id: string
|
||||||
|
target_kind: 'os' | 'image'
|
||||||
|
target: string
|
||||||
|
cve_id: string
|
||||||
|
severity: Severity
|
||||||
|
package: string
|
||||||
|
installed_version: string
|
||||||
|
fixed_version: string | null
|
||||||
|
title: string
|
||||||
|
url: string
|
||||||
|
status: FindingStatus
|
||||||
|
first_seen: string
|
||||||
|
last_seen: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SeverityCounts {
|
||||||
|
critical: number
|
||||||
|
high: number
|
||||||
|
medium: number
|
||||||
|
low: number
|
||||||
|
unknown: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface VulnSummary {
|
||||||
|
total: SeverityCounts
|
||||||
|
os: SeverityCounts
|
||||||
|
images: SeverityCounts
|
||||||
|
last_scan: string | null
|
||||||
|
scanner: string
|
||||||
|
}
|
||||||
|
|||||||
58
frontend/src/components/FindingTable.test.ts
Normal file
58
frontend/src/components/FindingTable.test.ts
Normal file
@ -0,0 +1,58 @@
|
|||||||
|
import { mount } from '@vue/test-utils'
|
||||||
|
import FindingTable from './FindingTable.vue'
|
||||||
|
import type { Finding } from '../api/types'
|
||||||
|
|
||||||
|
const f = (over: Partial<Finding>): Finding => ({
|
||||||
|
id: over.cve_id ?? 'x',
|
||||||
|
target_kind: 'os',
|
||||||
|
target: 'os',
|
||||||
|
cve_id: 'CVE-0',
|
||||||
|
severity: 'low',
|
||||||
|
package: 'pkg',
|
||||||
|
installed_version: '1',
|
||||||
|
fixed_version: null,
|
||||||
|
title: 'title',
|
||||||
|
url: 'https://x',
|
||||||
|
status: 'open',
|
||||||
|
first_seen: '2026-09-01T00:00:00Z',
|
||||||
|
last_seen: '2026-09-02T00:00:00Z',
|
||||||
|
...over,
|
||||||
|
})
|
||||||
|
|
||||||
|
const findings = [
|
||||||
|
f({ cve_id: 'CVE-1', severity: 'critical', package: 'zlib1g', fixed_version: '1.3' }),
|
||||||
|
f({
|
||||||
|
cve_id: 'CVE-2',
|
||||||
|
severity: 'high',
|
||||||
|
target_kind: 'image',
|
||||||
|
target: 'gitea/gitea:1.22',
|
||||||
|
status: 'acknowledged',
|
||||||
|
}),
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('FindingTable', () => {
|
||||||
|
it('renders severity, target, fix version and status', () => {
|
||||||
|
const w = mount(FindingTable, { props: { findings, canAct: true } })
|
||||||
|
const rows = w.findAll('tbody tr')
|
||||||
|
expect(rows).toHaveLength(2)
|
||||||
|
expect(rows[0].text()).toContain('critical')
|
||||||
|
expect(rows[0].text()).toContain('1.3')
|
||||||
|
expect(rows[1].text()).toContain('gitea/gitea:1.22')
|
||||||
|
expect(rows[1].text()).toContain('acknowledged')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits acknowledge/reopen and opens details', async () => {
|
||||||
|
const w = mount(FindingTable, { props: { findings, canAct: true } })
|
||||||
|
await w.findAll('button[name=ack]')[0].trigger('click')
|
||||||
|
expect(w.emitted('status')![0]).toEqual([findings[0], 'acknowledged'])
|
||||||
|
await w.findAll('button[name=ack]')[1].trigger('click')
|
||||||
|
expect(w.emitted('status')![1]).toEqual([findings[1], 'open'])
|
||||||
|
await w.findAll('button[name=details]')[0].trigger('click')
|
||||||
|
expect(w.emitted('select')![0][0]).toEqual(findings[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('hides actions for viewers', () => {
|
||||||
|
const w = mount(FindingTable, { props: { findings, canAct: false } })
|
||||||
|
expect(w.findAll('button[name=ack]')).toHaveLength(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
Reference in New Issue
Block a user