WP-20/21: vulnerability management with Trivy and mail notifications
Some checks failed
CI / backend (push) Has been cancelled
CI / frontend (push) Has been cancelled
CI / ui (push) Has been cancelled

Trivy scanner adapter (rootfs + image JSON, parsed and deduplicated),
findings repository, scan diff that keeps first_seen, marks disappeared
findings fixed and skips failed targets, vulnerability_scan job (daily by
default), digest mail for new findings at or above a configurable severity,
/api/vulnerabilities routes, Vulnerabilities page with severity tiles,
filters, details and acknowledge, notification threshold in settings.
Deploy script installs Trivy from the Aqua apt repository.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Dennis Nemec
2026-09-02 22:45:57 +02:00
parent 5c6e09ad10
commit 872f4373ff
13 changed files with 801 additions and 94 deletions

View File

@ -11,29 +11,39 @@ pub mod settings;
pub mod system;
pub mod test_support;
pub mod users;
pub mod vulnerabilities;
use std::sync::Arc;
use application::scheduler::Scheduler;
use application::{
AuthService, ClusterService, InventoryService, JobRunner, PackageRefreshJob, PackageUpgradeJob,
SettingsService, UserService,
SettingsService, UserService, VulnerabilityScanJob, VulnerabilityService,
};
use axum::{routing::get, Json, Router};
use domain::jobs::JobKind;
use domain::ports::Mailer;
use domain::ports::{ClusterGateway, HostInspector, HostUpdater};
use domain::ports::{ClusterGateway, HostInspector, HostUpdater, VulnerabilityScanner};
use infrastructure::{
AesGcmCipher, Argon2Hasher, DbPool, DebianInspector, DebianUpdater, FakeClusterGateway,
FakeHostInspector, FakeHostUpdater, JwtIssuer, KubeGateway, LettreMailer, SqliteAuditLog,
SqliteInventory, SqliteJobRuns, SqliteRefreshTokens, SqliteSettings, SqliteUsers,
SystemCommandRunner,
FakeHostInspector, FakeHostUpdater, FakeScanner, JwtIssuer, KubeGateway, LettreMailer,
SqliteAuditLog, SqliteFindings, SqliteInventory, SqliteJobRuns, SqliteRefreshTokens,
SqliteSettings, SqliteUsers, SystemCommandRunner, TrivyScanner,
};
use tower_http::services::{ServeDir, ServeFile};
use tower_http::trace::TraceLayer;
pub use config::Config;
/// External adapters the app is wired with (real or fake).
pub struct Adapters {
pub mailer: Arc<dyn Mailer>,
pub inspector: Arc<dyn HostInspector>,
pub updater: Arc<dyn HostUpdater>,
pub cluster: Arc<dyn ClusterGateway>,
pub scanner: Arc<dyn VulnerabilityScanner>,
}
#[derive(Clone)]
pub struct AppState {
pub cfg: Config,
@ -43,6 +53,7 @@ pub struct AppState {
pub jobs: Arc<JobRunner>,
pub inventory: Arc<InventoryService>,
pub cluster: Arc<ClusterService>,
pub vulns: Arc<VulnerabilityService>,
pub login_limiter: Arc<rate_limit::RateLimiter>,
}
@ -50,44 +61,42 @@ impl AppState {
/// Wire the services on top of a connected database.
pub fn new(cfg: Config, pool: DbPool) -> anyhow::Result<Self> {
let runner = Arc::new(SystemCommandRunner);
let (inspector, updater, cluster): (
Arc<dyn HostInspector>,
Arc<dyn HostUpdater>,
Arc<dyn ClusterGateway>,
) = if cfg.fake_host {
(
Arc::new(FakeHostInspector),
Arc::new(FakeHostUpdater),
Arc::new(FakeClusterGateway),
)
let adapters = if cfg.fake_host {
Adapters {
mailer: Arc::new(LettreMailer),
inspector: Arc::new(FakeHostInspector),
updater: Arc::new(FakeHostUpdater),
cluster: Arc::new(FakeClusterGateway),
scanner: Arc::new(FakeScanner),
}
} else {
(
Arc::new(DebianInspector::new(runner.clone())),
Arc::new(DebianUpdater::new(runner)),
Arc::new(KubeGateway::new(cfg.kubeconfig.clone())),
)
Adapters {
mailer: Arc::new(LettreMailer),
inspector: Arc::new(DebianInspector::new(runner.clone())),
updater: Arc::new(DebianUpdater::new(runner.clone())),
cluster: Arc::new(KubeGateway::new(cfg.kubeconfig.clone())),
scanner: Arc::new(TrivyScanner::new(runner)),
}
};
Self::with_adapters(
cfg,
pool,
Arc::new(LettreMailer),
inspector,
updater,
cluster,
|r| r,
)
Self::with_adapters(cfg, pool, adapters, |r| r)
}
/// Wiring with replaceable adapters (used by tests and the fake-host mode).
pub fn with_adapters(
cfg: Config,
pool: DbPool,
mailer: Arc<dyn Mailer>,
inspector: Arc<dyn HostInspector>,
updater: Arc<dyn HostUpdater>,
cluster: Arc<dyn ClusterGateway>,
adapters: Adapters,
register_jobs: impl FnOnce(JobRunner) -> JobRunner,
) -> anyhow::Result<Self> {
let Adapters {
mailer,
inspector,
updater,
cluster,
scanner,
} = adapters;
let pool_for_findings = pool.clone();
let cluster_gateway = cluster.clone();
let users = Arc::new(SqliteUsers(pool.clone()));
let hasher = Arc::new(Argon2Hasher);
let auth = AuthService::new(
@ -119,8 +128,17 @@ impl AppState {
inventory: inventory.clone(),
}),
);
let jobs = Arc::new(register_jobs(runner));
let cluster = Arc::new(ClusterService::new(cluster));
let cluster = Arc::new(ClusterService::new(cluster.clone()));
let vulns = Arc::new(VulnerabilityService::new(
scanner,
Arc::new(SqliteFindings(pool_for_findings)),
cluster_gateway,
settings.clone(),
));
let jobs = Arc::new(register_jobs(runner.register(
JobKind::VulnerabilityScan,
Arc::new(VulnerabilityScanJob(vulns.clone())),
)));
Ok(Self {
cfg,
auth: Arc::new(auth),
@ -129,6 +147,7 @@ impl AppState {
jobs,
inventory,
cluster,
vulns,
login_limiter: Arc::new(rate_limit::RateLimiter::new(
10,
std::time::Duration::from_secs(60),
@ -163,6 +182,7 @@ pub fn build_app(state: AppState) -> Router {
.nest("/api/jobs", jobs::router())
.nest("/api/system", system::router())
.nest("/api/cluster", cluster::router())
.nest("/api/vulnerabilities", vulnerabilities::router())
.fallback_service(spa)
.layer(TraceLayer::new_for_http())
.with_state(state)

View File

@ -28,6 +28,8 @@ impl Modify for BearerAuth {
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::vulnerabilities::list, crate::vulnerabilities::summary, crate::vulnerabilities::targets, crate::vulnerabilities::set_status,
crate::settings::get_notifications, crate::settings::put_notifications,
),
modifiers(&BearerAuth)
)]

View File

@ -5,6 +5,7 @@ use axum::routing::{get, post, put};
use axum::{Json, Router};
use domain::jobs::JobKind;
use domain::settings::{SmtpSecurity, SmtpSettings};
use domain::vuln::Severity;
use domain::DomainError;
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
@ -19,6 +20,39 @@ pub fn router() -> Router<AppState> {
.route("/smtp/test", post(test_smtp))
.route("/schedules", get(list_schedules))
.route("/schedules/{kind}", put(put_schedule))
.route(
"/notifications",
get(get_notifications).put(put_notifications),
)
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct NotificationSettings {
#[schema(value_type = String, example = "high")]
pub min_severity: Severity,
}
#[utoipa::path(get, path = "/api/settings/notifications", tag = "settings", security(("bearer" = [])), responses((status = 200, body = NotificationSettings)))]
async fn get_notifications(
State(state): State<AppState>,
_: AuthUser,
) -> Result<Json<NotificationSettings>, ApiError> {
Ok(Json(NotificationSettings {
min_severity: state.settings.notify_min_severity().await?,
}))
}
#[utoipa::path(put, path = "/api/settings/notifications", tag = "settings", security(("bearer" = [])), request_body = NotificationSettings, responses((status = 204)))]
async fn put_notifications(
State(state): State<AppState>,
_: AdminUser,
Json(req): Json<NotificationSettings>,
) -> Result<StatusCode, ApiError> {
state
.settings
.set_notify_min_severity(req.min_severity)
.await?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize, ToSchema)]

View File

@ -80,16 +80,14 @@ async fn build_test_app_with(cfg: Config) -> Router {
let pool = infrastructure::connect(&cfg.database_url)
.await
.expect("db");
let state = AppState::with_adapters(
cfg,
pool,
Arc::new(RecordingMailer),
Arc::new(infrastructure::FakeHostInspector),
Arc::new(infrastructure::FakeHostUpdater),
Arc::new(infrastructure::FakeClusterGateway),
register_test_jobs,
)
.expect("state");
let adapters = crate::Adapters {
mailer: Arc::new(RecordingMailer),
inspector: Arc::new(infrastructure::FakeHostInspector),
updater: Arc::new(infrastructure::FakeHostUpdater),
cluster: Arc::new(infrastructure::FakeClusterGateway),
scanner: Arc::new(infrastructure::FakeScanner),
};
let state = AppState::with_adapters(cfg, pool, adapters, register_test_jobs).expect("state");
state.bootstrap().await.expect("bootstrap");
build_app(state)
}

View File

@ -0,0 +1,96 @@
//! /api/vulnerabilities: findings, summary, status changes.
use application::vuln_service::Summary;
use axum::extract::{Path, Query, State};
use axum::routing::{get, post};
use axum::{Json, Router};
use domain::vuln::{Finding, FindingFilter, FindingStatus, Severity};
use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid;
use crate::error::ApiError;
use crate::extract::{AdminUser, AuthUser};
use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list))
.route("/summary", get(summary))
.route("/targets", get(targets))
.route("/{id}/status", post(set_status))
}
#[derive(Deserialize)]
pub struct ListQuery {
pub min_severity: Option<String>,
pub target: Option<String>,
pub status: Option<String>,
#[serde(default)]
pub include_fixed: bool,
}
#[utoipa::path(get, path = "/api/vulnerabilities", tag = "vulnerabilities", security(("bearer" = [])),
params(("min_severity" = Option<String>, Query), ("target" = Option<String>, Query), ("status" = Option<String>, Query), ("include_fixed" = Option<bool>, Query)),
responses((status = 200, body = Vec<Object>)))]
async fn list(
State(state): State<AppState>,
_: AuthUser,
Query(q): Query<ListQuery>,
) -> Result<Json<Vec<Finding>>, ApiError> {
let filter = FindingFilter {
min_severity: q.min_severity.as_deref().map(Severity::parse),
target: q.target,
status: q.status.as_deref().and_then(FindingStatus::parse),
include_fixed: q.include_fixed,
};
Ok(Json(state.vulns.list(filter).await?))
}
#[derive(Serialize, ToSchema)]
pub struct SummaryResponse {
#[serde(flatten)]
#[schema(value_type = Object)]
pub summary: Summary,
/// Scanner version, or the error why it is unavailable.
pub scanner: String,
}
#[utoipa::path(get, path = "/api/vulnerabilities/summary", tag = "vulnerabilities", security(("bearer" = [])), responses((status = 200, body = SummaryResponse)))]
async fn summary(
State(state): State<AppState>,
_: AuthUser,
) -> Result<Json<SummaryResponse>, ApiError> {
let scanner = match state.vulns.scanner_version().await {
Ok(v) => v,
Err(e) => format!("unavailable: {e}"),
};
Ok(Json(SummaryResponse {
summary: state.vulns.summary().await?,
scanner,
}))
}
#[utoipa::path(get, path = "/api/vulnerabilities/targets", tag = "vulnerabilities", security(("bearer" = [])), responses((status = 200, body = Vec<String>)))]
async fn targets(
State(state): State<AppState>,
_: AuthUser,
) -> Result<Json<Vec<String>>, ApiError> {
Ok(Json(state.vulns.targets().await?))
}
#[derive(Deserialize, ToSchema)]
pub struct StatusRequest {
#[schema(value_type = String, example = "acknowledged")]
pub status: FindingStatus,
}
#[utoipa::path(post, path = "/api/vulnerabilities/{id}/status", tag = "vulnerabilities", security(("bearer" = [])), request_body = StatusRequest,
responses((status = 200, body = Object), (status = 404), (status = 422)))]
async fn set_status(
State(state): State<AppState>,
_: AdminUser,
Path(id): Path<Uuid>,
Json(req): Json<StatusRequest>,
) -> Result<Json<Finding>, ApiError> {
Ok(Json(state.vulns.set_status(id, req.status).await?))
}