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:
Anso
2026-06-23 17:42:11 -04:00
committed by GitHub
parent 4c47c47a27
commit f794702171
29 changed files with 1685 additions and 72 deletions
+44
View File
@@ -18,6 +18,8 @@ export interface TrivyStatus {
autoUpdate: boolean;
honorSuppressionsOnDeploy: boolean;
preDeployScanAdvisory: boolean;
/** Outbound CVE exploit-intel (KEV + EPSS) fetch is enabled for this node. */
cveIntelEnabled: boolean;
busy: boolean;
}
@@ -122,11 +124,30 @@ export interface VulnerabilityDetail {
title: string | null;
description: string | null;
primary_url: string | null;
// Scan-intrinsic enrichment (nullable; absent on older scans). Drives the
// CVSS chip and evidence tags. `status`: fixed / will_not_fix / end_of_life.
status?: string | null;
cvss_score?: number | null;
cvss_vector?: string | null;
cvss_source?: string | null;
vendor_severity?: VulnSeverity | null;
purl?: string | null;
pkg_path?: string | null;
layer_digest?: string | null;
// Read-time exploit intel join (KEV / EPSS), attached by the vulnerabilities
// endpoint. Optional: absent until the intel cache has populated.
kev?: boolean;
epss_score?: number | null;
epss_percentile?: number | null;
suppressed?: boolean;
suppression_id?: number;
suppression_reason?: string;
}
/** Triage decision states (mirrors the backend TriageStatus). */
export type TriageStatus =
| 'needs_review' | 'affected' | 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored';
export interface CveSuppression {
id: number;
cve_id: string;
@@ -138,6 +159,8 @@ export interface CveSuppression {
expires_at: number | null;
replicated_from_control: number;
active: boolean;
status?: TriageStatus;
justification?: string | null;
}
export interface ScanSummary {
@@ -194,6 +217,10 @@ export interface ScanCompareResult {
row_limit?: number;
}
/** The Security page's action posture (the masthead verdict). Mirrors the
* backend `SecurityPostureState`. */
export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | 'Unknown';
/** Node-scoped security posture rollup for the Security page Overview. */
export interface SecurityOverview {
scannedImages: number;
@@ -216,6 +243,23 @@ export interface SecurityOverview {
/** Approximate count of enabled block-on-deploy policies eligible for this node. */
eligibleBlockPolicies: number;
};
// Posture facts. Optional because an older remote node (reached through the
// proxy) may not report them; the masthead falls back to a local derivation.
// Counts are facts; `posture` is the authoritative derived verb.
rawCritical?: number;
rawHigh?: number;
fixableCriticalHigh?: number;
knownExploited?: number;
publiclyExposed?: number;
dangerousCompose?: number;
needsReview?: number;
accepted?: number;
notAffected?: number;
/** Total actionable items, for the "N actions" affordance. */
actionable?: number;
posture?: SecurityPostureState;
/** True when the bounded posture pass hit its row cap on this node. */
posturePartial?: boolean;
}
/** Which detail tab the scan sheet opens on. Matches VulnerabilityScanSheet's tabs. */