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
@@ -5,6 +5,7 @@ import { cn } from '@/lib/utils';
import { formatTimeAgo } from '@/lib/relativeTime';
import { useIsMobile } from '@/hooks/use-is-mobile';
import { SecuritySevStrip, SecurityTotalsGrid, SecurityFooterBand } from './SecurityMobile';
import { SCANNER_DETECTIONS_NOTE } from './securityMasthead';
import type { SecurityOverview, ScanSummary, SecurityRiskTrendPoint } from '@/types/security';
import type { SecurityTab } from '@/lib/events';
import {
@@ -124,8 +125,14 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate,
)
)}
{/* The masthead hides its stat cluster on a phone; restate it here. */}
{isMobile && <SecuritySevStrip overview={overview} />}
{/* The masthead hides its stat cluster on a phone; restate it here, framed
as scanner detections rather than posture. */}
{isMobile && (
<div className="space-y-2">
<SecuritySevStrip overview={overview} />
<p className="font-mono text-[10px] leading-snug text-stat-subtitle">{SCANNER_DETECTIONS_NOTE}</p>
</div>
)}
{/* Charts lead the dashboard. */}
<div className="grid gap-4 lg:grid-cols-3">
@@ -46,7 +46,7 @@ interface TrivyManagerProps {
*/
export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck }: TrivyManagerProps) {
const { isAdmin } = useAuth();
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'advisory'>(null);
const [trivyBusy, setTrivyBusy] = useState<null | 'install' | 'update' | 'uninstall' | 'auto-update' | 'advisory' | 'cve-intel'>(null);
const [uninstallConfirm, setUninstallConfirm] = useState(false);
const runTrivyOp = async (
@@ -118,6 +118,25 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck
}
};
const handleCveIntelToggle = async (enabled: boolean) => {
setTrivyBusy('cve-intel');
try {
const res = await apiFetch('/security/cve-intel-enabled', {
method: 'PUT',
body: JSON.stringify({ enabled }),
});
if (!res.ok) {
const err = await res.json().catch(() => ({}));
throw new Error(err?.error || 'Failed to update setting');
}
await refresh();
} catch (err) {
toast.error((err as Error)?.message || 'Failed to update setting');
} finally {
setTrivyBusy(null);
}
};
return (
<>
<div className="rounded-lg border border-card-border border-t-card-border-top bg-card shadow-card-bevel p-4 space-y-3">
@@ -207,6 +226,22 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck
/>
</div>
)}
{isAdmin && (
<div className="flex items-center justify-between rounded-lg border border-glass-border px-3 py-2.5">
<div>
<Label className="text-sm">Exploit intelligence (KEV + EPSS)</Label>
<p className="text-xs text-muted-foreground">
Fetch CISA Known Exploited Vulnerabilities and EPSS scores daily to prioritize findings. Reaches cisa.gov and api.first.org; turn off for air-gapped hosts.
</p>
</div>
<TogglePill
checked={status.cveIntelEnabled}
onChange={handleCveIntelToggle}
disabled={trivyBusy !== null}
/>
</div>
)}
</div>
<ConfirmModal
@@ -34,7 +34,7 @@ function setup({ isPaid }: { isPaid: boolean }) {
vi.mocked(AuthContext.useAuth).mockReturnValue({ isAdmin: true } as unknown as ReturnType<typeof AuthContext.useAuth>);
vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType<typeof NodeContext.useNodes>);
vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, busy: false },
status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, cveIntelEnabled: true, busy: false },
updateCheck: null,
refresh: vi.fn().mockResolvedValue(undefined),
refreshUpdateCheck: vi.fn().mockResolvedValue(undefined),
@@ -1,6 +1,9 @@
/**
* The Security masthead state word is the headline posture signal an operator
* reads first, so its derivation is locked here. Critical must beat High.
* reads first, so its derivation is locked here. Posture is an action verdict
* (Action needed / Monitoring / Secure / Unknown), not a raw severity count: a
* page is never "Secure" merely because counts are non-zero, and never "Action
* needed" merely because a Critical exists with nothing to do about it.
*/
import { it, expect } from 'vitest';
import { deriveMasthead } from '../securityMasthead';
@@ -8,7 +11,7 @@ import type { SecurityOverview } from '@/types/security';
function overview(o: Partial<SecurityOverview>): SecurityOverview {
return {
scannedImages: 0,
scannedImages: 1,
critical: 0,
high: 0,
fixable: 0,
@@ -16,7 +19,8 @@ function overview(o: Partial<SecurityOverview>): SecurityOverview {
misconfigs: 0,
staleScans: 0,
failedScans: 0,
lastSuccessfulScanAt: null,
// Default to "has completed a scan" so cases exercise posture, not Unknown.
lastSuccessfulScanAt: 1700000000000,
scanner: { available: true, version: '1', source: 'managed', autoUpdate: false },
deployEnforcement: { honorSuppressionsOnDeploy: false, eligibleBlockPolicies: 0 },
...o,
@@ -28,14 +32,51 @@ it('reads Unknown/idle when there is no overview or a load error', () => {
expect(deriveMasthead(overview({ critical: 5 }), true)).toEqual({ state: 'Unknown', tone: 'idle' });
});
it('reads Critical/error when any critical finding exists (critical wins over high)', () => {
expect(deriveMasthead(overview({ critical: 1, high: 9 }), false)).toEqual({ state: 'Critical', tone: 'error' });
it('reads Unknown when the scanner is unavailable, even with no findings', () => {
expect(
deriveMasthead(overview({ scanner: { available: false, version: null, source: 'none', autoUpdate: false } }), false),
).toEqual({ state: 'Unknown', tone: 'idle' });
});
it('reads At risk/warn when there are highs but no criticals', () => {
expect(deriveMasthead(overview({ critical: 0, high: 2 }), false)).toEqual({ state: 'At risk', tone: 'warn' });
it('reads Unknown when no scan has ever completed', () => {
expect(deriveMasthead(overview({ lastSuccessfulScanAt: null }), false)).toEqual({ state: 'Unknown', tone: 'idle' });
});
it('reads Secure/live when there are no critical or high findings', () => {
expect(deriveMasthead(overview({ critical: 0, high: 0 }), false)).toEqual({ state: 'Secure', tone: 'live' });
it('reads Action needed/error when a fix is available (even if counts look severe)', () => {
expect(deriveMasthead(overview({ critical: 9, high: 9, fixable: 1 }), false)).toEqual({
state: 'Action needed',
tone: 'error',
});
});
it('reads Action needed when a secret is detected', () => {
expect(deriveMasthead(overview({ secrets: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
});
it('reads Action needed when a misconfiguration is detected', () => {
expect(deriveMasthead(overview({ misconfigs: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
});
it('reads Monitoring/warn when criticals/highs exist but nothing is actionable', () => {
expect(deriveMasthead(overview({ critical: 3, high: 7, fixable: 0 }), false)).toEqual({
state: 'Monitoring',
tone: 'warn',
});
});
it('reads Secure/live when a scan completed and nothing is actionable or severe', () => {
expect(deriveMasthead(overview({}), false)).toEqual({ state: 'Secure', tone: 'live' });
});
it('prefers the backend posture over the local bootstrap when present', () => {
// Bootstrap from these facts would read Action needed (fixable > 0); the
// authoritative backend verdict wins.
expect(deriveMasthead(overview({ fixable: 5, posture: 'Monitoring' }), false)).toEqual({
state: 'Monitoring',
tone: 'warn',
});
});
it('falls back to the local bootstrap when the node reports no posture', () => {
expect(deriveMasthead(overview({ fixable: 1 }), false)).toEqual({ state: 'Action needed', tone: 'error' });
});
@@ -1,16 +1,46 @@
import type { MastheadTone } from '@/components/ui/PageMasthead';
import type { SecurityOverview } from '@/types/security';
import type { SecurityOverview, SecurityPostureState } from '@/types/security';
export type SecurityPosture = SecurityPostureState;
const POSTURE_TONE: Record<SecurityPosture, MastheadTone> = {
'Action needed': 'error',
Monitoring: 'warn',
Secure: 'live',
Unknown: 'idle',
};
/** Standing reframe shown near the masthead: raw counts are scanner detections,
* not the product posture. Kept short enough for a one-to-two-line caption. */
export const SCANNER_DETECTIONS_NOTE =
'Scanner detections show vulnerable components present in images, not proven exploitable risk. Posture weighs fix availability, exposure, and exploit intelligence.';
/**
* Derives the Security page masthead state word and tone from the overview.
* Critical outranks High; an absent overview or a load error reads as Unknown.
* Derives the Security masthead from action posture, not raw severity. Raw
* Critical/High counts are scanner detections shown separately; they no longer
* decide the headline. "Secure" means nothing is actionable right now, never a
* claim that no vulnerabilities exist.
*
* The backend computes the authoritative `posture` (one bucketing function), so
* this prefers `overview.posture` when present. The local bootstrap below is the
* fallback for an older remote node reached through the proxy that does not
* report posture: "actionable" is approximated from the overview facts that
* already exist (fixable findings, secrets, misconfigs); Unknown covers a
* missing scanner or a node that has never completed a scan.
*/
export function deriveMasthead(
overview: SecurityOverview | null,
error: boolean,
): { state: string; tone: MastheadTone } {
if (error || !overview) return { state: 'Unknown', tone: 'idle' };
if (overview.critical > 0) return { state: 'Critical', tone: 'error' };
if (overview.high > 0) return { state: 'At risk', tone: 'warn' };
return { state: 'Secure', tone: 'live' };
): { state: SecurityPosture; tone: MastheadTone } {
const posture = resolvePosture(overview, error);
return { state: posture, tone: POSTURE_TONE[posture] };
}
function resolvePosture(overview: SecurityOverview | null, error: boolean): SecurityPosture {
if (error || !overview) return 'Unknown';
if (overview.posture && overview.posture in POSTURE_TONE) return overview.posture;
if (!overview.scanner.available || overview.lastSuccessfulScanAt === null) return 'Unknown';
if (overview.fixable > 0 || overview.secrets > 0 || overview.misconfigs > 0) return 'Action needed';
if (overview.critical > 0 || overview.high > 0) return 'Monitoring';
return 'Secure';
}