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

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