mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-31 20:58:04 +00:00
feat(security): action-posture Security dashboard with exploit intel and triage (#1424)
* feat(security): reframe masthead as action posture, not worst-CVE severity Derive the Security masthead from an action posture (Action needed / Monitoring / Secure / Unknown) instead of raw scanner severity, and label the raw Critical/High counts as scanner detections. "Secure" now means nothing is actionable right now, never a claim that no vulnerabilities exist; Unknown covers a missing scanner or a node with no completed scan. Phase-1 bootstrap: "actionable" is approximated from the overview facts that already exist (fixable findings, secrets, misconfigs); a later phase moves the bucketing to the backend. * feat(security): derive overview action posture from triaged facts Add deriveSecurityPosture as the single bucketing function and extend /security/overview with posture facts (fixableCriticalHigh, dangerousCompose, accepted, rawCritical/rawHigh, plus knownExploited/publiclyExposed placeholders that later phases populate) and the derived posture verb. Suppression- and acknowledgement-aware counts come from one bounded read-time pass over the latest-scan Critical/High findings, grouped per image so the existing read-time filters apply unchanged. The pass is capped and flags posturePartial, so a large node degrades gracefully instead of scanning every detail row. The masthead now prefers the backend posture and keeps the local bootstrap only as a fallback for older remote nodes reached through the proxy. * feat(security): capture Trivy finding enrichment (status, CVSS, vendor, purl, layer) parseTrivyOutput now keeps the per-finding fields Trivy already returns and we previously discarded: Status (fixed / will_not_fix / end_of_life / ...), CVSS (score + vector, preferring the NVD source then falling back), vendor severity, package URL, package path, and layer digest. Persisted on vulnerability_details via additive nullable columns (guarded ALTER), bound null when absent, and carried through the cached-scan reconstruction path. These fields separate scary from exploitable and feed the action posture and the per-finding evidence tags. Field paths verified against Trivy's documented image-scan JSON; covered by parse and insert/read round-trip tests. * feat(security): add CVE exploit-intel service (CISA KEV + FIRST EPSS) Add CveIntelService, a daily background cache of CISA KEV membership and FIRST EPSS scores stored in a new cve_intel table and joined to findings at read time by CVE id (never frozen onto scan rows, so a CVE entering KEV later lights up on scans already stored). EPSS is fetched only for CVE ids present in stored findings, batched; both feeds are best-effort and keep the last cache on failure, so the Security page degrades gracefully offline. Wired into startup/shutdown like the other background services. The overview now counts known-exploited Critical/High findings, and KEV membership escalates posture to Action needed even when no fix is available. A per-instance "Exploit intelligence" toggle on the scanner setup surface lets air-gapped or firewalled hosts disable the outbound fetch; the daily tick keeps running but skips the fetch body when it is off. * feat(security): show per-finding evidence tags (KEV, EPSS, vendor status, CVSS) The vulnerabilities endpoint joins read-time exploit intel (KEV membership and EPSS score) onto each finding by CVE id, and the scan sheet renders evidence tags beside each CVE: known-exploited, EPSS probability, vendor will-not-fix / end-of-life, and the CVSS score. Severity becomes one signal among several so an operator can tell scary from exploitable, with no invented composite score. * feat(security): evolve CVE suppressions into triage decisions Layer a triage status and optional OpenVEX justification onto CVE suppressions. Statuses: needs review / affected / not affected / accepted risk / fixed / false positive / ignored. Dismissing states (not affected, accepted, fixed, false positive, ignored) stop a finding from driving the action posture; needs review and affected stay actionable and are surfaced as counts. Existing rows default to "accepted" (the prior suppress behavior), so nothing changes for them. The overview now reports needsReview / notAffected / accepted as distinct facts derived from the triage status. The decision replicates across the fleet (snapshot + replicated-insert carry status + justification) so a replica's posture matches the control node. The inline suppress dialog gains a triage decision selector; the read-time filter surfaces the status and justification on every finding. * feat(security): export fleet triage decisions as OpenVEX (Admiral) Add an OpenVEX exporter that turns the instance's CVE triage decisions into a standard VEX document (not_affected / fixed / affected / under_investigation, with justifications), and a GET /security/vex/export endpoint to download it. Authoring fleet VEX is a governance capability, so it is gated to Admiral (paid) plus admin, mirroring the SARIF export gate; the Suppressions panel shows an Export VEX action only on Admiral. * docs(security): document action posture, evidence tags, exploit intel, and triage Update the Security page and CVE suppressions docs for the action-posture masthead (scanner detections vs product posture), per-finding evidence tags (KEV / EPSS / CVSS / vendor status), the exploit-intelligence toggle (CISA KEV + FIRST EPSS) on scanner setup, triage decisions layered on suppressions, and OpenVEX export of fleet triage decisions. * test(security): match intel hosts exactly in CveIntelService test Route the fetch stub and its call assertions by exact hostname (www.cisa.gov / api.first.org) instead of a domain substring check. Resolves the js/incomplete-url-substring-sanitization code-scanning alerts on the test's URL routing; behavior is unchanged.
This commit is contained in:
@@ -72,12 +72,18 @@ function diag(msg: string, ...args: unknown[]): void {
|
||||
interface TrivyRawVulnerability {
|
||||
VulnerabilityID?: string;
|
||||
PkgName?: string;
|
||||
PkgPath?: string;
|
||||
PkgIdentifier?: { PURL?: string };
|
||||
InstalledVersion?: string;
|
||||
FixedVersion?: string;
|
||||
Status?: string;
|
||||
Severity?: string;
|
||||
Title?: string;
|
||||
Description?: string;
|
||||
PrimaryURL?: string;
|
||||
Layer?: { Digest?: string; DiffID?: string };
|
||||
VendorSeverity?: Record<string, number>;
|
||||
CVSS?: Record<string, { V3Vector?: string; V3Score?: number }>;
|
||||
}
|
||||
|
||||
interface TrivyRawSecret {
|
||||
@@ -178,6 +184,18 @@ export interface TrivyVulnerability {
|
||||
title: string;
|
||||
description: string;
|
||||
primaryUrl: string | null;
|
||||
// Scan-intrinsic enrichment Trivy returns per finding. These separate scary
|
||||
// from exploitable: `status` (fixed / will_not_fix / end_of_life / ...) drives
|
||||
// posture, the others power evidence tags. Captured here, joined with
|
||||
// time-varying intel (KEV/EPSS) only at read time.
|
||||
status: string | null;
|
||||
cvssScore: number | null;
|
||||
cvssVector: string | null;
|
||||
cvssSource: string | null;
|
||||
vendorSeverity: VulnSeverity | null;
|
||||
purl: string | null;
|
||||
pkgPath: string | null;
|
||||
layerDigest: string | null;
|
||||
}
|
||||
|
||||
export interface TrivySecret {
|
||||
@@ -274,6 +292,37 @@ function normalizeSeverity(raw: string | undefined): VulnSeverity {
|
||||
return 'UNKNOWN';
|
||||
}
|
||||
|
||||
// Trivy reports CVSS keyed by source (nvd, redhat, ...). Prefer NVD, else the
|
||||
// first available source. Returns nulls when no V3 score/vector is present.
|
||||
function pickCvss(
|
||||
cvss: Record<string, { V3Vector?: string; V3Score?: number }> | undefined,
|
||||
): { score: number | null; vector: string | null; source: string | null } {
|
||||
if (!cvss) return { score: null, vector: null, source: null };
|
||||
const source = cvss.nvd ? 'nvd' : Object.keys(cvss)[0];
|
||||
const entry = source ? cvss[source] : undefined;
|
||||
if (!entry || (typeof entry.V3Score !== 'number' && !entry.V3Vector)) {
|
||||
return { score: null, vector: null, source: null };
|
||||
}
|
||||
return {
|
||||
score: typeof entry.V3Score === 'number' ? entry.V3Score : null,
|
||||
vector: entry.V3Vector ?? null,
|
||||
source: source ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
// Trivy's VendorSeverity is a vendor->numeric map (1=Low..4=Critical). Collapse
|
||||
// to the highest vendor rating as a label so the UI can flag a vendor that rates
|
||||
// a finding differently from NVD.
|
||||
const VENDOR_SEVERITY_LABEL: Record<number, VulnSeverity> = { 1: 'LOW', 2: 'MEDIUM', 3: 'HIGH', 4: 'CRITICAL' };
|
||||
function pickVendorSeverity(map: Record<string, number> | undefined): VulnSeverity | null {
|
||||
if (!map) return null;
|
||||
let max = 0;
|
||||
for (const v of Object.values(map)) {
|
||||
if (typeof v === 'number' && v > max) max = v;
|
||||
}
|
||||
return VENDOR_SEVERITY_LABEL[max] ?? null;
|
||||
}
|
||||
|
||||
function computeHighestSeverity(vulns: TrivyVulnerability[]): VulnSeverity | null {
|
||||
if (vulns.length === 0) return null;
|
||||
let highestIdx = -1;
|
||||
@@ -310,6 +359,7 @@ export function parseTrivyOutput(raw: string): {
|
||||
const key = `${id}::${pkg}`;
|
||||
if (vulnSeen.has(key)) continue;
|
||||
vulnSeen.add(key);
|
||||
const cvss = pickCvss(v.CVSS);
|
||||
vulnerabilities.push({
|
||||
vulnerabilityId: id,
|
||||
pkgName: pkg,
|
||||
@@ -319,6 +369,14 @@ export function parseTrivyOutput(raw: string): {
|
||||
title: v.Title ?? '',
|
||||
description: v.Description ?? '',
|
||||
primaryUrl: v.PrimaryURL ? v.PrimaryURL : null,
|
||||
status: v.Status ? v.Status : null,
|
||||
cvssScore: cvss.score,
|
||||
cvssVector: cvss.vector,
|
||||
cvssSource: cvss.source,
|
||||
vendorSeverity: pickVendorSeverity(v.VendorSeverity),
|
||||
purl: v.PkgIdentifier?.PURL ?? null,
|
||||
pkgPath: v.PkgPath ? v.PkgPath : null,
|
||||
layerDigest: v.Layer?.Digest ?? v.Layer?.DiffID ?? null,
|
||||
});
|
||||
}
|
||||
for (const s of result.Secrets ?? []) {
|
||||
@@ -615,6 +673,14 @@ class TrivyService {
|
||||
title: d.title ?? '',
|
||||
description: d.description ?? '',
|
||||
primaryUrl: d.primary_url,
|
||||
status: d.status ?? null,
|
||||
cvssScore: d.cvss_score ?? null,
|
||||
cvssVector: d.cvss_vector ?? null,
|
||||
cvssSource: d.cvss_source ?? null,
|
||||
vendorSeverity: d.vendor_severity ?? null,
|
||||
purl: d.purl ?? null,
|
||||
pkgPath: d.pkg_path ?? null,
|
||||
layerDigest: d.layer_digest ?? null,
|
||||
})),
|
||||
secrets: cachedSecrets.map((s) => ({
|
||||
ruleId: s.rule_id,
|
||||
@@ -809,6 +875,14 @@ class TrivyService {
|
||||
title: v.title || null,
|
||||
description: v.description || null,
|
||||
primary_url: v.primaryUrl,
|
||||
status: v.status,
|
||||
cvss_score: v.cvssScore,
|
||||
cvss_vector: v.cvssVector,
|
||||
cvss_source: v.cvssSource,
|
||||
vendor_severity: v.vendorSeverity,
|
||||
purl: v.purl,
|
||||
pkg_path: v.pkgPath,
|
||||
layer_digest: v.layerDigest,
|
||||
})),
|
||||
);
|
||||
db.insertSecretFindings(
|
||||
|
||||
Reference in New Issue
Block a user