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:
@ -4,6 +4,7 @@ use axum::http::StatusCode;
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use domain::cluster::{ClusterOverview, WorkloadKind, WorkloadRef};
|
||||
use domain::jobs::JobKind;
|
||||
use domain::DomainError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
@ -18,6 +19,7 @@ pub fn router() -> Router<AppState> {
|
||||
.route("/workloads/{ns}/{kind}/{name}/restart", post(restart))
|
||||
.route("/workloads/{ns}/{kind}/{name}/scale", post(scale))
|
||||
.route("/workloads/{ns}/{kind}/{name}/image", post(set_image))
|
||||
.route("/images/check", post(check_images))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
@ -75,6 +77,26 @@ async fn scale(
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct CheckImagesRequest {
|
||||
/// One image, or all images running on the cluster when omitted.
|
||||
pub image: Option<String>,
|
||||
}
|
||||
|
||||
#[utoipa::path(post, path = "/api/cluster/images/check", tag = "cluster", security(("bearer" = [])), request_body = CheckImagesRequest,
|
||||
responses((status = 202, body = crate::jobs::JobRunDto), (status = 409)))]
|
||||
async fn check_images(
|
||||
State(state): State<AppState>,
|
||||
AdminUser(admin): AdminUser,
|
||||
Json(req): Json<CheckImagesRequest>,
|
||||
) -> Result<(StatusCode, Json<crate::jobs::JobRunDto>), ApiError> {
|
||||
let run = state
|
||||
.jobs
|
||||
.start(JobKind::ImageUpdateCheck, req.image, &admin.email)
|
||||
.await?;
|
||||
Ok((StatusCode::ACCEPTED, Json(run.into())))
|
||||
}
|
||||
|
||||
#[derive(Deserialize, ToSchema)]
|
||||
pub struct ImageRequest {
|
||||
pub image: String,
|
||||
|
||||
@ -20,24 +20,24 @@ use std::sync::Arc;
|
||||
|
||||
use application::scheduler::Scheduler;
|
||||
use application::{
|
||||
AuthService, BackupDeps, BackupJob, BackupService, ClusterService, InventoryService, JobRunner,
|
||||
PackageRefreshJob, PackageUpgradeJob, SettingsService, UserService, VulnerabilityScanJob,
|
||||
VulnerabilityService,
|
||||
AuthService, BackupDeps, BackupJob, BackupService, ClusterService, ImageUpdateCheckJob,
|
||||
ImageUpdateService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob,
|
||||
SettingsService, UserService, VulnerabilityScanJob, VulnerabilityService,
|
||||
};
|
||||
use axum::{routing::get, Json, Router};
|
||||
use domain::jobs::JobKind;
|
||||
use domain::ports::Mailer;
|
||||
use domain::ports::{
|
||||
BackupCollector, BackupStorage, ClusterGateway, FileEncryptor, HostInspector, HostUpdater,
|
||||
VulnerabilityScanner,
|
||||
ImageRegistry, VulnerabilityScanner,
|
||||
};
|
||||
use infrastructure::{
|
||||
AesGcmCipher, Argon2Hasher, CommandBackupStorage, DbPool, DebianInspector, DebianUpdater,
|
||||
DirBackupStorage, FakeBackupCollector, FakeClusterGateway, FakeHostInspector, FakeHostUpdater,
|
||||
FakeScanner, JwtIssuer, KubeBackupCollector, KubeGateway, LettreMailer, OpensslEncryptor,
|
||||
SqliteAuditLog, SqliteBackupRecords, SqliteBackupStrategies, SqliteBackupTargets,
|
||||
SqliteFindings, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings,
|
||||
SqliteUsers, SystemCommandRunner, TrivyScanner,
|
||||
AesGcmCipher, Argon2Hasher, CommandBackupStorage, CurlImageRegistry, DbPool, DebianInspector,
|
||||
DebianUpdater, DirBackupStorage, FakeBackupCollector, FakeClusterGateway, FakeHostInspector,
|
||||
FakeHostUpdater, FakeImageRegistry, FakeScanner, JwtIssuer, KubeBackupCollector, KubeGateway,
|
||||
LettreMailer, OpensslEncryptor, SqliteAuditLog, SqliteBackupRecords, SqliteBackupStrategies,
|
||||
SqliteBackupTargets, SqliteFindings, SqliteImageUpdates, SqliteInventory, SqliteJobRuns,
|
||||
SqliteRefreshTokens, SqliteSettings, SqliteUsers, SystemCommandRunner, TrivyScanner,
|
||||
};
|
||||
use tower_http::services::{ServeDir, ServeFile};
|
||||
use tower_http::trace::TraceLayer;
|
||||
@ -51,6 +51,7 @@ pub struct Adapters {
|
||||
pub updater: Arc<dyn HostUpdater>,
|
||||
pub cluster: Arc<dyn ClusterGateway>,
|
||||
pub scanner: Arc<dyn VulnerabilityScanner>,
|
||||
pub registry: Arc<dyn ImageRegistry>,
|
||||
pub storage: Arc<dyn BackupStorage>,
|
||||
pub collector: Arc<dyn BackupCollector>,
|
||||
pub encryptor: Arc<dyn FileEncryptor>,
|
||||
@ -67,6 +68,7 @@ pub struct AppState {
|
||||
pub cluster: Arc<ClusterService>,
|
||||
pub vulns: Arc<VulnerabilityService>,
|
||||
pub backups: Arc<BackupService>,
|
||||
pub image_updates: Arc<ImageUpdateService>,
|
||||
pub login_limiter: Arc<rate_limit::RateLimiter>,
|
||||
}
|
||||
|
||||
@ -81,6 +83,7 @@ impl AppState {
|
||||
updater: Arc::new(FakeHostUpdater),
|
||||
cluster: Arc::new(FakeClusterGateway),
|
||||
scanner: Arc::new(FakeScanner),
|
||||
registry: Arc::new(FakeImageRegistry),
|
||||
storage: Arc::new(DirBackupStorage::new(cfg.work_dir.join("fake-remote"))),
|
||||
collector: Arc::new(FakeBackupCollector),
|
||||
encryptor: Arc::new(OpensslEncryptor::new(runner.clone())),
|
||||
@ -91,6 +94,7 @@ impl AppState {
|
||||
inspector: Arc::new(DebianInspector::new(runner.clone())),
|
||||
updater: Arc::new(DebianUpdater::new(runner.clone())),
|
||||
cluster: Arc::new(KubeGateway::new(cfg.kubeconfig.clone())),
|
||||
registry: Arc::new(CurlImageRegistry::new(runner.clone())),
|
||||
scanner: Arc::new(match &cfg.containerd_socket {
|
||||
Some(sock) => TrivyScanner::new(runner.clone()).with_containerd(sock),
|
||||
None => TrivyScanner::new(runner.clone()),
|
||||
@ -119,6 +123,7 @@ impl AppState {
|
||||
updater,
|
||||
cluster,
|
||||
scanner,
|
||||
registry,
|
||||
storage,
|
||||
collector,
|
||||
encryptor,
|
||||
@ -168,19 +173,31 @@ impl AppState {
|
||||
);
|
||||
let login_rate_limit = cfg.login_rate_limit;
|
||||
let cluster = Arc::new(ClusterService::new(cluster.clone()));
|
||||
let findings_repo = Arc::new(SqliteFindings(pool_for_findings.clone()));
|
||||
let vulns = Arc::new(VulnerabilityService::new(
|
||||
scanner,
|
||||
Arc::new(SqliteFindings(pool_for_findings)),
|
||||
cluster_gateway,
|
||||
scanner.clone(),
|
||||
findings_repo.clone(),
|
||||
cluster_gateway.clone(),
|
||||
settings.clone(),
|
||||
));
|
||||
let image_updates = Arc::new(ImageUpdateService::new(
|
||||
registry,
|
||||
scanner,
|
||||
findings_repo,
|
||||
Arc::new(SqliteImageUpdates(pool_for_findings)),
|
||||
cluster_gateway,
|
||||
));
|
||||
let jobs = Arc::new(register_jobs(
|
||||
runner
|
||||
.register(
|
||||
JobKind::VulnerabilityScan,
|
||||
Arc::new(VulnerabilityScanJob(vulns.clone())),
|
||||
)
|
||||
.register(JobKind::Backup, Arc::new(BackupJob(backups.clone()))),
|
||||
.register(JobKind::Backup, Arc::new(BackupJob(backups.clone())))
|
||||
.register(
|
||||
JobKind::ImageUpdateCheck,
|
||||
Arc::new(ImageUpdateCheckJob(image_updates.clone())),
|
||||
),
|
||||
));
|
||||
Ok(Self {
|
||||
cfg,
|
||||
@ -192,6 +209,7 @@ impl AppState {
|
||||
cluster,
|
||||
vulns,
|
||||
backups,
|
||||
image_updates,
|
||||
login_limiter: Arc::new(rate_limit::RateLimiter::new(
|
||||
login_rate_limit,
|
||||
std::time::Duration::from_secs(60),
|
||||
|
||||
@ -27,7 +27,7 @@ impl Modify for BearerAuth {
|
||||
crate::settings::get_smtp, crate::settings::put_smtp, crate::settings::test_smtp, crate::settings::list_schedules, crate::settings::put_schedule,
|
||||
crate::jobs::list, crate::jobs::kinds, crate::jobs::get_one, crate::jobs::run,
|
||||
crate::system::inventory, crate::system::upgrade,
|
||||
crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image,
|
||||
crate::cluster::overview, crate::cluster::restart, crate::cluster::scale, crate::cluster::set_image, crate::cluster::check_images,
|
||||
crate::vulnerabilities::list, crate::vulnerabilities::groups, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status,
|
||||
crate::settings::get_notifications, crate::settings::put_notifications,
|
||||
crate::backups::list_targets, crate::backups::get_target, crate::backups::create_target, crate::backups::update_target,
|
||||
|
||||
@ -90,6 +90,7 @@ async fn build_test_app_with(cfg: Config) -> Router {
|
||||
updater: Arc::new(infrastructure::FakeHostUpdater),
|
||||
cluster: Arc::new(infrastructure::FakeClusterGateway),
|
||||
scanner: Arc::new(infrastructure::FakeScanner),
|
||||
registry: Arc::new(infrastructure::FakeImageRegistry),
|
||||
storage: Arc::new(infrastructure::DirBackupStorage::new(
|
||||
cfg.work_dir.join("remote"),
|
||||
)),
|
||||
|
||||
@ -3,7 +3,7 @@ use application::vuln_service::{Summary, TargetSummary};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use domain::vuln::{Finding, FindingFilter, FindingGroup, FindingStatus, Severity, TargetKind};
|
||||
use domain::vuln::{Finding, FindingFilter, FindingStatus, Severity, TargetKind};
|
||||
use domain::DomainError;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use utoipa::ToSchema;
|
||||
@ -74,8 +74,27 @@ async fn groups(
|
||||
State(state): State<AppState>,
|
||||
_: AuthUser,
|
||||
Query(q): Query<ListQuery>,
|
||||
) -> Result<Json<Vec<FindingGroup>>, ApiError> {
|
||||
Ok(Json(state.vulns.groups(q.into_filter()?).await?))
|
||||
) -> Result<Json<serde_json::Value>, ApiError> {
|
||||
let filter = q.into_filter()?;
|
||||
// container groups carry the workloads that run the image, host groups do not
|
||||
if filter.target_kind == Some(TargetKind::Image) {
|
||||
let groups = state.vulns.image_groups(filter).await?;
|
||||
let mut out = Vec::with_capacity(groups.len());
|
||||
for g in groups {
|
||||
// the last update check of this image, if one has run
|
||||
let update = state.image_updates.stored(&g.group.key).await?;
|
||||
let mut value =
|
||||
serde_json::to_value(g).map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||
value["update"] =
|
||||
serde_json::to_value(update).map_err(|e| DomainError::Storage(e.to_string()))?;
|
||||
out.push(value);
|
||||
}
|
||||
return Ok(Json(serde_json::Value::Array(out)));
|
||||
}
|
||||
let groups = state.vulns.groups(filter).await?;
|
||||
Ok(Json(
|
||||
serde_json::to_value(groups).map_err(|e| DomainError::Storage(e.to_string()))?,
|
||||
))
|
||||
}
|
||||
|
||||
#[derive(Serialize, ToSchema)]
|
||||
|
||||
@ -330,3 +330,123 @@ async fn findings_are_rolled_up_per_package_and_image() {
|
||||
StatusCode::UNAUTHORIZED
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn image_groups_link_to_the_workloads_running_them() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, ADMIN, PW).await.access;
|
||||
run_scan(&app, &token).await;
|
||||
|
||||
let res = get(
|
||||
&app,
|
||||
"/api/vulnerabilities/groups?scope=container&min_severity=unknown",
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
let groups = res.json.as_array().unwrap();
|
||||
let gitea = groups
|
||||
.iter()
|
||||
.find(|g| g["key"].as_str().unwrap().contains("gitea"))
|
||||
.unwrap();
|
||||
assert_eq!(gitea["running"], true);
|
||||
let workloads = gitea["workloads"].as_array().unwrap();
|
||||
assert_eq!(workloads.len(), 1);
|
||||
assert_eq!(workloads[0]["namespace"], "gitea");
|
||||
assert_eq!(workloads[0]["kind"], "deployment");
|
||||
assert_eq!(workloads[0]["name"], "gitea");
|
||||
|
||||
// host groups do not carry cluster usage
|
||||
let host = get(
|
||||
&app,
|
||||
"/api/vulnerabilities/groups?scope=host&min_severity=unknown",
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert!(host
|
||||
.json
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|g| g.get("workloads").is_none()));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_image_update_check_suggests_a_newer_tag_that_fixes_findings() {
|
||||
let app = test_app_with_admin().await;
|
||||
let token = common::login(&app, ADMIN, PW).await.access;
|
||||
run_scan(&app, &token).await;
|
||||
|
||||
// nothing is known before a check
|
||||
let groups = get(
|
||||
&app,
|
||||
"/api/vulnerabilities/groups?scope=container&min_severity=unknown",
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
let gitea = groups
|
||||
.json
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|g| g["key"].as_str().unwrap().contains("gitea"))
|
||||
.unwrap()
|
||||
.clone();
|
||||
assert!(gitea["update"].is_null());
|
||||
|
||||
let run = post(
|
||||
&app,
|
||||
"/api/cluster/images/check",
|
||||
json!({"image": gitea["key"]}),
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(run.status, StatusCode::ACCEPTED, "{}", run.json);
|
||||
assert_eq!(run.json["kind"], "image_update_check");
|
||||
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" {
|
||||
assert_eq!(r.json["status"], "success", "{}", r.json["log"]);
|
||||
break;
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(30)).await;
|
||||
}
|
||||
|
||||
let groups = get(
|
||||
&app,
|
||||
"/api/vulnerabilities/groups?scope=container&min_severity=unknown",
|
||||
Some(&token),
|
||||
)
|
||||
.await;
|
||||
let gitea = groups
|
||||
.json
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.find(|g| g["key"].as_str().unwrap().contains("gitea"))
|
||||
.unwrap()
|
||||
.clone();
|
||||
let update = &gitea["update"];
|
||||
assert!(
|
||||
update["candidate"].as_str().unwrap().ends_with(":9.9.9"),
|
||||
"{update}"
|
||||
);
|
||||
assert!(update["fixed"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.contains(&json!("CVE-2024-24790")));
|
||||
assert_eq!(update["fixed_counts"]["critical"], 1);
|
||||
assert!(update["checked_at"].is_string());
|
||||
|
||||
// only admins may start a check
|
||||
post(&app, "/api/users", json!({"email": "u@x.de", "display_name": "U", "password": "user-password-123", "role": "user"}), Some(&token)).await;
|
||||
let user = common::login(&app, "u@x.de", "user-password-123")
|
||||
.await
|
||||
.access;
|
||||
assert_eq!(
|
||||
post(&app, "/api/cluster/images/check", json!({}), Some(&user))
|
||||
.await
|
||||
.status,
|
||||
StatusCode::FORBIDDEN
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user