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:
@ -295,8 +295,8 @@ if not, the listed assumptions apply.
|
||||
### Milestone 2 – Update management (delivered 2026-09-02)
|
||||
WP-02 remainder, WP-10, WP-11, WP-12. Verified against the real host: 430 packages, microk8s overview with 16 workloads.
|
||||
|
||||
### Milestone 3 – Vulnerability management (planned)
|
||||
WP-20, WP-21.
|
||||
### Milestone 3 – Vulnerability management (delivered 2026-09-02)
|
||||
WP-20, WP-21. Trivy is installed by the deploy script.
|
||||
|
||||
### Milestone 4 – Backup management (planned)
|
||||
WP-30, WP-31, WP-32.
|
||||
@ -358,8 +358,8 @@ The server is reachable via `ssh softvisor` (as root). Findings from the inspect
|
||||
| WP-10 | M2 | done | 2026-09-02; snaps inventoried, no upstream check |
|
||||
| WP-11 | M2 | done | 2026-09-02; runs as root via systemd, sudoers scoping deferred to WP-41; log via polling |
|
||||
| WP-12 | M2 | done | 2026-09-02; overview, restart, scale, set image; upstream tag check not done |
|
||||
| WP-20 | M3 | todo | |
|
||||
| WP-21 | M3 | todo | |
|
||||
| WP-20 | M3 | done | 2026-09-02; Trivy rootfs + image scans, diff with first/last seen, fixed detection |
|
||||
| WP-21 | M3 | done | 2026-09-02; findings UI, acknowledge, mail digest with severity threshold; dashboard widget in WP-40 |
|
||||
| WP-30 | M4 | todo | |
|
||||
| WP-31 | M4 | todo | |
|
||||
| WP-32 | M4 | todo | |
|
||||
|
||||
@ -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?))
|
||||
}
|
||||
@ -196,12 +196,13 @@ async fn new_findings_at_or_above_threshold_trigger_one_mail() {
|
||||
]),
|
||||
)]);
|
||||
svc.scan(&VecLog::default()).await.unwrap();
|
||||
{
|
||||
let sent = f.mailer.0.lock().unwrap();
|
||||
assert_eq!(sent.len(), 1);
|
||||
assert!(sent[0].1.contains("1 new"), "{}", sent[0].1);
|
||||
assert!(sent[0].2.contains("CVE-3"));
|
||||
assert!(!sent[0].2.contains("CVE-4"), "below threshold");
|
||||
drop(sent);
|
||||
}
|
||||
|
||||
f.settings
|
||||
.set_notify_min_severity(Severity::Low)
|
||||
|
||||
@ -1,11 +1,14 @@
|
||||
//! Vulnerability scanning: diffs scanner results against stored findings,
|
||||
//! notifies about new ones by mail.
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
|
||||
use async_trait::async_trait;
|
||||
use domain::ports::{ClusterGateway, FindingRepository, VulnerabilityScanner};
|
||||
use chrono::{DateTime, Utc};
|
||||
use domain::ports::{ClusterGateway, FindingRepository, LineSink, VulnerabilityScanner};
|
||||
use domain::vuln::{
|
||||
Finding, FindingFilter, FindingStatus, ScanReport, Severity, SeverityCounts, TargetKind,
|
||||
Finding, FindingFilter, FindingStatus, RawFinding, ScanReport, Severity, SeverityCounts,
|
||||
TargetKind,
|
||||
};
|
||||
use domain::DomainError;
|
||||
use uuid::Uuid;
|
||||
@ -14,10 +17,30 @@ use crate::jobs::{JobHandler, JobLog};
|
||||
use crate::SettingsService;
|
||||
|
||||
pub struct VulnerabilityService {
|
||||
pub(crate) scanner: Arc<dyn VulnerabilityScanner>,
|
||||
pub(crate) findings: Arc<dyn FindingRepository>,
|
||||
pub(crate) cluster: Arc<dyn ClusterGateway>,
|
||||
pub(crate) settings: Arc<SettingsService>,
|
||||
scanner: Arc<dyn VulnerabilityScanner>,
|
||||
findings: Arc<dyn FindingRepository>,
|
||||
cluster: Arc<dyn ClusterGateway>,
|
||||
settings: Arc<SettingsService>,
|
||||
}
|
||||
|
||||
/// Key of the notification threshold setting.
|
||||
pub const KEY_NOTIFY_MIN_SEVERITY: &str = "vuln.notify_min_severity";
|
||||
pub const DEFAULT_NOTIFY_MIN_SEVERITY: Severity = Severity::High;
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Summary {
|
||||
pub total: SeverityCounts,
|
||||
pub os: SeverityCounts,
|
||||
pub images: SeverityCounts,
|
||||
pub last_scan: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
struct ChannelSink(tokio::sync::mpsc::UnboundedSender<String>);
|
||||
|
||||
impl LineSink for ChannelSink {
|
||||
fn line(&self, text: &str) {
|
||||
let _ = self.0.send(text.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
impl VulnerabilityService {
|
||||
@ -35,50 +58,242 @@ impl VulnerabilityService {
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan the OS and all cluster images, persist the diff, notify about new findings.
|
||||
pub async fn scan(&self, _log: &dyn JobLog) -> Result<ScanReport, DomainError> {
|
||||
todo!()
|
||||
pub async fn scanner_version(&self) -> Result<String, DomainError> {
|
||||
self.scanner.version().await
|
||||
}
|
||||
|
||||
pub async fn list(&self, _filter: FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||
todo!()
|
||||
/// Scan the OS and all cluster images, persist the diff, notify about new findings.
|
||||
pub async fn scan(&self, log: &dyn JobLog) -> Result<ScanReport, DomainError> {
|
||||
let mut targets: Vec<(TargetKind, String)> = vec![(TargetKind::Os, "os".into())];
|
||||
match self.cluster.overview().await {
|
||||
Ok(o) => targets.extend(o.images().into_iter().map(|i| (TargetKind::Image, i))),
|
||||
Err(e) => {
|
||||
log.line(&format!("cluster unavailable, scanning OS only: {e}"))
|
||||
.await
|
||||
}
|
||||
}
|
||||
let mut report = ScanReport {
|
||||
targets: targets.iter().map(|(_, t)| t.clone()).collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let now = Utc::now();
|
||||
for (kind, target) in &targets {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::<String>();
|
||||
let sink = ChannelSink(tx);
|
||||
let scan = async {
|
||||
let r = match kind {
|
||||
TargetKind::Os => self.scanner.scan_os(&sink).await,
|
||||
TargetKind::Image => self.scanner.scan_image(target, &sink).await,
|
||||
};
|
||||
drop(sink);
|
||||
r
|
||||
};
|
||||
let drain = async {
|
||||
while let Some(l) = rx.recv().await {
|
||||
log.line(&l).await;
|
||||
}
|
||||
};
|
||||
let (result, _) = tokio::join!(scan, drain);
|
||||
match result {
|
||||
Ok(raw) => {
|
||||
let (new, fixed) = self.apply(*kind, target, raw, now).await?;
|
||||
log.line(&format!("{target}: {} new, {fixed} fixed", new.len()))
|
||||
.await;
|
||||
report.new_findings.extend(new);
|
||||
report.fixed += fixed;
|
||||
}
|
||||
Err(e) => {
|
||||
log.line(&format!(
|
||||
"{target}: scan failed, keeping previous findings: {e}"
|
||||
))
|
||||
.await;
|
||||
report.failed_targets.push(target.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
report.total_open = self.findings.counts(None).await?.total();
|
||||
self.notify(&report, log).await;
|
||||
Ok(report)
|
||||
}
|
||||
|
||||
/// Diff scanner output against the stored active findings of one target.
|
||||
async fn apply(
|
||||
&self,
|
||||
kind: TargetKind,
|
||||
target: &str,
|
||||
raw: Vec<RawFinding>,
|
||||
now: DateTime<Utc>,
|
||||
) -> Result<(Vec<Finding>, usize), DomainError> {
|
||||
let existing = self.findings.active_by_target(target).await?;
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
let mut still_present = Vec::new();
|
||||
let mut new = Vec::new();
|
||||
for r in raw {
|
||||
if !seen.insert(r.key()) {
|
||||
continue;
|
||||
}
|
||||
match existing.iter().find(|f| f.raw.key() == r.key()) {
|
||||
Some(f) => still_present.push(f.id),
|
||||
None => {
|
||||
let f = Finding {
|
||||
id: Uuid::new_v4(),
|
||||
target_kind: kind,
|
||||
target: target.into(),
|
||||
raw: r,
|
||||
status: FindingStatus::Open,
|
||||
first_seen: now,
|
||||
last_seen: now,
|
||||
};
|
||||
self.findings.insert(&f).await?;
|
||||
new.push(f);
|
||||
}
|
||||
}
|
||||
}
|
||||
self.findings.touch(&still_present, now).await?;
|
||||
let mut fixed = 0;
|
||||
for f in existing.iter().filter(|f| !still_present.contains(&f.id)) {
|
||||
self.findings.set_status(f.id, FindingStatus::Fixed).await?;
|
||||
fixed += 1;
|
||||
}
|
||||
Ok((new, fixed))
|
||||
}
|
||||
|
||||
async fn notify(&self, report: &ScanReport, log: &dyn JobLog) {
|
||||
let min = match self.settings.notify_min_severity().await {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
log.line(&format!("notification settings unavailable: {e}"))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
let relevant: Vec<&Finding> = report
|
||||
.new_findings
|
||||
.iter()
|
||||
.filter(|f| f.raw.severity >= min)
|
||||
.collect();
|
||||
if relevant.is_empty() {
|
||||
return;
|
||||
}
|
||||
let plural = if relevant.len() == 1 { "y" } else { "ies" };
|
||||
let subject = format!(
|
||||
"[SoftVisor Monitoring] {} new vulnerabilit{plural} (>= {})",
|
||||
relevant.len(),
|
||||
min.as_str()
|
||||
);
|
||||
let mut body = format!(
|
||||
"The vulnerability scan found {} new finding(s) at or above severity {}:\n\n",
|
||||
relevant.len(),
|
||||
min.as_str()
|
||||
);
|
||||
for f in &relevant {
|
||||
let fixed = f
|
||||
.raw
|
||||
.fixed_version
|
||||
.as_ref()
|
||||
.map(|v| format!(" (fixed in {v})"))
|
||||
.unwrap_or_default();
|
||||
body.push_str(&format!(
|
||||
"- {} [{}] {} {} in {}{fixed}\n {}\n",
|
||||
f.raw.cve_id,
|
||||
f.raw.severity.as_str(),
|
||||
f.raw.package,
|
||||
f.raw.installed_version,
|
||||
f.target,
|
||||
f.raw.url
|
||||
));
|
||||
}
|
||||
body.push_str(&format!("\nTotal open findings: {}\n", report.total_open));
|
||||
match self.settings.send_mail(None, &subject, &body).await {
|
||||
Ok(()) => {
|
||||
log.line(&format!(
|
||||
"notification mail sent for {} finding(s)",
|
||||
relevant.len()
|
||||
))
|
||||
.await
|
||||
}
|
||||
Err(DomainError::Validation(_)) => log.line("no mail sent: smtp not configured").await,
|
||||
Err(e) => log.line(&format!("notification mail failed: {e}")).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn list(&self, filter: FindingFilter) -> Result<Vec<Finding>, DomainError> {
|
||||
self.findings.list(&filter).await
|
||||
}
|
||||
|
||||
pub async fn targets(&self) -> Result<Vec<String>, DomainError> {
|
||||
let mut t: Vec<String> = self
|
||||
.findings
|
||||
.list(&FindingFilter::default())
|
||||
.await?
|
||||
.into_iter()
|
||||
.map(|f| f.target)
|
||||
.collect();
|
||||
t.sort();
|
||||
t.dedup();
|
||||
Ok(t)
|
||||
}
|
||||
|
||||
pub async fn summary(&self) -> Result<Summary, DomainError> {
|
||||
todo!()
|
||||
let all = self
|
||||
.findings
|
||||
.list(&FindingFilter {
|
||||
include_fixed: true,
|
||||
..Default::default()
|
||||
})
|
||||
.await?;
|
||||
Ok(Summary {
|
||||
total: self.findings.counts(None).await?,
|
||||
os: self.findings.counts(Some(TargetKind::Os)).await?,
|
||||
images: self.findings.counts(Some(TargetKind::Image)).await?,
|
||||
last_scan: all.iter().map(|f| f.last_seen).max(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Only open <-> acknowledged transitions are allowed by users.
|
||||
pub async fn set_status(
|
||||
&self,
|
||||
_id: Uuid,
|
||||
_status: FindingStatus,
|
||||
id: Uuid,
|
||||
status: FindingStatus,
|
||||
) -> Result<Finding, DomainError> {
|
||||
todo!()
|
||||
if status == FindingStatus::Fixed {
|
||||
return Err(DomainError::Validation(
|
||||
"findings are marked fixed by the scanner only".into(),
|
||||
));
|
||||
}
|
||||
|
||||
pub async fn scanner_version(&self) -> Result<String, DomainError> {
|
||||
self.scanner.version().await
|
||||
let f = self.findings.get(id).await?.ok_or(DomainError::NotFound)?;
|
||||
if f.status == FindingStatus::Fixed {
|
||||
return Err(DomainError::Validation("finding is already fixed".into()));
|
||||
}
|
||||
self.findings.set_status(id, status).await?;
|
||||
Ok(Finding { status, ..f })
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||
pub struct Summary {
|
||||
pub total: SeverityCounts,
|
||||
pub os: SeverityCounts,
|
||||
pub images: SeverityCounts,
|
||||
pub last_scan: Option<chrono::DateTime<chrono::Utc>>,
|
||||
}
|
||||
|
||||
pub struct VulnerabilityScanJob(pub Arc<VulnerabilityService>);
|
||||
|
||||
#[async_trait]
|
||||
impl JobHandler for VulnerabilityScanJob {
|
||||
async fn run(&self, _params: Option<String>, _log: &dyn JobLog) -> Result<(), String> {
|
||||
todo!()
|
||||
async fn run(&self, _params: Option<String>, log: &dyn JobLog) -> Result<(), String> {
|
||||
let version = self
|
||||
.0
|
||||
.scanner_version()
|
||||
.await
|
||||
.map_err(|e| format!("scanner not available: {e}"))?;
|
||||
log.line(&format!("scanner version {version}")).await;
|
||||
let r = self.0.scan(log).await.map_err(|e| e.to_string())?;
|
||||
log.line(&format!(
|
||||
"scanned {} target(s), {} failed: {} new, {} fixed, {} open in total",
|
||||
r.targets.len(),
|
||||
r.failed_targets.len(),
|
||||
r.new_findings.len(),
|
||||
r.fixed,
|
||||
r.total_open
|
||||
))
|
||||
.await;
|
||||
if !r.targets.is_empty() && r.failed_targets.len() == r.targets.len() {
|
||||
return Err("all targets failed to scan".into());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// Key of the notification threshold setting.
|
||||
pub const KEY_NOTIFY_MIN_SEVERITY: &str = "vuln.notify_min_severity";
|
||||
pub const DEFAULT_NOTIFY_MIN_SEVERITY: Severity = Severity::High;
|
||||
|
||||
@ -41,6 +41,14 @@ fi
|
||||
# add settings introduced later to an existing .env
|
||||
ssh "$HOST" "grep -q '^MASTER_KEY=' $DIR/.env || echo MASTER_KEY=$(openssl rand -hex 32) >> $DIR/.env"
|
||||
|
||||
echo "== trivy (vulnerability scanner)"
|
||||
ssh "$HOST" 'command -v trivy >/dev/null || {
|
||||
apt-get install -y -q wget apt-transport-https gnupg >/dev/null
|
||||
wget -qO- https://aquasecurity.github.io/trivy-repo/deb/public.key | gpg --dearmor -o /usr/share/keyrings/trivy.gpg
|
||||
echo "deb [signed-by=/usr/share/keyrings/trivy.gpg] https://aquasecurity.github.io/trivy-repo/deb generic main" > /etc/apt/sources.list.d/trivy.list
|
||||
apt-get update -q >/dev/null && apt-get install -y -q trivy >/dev/null
|
||||
}; trivy --version | head -1'
|
||||
|
||||
echo "== systemd"
|
||||
ssh "$HOST" "cat > /etc/systemd/system/monitoring.service" <<UNIT
|
||||
[Unit]
|
||||
|
||||
69
frontend/src/components/FindingTable.vue
Normal file
69
frontend/src/components/FindingTable.vue
Normal file
@ -0,0 +1,69 @@
|
||||
<script setup lang="ts">
|
||||
import type { Finding } from '../api/types'
|
||||
|
||||
defineProps<{ findings: Finding[]; canAct: boolean }>()
|
||||
defineEmits<{ status: [f: Finding, status: 'open' | 'acknowledged']; select: [f: Finding] }>()
|
||||
|
||||
const sev: Record<string, string> = {
|
||||
critical: 'bg-red-600 text-white',
|
||||
high: 'bg-orange-500 text-white',
|
||||
medium: 'bg-amber-300 text-amber-900',
|
||||
low: 'bg-gray-200 text-gray-700',
|
||||
unknown: 'bg-gray-100 text-gray-500',
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<table class="w-full text-left text-sm">
|
||||
<thead class="border-b border-gray-200 text-gray-500">
|
||||
<tr>
|
||||
<th class="py-2">Severity</th>
|
||||
<th>CVE</th>
|
||||
<th>Package</th>
|
||||
<th>Installed</th>
|
||||
<th>Fixed in</th>
|
||||
<th>Target</th>
|
||||
<th>Status</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="f in findings"
|
||||
:key="f.id"
|
||||
class="border-b border-gray-100"
|
||||
:class="f.status === 'acknowledged' ? 'text-gray-500' : ''"
|
||||
>
|
||||
<td class="py-1.5">
|
||||
<span class="rounded-full px-2 py-0.5 text-xs font-medium" :class="sev[f.severity]">{{
|
||||
f.severity
|
||||
}}</span>
|
||||
</td>
|
||||
<td class="font-mono">{{ f.cve_id }}</td>
|
||||
<td class="font-mono">{{ f.package }}</td>
|
||||
<td class="font-mono text-xs">{{ f.installed_version }}</td>
|
||||
<td class="font-mono text-xs" :class="f.fixed_version ? 'text-green-700' : 'text-gray-400'">
|
||||
{{ f.fixed_version ?? '–' }}
|
||||
</td>
|
||||
<td class="max-w-xs truncate font-mono text-xs" :title="f.target">{{ f.target }}</td>
|
||||
<td>{{ f.status }}</td>
|
||||
<td class="space-x-3 whitespace-nowrap text-right">
|
||||
<button name="details" class="text-blue-600 hover:underline" @click="$emit('select', f)">
|
||||
Details
|
||||
</button>
|
||||
<button
|
||||
v-if="canAct"
|
||||
name="ack"
|
||||
class="text-blue-600 hover:underline"
|
||||
@click="$emit('status', f, f.status === 'acknowledged' ? 'open' : 'acknowledged')"
|
||||
>
|
||||
{{ f.status === 'acknowledged' ? 'Reopen' : 'Acknowledge' }}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
<tr v-if="findings.length === 0">
|
||||
<td colspan="8" class="py-6 text-center text-gray-500">No findings.</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</template>
|
||||
@ -1,7 +1,7 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type { ScheduleView, SmtpPayload, SmtpView } from '../api/types'
|
||||
import type { ScheduleView, Severity, SmtpPayload, SmtpView } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import SmtpForm from '../components/SmtpForm.vue'
|
||||
@ -14,6 +14,7 @@ const busy = ref(false)
|
||||
const testTo = ref('')
|
||||
const schedules = ref<ScheduleView[]>([])
|
||||
const cronInputs = ref<Record<string, string>>({})
|
||||
const minSeverity = ref<Severity>('high')
|
||||
|
||||
const labels: Record<string, string> = {
|
||||
package_refresh: 'Package refresh',
|
||||
@ -29,6 +30,9 @@ async function load() {
|
||||
smtp.value = res.smtp
|
||||
schedules.value = await api.get<ScheduleView[]>('/api/settings/schedules')
|
||||
cronInputs.value = Object.fromEntries(schedules.value.map((s) => [s.kind, s.cron ?? '']))
|
||||
minSeverity.value = (
|
||||
await api.get<{ min_severity: Severity }>('/api/settings/notifications')
|
||||
).min_severity
|
||||
loaded.value = true
|
||||
}
|
||||
onMounted(() => load().catch(fail))
|
||||
@ -58,6 +62,18 @@ async function sendTest() {
|
||||
}
|
||||
}
|
||||
|
||||
async function saveNotifications() {
|
||||
busy.value = true
|
||||
try {
|
||||
await api.put('/api/settings/notifications', { min_severity: minSeverity.value })
|
||||
toast.success('Notification settings saved')
|
||||
} catch (e) {
|
||||
fail(e)
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSchedules() {
|
||||
busy.value = true
|
||||
try {
|
||||
@ -113,6 +129,38 @@ async function saveSchedules() {
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-lg font-medium">Vulnerability notifications</h2>
|
||||
<p class="mb-4 text-sm text-gray-600">
|
||||
A mail is sent to the notification recipients when a scan finds new vulnerabilities at or
|
||||
above this severity.
|
||||
</p>
|
||||
<form class="flex max-w-xl items-end gap-2" @submit.prevent="saveNotifications">
|
||||
<div>
|
||||
<label for="notify-sev" class="block text-sm font-medium">Notify from severity</label>
|
||||
<select
|
||||
id="notify-sev"
|
||||
v-model="minSeverity"
|
||||
:disabled="!auth.isAdmin"
|
||||
class="mt-1 rounded-md border border-gray-300 px-3 py-2"
|
||||
>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
</select>
|
||||
</div>
|
||||
<button
|
||||
v-if="auth.isAdmin"
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
>
|
||||
Save notifications
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="mt-10">
|
||||
<h2 class="text-lg font-medium">Schedules</h2>
|
||||
<p class="mb-4 text-sm text-gray-600">
|
||||
|
||||
219
frontend/src/pages/VulnerabilitiesPage.vue
Normal file
219
frontend/src/pages/VulnerabilitiesPage.vue
Normal file
@ -0,0 +1,219 @@
|
||||
<script setup lang="ts">
|
||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { api, ApiError } from '../api/client'
|
||||
import type { Finding, JobRun, Severity, VulnSummary } from '../api/types'
|
||||
import { useAuthStore } from '../stores/auth'
|
||||
import { useToastStore } from '../stores/toast'
|
||||
import FindingTable from '../components/FindingTable.vue'
|
||||
|
||||
const auth = useAuthStore()
|
||||
const toast = useToastStore()
|
||||
const summary = ref<VulnSummary | null>(null)
|
||||
const findings = ref<Finding[]>([])
|
||||
const targets = ref<string[]>([])
|
||||
const minSeverity = ref<Severity>('low')
|
||||
const target = ref('')
|
||||
const status = ref('')
|
||||
const scanning = ref(false)
|
||||
const scanRun = ref<JobRun | null>(null)
|
||||
const selected = ref<Finding | null>(null)
|
||||
let timer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Request failed')
|
||||
|
||||
async function load() {
|
||||
const q = new URLSearchParams()
|
||||
q.set('min_severity', minSeverity.value)
|
||||
if (target.value) q.set('target', target.value)
|
||||
if (status.value) q.set('status', status.value)
|
||||
const [s, f, t] = await Promise.all([
|
||||
api.get<VulnSummary>('/api/vulnerabilities/summary'),
|
||||
api.get<Finding[]>(`/api/vulnerabilities?${q}`),
|
||||
api.get<string[]>('/api/vulnerabilities/targets'),
|
||||
])
|
||||
summary.value = s
|
||||
findings.value = f
|
||||
targets.value = t
|
||||
}
|
||||
onMounted(() => load().catch(fail))
|
||||
onUnmounted(() => clearInterval(timer))
|
||||
watch([minSeverity, target, status], () => load().catch(fail))
|
||||
|
||||
async function scan() {
|
||||
scanning.value = true
|
||||
try {
|
||||
scanRun.value = await api.post<JobRun>('/api/jobs/run', { kind: 'vulnerability_scan' })
|
||||
timer = setInterval(async () => {
|
||||
if (!scanRun.value) return
|
||||
const r = await api.get<JobRun>(`/api/jobs/${scanRun.value.id}`)
|
||||
scanRun.value = r
|
||||
if (r.status !== 'running') {
|
||||
clearInterval(timer)
|
||||
scanning.value = false
|
||||
if (r.status === 'success') toast.success('Scan finished')
|
||||
else toast.error('Scan failed, see the log')
|
||||
await load()
|
||||
}
|
||||
}, 1500)
|
||||
} catch (e) {
|
||||
scanning.value = false
|
||||
fail(e)
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(f: Finding, s: 'open' | 'acknowledged') {
|
||||
try {
|
||||
await api.post(`/api/vulnerabilities/${f.id}/status`, { status: s })
|
||||
await load()
|
||||
} catch (e) {
|
||||
fail(e)
|
||||
}
|
||||
}
|
||||
|
||||
const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] = [
|
||||
{ key: 'critical', label: 'Critical', cls: 'text-red-700' },
|
||||
{ key: 'high', label: 'High', cls: 'text-orange-600' },
|
||||
{ key: 'medium', label: 'Medium', cls: 'text-amber-600' },
|
||||
{ key: 'low', label: 'Low', cls: 'text-gray-600' },
|
||||
]
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-semibold">Vulnerabilities</h1>
|
||||
<button
|
||||
v-if="auth.isAdmin"
|
||||
:disabled="scanning"
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white hover:bg-blue-700 disabled:opacity-50"
|
||||
@click="scan"
|
||||
>
|
||||
{{ scanning ? 'Scanning…' : 'Scan now' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="summary" class="mt-6 grid gap-4 md:grid-cols-5">
|
||||
<div v-for="t in tiles" :key="t.key" class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div class="text-xs uppercase text-gray-500">{{ t.label }}</div>
|
||||
<div :data-testid="`count-${t.key}`" class="mt-1 text-2xl font-semibold" :class="t.cls">
|
||||
{{ summary.total[t.key] }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">
|
||||
OS {{ summary.os[t.key] }} · images {{ summary.images[t.key] }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
||||
<div class="text-xs uppercase text-gray-500">Last scan</div>
|
||||
<div class="mt-1 text-sm font-medium">
|
||||
{{ summary.last_scan ? new Date(summary.last_scan).toLocaleString() : 'never' }}
|
||||
</div>
|
||||
<div class="text-xs text-gray-500">scanner {{ summary.scanner }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="scanRun && scanRun.status === 'running'"
|
||||
class="mt-4 rounded-md border border-gray-200 bg-white"
|
||||
>
|
||||
<pre class="max-h-40 overflow-auto p-3 text-xs">{{ scanRun.log || '(starting)' }}</pre>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 flex flex-wrap items-end gap-4">
|
||||
<div>
|
||||
<label for="f-sev" class="block text-sm font-medium">Minimum severity</label>
|
||||
<select
|
||||
id="f-sev"
|
||||
v-model="minSeverity"
|
||||
class="mt-1 rounded-md border border-gray-300 px-3 py-2"
|
||||
>
|
||||
<option value="critical">Critical</option>
|
||||
<option value="high">High</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="low">Low</option>
|
||||
<option value="unknown">All</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-target" class="block text-sm font-medium">Target</label>
|
||||
<select
|
||||
id="f-target"
|
||||
v-model="target"
|
||||
class="mt-1 max-w-xs rounded-md border border-gray-300 px-3 py-2"
|
||||
>
|
||||
<option value="">All targets</option>
|
||||
<option v-for="t in targets" :key="t" :value="t">{{ t }}</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label for="f-status" class="block text-sm font-medium">Status</label>
|
||||
<select
|
||||
id="f-status"
|
||||
v-model="status"
|
||||
class="mt-1 rounded-md border border-gray-300 px-3 py-2"
|
||||
>
|
||||
<option value="">Open + acknowledged</option>
|
||||
<option value="open">Open</option>
|
||||
<option value="acknowledged">Acknowledged</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="pb-2 text-sm text-gray-500">{{ findings.length }} findings</span>
|
||||
</div>
|
||||
|
||||
<FindingTable
|
||||
class="mt-4"
|
||||
:findings="findings"
|
||||
:can-act="auth.isAdmin"
|
||||
@status="setStatus"
|
||||
@select="selected = $event"
|
||||
/>
|
||||
|
||||
<div
|
||||
v-if="selected"
|
||||
class="fixed inset-0 flex items-center justify-center bg-black/30"
|
||||
@click.self="selected = null"
|
||||
>
|
||||
<div class="w-full max-w-lg rounded-lg bg-white p-6 shadow-lg" role="dialog">
|
||||
<h2 class="font-mono text-lg font-semibold">{{ selected.cve_id }}</h2>
|
||||
<p class="mt-2 text-sm">{{ selected.title }}</p>
|
||||
<dl class="mt-4 grid grid-cols-3 gap-y-2 text-sm">
|
||||
<dt class="text-gray-500">Severity</dt>
|
||||
<dd class="col-span-2">{{ selected.severity }}</dd>
|
||||
<dt class="text-gray-500">Package</dt>
|
||||
<dd class="col-span-2 font-mono">
|
||||
{{ selected.package }} {{ selected.installed_version }}
|
||||
</dd>
|
||||
<dt class="text-gray-500">Fixed in</dt>
|
||||
<dd class="col-span-2 font-mono">{{ selected.fixed_version ?? 'no fix available' }}</dd>
|
||||
<dt class="text-gray-500">Target</dt>
|
||||
<dd class="col-span-2 font-mono break-all">{{ selected.target }}</dd>
|
||||
<dt class="text-gray-500">First seen</dt>
|
||||
<dd class="col-span-2">{{ new Date(selected.first_seen).toLocaleString() }}</dd>
|
||||
<dt class="text-gray-500">Last seen</dt>
|
||||
<dd class="col-span-2">{{ new Date(selected.last_seen).toLocaleString() }}</dd>
|
||||
</dl>
|
||||
<div class="mt-6 flex items-center justify-between">
|
||||
<a
|
||||
:href="selected.url"
|
||||
target="_blank"
|
||||
rel="noopener"
|
||||
class="text-sm text-blue-600 hover:underline"
|
||||
>Advisory</a
|
||||
>
|
||||
<div class="flex gap-2">
|
||||
<RouterLink
|
||||
v-if="selected.target_kind === 'os' && selected.fixed_version"
|
||||
to="/updates"
|
||||
class="rounded-md border border-gray-300 px-4 py-2 text-sm"
|
||||
>
|
||||
Go to updates
|
||||
</RouterLink>
|
||||
<button
|
||||
class="rounded-md bg-blue-600 px-4 py-2 text-sm text-white"
|
||||
@click="selected = null"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@ -10,6 +10,7 @@ import SettingsPage from './pages/SettingsPage.vue'
|
||||
import JobsPage from './pages/JobsPage.vue'
|
||||
import UpdatesPage from './pages/UpdatesPage.vue'
|
||||
import ClusterPage from './pages/ClusterPage.vue'
|
||||
import VulnerabilitiesPage from './pages/VulnerabilitiesPage.vue'
|
||||
|
||||
export const router = createRouter({
|
||||
history: createWebHistory(),
|
||||
@ -22,11 +23,7 @@ export const router = createRouter({
|
||||
{ path: '', name: 'dashboard', component: DashboardPage },
|
||||
{ path: 'updates', component: UpdatesPage },
|
||||
{ path: 'cluster', component: ClusterPage },
|
||||
{
|
||||
path: 'vulnerabilities',
|
||||
component: PlaceholderPage,
|
||||
props: { title: 'Vulnerabilities' },
|
||||
},
|
||||
{ path: 'vulnerabilities', component: VulnerabilitiesPage },
|
||||
{ path: 'backups', component: PlaceholderPage, props: { title: 'Backups' } },
|
||||
{ path: 'users', component: UsersPage, meta: { admin: true } },
|
||||
{ path: 'jobs', component: JobsPage },
|
||||
|
||||
Reference in New Issue
Block a user