WP-20/21: vulnerability management with Trivy and mail notifications
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:
@ -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)
|
||||
|
||||
@ -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)
|
||||
)]
|
||||
|
||||
@ -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)]
|
||||
|
||||
@ -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)
|
||||
}
|
||||
|
||||
96
backend/crates/api/src/vulnerabilities.rs
Normal file
96
backend/crates/api/src/vulnerabilities.rs
Normal 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?))
|
||||
}
|
||||
Reference in New Issue
Block a user