Link image findings to their workloads and suggest a fixing image update

Container findings now name the workloads that run the image and link
into the Kubernetes view, which highlights them. An update check asks the
registry for newer tags of the same variant, scans the newest one and
records which of the open findings are gone in it. The image row then
shows the candidate tag, how many findings it fixes and how many remain,
marks those CVEs in the expanded list, and offers to roll every workload
over to it. The check runs as a job, nightly for all running images or on
demand for one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-03 20:19:12 +02:00
parent 278b5e47a3
commit b984cc8c9f
31 changed files with 1674 additions and 29 deletions

View File

@ -0,0 +1,165 @@
use std::sync::{Arc, Mutex};
use async_trait::async_trait;
use domain::vuln::Severity;
use domain::DomainError;
use crate::jobs::{JobHandler, JobLog};
use crate::test_fakes::{raw, FakeScanner, MemCluster, MemFindings, MemImageUpdates, MemRegistry};
use crate::{ImageUpdateCheckJob, ImageUpdateService, 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());
}
}
const GITEA: &str = "gitea/gitea:1.22.3";
struct F {
findings: Arc<MemFindings>,
updates: Arc<MemImageUpdates>,
registry: Arc<MemRegistry>,
}
fn fixture(scanner: FakeScanner, tags: &[&str]) -> (F, Arc<ImageUpdateService>) {
let findings = Arc::new(MemFindings::default());
let updates = Arc::new(MemImageUpdates::default());
let registry = Arc::new(MemRegistry::default());
*registry.tags.lock().unwrap() = tags.iter().map(|t| t.to_string()).collect();
let cluster = Arc::new(MemCluster::default());
let svc = ImageUpdateService::new(
registry.clone(),
Arc::new(scanner),
findings.clone(),
updates.clone(),
cluster,
);
(
F {
findings,
updates,
registry,
},
Arc::new(svc),
)
}
/// Scan the running image first so there are open findings to compare against.
async fn seed(findings: Arc<MemFindings>, scanner: FakeScanner, cluster: Arc<MemCluster>) {
let settings = crate::test_fakes::settings_service();
let vulns = VulnerabilityService::new(Arc::new(scanner), findings, cluster, settings);
vulns.scan(&VecLog::default()).await.unwrap();
}
#[tokio::test]
async fn suggests_the_newest_tag_of_the_same_variant() {
let (_f, svc) = fixture(
FakeScanner::default(),
&["1.22.3", "1.22.4", "1.23.0", "1.24.0-rc1", "latest"],
);
assert_eq!(
svc.candidate(GITEA).await.unwrap().as_deref(),
Some("gitea/gitea:1.23.0")
);
let (_f, svc) = fixture(FakeScanner::default(), &["1.22.3"]);
assert_eq!(
svc.candidate(GITEA).await.unwrap(),
None,
"already the newest"
);
}
#[tokio::test]
async fn a_check_records_which_findings_the_candidate_fixes() {
// the running image has three flaws, the candidate only one of them
let running = FakeScanner::default().with(
GITEA,
Ok(vec![
raw("CVE-1", "git", "2.39", Severity::Critical, Some("2.40")),
raw("CVE-2", "curl", "7.8", Severity::High, None),
raw("CVE-3", "zlib", "1.2", Severity::Low, None),
]),
);
let candidate = FakeScanner::default().with(
"gitea/gitea:1.23.0",
Ok(vec![raw("CVE-3", "zlib", "1.2", Severity::Low, None)]),
);
let (f, svc) = fixture(candidate, &["1.22.3", "1.23.0"]);
seed(f.findings.clone(), running, Arc::new(MemCluster::default())).await;
let log = VecLog::default();
let update = svc.check(GITEA, &log).await.unwrap().unwrap();
assert_eq!(update.candidate, "gitea/gitea:1.23.0");
assert_eq!(update.fixed, vec!["CVE-1", "CVE-2"]);
assert_eq!(update.fixed_counts.critical, 1);
assert_eq!(update.fixed_counts.high, 1);
assert_eq!(
update.candidate_total, 1,
"the candidate still has one finding"
);
assert_eq!(svc.stored(GITEA).await.unwrap().as_ref(), Some(&update));
let lines = log.0.lock().unwrap().join("\n");
assert!(lines.contains("fixes 2 finding(s)"), "{lines}");
// a repeated check overwrites the previous result
svc.check(GITEA, &VecLog::default()).await.unwrap();
assert_eq!(svc.all().await.unwrap().len(), 1);
}
#[tokio::test]
async fn nothing_is_recorded_when_the_image_is_current() {
let (f, svc) = fixture(FakeScanner::default(), &["1.22.3"]);
let log = VecLog::default();
assert!(svc.check(GITEA, &log).await.unwrap().is_none());
assert!(f.updates.0.lock().unwrap().is_empty());
assert!(log
.0
.lock()
.unwrap()
.iter()
.any(|l| l.contains("newest tag")));
}
#[tokio::test]
async fn a_registry_that_cannot_be_reached_fails_the_check_of_that_image_only() {
let (f, svc) = fixture(FakeScanner::default(), &["1.22.3", "1.23.0"]);
f.registry
.fail
.store(true, std::sync::atomic::Ordering::SeqCst);
assert!(matches!(
svc.check(GITEA, &VecLog::default()).await.unwrap_err(),
DomainError::Unavailable(_)
));
// checking every running image keeps going and reports the failures in the log
let log = VecLog::default();
let found = svc.check_running(&log).await.unwrap();
assert_eq!(found, 0);
let lines = log.0.lock().unwrap().join("\n");
assert!(lines.contains("check failed"), "{lines}");
}
#[tokio::test]
async fn the_job_checks_one_image_or_all_running_ones() {
let candidate = FakeScanner::default().with("gitea/gitea:1.23.0", Ok(vec![]));
let (f, svc) = fixture(candidate, &["1.22.3", "1.23.0", "16.4.0", "17.0.0"]);
let job = ImageUpdateCheckJob(svc.clone());
job.run(Some(GITEA.into()), &VecLog::default())
.await
.unwrap();
assert_eq!(f.updates.0.lock().unwrap().len(), 1);
let log = VecLog::default();
job.run(None, &log).await.unwrap();
assert!(log
.0
.lock()
.unwrap()
.iter()
.any(|l| l.contains("running image(s)")));
assert!(!f.updates.0.lock().unwrap().is_empty());
}

