Split the vulnerability view into host and container categories
The findings of the Debian host (OS packages and applications found in the root filesystem) and of the container images in the cluster were mixed in one flat table. They are now two prominent categories: a card each with its own severity split and target count, acting as the primary selector, and a table that adapts to the selection. Host findings show the package source reported by the scanner (debian, gobinary, node-pkg …) instead of the target, container findings show the image. Backend: findings carry the scanner's package source, the list endpoint takes ?scope=host|container, and the targets endpoint reports each target with its category and open count. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@ -1,9 +1,10 @@
|
|||||||
//! /api/vulnerabilities: findings, summary, status changes.
|
//! /api/vulnerabilities: findings, summary, status changes.
|
||||||
use application::vuln_service::Summary;
|
use application::vuln_service::{Summary, TargetSummary};
|
||||||
use axum::extract::{Path, Query, State};
|
use axum::extract::{Path, Query, State};
|
||||||
use axum::routing::{get, post};
|
use axum::routing::{get, post};
|
||||||
use axum::{Json, Router};
|
use axum::{Json, Router};
|
||||||
use domain::vuln::{Finding, FindingFilter, FindingStatus, Severity};
|
use domain::vuln::{Finding, FindingFilter, FindingStatus, Severity, TargetKind};
|
||||||
|
use domain::DomainError;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use utoipa::ToSchema;
|
use utoipa::ToSchema;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
@ -23,6 +24,8 @@ pub fn router() -> Router<AppState> {
|
|||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct ListQuery {
|
pub struct ListQuery {
|
||||||
pub min_severity: Option<String>,
|
pub min_severity: Option<String>,
|
||||||
|
/// `host` (OS, packages, applications) or `container` (images in the cluster).
|
||||||
|
pub scope: Option<String>,
|
||||||
pub target: Option<String>,
|
pub target: Option<String>,
|
||||||
pub status: Option<String>,
|
pub status: Option<String>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
@ -30,15 +33,24 @@ pub struct ListQuery {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(get, path = "/api/vulnerabilities", tag = "vulnerabilities", security(("bearer" = [])),
|
#[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)),
|
params(("min_severity" = Option<String>, Query), ("scope" = Option<String>, Query), ("target" = Option<String>, Query), ("status" = Option<String>, Query), ("include_fixed" = Option<bool>, Query)),
|
||||||
responses((status = 200, body = Vec<Object>)))]
|
responses((status = 200, body = Vec<Object>)))]
|
||||||
async fn list(
|
async fn list(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_: AuthUser,
|
_: AuthUser,
|
||||||
Query(q): Query<ListQuery>,
|
Query(q): Query<ListQuery>,
|
||||||
) -> Result<Json<Vec<Finding>>, ApiError> {
|
) -> Result<Json<Vec<Finding>>, ApiError> {
|
||||||
|
let target_kind = match q.scope.as_deref() {
|
||||||
|
None => None,
|
||||||
|
Some(s) => Some(TargetKind::parse_scope(s).ok_or_else(|| {
|
||||||
|
DomainError::Validation(format!(
|
||||||
|
"unknown scope '{s}', expected 'host' or 'container'"
|
||||||
|
))
|
||||||
|
})?),
|
||||||
|
};
|
||||||
let filter = FindingFilter {
|
let filter = FindingFilter {
|
||||||
min_severity: q.min_severity.as_deref().map(Severity::parse),
|
min_severity: q.min_severity.as_deref().map(Severity::parse),
|
||||||
|
target_kind,
|
||||||
target: q.target,
|
target: q.target,
|
||||||
status: q.status.as_deref().and_then(FindingStatus::parse),
|
status: q.status.as_deref().and_then(FindingStatus::parse),
|
||||||
include_fixed: q.include_fixed,
|
include_fixed: q.include_fixed,
|
||||||
@ -70,11 +82,11 @@ async fn summary(
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
#[utoipa::path(get, path = "/api/vulnerabilities/targets", tag = "vulnerabilities", security(("bearer" = [])), responses((status = 200, body = Vec<String>)))]
|
#[utoipa::path(get, path = "/api/vulnerabilities/targets", tag = "vulnerabilities", security(("bearer" = [])), responses((status = 200, body = Vec<Object>)))]
|
||||||
async fn targets(
|
async fn targets(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
_: AuthUser,
|
_: AuthUser,
|
||||||
) -> Result<Json<Vec<String>>, ApiError> {
|
) -> Result<Json<Vec<TargetSummary>>, ApiError> {
|
||||||
Ok(Json(state.vulns.targets().await?))
|
Ok(Json(state.vulns.targets().await?))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -72,7 +72,12 @@ async fn scan_populates_findings_summary_and_filters() {
|
|||||||
.iter()
|
.iter()
|
||||||
.all(|f| f["target"] == "os"));
|
.all(|f| f["target"] == "os"));
|
||||||
let targets = get(&app, "/api/vulnerabilities/targets", Some(&token)).await;
|
let targets = get(&app, "/api/vulnerabilities/targets", Some(&token)).await;
|
||||||
assert!(targets.json.as_array().unwrap().iter().any(|t| t == "os"));
|
assert!(targets
|
||||||
|
.json
|
||||||
|
.as_array()
|
||||||
|
.unwrap()
|
||||||
|
.iter()
|
||||||
|
.any(|t| t["target"] == "os" && t["kind"] == "os"));
|
||||||
|
|
||||||
// acknowledge one
|
// acknowledge one
|
||||||
let id = list[0]["id"].as_str().unwrap();
|
let id = list[0]["id"].as_str().unwrap();
|
||||||
|
|||||||
@ -525,6 +525,7 @@ pub fn raw(
|
|||||||
fixed_version: fixed.map(String::from),
|
fixed_version: fixed.map(String::from),
|
||||||
title: format!("{cve} in {pkg}"),
|
title: format!("{cve} in {pkg}"),
|
||||||
url: format!("https://nvd.nist.gov/vuln/detail/{cve}"),
|
url: format!("https://nvd.nist.gov/vuln/detail/{cve}"),
|
||||||
|
source: "debian".into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -627,6 +628,7 @@ impl FindingRepository for MemFindings {
|
|||||||
.iter()
|
.iter()
|
||||||
.filter(|f| filter.include_fixed || f.status != FindingStatus::Fixed)
|
.filter(|f| filter.include_fixed || f.status != FindingStatus::Fixed)
|
||||||
.filter(|f| filter.min_severity.is_none_or(|m| f.raw.severity >= m))
|
.filter(|f| filter.min_severity.is_none_or(|m| f.raw.severity >= m))
|
||||||
|
.filter(|f| filter.target_kind.is_none_or(|k| f.target_kind == k))
|
||||||
.filter(|f| filter.target.as_ref().is_none_or(|t| &f.target == t))
|
.filter(|f| filter.target.as_ref().is_none_or(|t| &f.target == t))
|
||||||
.filter(|f| filter.status.is_none_or(|s| f.status == s))
|
.filter(|f| filter.status.is_none_or(|s| f.status == s))
|
||||||
.cloned()
|
.cloned()
|
||||||
|
|||||||
@ -27,6 +27,19 @@ pub struct VulnerabilityService {
|
|||||||
pub const KEY_NOTIFY_MIN_SEVERITY: &str = "vuln.notify_min_severity";
|
pub const KEY_NOTIFY_MIN_SEVERITY: &str = "vuln.notify_min_severity";
|
||||||
pub const DEFAULT_NOTIFY_MIN_SEVERITY: Severity = Severity::High;
|
pub const DEFAULT_NOTIFY_MIN_SEVERITY: Severity = Severity::High;
|
||||||
|
|
||||||
|
/// One scanned target with its number of open findings.
|
||||||
|
#[derive(Clone, Debug, PartialEq, Eq, serde::Serialize)]
|
||||||
|
pub struct TargetSummary {
|
||||||
|
#[serde(serialize_with = "serialize_kind")]
|
||||||
|
pub kind: TargetKind,
|
||||||
|
pub target: String,
|
||||||
|
pub open: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn serialize_kind<S: serde::Serializer>(k: &TargetKind, s: S) -> Result<S::Ok, S::Error> {
|
||||||
|
s.serialize_str(k.as_str())
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq, serde::Serialize)]
|
||||||
pub struct Summary {
|
pub struct Summary {
|
||||||
pub total: SeverityCounts,
|
pub total: SeverityCounts,
|
||||||
@ -221,17 +234,21 @@ impl VulnerabilityService {
|
|||||||
self.findings.list(&filter).await
|
self.findings.list(&filter).await
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn targets(&self) -> Result<Vec<String>, DomainError> {
|
/// Scanned targets that still have open findings, host first.
|
||||||
let mut t: Vec<String> = self
|
pub async fn targets(&self) -> Result<Vec<TargetSummary>, DomainError> {
|
||||||
.findings
|
let mut by_target: std::collections::HashMap<(TargetKind, String), usize> =
|
||||||
.list(&FindingFilter::default())
|
Default::default();
|
||||||
.await?
|
for f in self.findings.list(&FindingFilter::default()).await? {
|
||||||
|
*by_target.entry((f.target_kind, f.target)).or_default() += 1;
|
||||||
|
}
|
||||||
|
let mut targets: Vec<TargetSummary> = by_target
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|f| f.target)
|
.map(|((kind, target), open)| TargetSummary { kind, target, open })
|
||||||
.collect();
|
.collect();
|
||||||
t.sort();
|
targets.sort_by(|a, b| {
|
||||||
t.dedup();
|
(a.kind != TargetKind::Os, &a.target).cmp(&(b.kind != TargetKind::Os, &b.target))
|
||||||
Ok(t)
|
});
|
||||||
|
Ok(targets)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub async fn summary(&self) -> Result<Summary, DomainError> {
|
pub async fn summary(&self) -> Result<Summary, DomainError> {
|
||||||
|
|||||||
@ -43,7 +43,7 @@ impl Severity {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum TargetKind {
|
pub enum TargetKind {
|
||||||
Os,
|
Os,
|
||||||
@ -51,6 +51,30 @@ pub enum TargetKind {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TargetKind {
|
impl TargetKind {
|
||||||
|
/// Category shown to the user: the Debian host, or the container images in the cluster.
|
||||||
|
pub fn label(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TargetKind::Os => "Host",
|
||||||
|
TargetKind::Image => "Containers",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Scope name used by the API (`?scope=`).
|
||||||
|
pub fn scope(self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
TargetKind::Os => "host",
|
||||||
|
TargetKind::Image => "container",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_scope(s: &str) -> Option<TargetKind> {
|
||||||
|
match s {
|
||||||
|
"host" => Some(TargetKind::Os),
|
||||||
|
"container" => Some(TargetKind::Image),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn as_str(self) -> &'static str {
|
pub fn as_str(self) -> &'static str {
|
||||||
match self {
|
match self {
|
||||||
TargetKind::Os => "os",
|
TargetKind::Os => "os",
|
||||||
@ -102,6 +126,10 @@ pub struct RawFinding {
|
|||||||
pub fixed_version: Option<String>,
|
pub fixed_version: Option<String>,
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub url: String,
|
pub url: String,
|
||||||
|
/// Package source reported by the scanner: `debian` for OS packages, `gobinary`,
|
||||||
|
/// `node-pkg`, `python-pkg` … for applications and libraries found on the target.
|
||||||
|
#[serde(default)]
|
||||||
|
pub source: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl RawFinding {
|
impl RawFinding {
|
||||||
@ -130,6 +158,8 @@ pub struct Finding {
|
|||||||
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
#[derive(Clone, Debug, Default, PartialEq, Eq)]
|
||||||
pub struct FindingFilter {
|
pub struct FindingFilter {
|
||||||
pub min_severity: Option<Severity>,
|
pub min_severity: Option<Severity>,
|
||||||
|
/// Restrict to one category (host or containers).
|
||||||
|
pub target_kind: Option<TargetKind>,
|
||||||
pub target: Option<String>,
|
pub target: Option<String>,
|
||||||
pub status: Option<FindingStatus>,
|
pub status: Option<FindingStatus>,
|
||||||
/// Include fixed findings (default: only open + acknowledged).
|
/// Include fixed findings (default: only open + acknowledged).
|
||||||
|
|||||||
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE findings ADD COLUMN source TEXT NOT NULL DEFAULT '';
|
||||||
@ -515,6 +515,7 @@ fn finding_from_row(r: &SqliteRow) -> Finding {
|
|||||||
fixed_version: r.get("fixed_version"),
|
fixed_version: r.get("fixed_version"),
|
||||||
title: r.get("title"),
|
title: r.get("title"),
|
||||||
url: r.get("url"),
|
url: r.get("url"),
|
||||||
|
source: r.get("source"),
|
||||||
},
|
},
|
||||||
status: FindingStatus::parse(r.get::<String, _>("status").as_str())
|
status: FindingStatus::parse(r.get::<String, _>("status").as_str())
|
||||||
.unwrap_or(FindingStatus::Open),
|
.unwrap_or(FindingStatus::Open),
|
||||||
@ -523,7 +524,7 @@ fn finding_from_row(r: &SqliteRow) -> Finding {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const FINDING_COLS: &str = "id, target_kind, target, cve_id, severity, package, installed_version, fixed_version, title, url, status, first_seen, last_seen";
|
const FINDING_COLS: &str = "id, target_kind, target, cve_id, severity, package, installed_version, fixed_version, title, url, source, status, first_seen, last_seen";
|
||||||
|
|
||||||
/// Severity ordering for SQL: higher is worse.
|
/// Severity ordering for SQL: higher is worse.
|
||||||
fn severity_rank(s: Severity) -> i32 {
|
fn severity_rank(s: Severity) -> i32 {
|
||||||
@ -552,7 +553,7 @@ impl domain::ports::FindingRepository for SqliteFindings {
|
|||||||
}
|
}
|
||||||
async fn insert(&self, f: &Finding) -> Result<(), DomainError> {
|
async fn insert(&self, f: &Finding) -> Result<(), DomainError> {
|
||||||
sqlx::query(&format!(
|
sqlx::query(&format!(
|
||||||
"INSERT INTO findings ({FINDING_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
"INSERT INTO findings ({FINDING_COLS}) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
|
||||||
))
|
))
|
||||||
.bind(f.id)
|
.bind(f.id)
|
||||||
.bind(f.target_kind.as_str())
|
.bind(f.target_kind.as_str())
|
||||||
@ -564,6 +565,7 @@ impl domain::ports::FindingRepository for SqliteFindings {
|
|||||||
.bind(&f.raw.fixed_version)
|
.bind(&f.raw.fixed_version)
|
||||||
.bind(&f.raw.title)
|
.bind(&f.raw.title)
|
||||||
.bind(&f.raw.url)
|
.bind(&f.raw.url)
|
||||||
|
.bind(&f.raw.source)
|
||||||
.bind(f.status.as_str())
|
.bind(f.status.as_str())
|
||||||
.bind(f.first_seen.to_rfc3339())
|
.bind(f.first_seen.to_rfc3339())
|
||||||
.bind(f.last_seen.to_rfc3339())
|
.bind(f.last_seen.to_rfc3339())
|
||||||
@ -613,6 +615,9 @@ impl domain::ports::FindingRepository for SqliteFindings {
|
|||||||
if filter.target.is_some() {
|
if filter.target.is_some() {
|
||||||
sql.push_str(" AND target = ?");
|
sql.push_str(" AND target = ?");
|
||||||
}
|
}
|
||||||
|
if filter.target_kind.is_some() {
|
||||||
|
sql.push_str(" AND target_kind = ?");
|
||||||
|
}
|
||||||
if filter.min_severity.is_some() {
|
if filter.min_severity.is_some() {
|
||||||
sql.push_str(&format!(" AND {SEVERITY_RANK_SQL} >= ?"));
|
sql.push_str(&format!(" AND {SEVERITY_RANK_SQL} >= ?"));
|
||||||
}
|
}
|
||||||
@ -626,6 +631,9 @@ impl domain::ports::FindingRepository for SqliteFindings {
|
|||||||
if let Some(t) = &filter.target {
|
if let Some(t) = &filter.target {
|
||||||
q = q.bind(t);
|
q = q.bind(t);
|
||||||
}
|
}
|
||||||
|
if let Some(k) = filter.target_kind {
|
||||||
|
q = q.bind(k.as_str());
|
||||||
|
}
|
||||||
if let Some(m) = filter.min_severity {
|
if let Some(m) = filter.min_severity {
|
||||||
q = q.bind(severity_rank(m));
|
q = q.bind(severity_rank(m));
|
||||||
}
|
}
|
||||||
|
|||||||
@ -36,6 +36,9 @@ struct Report {
|
|||||||
|
|
||||||
#[derive(serde::Deserialize)]
|
#[derive(serde::Deserialize)]
|
||||||
struct ResultEntry {
|
struct ResultEntry {
|
||||||
|
/// Package source: `debian` for OS packages, `gobinary`/`node-pkg`/… for applications.
|
||||||
|
#[serde(rename = "Type", default)]
|
||||||
|
kind: String,
|
||||||
#[serde(rename = "Vulnerabilities", default)]
|
#[serde(rename = "Vulnerabilities", default)]
|
||||||
vulnerabilities: Option<Vec<Vuln>>,
|
vulnerabilities: Option<Vec<Vuln>>,
|
||||||
}
|
}
|
||||||
@ -68,8 +71,14 @@ pub fn parse_trivy_json(json: &str) -> Result<Vec<RawFinding>, DomainError> {
|
|||||||
.results
|
.results
|
||||||
.unwrap_or_default()
|
.unwrap_or_default()
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.flat_map(|r| r.vulnerabilities.unwrap_or_default());
|
.flat_map(|r| {
|
||||||
for v in vulns {
|
let kind = r.kind;
|
||||||
|
r.vulnerabilities
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.map(move |v| (kind.clone(), v))
|
||||||
|
});
|
||||||
|
for (source, v) in vulns {
|
||||||
let f = RawFinding {
|
let f = RawFinding {
|
||||||
title: v
|
title: v
|
||||||
.title
|
.title
|
||||||
@ -83,6 +92,7 @@ pub fn parse_trivy_json(json: &str) -> Result<Vec<RawFinding>, DomainError> {
|
|||||||
package: v.pkg,
|
package: v.pkg,
|
||||||
installed_version: v.installed,
|
installed_version: v.installed,
|
||||||
fixed_version: v.fixed.filter(|f| !f.is_empty()),
|
fixed_version: v.fixed.filter(|f| !f.is_empty()),
|
||||||
|
source,
|
||||||
};
|
};
|
||||||
if seen.insert(f.key()) {
|
if seen.insert(f.key()) {
|
||||||
out.push(f);
|
out.push(f);
|
||||||
@ -190,6 +200,7 @@ impl VulnerabilityScanner for FakeScanner {
|
|||||||
fixed_version: Some("3.0.16-1~deb12u1".into()),
|
fixed_version: Some("3.0.16-1~deb12u1".into()),
|
||||||
title: "openssl: SSL_select_next_proto buffer overread".into(),
|
title: "openssl: SSL_select_next_proto buffer overread".into(),
|
||||||
url: "https://avd.aquasec.com/nvd/cve-2024-5535".into(),
|
url: "https://avd.aquasec.com/nvd/cve-2024-5535".into(),
|
||||||
|
source: "debian".into(),
|
||||||
},
|
},
|
||||||
RawFinding {
|
RawFinding {
|
||||||
cve_id: "CVE-2023-45853".into(),
|
cve_id: "CVE-2023-45853".into(),
|
||||||
@ -199,6 +210,7 @@ impl VulnerabilityScanner for FakeScanner {
|
|||||||
fixed_version: None,
|
fixed_version: None,
|
||||||
title: "zlib: integer overflow in zipOpenNewFileInZip4_64".into(),
|
title: "zlib: integer overflow in zipOpenNewFileInZip4_64".into(),
|
||||||
url: "https://avd.aquasec.com/nvd/cve-2023-45853".into(),
|
url: "https://avd.aquasec.com/nvd/cve-2023-45853".into(),
|
||||||
|
source: "debian".into(),
|
||||||
},
|
},
|
||||||
RawFinding {
|
RawFinding {
|
||||||
cve_id: "CVE-2011-3374".into(),
|
cve_id: "CVE-2011-3374".into(),
|
||||||
@ -208,6 +220,7 @@ impl VulnerabilityScanner for FakeScanner {
|
|||||||
fixed_version: None,
|
fixed_version: None,
|
||||||
title: "apt: unsigned repository".into(),
|
title: "apt: unsigned repository".into(),
|
||||||
url: "https://avd.aquasec.com/nvd/cve-2011-3374".into(),
|
url: "https://avd.aquasec.com/nvd/cve-2011-3374".into(),
|
||||||
|
source: "debian".into(),
|
||||||
},
|
},
|
||||||
// production-sized identifiers: long Go module path, multi-version fix list
|
// production-sized identifiers: long Go module path, multi-version fix list
|
||||||
RawFinding {
|
RawFinding {
|
||||||
@ -220,6 +233,7 @@ impl VulnerabilityScanner for FakeScanner {
|
|||||||
),
|
),
|
||||||
title: "otelhttp: unbounded cardinality metrics".into(),
|
title: "otelhttp: unbounded cardinality metrics".into(),
|
||||||
url: "https://github.com/advisories/GHSA-hrxh-6v49-42gf".into(),
|
url: "https://github.com/advisories/GHSA-hrxh-6v49-42gf".into(),
|
||||||
|
source: "gobinary".into(),
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
}
|
}
|
||||||
@ -238,6 +252,7 @@ impl VulnerabilityScanner for FakeScanner {
|
|||||||
fixed_version: Some("1.21.11".into()),
|
fixed_version: Some("1.21.11".into()),
|
||||||
title: "golang: net/netip unexpected behavior".into(),
|
title: "golang: net/netip unexpected behavior".into(),
|
||||||
url: "https://avd.aquasec.com/nvd/cve-2024-24790".into(),
|
url: "https://avd.aquasec.com/nvd/cve-2024-24790".into(),
|
||||||
|
source: "gobinary".into(),
|
||||||
}]
|
}]
|
||||||
} else if image.contains("postgres") {
|
} else if image.contains("postgres") {
|
||||||
vec![RawFinding {
|
vec![RawFinding {
|
||||||
@ -248,6 +263,7 @@ impl VulnerabilityScanner for FakeScanner {
|
|||||||
fixed_version: Some("3.0.14".into()),
|
fixed_version: Some("3.0.14".into()),
|
||||||
title: "openssl: use after free".into(),
|
title: "openssl: use after free".into(),
|
||||||
url: "https://avd.aquasec.com/nvd/cve-2024-4741".into(),
|
url: "https://avd.aquasec.com/nvd/cve-2024-4741".into(),
|
||||||
|
source: "debian".into(),
|
||||||
}]
|
}]
|
||||||
} else {
|
} else {
|
||||||
vec![]
|
vec![]
|
||||||
|
|||||||
@ -34,7 +34,7 @@ test('no page scrolls horizontally, with data and on a narrow window', async ({
|
|||||||
await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
|
await expect(page.getByTestId('os-name')).toContainText('Debian', { timeout: 15_000 })
|
||||||
await page.goto('/vulnerabilities')
|
await page.goto('/vulnerabilities')
|
||||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||||
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||||
|
|
||||||
for (const width of [1440, 1280, 1024, 900]) {
|
for (const width of [1440, 1280, 1024, 900]) {
|
||||||
await page.setViewportSize({ width, height: 800 })
|
await page.setViewportSize({ width, height: 800 })
|
||||||
@ -51,7 +51,7 @@ test('the findings table scrolls inside its own container', async ({ page }) =>
|
|||||||
await login(page)
|
await login(page)
|
||||||
await page.goto('/vulnerabilities')
|
await page.goto('/vulnerabilities')
|
||||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||||
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||||
|
|
||||||
expect(await pageOverflow(page)).toBe(0)
|
expect(await pageOverflow(page)).toBe(0)
|
||||||
const scrollable = await page
|
const scrollable = await page
|
||||||
|
|||||||
@ -5,6 +5,7 @@ async function scan(page: Page) {
|
|||||||
await page.getByLabel('Email').fill('admin@example.com')
|
await page.getByLabel('Email').fill('admin@example.com')
|
||||||
await page.getByLabel('Password').fill('admin-password-123')
|
await page.getByLabel('Password').fill('admin-password-123')
|
||||||
await page.getByRole('button', { name: 'Sign in' }).click()
|
await page.getByRole('button', { name: 'Sign in' }).click()
|
||||||
|
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible()
|
||||||
await page.goto('/vulnerabilities')
|
await page.goto('/vulnerabilities')
|
||||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||||
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||||
@ -30,10 +31,15 @@ test('host and container findings are separated into two categories', async ({ p
|
|||||||
await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toBeVisible()
|
await expect(page.getByRole('row', { name: /CVE-2024-24790/ })).toBeVisible()
|
||||||
await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toHaveCount(0)
|
await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toHaveCount(0)
|
||||||
await expect(page.getByTestId('findings-scroll')).toContainText('Image')
|
await expect(page.getByTestId('findings-scroll')).toContainText('Image')
|
||||||
await expect(page.getByText('gitea')).toBeVisible()
|
await expect(page.getByRole('row', { name: /gitea\/gitea/ }).first()).toBeVisible()
|
||||||
|
|
||||||
// the target filter only offers targets of the selected category
|
// the filter is labelled per category and only offers targets of that category
|
||||||
const options = await page.getByLabel('Target').locator('option').allTextContents()
|
const images = await page.getByLabel('Image', { exact: true }).locator('option').allTextContents()
|
||||||
expect(options.some((o) => o.includes('gitea'))).toBe(true)
|
expect(images.some((o) => o.includes('gitea'))).toBe(true)
|
||||||
expect(options).not.toContain('os')
|
expect(images.some((o) => o.startsWith('os'))).toBe(false)
|
||||||
|
|
||||||
|
await page.getByTestId('scope-host').click()
|
||||||
|
const hosts = await page.getByLabel('Target', { exact: true }).locator('option').allTextContents()
|
||||||
|
expect(hosts.some((o) => o.startsWith('os'))).toBe(true)
|
||||||
|
expect(hosts.some((o) => o.includes('gitea'))).toBe(false)
|
||||||
})
|
})
|
||||||
|
|||||||
@ -8,7 +8,7 @@ test('admin runs a scan, filters findings and acknowledges one', async ({ page }
|
|||||||
await page.getByRole('navigation').getByRole('link', { name: 'Vulnerabilities' }).click()
|
await page.getByRole('navigation').getByRole('link', { name: 'Vulnerabilities' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Scan now' }).click()
|
await page.getByRole('button', { name: 'Scan now' }).click()
|
||||||
await expect(page.getByTestId('count-critical')).not.toHaveText('0', { timeout: 20_000 })
|
await expect(page.getByTestId('scope-host-critical')).not.toHaveText('0', { timeout: 20_000 })
|
||||||
await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible()
|
await expect(page.getByRole('row', { name: /CVE-2023-45853/ })).toBeVisible()
|
||||||
|
|
||||||
await page.getByLabel('Minimum severity').selectOption('critical')
|
await page.getByLabel('Minimum severity').selectOption('critical')
|
||||||
|
|||||||
@ -33,7 +33,7 @@ const findings = [
|
|||||||
|
|
||||||
describe('FindingTable', () => {
|
describe('FindingTable', () => {
|
||||||
it('renders severity, target, fix version and status', () => {
|
it('renders severity, target, fix version and status', () => {
|
||||||
const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'host' } })
|
const w = mount(FindingTable, { props: { findings, canAct: true, scope: 'container' } })
|
||||||
const rows = w.findAll('tbody tr')
|
const rows = w.findAll('tbody tr')
|
||||||
expect(rows).toHaveLength(2)
|
expect(rows).toHaveLength(2)
|
||||||
expect(rows[0].text()).toContain('critical')
|
expect(rows[0].text()).toContain('critical')
|
||||||
|
|||||||
@ -1,7 +1,7 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { Finding } from '../api/types'
|
import type { Finding, FindingScope } from '../api/types'
|
||||||
|
|
||||||
defineProps<{ findings: Finding[]; canAct: boolean }>()
|
defineProps<{ findings: Finding[]; canAct: boolean; scope: FindingScope }>()
|
||||||
defineEmits<{ status: [f: Finding, status: 'open' | 'acknowledged']; select: [f: Finding] }>()
|
defineEmits<{ status: [f: Finding, status: 'open' | 'acknowledged']; select: [f: Finding] }>()
|
||||||
|
|
||||||
const sev: Record<string, string> = {
|
const sev: Record<string, string> = {
|
||||||
@ -23,7 +23,7 @@ const sev: Record<string, string> = {
|
|||||||
<th>Package</th>
|
<th>Package</th>
|
||||||
<th>Installed</th>
|
<th>Installed</th>
|
||||||
<th>Fixed in</th>
|
<th>Fixed in</th>
|
||||||
<th>Target</th>
|
<th>{{ scope === 'host' ? 'Source' : 'Image' }}</th>
|
||||||
<th>Status</th>
|
<th>Status</th>
|
||||||
<th></th>
|
<th></th>
|
||||||
</tr>
|
</tr>
|
||||||
@ -52,7 +52,18 @@ const sev: Record<string, string> = {
|
|||||||
>
|
>
|
||||||
{{ f.fixed_version ?? '–' }}
|
{{ f.fixed_version ?? '–' }}
|
||||||
</td>
|
</td>
|
||||||
<td class="max-w-[18rem] truncate font-mono text-xs" :title="f.target">{{ f.target }}</td>
|
<td
|
||||||
|
class="max-w-[18rem] truncate text-xs"
|
||||||
|
:title="scope === 'host' ? f.source : f.target"
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
v-if="scope === 'host'"
|
||||||
|
class="rounded bg-gray-100 px-1.5 py-0.5 font-mono text-gray-700"
|
||||||
|
>
|
||||||
|
{{ f.source || 'unknown' }}
|
||||||
|
</span>
|
||||||
|
<span v-else class="font-mono">{{ f.target }}</span>
|
||||||
|
</td>
|
||||||
<td>{{ f.status }}</td>
|
<td>{{ f.status }}</td>
|
||||||
<td class="space-x-3 whitespace-nowrap pl-2 text-right">
|
<td class="space-x-3 whitespace-nowrap pl-2 text-right">
|
||||||
<button
|
<button
|
||||||
|
|||||||
75
frontend/src/components/ScopeTabs.vue
Normal file
75
frontend/src/components/ScopeTabs.vue
Normal file
@ -0,0 +1,75 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import type { FindingScope, SeverityCounts } from '../api/types'
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: FindingScope
|
||||||
|
host: SeverityCounts
|
||||||
|
container: SeverityCounts
|
||||||
|
hostTargets: number
|
||||||
|
containerTargets: number
|
||||||
|
}>()
|
||||||
|
const emit = defineEmits<{ 'update:modelValue': [scope: FindingScope] }>()
|
||||||
|
|
||||||
|
const total = (c: SeverityCounts) => c.critical + c.high + c.medium + c.low + c.unknown
|
||||||
|
|
||||||
|
const cards = () => [
|
||||||
|
{
|
||||||
|
scope: 'host' as const,
|
||||||
|
title: 'Host',
|
||||||
|
subtitle: 'OS, packages and applications on the Debian server',
|
||||||
|
counts: props.host,
|
||||||
|
targets: `${props.hostTargets} scanned root filesystem${props.hostTargets === 1 ? '' : 's'}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
scope: 'container' as const,
|
||||||
|
title: 'Containers',
|
||||||
|
subtitle: 'Container images running in the Kubernetes cluster',
|
||||||
|
counts: props.container,
|
||||||
|
targets: `${props.containerTargets} images`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
const chips = [
|
||||||
|
{ key: 'critical' as const, label: 'Critical', cls: 'bg-red-600 text-white' },
|
||||||
|
{ key: 'high' as const, label: 'High', cls: 'bg-orange-500 text-white' },
|
||||||
|
{ key: 'medium' as const, label: 'Medium', cls: 'bg-amber-300 text-amber-900' },
|
||||||
|
{ key: 'low' as const, label: 'Low', cls: 'bg-gray-200 text-gray-700' },
|
||||||
|
]
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="grid gap-4 md:grid-cols-2">
|
||||||
|
<button
|
||||||
|
v-for="c in cards()"
|
||||||
|
:key="c.scope"
|
||||||
|
type="button"
|
||||||
|
:data-testid="`scope-${c.scope}`"
|
||||||
|
:aria-pressed="modelValue === c.scope"
|
||||||
|
class="rounded-lg border p-4 text-left transition"
|
||||||
|
:class="
|
||||||
|
modelValue === c.scope
|
||||||
|
? 'border-blue-500 bg-white ring-2 ring-blue-200'
|
||||||
|
: 'border-gray-200 bg-gray-50 hover:border-gray-300 hover:bg-white'
|
||||||
|
"
|
||||||
|
@click="emit('update:modelValue', c.scope)"
|
||||||
|
>
|
||||||
|
<div class="flex items-baseline justify-between">
|
||||||
|
<span class="text-lg font-semibold">{{ c.title }}</span>
|
||||||
|
<span class="text-sm text-gray-500">{{ total(c.counts) }} open</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-0.5 text-sm text-gray-600">{{ c.subtitle }}</p>
|
||||||
|
<div class="mt-3 flex flex-wrap gap-2">
|
||||||
|
<span
|
||||||
|
v-for="s in chips"
|
||||||
|
:key="s.key"
|
||||||
|
class="inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium"
|
||||||
|
:class="s.cls"
|
||||||
|
>
|
||||||
|
{{ s.label }}
|
||||||
|
<span :data-testid="`scope-${c.scope}-${s.key}`">{{ c.counts[s.key] }}</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="mt-2 text-xs text-gray-500">{{ c.targets }}</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@ -1,16 +1,25 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
import { api, ApiError } from '../api/client'
|
import { api, ApiError } from '../api/client'
|
||||||
import type { Finding, JobRun, Severity, VulnSummary } from '../api/types'
|
import type {
|
||||||
|
Finding,
|
||||||
|
FindingScope,
|
||||||
|
JobRun,
|
||||||
|
Severity,
|
||||||
|
TargetSummary,
|
||||||
|
VulnSummary,
|
||||||
|
} from '../api/types'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import { useToastStore } from '../stores/toast'
|
import { useToastStore } from '../stores/toast'
|
||||||
import FindingTable from '../components/FindingTable.vue'
|
import FindingTable from '../components/FindingTable.vue'
|
||||||
|
import ScopeTabs from '../components/ScopeTabs.vue'
|
||||||
|
|
||||||
const auth = useAuthStore()
|
const auth = useAuthStore()
|
||||||
const toast = useToastStore()
|
const toast = useToastStore()
|
||||||
const summary = ref<VulnSummary | null>(null)
|
const summary = ref<VulnSummary | null>(null)
|
||||||
const findings = ref<Finding[]>([])
|
const findings = ref<Finding[]>([])
|
||||||
const targets = ref<string[]>([])
|
const targets = ref<TargetSummary[]>([])
|
||||||
|
const scope = ref<FindingScope>('host')
|
||||||
const minSeverity = ref<Severity>('low')
|
const minSeverity = ref<Severity>('low')
|
||||||
const target = ref('')
|
const target = ref('')
|
||||||
const status = ref('')
|
const status = ref('')
|
||||||
@ -23,21 +32,33 @@ const fail = (e: unknown) => toast.error(e instanceof ApiError ? e.message : 'Re
|
|||||||
|
|
||||||
async function load() {
|
async function load() {
|
||||||
const q = new URLSearchParams()
|
const q = new URLSearchParams()
|
||||||
|
q.set('scope', scope.value)
|
||||||
q.set('min_severity', minSeverity.value)
|
q.set('min_severity', minSeverity.value)
|
||||||
if (target.value) q.set('target', target.value)
|
if (target.value) q.set('target', target.value)
|
||||||
if (status.value) q.set('status', status.value)
|
if (status.value) q.set('status', status.value)
|
||||||
const [s, f, t] = await Promise.all([
|
const [s, f, t] = await Promise.all([
|
||||||
api.get<VulnSummary>('/api/vulnerabilities/summary'),
|
api.get<VulnSummary>('/api/vulnerabilities/summary'),
|
||||||
api.get<Finding[]>(`/api/vulnerabilities?${q}`),
|
api.get<Finding[]>(`/api/vulnerabilities?${q}`),
|
||||||
api.get<string[]>('/api/vulnerabilities/targets'),
|
api.get<TargetSummary[]>('/api/vulnerabilities/targets'),
|
||||||
])
|
])
|
||||||
summary.value = s
|
summary.value = s
|
||||||
findings.value = f
|
findings.value = f
|
||||||
targets.value = t
|
targets.value = t
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Targets of the selected category, for the filter dropdown.
|
||||||
|
const scopeTargets = computed(() =>
|
||||||
|
targets.value.filter((t) => (scope.value === 'host' ? t.kind === 'os' : t.kind === 'image')),
|
||||||
|
)
|
||||||
|
|
||||||
|
function selectScope(next: FindingScope) {
|
||||||
|
if (next === scope.value) return
|
||||||
|
scope.value = next
|
||||||
|
target.value = ''
|
||||||
|
}
|
||||||
onMounted(() => load().catch(fail))
|
onMounted(() => load().catch(fail))
|
||||||
onUnmounted(() => clearInterval(timer))
|
onUnmounted(() => clearInterval(timer))
|
||||||
watch([minSeverity, target, status], () => load().catch(fail))
|
watch([scope, minSeverity, target, status], () => load().catch(fail))
|
||||||
|
|
||||||
async function scan() {
|
async function scan() {
|
||||||
scanning.value = true
|
scanning.value = true
|
||||||
@ -69,13 +90,6 @@ async function setStatus(f: Finding, s: 'open' | 'acknowledged') {
|
|||||||
fail(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>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@ -91,24 +105,22 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-if="summary" class="mt-6 grid gap-4 md:grid-cols-5">
|
<ScopeTabs
|
||||||
<div v-for="t in tiles" :key="t.key" class="rounded-lg border border-gray-200 bg-white p-4">
|
v-if="summary"
|
||||||
<div class="text-xs uppercase text-gray-500">{{ t.label }}</div>
|
class="mt-6"
|
||||||
<div :data-testid="`count-${t.key}`" class="mt-1 text-2xl font-semibold" :class="t.cls">
|
:model-value="scope"
|
||||||
{{ summary.total[t.key] }}
|
:host="summary.os"
|
||||||
</div>
|
:container="summary.images"
|
||||||
<div class="text-xs text-gray-500">
|
:host-targets="targets.filter((t) => t.kind === 'os').length"
|
||||||
OS {{ summary.os[t.key] }} · images {{ summary.images[t.key] }}
|
:container-targets="targets.filter((t) => t.kind === 'image').length"
|
||||||
</div>
|
@update:model-value="selectScope"
|
||||||
</div>
|
/>
|
||||||
<div class="rounded-lg border border-gray-200 bg-white p-4">
|
|
||||||
<div class="text-xs uppercase text-gray-500">Last scan</div>
|
<p v-if="summary" class="mt-3 text-sm text-gray-500">
|
||||||
<div class="mt-1 text-sm font-medium">
|
Last scan {{ summary.last_scan ? new Date(summary.last_scan).toLocaleString() : 'never' }} ·
|
||||||
{{ summary.last_scan ? new Date(summary.last_scan).toLocaleString() : 'never' }}
|
scanner
|
||||||
</div>
|
{{ summary.scanner }}
|
||||||
<div class="text-xs text-gray-500">scanner {{ summary.scanner }}</div>
|
</p>
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-if="scanRun && scanRun.status === 'running'"
|
v-if="scanRun && scanRun.status === 'running'"
|
||||||
@ -133,14 +145,18 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label for="f-target" class="block text-sm font-medium">Target</label>
|
<label for="f-target" class="block text-sm font-medium">{{
|
||||||
|
scope === 'host' ? 'Target' : 'Image'
|
||||||
|
}}</label>
|
||||||
<select
|
<select
|
||||||
id="f-target"
|
id="f-target"
|
||||||
v-model="target"
|
v-model="target"
|
||||||
class="mt-1 max-w-xs rounded-md border border-gray-300 px-3 py-2"
|
class="mt-1 max-w-xs rounded-md border border-gray-300 px-3 py-2"
|
||||||
>
|
>
|
||||||
<option value="">All targets</option>
|
<option value="">{{ scope === 'host' ? 'All targets' : 'All images' }}</option>
|
||||||
<option v-for="t in targets" :key="t" :value="t">{{ t }}</option>
|
<option v-for="t in scopeTargets" :key="t.target" :value="t.target">
|
||||||
|
{{ t.target }} ({{ t.open }})
|
||||||
|
</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
@ -155,12 +171,15 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
|
|||||||
<option value="acknowledged">Acknowledged</option>
|
<option value="acknowledged">Acknowledged</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<span class="pb-2 text-sm text-gray-500">{{ findings.length }} findings</span>
|
<span class="pb-2 text-sm text-gray-500">
|
||||||
|
{{ findings.length }} {{ scope === 'host' ? 'host' : 'container' }} findings
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FindingTable
|
<FindingTable
|
||||||
class="mt-4"
|
class="mt-4"
|
||||||
:findings="findings"
|
:findings="findings"
|
||||||
|
:scope="scope"
|
||||||
:can-act="auth.isAdmin"
|
:can-act="auth.isAdmin"
|
||||||
@status="setStatus"
|
@status="setStatus"
|
||||||
@select="selected = $event"
|
@select="selected = $event"
|
||||||
@ -183,8 +202,13 @@ const tiles: { key: keyof VulnSummary['total']; label: string; cls: string }[] =
|
|||||||
</dd>
|
</dd>
|
||||||
<dt class="text-gray-500">Fixed in</dt>
|
<dt class="text-gray-500">Fixed in</dt>
|
||||||
<dd class="col-span-2 font-mono">{{ selected.fixed_version ?? 'no fix available' }}</dd>
|
<dd class="col-span-2 font-mono">{{ selected.fixed_version ?? 'no fix available' }}</dd>
|
||||||
<dt class="text-gray-500">Target</dt>
|
<dt class="text-gray-500">{{ selected.target_kind === 'os' ? 'Found on' : 'Image' }}</dt>
|
||||||
<dd class="col-span-2 font-mono break-all">{{ selected.target }}</dd>
|
<dd class="col-span-2 font-mono break-all">
|
||||||
|
{{ selected.target_kind === 'os' ? 'Debian host' : selected.target }}
|
||||||
|
<span v-if="selected.source" class="ml-1 rounded bg-gray-100 px-1.5 py-0.5 text-xs">{{
|
||||||
|
selected.source
|
||||||
|
}}</span>
|
||||||
|
</dd>
|
||||||
<dt class="text-gray-500">First seen</dt>
|
<dt class="text-gray-500">First seen</dt>
|
||||||
<dd class="col-span-2">{{ new Date(selected.first_seen).toLocaleString() }}</dd>
|
<dd class="col-span-2">{{ new Date(selected.first_seen).toLocaleString() }}</dd>
|
||||||
<dt class="text-gray-500">Last seen</dt>
|
<dt class="text-gray-500">Last seen</dt>
|
||||||
|
|||||||
Reference in New Issue
Block a user