feat(security): prioritization-led Overview charts (posture + exploit intel) (#1427)

* feat(security): rework Overview charts around posture and exploit intel

Replace the three severity-variation charts (severity donut, top exposed images,
findings by type) with prioritization views, keeping the risk trend for context:

- Action posture: bars of fixable / known-exploited / needs-review / accepted /
  not-affected with a known-exploited headline, derived from the existing
  overview facts (no new fetch).
- Top exploit-risk findings: ranked actionable Critical/High by KEV, then EPSS,
  then CVSS, each row opening its scan.
- Severity x exploitability: a CVSS-by-EPSS quadrant that separates
  scary-but-not-exploitable from act-first.

The two intel panels are fed by a new bounded GET /security/overview/exploit-intel
(latest-scan Critical/High, suppression-filtered, KEV/EPSS joined at read time)
and degrade to clear empty states until exploit intel is fetched and images are
rescanned.

* fix(security): drop the redundant SECURITY kicker from the desktop masthead

The desktop nav strip already names the page, so the masthead's "SECURITY" label
above the posture word was redundant. Make PageMasthead's kicker optional (pages
that pass one render unchanged) and omit it on the Security masthead. The mobile
masthead keeps its kicker, since on the phone layout it is the page identity and
there is no nav strip.

* docs(security): describe the prioritization-led Overview charts

Update the Security overview docs for the reworked chart set (risk trend, action
posture, top exploit-risk, and the severity-by-exploitability quadrant) and note
the exploit-risk charts populate once exploit intelligence is enabled.

* fix(security): move the scanner-detections note into a masthead info icon

Replace the standing "scanner detections show vulnerable components..." caption
below the masthead (desktop and mobile) with an info affordance next to the
scanned-images count in the masthead subtitle. Declutters the overview while
keeping the disclaimer one hover away.

* fix(security): apply "assume it's automatable" to exploit-risk ranking

Absence of exploitability evidence must not be treated as low risk. Rank the top
exploit-risk list by tier (known-exploited > known-high EPSS > unknown EPSS >
known-low EPSS) so an unrated finding outranks one with evidence of low
likelihood; label unrated findings "EPSS n/a"; and reword the quadrant footnote
so excluded findings read as unrated rather than lower risk.
This commit is contained in:
Anso
2026-06-23 21:55:11 -04:00
committed by GitHub
parent f794702171
commit b1630788ba
10 changed files with 527 additions and 212 deletions
+31 -12
View File
@@ -17,7 +17,7 @@ import { useIsMobile } from '@/hooks/use-is-mobile';
import { Masthead, type Tone } from './mobile/mobile-ui';
import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile';
import type { SecurityTab } from '@/lib/events';
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, FleetRole } from '@/types/security';
import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole } from '@/types/security';
import { VulnerabilityScanSheet } from './VulnerabilityScanSheet';
import { SuppressionsPanel } from './settings/SuppressionsPanel';
import { MisconfigAckPanel } from './settings/MisconfigAckPanel';
@@ -75,6 +75,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
const [summariesLoading, setSummariesLoading] = useState(true);
const [summariesError, setSummariesError] = useState(false);
const [trend, setTrend] = useState<SecurityRiskTrendPoint[]>([]);
const [exploitIntel, setExploitIntel] = useState<ExploitIntelFinding[]>([]);
const [isReplica, setIsReplica] = useState(false);
// Bumped after a node-wide scan completes to refetch the active node's posture.
const [reloadToken, setReloadToken] = useState(0);
@@ -114,6 +115,12 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
.then((r) => (r.ok ? r.json() : []))
.then((t) => (Array.isArray(t) ? t : []))
.catch(() => []);
// Exploit-intel powers two overview charts; isolate it like the trend so a
// failure (or an older node without the endpoint) degrades to empty panels.
const exploitIntelPromise: Promise<ExploitIntelFinding[]> = apiFetch('/security/overview/exploit-intel')
.then((r) => (r.ok ? r.json() : { items: [] }))
.then((b) => (b && Array.isArray(b.items) ? b.items : []))
.catch(() => []);
try {
const [overviewRes, summariesRes] = await Promise.all([
apiFetch('/security/overview'),
@@ -154,8 +161,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
} finally {
if (!cancelled) setSummariesLoading(false);
}
const trend = await trendPromise;
if (!cancelled) setTrend(trend);
const [trendData, intelData] = await Promise.all([trendPromise, exploitIntelPromise]);
if (!cancelled) {
setTrend(trendData);
setExploitIntel(intelData);
}
})();
return () => { cancelled = true; };
}, [activeNode?.id, reloadToken]);
@@ -202,9 +212,22 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
{ value: 'scanner', label: 'Scanner setup' },
];
const subtitle = overview
? `${overview.scannedImages} ${overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner ${overview.scanner.available ? 'ready' : 'not installed'}`
: undefined;
// The scanner-detections disclaimer rides as an info affordance next to the
// scanned-images count rather than a standing caption below the masthead.
const subtitle = overview ? (
<span className="inline-flex items-center gap-1.5">
<span>
{overview.scannedImages} {overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner {overview.scanner.available ? 'ready' : 'not installed'}
</span>
<span
className="inline-flex shrink-0 cursor-help text-stat-subtitle/70 hover:text-stat-subtitle"
title={SCANNER_DETECTIONS_NOTE}
aria-label={SCANNER_DETECTIONS_NOTE}
>
<Info className="h-3 w-3" strokeWidth={1.5} aria-hidden="true" />
</span>
</span>
) : undefined;
// The tab panels are identical on desktop and mobile; only the masthead and
// the tab strip differ, so the panels are shared between both layouts.
@@ -214,8 +237,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
<OverviewTab
overview={overview}
loadError={overviewLoadError}
summaries={summaries}
trend={trend}
exploitIntel={exploitIntel}
onNavigate={onTabChange}
onInspect={onInspect}
canScan={canScan}
@@ -329,12 +352,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
return (
<div className="h-full overflow-auto p-6">
<PageMasthead
kicker="SECURITY"
state={state}
tone={tone}
pulsing={pulsing}
size="hero"
className="rounded-lg mb-2"
className="rounded-lg mb-4"
subtitle={subtitle}
metadata={overview ? [
{ label: 'CRITICAL', value: String(overview.critical), tone: overview.critical > 0 ? 'error' : 'value' },
@@ -342,9 +364,6 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security
{ label: 'LAST SCAN', value: overview.lastSuccessfulScanAt ? formatTimeAgo(overview.lastSuccessfulScanAt) : 'never', tone: 'subtitle' },
] : undefined}
/>
<p className="mb-4 max-w-3xl font-mono text-[11px] leading-snug text-stat-subtitle">
{SCANNER_DETECTIONS_NOTE}
</p>
<Tabs value={activeTab} onValueChange={(v) => onTabChange(v as SecurityTab)}>
<TabsList className="mb-4">