View File

@ -1,6 +1,7 @@
mod auth_service_tests;
mod backup_tests;
mod cluster_tests;
mod image_update_tests;
mod inventory_tests;
mod jobs_tests;
mod scheduler_tests;

View File

@ -25,6 +25,7 @@ struct F {
mailer: Arc<MemMailer>,
settings: Arc<SettingsService>,
results: ScanResults,
cluster: Arc<MemCluster>,
}
fn fixture(scanner: FakeScanner) -> (F, VulnerabilityService) {
@ -36,10 +37,11 @@ fn fixture(scanner: FakeScanner) -> (F, VulnerabilityService) {
Arc::new(FakeCipher),
mailer.clone(),
));
let cluster = Arc::new(MemCluster::default());
let svc = VulnerabilityService::new(
Arc::new(scanner),
findings.clone(),
Arc::new(MemCluster::default()),
cluster.clone(),
settings.clone(),
);
(
@ -48,6 +50,7 @@ fn fixture(scanner: FakeScanner) -> (F, VulnerabilityService) {
mailer,
settings,
results,
cluster,
},
svc,
)
@ -533,3 +536,66 @@ async fn findings_of_one_group_can_be_listed() {
.unwrap();
assert_eq!(image.len(), 1);
}
#[tokio::test]
async fn container_groups_name_the_workloads_that_run_the_image() {
let scanner = FakeScanner::default()
.with(
"os",
Ok(vec![raw("CVE-1", "openssl", "3.0.1", Severity::High, None)]),
)
.with(
GITEA,
Ok(vec![raw("CVE-2", "git", "2.39", Severity::High, None)]),
);
let (_f, svc) = fixture(scanner);
svc.scan(&VecLog::default()).await.unwrap();
let images = svc
.image_groups(FindingFilter {
target_kind: Some(TargetKind::Image),
..Default::default()
})
.await
.unwrap();
let gitea = images.iter().find(|g| g.group.key == GITEA).unwrap();
assert_eq!(gitea.workloads.len(), 1);
assert_eq!(gitea.workloads[0].namespace, "gitea");
assert_eq!(gitea.workloads[0].name, "gitea");
assert_eq!(
gitea.workloads[0].kind,
domain::cluster::WorkloadKind::Deployment
);
assert!(gitea.running, "the image is in use on the cluster");
// an image that no workload runs any more is reported as not running
let stale = images.iter().find(|g| g.group.key == PG);
assert!(stale.is_none_or(|g| g.workloads.is_empty()));
}
#[tokio::test]
async fn image_groups_survive_an_unreachable_cluster() {
let scanner = FakeScanner::default().with(
GITEA,
Ok(vec![raw("CVE-2", "git", "2.39", Severity::High, None)]),
);
let (f, svc) = fixture(scanner);
svc.scan(&VecLog::default()).await.unwrap();
f.cluster
.fail
.store(true, std::sync::atomic::Ordering::SeqCst);
let images = svc
.image_groups(FindingFilter {
target_kind: Some(TargetKind::Image),
..Default::default()
})
.await
.unwrap();
assert_eq!(images.len(), 1, "findings are still listed");
assert!(images[0].workloads.is_empty());
assert!(
!images[0].running,
"unknown while the cluster is unreachable"
);
}