diff --git a/backend/crates/api/tests/vulnerabilities.rs b/backend/crates/api/tests/vulnerabilities.rs new file mode 100644 index 0000000..e8606db --- /dev/null +++ b/backend/crates/api/tests/vulnerabilities.rs @@ -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 + ); +} diff --git a/backend/crates/application/src/lib.rs b/backend/crates/application/src/lib.rs index 396e102..d203e24 100644 --- a/backend/crates/application/src/lib.rs +++ b/backend/crates/application/src/lib.rs @@ -7,6 +7,7 @@ pub mod scheduler; pub mod settings_service; pub mod upgrade_service; pub mod user_service; +pub mod vuln_service; pub use auth_service::AuthService; pub use cluster_service::ClusterService; @@ -15,6 +16,7 @@ pub use jobs::{JobHandler, JobLog, JobRunner}; pub use settings_service::SettingsService; pub use upgrade_service::{PackageUpgradeJob, UpgradeParams}; pub use user_service::UserService; +pub use vuln_service::{VulnerabilityScanJob, VulnerabilityService}; #[cfg(test)] pub(crate) mod test_fakes; diff --git a/backend/crates/application/src/settings_service.rs b/backend/crates/application/src/settings_service.rs index a112347..fbe98e0 100644 --- a/backend/crates/application/src/settings_service.rs +++ b/backend/crates/application/src/settings_service.rs @@ -6,6 +6,8 @@ use domain::settings::{SmtpSettings, KEY_SMTP, SECRET_KEYS}; use domain::DomainError; use crate::scheduler::validate_cron; +use crate::vuln_service::{DEFAULT_NOTIFY_MIN_SEVERITY, KEY_NOTIFY_MIN_SEVERITY}; +use domain::vuln::Severity; pub struct SettingsService { repo: Arc, @@ -104,6 +106,20 @@ impl SettingsService { } } +impl SettingsService { + pub async fn notify_min_severity(&self) -> Result { + 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 { format!("schedule.{}", kind.as_str()) } diff --git a/backend/crates/application/src/test_fakes.rs b/backend/crates/application/src/test_fakes.rs index 3353285..f7193fa 100644 --- a/backend/crates/application/src/test_fakes.rs +++ b/backend/crates/application/src/test_fakes.rs @@ -494,3 +494,150 @@ impl ClusterGateway for MemCluster { 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, 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, &str>) -> Self { + self.results + .lock() + .unwrap() + .insert(target.into(), r.map_err(String::from)); + self + } + fn get(&self, target: &str) -> Result, 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 { + Ok("fake 0.1".into()) + } + async fn scan_os( + &self, + out: &dyn domain::ports::LineSink, + ) -> Result, DomainError> { + out.line("scanning os"); + self.get("os") + } + async fn scan_image( + &self, + image: &str, + out: &dyn domain::ports::LineSink, + ) -> Result, DomainError> { + out.line(&format!("scanning {image}")); + self.get(image) + } +} + +#[derive(Default)] +pub struct MemFindings(pub Mutex>); + +#[async_trait] +impl FindingRepository for MemFindings { + async fn active_by_target(&self, target: &str) -> Result, 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, + ) -> 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, DomainError> { + Ok(self.0.lock().unwrap().iter().find(|f| f.id == id).cloned()) + } + async fn list(&self, filter: &FindingFilter) -> Result, DomainError> { + let mut v: Vec = 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) -> Result { + 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) + } +} diff --git a/backend/crates/application/src/tests/mod.rs b/backend/crates/application/src/tests/mod.rs index 22c5f8b..fb33967 100644 --- a/backend/crates/application/src/tests/mod.rs +++ b/backend/crates/application/src/tests/mod.rs @@ -6,3 +6,4 @@ mod scheduler_tests; mod settings_tests; mod upgrade_tests; mod user_service_tests; +mod vuln_tests; diff --git a/backend/crates/application/src/tests/vuln_tests.rs b/backend/crates/application/src/tests/vuln_tests.rs new file mode 100644 index 0000000..717ce50 --- /dev/null +++ b/backend/crates/application/src/tests/vuln_tests.rs @@ -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>); +#[async_trait] +impl JobLog for VecLog { + async fn line(&self, text: &str) { + self.0.lock().unwrap().push(text.into()); + } +} + +struct F { + findings: Arc, + mailer: Arc, + settings: Arc, + 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!["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!["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); +} diff --git a/backend/crates/application/src/vuln_service.rs b/backend/crates/application/src/vuln_service.rs new file mode 100644 index 0000000..1ee0817 --- /dev/null +++ b/backend/crates/application/src/vuln_service.rs @@ -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, + pub(crate) findings: Arc, + pub(crate) cluster: Arc, + pub(crate) settings: Arc, +} + +impl VulnerabilityService { + pub fn new( + scanner: Arc, + findings: Arc, + cluster: Arc, + settings: Arc, + ) -> 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 { + todo!() + } + + pub async fn list(&self, _filter: FindingFilter) -> Result, DomainError> { + todo!() + } + + pub async fn summary(&self) -> Result { + todo!() + } + + /// Only open <-> acknowledged transitions are allowed by users. + pub async fn set_status( + &self, + _id: Uuid, + _status: FindingStatus, + ) -> Result { + todo!() + } + + pub async fn scanner_version(&self) -> Result { + 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>, +} + +pub struct VulnerabilityScanJob(pub Arc); + +#[async_trait] +impl JobHandler for VulnerabilityScanJob { + async fn run(&self, _params: Option, _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; diff --git a/backend/crates/domain/src/lib.rs b/backend/crates/domain/src/lib.rs index f641596..9036b91 100644 --- a/backend/crates/domain/src/lib.rs +++ b/backend/crates/domain/src/lib.rs @@ -8,5 +8,6 @@ pub mod jobs; pub mod ports; pub mod settings; pub mod user; +pub mod vuln; pub use error::DomainError; diff --git a/backend/crates/domain/src/ports.rs b/backend/crates/domain/src/ports.rs index d66b5fb..9ed16b1 100644 --- a/backend/crates/domain/src/ports.rs +++ b/backend/crates/domain/src/ports.rs @@ -8,6 +8,7 @@ use crate::host::{Inventory, OsInfo, Package}; use crate::jobs::{JobKind, JobRun, JobStatus}; use crate::settings::SmtpSettings; use crate::user::{User, UserUpdate}; +use crate::vuln::{Finding, FindingFilter, FindingStatus, RawFinding, SeverityCounts, TargetKind}; use crate::DomainError; #[async_trait] @@ -117,3 +118,31 @@ pub trait ClusterGateway: Send + Sync { image: &str, ) -> Result<(), DomainError>; } + +#[async_trait] +pub trait VulnerabilityScanner: Send + Sync { + /// Scanner version, or an error if it is not installed. + async fn version(&self) -> Result; + async fn scan_os(&self, out: &dyn LineSink) -> Result, DomainError>; + async fn scan_image( + &self, + image: &str, + out: &dyn LineSink, + ) -> Result, DomainError>; +} + +#[async_trait] +pub trait FindingRepository: Send + Sync { + /// Open and acknowledged findings of one target. + async fn active_by_target(&self, target: &str) -> Result, DomainError>; + async fn insert(&self, finding: &Finding) -> Result<(), DomainError>; + async fn touch( + &self, + ids: &[Uuid], + last_seen: chrono::DateTime, + ) -> Result<(), DomainError>; + async fn set_status(&self, id: Uuid, status: FindingStatus) -> Result<(), DomainError>; + async fn get(&self, id: Uuid) -> Result, DomainError>; + async fn list(&self, filter: &FindingFilter) -> Result, DomainError>; + async fn counts(&self, kind: Option) -> Result; +} diff --git a/backend/crates/domain/src/vuln.rs b/backend/crates/domain/src/vuln.rs new file mode 100644 index 0000000..9108518 --- /dev/null +++ b/backend/crates/domain/src/vuln.rs @@ -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 { + 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 { + 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, + 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, + pub last_seen: DateTime, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct FindingFilter { + pub min_severity: Option, + pub target: Option, + pub status: Option, + /// 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, + pub failed_targets: Vec, + pub total_open: usize, + pub new_findings: Vec, + pub fixed: usize, +} diff --git a/backend/crates/infrastructure/migrations/0004_findings.sql b/backend/crates/infrastructure/migrations/0004_findings.sql new file mode 100644 index 0000000..6252289 --- /dev/null +++ b/backend/crates/infrastructure/migrations/0004_findings.sql @@ -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); diff --git a/backend/crates/infrastructure/src/lib.rs b/backend/crates/infrastructure/src/lib.rs index fc8f0cb..476484d 100644 --- a/backend/crates/infrastructure/src/lib.rs +++ b/backend/crates/infrastructure/src/lib.rs @@ -7,6 +7,7 @@ pub mod mail; pub mod password; pub mod sqlite; pub mod token; +pub mod trivy; pub use cipher::AesGcmCipher; pub use db::{connect, DbPool}; @@ -16,6 +17,7 @@ pub use host::{ pub use k8s::{FakeClusterGateway, KubeGateway}; pub use mail::LettreMailer; pub use password::Argon2Hasher; -pub use sqlite::SqliteInventory; pub use sqlite::{SqliteAuditLog, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers}; +pub use sqlite::{SqliteFindings, SqliteInventory}; pub use token::JwtIssuer; +pub use trivy::{FakeScanner, TrivyScanner}; diff --git a/backend/crates/infrastructure/src/sqlite.rs b/backend/crates/infrastructure/src/sqlite.rs index 584c8a3..27ec044 100644 --- a/backend/crates/infrastructure/src/sqlite.rs +++ b/backend/crates/infrastructure/src/sqlite.rs @@ -485,3 +485,262 @@ impl domain::ports::InventoryRepository for SqliteInventory { .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::("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::("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::("status").as_str()) + .unwrap_or(FindingStatus::Open), + first_seen: parse_ts(r.get::("first_seen").as_str()), + last_seen: parse_ts(r.get::("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, 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) -> 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, 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, 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) -> Result { + 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!["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 + ); + } +} diff --git a/backend/crates/infrastructure/src/trivy.rs b/backend/crates/infrastructure/src/trivy.rs new file mode 100644 index 0000000..c40ac4b --- /dev/null +++ b/backend/crates/infrastructure/src/trivy.rs @@ -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, +} + +impl TrivyScanner { + pub fn new(runner: Arc) -> Self { + Self { runner } + } +} + +/// Parse `trivy ... --format json` output into findings (all results merged, deduplicated). +pub fn parse_trivy_json(_json: &str) -> Result, DomainError> { + todo!() +} + +#[async_trait] +impl VulnerabilityScanner for TrivyScanner { + async fn version(&self) -> Result { + let _ = &self.runner; + todo!() + } + async fn scan_os(&self, _out: &dyn LineSink) -> Result, DomainError> { + todo!() + } + async fn scan_image( + &self, + _image: &str, + _out: &dyn LineSink, + ) -> Result, DomainError> { + todo!() + } +} + +/// Sample findings for development (FAKE_HOST=true). +pub struct FakeScanner; + +#[async_trait] +impl VulnerabilityScanner for FakeScanner { + async fn version(&self) -> Result { + Ok("fake-trivy 0.0".into()) + } + async fn scan_os(&self, out: &dyn LineSink) -> Result, 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, 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 { + 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, DomainError> { + Ok(None) + } + async fn run_streaming( + &self, + _: &str, + _: &[&str], + _: &dyn LineSink, + ) -> Result { + 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 + ); + } +} diff --git a/frontend/e2e/vulnerabilities.spec.ts b/frontend/e2e/vulnerabilities.spec.ts new file mode 100644 index 0000000..881303b --- /dev/null +++ b/frontend/e2e/vulnerabilities.spec.ts @@ -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') +}) diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index d03bd84..b8a1288 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -128,3 +128,38 @@ export interface ClusterOverview { images: 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 +} diff --git a/frontend/src/components/FindingTable.test.ts b/frontend/src/components/FindingTable.test.ts new file mode 100644 index 0000000..b2faaa8 --- /dev/null +++ b/frontend/src/components/FindingTable.test.ts @@ -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 => ({ + 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) + }) +})