WP-20/21: contract and failing tests for vulnerability management
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:40:48 +02:00
parent e6ef9b21d3
commit 5c6e09ad10
17 changed files with 1530 additions and 1 deletions

View File

@ -8,5 +8,6 @@ pub mod jobs;
pub mod ports;
pub mod settings;
pub mod user;
pub mod vuln;
pub use error::DomainError;

View File

@ -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<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>;
}

View 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,
}