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
@@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { type ReactNode, useCallback, useEffect, useMemo, useState } from 'react';
import { SystemSheet, SheetSection } from '@/components/ui/system-sheet';
import { ScrollArea } from '@/components/ui/scroll-area';
import { Button } from '@/components/ui/button';
@@ -53,8 +53,20 @@ import type {
SecretFinding,
MisconfigFinding,
ScanDetailTab,
TriageStatus,
} from '@/types/security';
// Triage decision options for the suppress dialog (value -> label). 'accepted'
// is the default: a plain suppress is an accepted risk.
const TRIAGE_STATUS_OPTIONS: ReadonlyArray<{ value: TriageStatus; label: string }> = [
{ value: 'accepted', label: 'Accepted risk' },
{ value: 'not_affected', label: 'Not affected' },
{ value: 'false_positive', label: 'False positive' },
{ value: 'needs_review', label: 'Needs review' },
{ value: 'fixed', label: 'Fixed' },
{ value: 'ignored', label: 'Ignored until expiry' },
];
interface VulnerabilityScanSheetProps {
scanId: number | null;
onClose: () => void;
@@ -78,6 +90,7 @@ interface SuppressDialogState {
imagePattern: string;
reason: string;
expiresInDays: string;
status: TriageStatus;
}
interface AckDialogState {
@@ -115,6 +128,47 @@ function SeverityChip({ severity }: { severity: VulnSeverity }) {
);
}
const EVIDENCE_TAG_CLASSES = {
danger: 'text-destructive border-destructive/40 bg-destructive/10',
warn: 'text-warning border-warning/40 bg-warning/10',
muted: 'text-stat-subtitle border-border bg-muted/30',
neutral: 'text-stat-value border-border bg-muted/20',
} as const;
function EvidenceTag({ tone, children }: { tone: keyof typeof EVIDENCE_TAG_CLASSES; children: ReactNode }) {
return (
<span className={cn('inline-flex items-center rounded border px-1.5 py-px text-[9px] font-mono uppercase tracking-[0.1em]', EVIDENCE_TAG_CLASSES[tone])}>
{children}
</span>
);
}
/**
* Small, independently-verifiable evidence atoms per finding. Severity is one
* signal among several, not the only one: these surface exploit intel (KEV,
* EPSS), vendor status, and the CVSS score so an operator can tell scary from
* exploitable without an invented composite priority number.
*/
function EvidenceTags({ d }: { d: VulnerabilityDetail }) {
const tags: ReactNode[] = [];
if (d.kev) tags.push(<EvidenceTag key="kev" tone="danger">KEV</EvidenceTag>);
if (typeof d.epss_score === 'number') {
tags.push(
<EvidenceTag key="epss" tone={d.epss_score >= 0.1 ? 'warn' : 'muted'}>
EPSS {Math.round(d.epss_score * 100)}%
</EvidenceTag>,
);
}
if (d.status === 'will_not_fix' || d.status === 'end_of_life') {
tags.push(<EvidenceTag key="wontfix" tone="muted">{"Won't fix"}</EvidenceTag>);
}
if (typeof d.cvss_score === 'number') {
tags.push(<EvidenceTag key="cvss" tone="neutral">CVSS {d.cvss_score}</EvidenceTag>);
}
if (tags.length === 0) return null;
return <span className="mt-1 flex flex-wrap items-center gap-1">{tags}</span>;
}
export function VulnerabilityScanSheet({
scanId,
onClose,
@@ -319,6 +373,7 @@ export function VulnerabilityScanSheet({
imagePattern: '',
reason: '',
expiresInDays: '',
status: 'accepted',
});
}, []);
@@ -350,6 +405,7 @@ export function VulnerabilityScanSheet({
image_pattern: suppressForm.imagePattern.trim() || null,
reason,
expires_at: expiresAt,
status: suppressForm.status,
}),
});
if (!res.ok) {
@@ -737,28 +793,31 @@ export function VulnerabilityScanSheet({
key={d.id}
className={cn(SEVERITY_ROW_TINT[d.severity], d.suppressed && 'opacity-60')}
>
<TableCell className="font-mono text-xs tabular-nums">
<span className="inline-flex items-center gap-1.5">
{d.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
>
{d.vulnerability_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
d.vulnerability_id
)}
<TableCell className="font-mono text-xs tabular-nums align-top">
<span className="flex flex-col">
<span className="inline-flex items-center gap-1.5">
{d.suppressed && (
<ShieldOff
className="w-3 h-3 text-muted-foreground"
strokeWidth={1.5}
aria-label="Suppressed"
/>
)}
{href ? (
<a
href={href}
target="_blank"
rel="noreferrer noopener"
className="inline-flex items-center gap-1 hover:underline"
>
{d.vulnerability_id}
<ExternalLink className="w-3 h-3" strokeWidth={1.5} />
</a>
) : (
d.vulnerability_id
)}
</span>
<EvidenceTags d={d} />
</span>
</TableCell>
<TableCell
@@ -1070,6 +1129,24 @@ export function VulnerabilityScanSheet({
Glob pattern matched against the image reference. Leave blank to suppress this CVE on any image.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-status">Triage decision</Label>
<select
id="suppress-status"
className="flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-sm shadow-sm focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50"
value={suppressForm.status}
onChange={(e) =>
setSuppressForm((f) => (f ? { ...f, status: e.target.value as TriageStatus } : f))
}
>
{TRIAGE_STATUS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
<p className="text-xs text-muted-foreground">
How this finding was triaged. Decided states (accepted, not affected, false positive, fixed, ignored) stop driving the posture; needs review stays counted but actionable.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="suppress-reason">Reason</Label>
<textarea