//! Vulnerability scanning: diffs scanner results against stored findings, //! notifies about new ones by mail. use std::collections::HashSet; use std::sync::Arc; use async_trait::async_trait; use chrono::{DateTime, Utc}; use domain::ports::{ClusterGateway, FindingRepository, LineSink, VulnerabilityScanner}; use domain::vuln::{ Finding, FindingFilter, FindingStatus, RawFinding, ScanReport, Severity, SeverityCounts, TargetKind, }; use domain::DomainError; use uuid::Uuid; use crate::jobs::{JobHandler, JobLog}; use crate::SettingsService; pub struct VulnerabilityService { scanner: Arc, findings: Arc, cluster: Arc, settings: Arc, } /// 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; #[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)] pub struct Summary { pub total: SeverityCounts, pub os: SeverityCounts, pub images: SeverityCounts, pub last_scan: Option>, } struct ChannelSink(tokio::sync::mpsc::UnboundedSender); impl LineSink for ChannelSink { fn line(&self, text: &str) { let _ = self.0.send(text.to_string()); } } impl VulnerabilityService { pub fn new( scanner: Arc, findings: Arc, cluster: Arc, settings: Arc, ) -> Self { Self { scanner, findings, cluster, settings, } } pub async fn scanner_version(&self) -> Result { self.scanner.version().await } /// Scan the OS and all cluster images, persist the diff, notify about new findings. pub async fn scan(&self, log: &dyn JobLog) -> Result { let mut targets: Vec<(TargetKind, String)> = vec![(TargetKind::Os, "os".into())]; match self.cluster.overview().await { Ok(o) => targets.extend(o.images().into_iter().map(|i| (TargetKind::Image, i))), Err(e) => { log.line(&format!("cluster unavailable, scanning OS only: {e}")) .await } } let mut report = ScanReport { targets: targets.iter().map(|(_, t)| t.clone()).collect(), ..Default::default() }; let now = Utc::now(); for (kind, target) in &targets { let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); let sink = ChannelSink(tx); let scan = async { let r = match kind { TargetKind::Os => self.scanner.scan_os(&sink).await, TargetKind::Image => self.scanner.scan_image(target, &sink).await, }; drop(sink); r }; let drain = async { while let Some(l) = rx.recv().await { log.line(&l).await; } }; let (result, _) = tokio::join!(scan, drain); match result { Ok(raw) => { let (new, fixed) = self.apply(*kind, target, raw, now).await?; log.line(&format!("{target}: {} new, {fixed} fixed", new.len())) .await; report.new_findings.extend(new); report.fixed += fixed; } Err(e) => { log.line(&format!( "{target}: scan failed, keeping previous findings: {e}" )) .await; report.failed_targets.push(target.clone()); } } } report.total_open = self.findings.counts(None).await?.total(); self.notify(&report, log).await; Ok(report) } /// Diff scanner output against the stored active findings of one target. async fn apply( &self, kind: TargetKind, target: &str, raw: Vec, now: DateTime, ) -> Result<(Vec, usize), DomainError> { let existing = self.findings.active_by_target(target).await?; let mut seen: HashSet = HashSet::new(); let mut still_present = Vec::new(); let mut new = Vec::new(); for r in raw { if !seen.insert(r.key()) { continue; } match existing.iter().find(|f| f.raw.key() == r.key()) { Some(f) => still_present.push(f.id), None => { let f = Finding { id: Uuid::new_v4(), target_kind: kind, target: target.into(), raw: r, status: FindingStatus::Open, first_seen: now, last_seen: now, }; self.findings.insert(&f).await?; new.push(f); } } } self.findings.touch(&still_present, now).await?; let mut fixed = 0; for f in existing.iter().filter(|f| !still_present.contains(&f.id)) { self.findings.set_status(f.id, FindingStatus::Fixed).await?; fixed += 1; } Ok((new, fixed)) } async fn notify(&self, report: &ScanReport, log: &dyn JobLog) { let min = match self.settings.notify_min_severity().await { Ok(m) => m, Err(e) => { log.line(&format!("notification settings unavailable: {e}")) .await; return; } }; let relevant: Vec<&Finding> = report .new_findings .iter() .filter(|f| f.raw.severity >= min) .collect(); if relevant.is_empty() { return; } let plural = if relevant.len() == 1 { "y" } else { "ies" }; let subject = format!( "[SoftVisor Monitoring] {} new vulnerabilit{plural} (>= {})", relevant.len(), min.as_str() ); let mut body = format!( "The vulnerability scan found {} new finding(s) at or above severity {}:\n\n", relevant.len(), min.as_str() ); for f in &relevant { let fixed = f .raw .fixed_version .as_ref() .map(|v| format!(" (fixed in {v})")) .unwrap_or_default(); body.push_str(&format!( "- {} [{}] {} {} in {}{fixed}\n {}\n", f.raw.cve_id, f.raw.severity.as_str(), f.raw.package, f.raw.installed_version, f.target, f.raw.url )); } body.push_str(&format!("\nTotal open findings: {}\n", report.total_open)); match self.settings.send_mail(None, &subject, &body).await { Ok(()) => { log.line(&format!( "notification mail sent for {} finding(s)", relevant.len() )) .await } Err(DomainError::Validation(_)) => log.line("no mail sent: smtp not configured").await, Err(e) => log.line(&format!("notification mail failed: {e}")).await, } } pub async fn list(&self, filter: FindingFilter) -> Result, DomainError> { self.findings.list(&filter).await } pub async fn targets(&self) -> Result, DomainError> { let mut t: Vec = self .findings .list(&FindingFilter::default()) .await? .into_iter() .map(|f| f.target) .collect(); t.sort(); t.dedup(); Ok(t) } pub async fn summary(&self) -> Result { let all = self .findings .list(&FindingFilter { include_fixed: true, ..Default::default() }) .await?; Ok(Summary { total: self.findings.counts(None).await?, os: self.findings.counts(Some(TargetKind::Os)).await?, images: self.findings.counts(Some(TargetKind::Image)).await?, last_scan: all.iter().map(|f| f.last_seen).max(), }) } /// Only open <-> acknowledged transitions are allowed by users. pub async fn set_status( &self, id: Uuid, status: FindingStatus, ) -> Result { if status == FindingStatus::Fixed { return Err(DomainError::Validation( "findings are marked fixed by the scanner only".into(), )); } let f = self.findings.get(id).await?.ok_or(DomainError::NotFound)?; if f.status == FindingStatus::Fixed { return Err(DomainError::Validation("finding is already fixed".into())); } self.findings.set_status(id, status).await?; Ok(Finding { status, ..f }) } } pub struct VulnerabilityScanJob(pub Arc); #[async_trait] impl JobHandler for VulnerabilityScanJob { async fn run(&self, _params: Option, log: &dyn JobLog) -> Result<(), String> { let version = self .0 .scanner_version() .await .map_err(|e| format!("scanner not available: {e}"))?; log.line(&format!("scanner version {version}")).await; let r = self.0.scan(log).await.map_err(|e| e.to_string())?; log.line(&format!( "scanned {} target(s), {} failed: {} new, {} fixed, {} open in total", r.targets.len(), r.failed_targets.len(), r.new_findings.len(), r.fixed, r.total_open )) .await; if !r.targets.is_empty() && r.failed_targets.len() == r.targets.len() { return Err("all targets failed to scan".into()); } Ok(()) } }