From b1630788bad5f475eab716c3186af8d746b11a91 Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 23 Jun 2026 21:55:11 -0400 Subject: [PATCH] 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. --- .../__tests__/security-overview-route.test.ts | 46 +++ backend/src/routes/security.ts | 55 +++ backend/src/services/DatabaseService.ts | 51 +++ docs/features/security.mdx | 11 +- frontend/src/components/SecurityView.tsx | 43 ++- .../src/components/security/OverviewTab.tsx | 42 +-- .../security/SecurityCharts.test.tsx | 145 +++++--- .../components/security/SecurityCharts.tsx | 315 +++++++++++------- frontend/src/components/ui/PageMasthead.tsx | 11 +- frontend/src/types/security.ts | 20 ++ 10 files changed, 527 insertions(+), 212 deletions(-) diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index 5dc16e90..a34e524c 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -395,3 +395,49 @@ describe('GET /api/security/vex/export (Admiral)', () => { expect(stmt).toMatchObject({ status: 'not_affected', justification: 'component_not_present', products: ['nginx*'] }); }); }); + +describe('GET /api/security/overview/exploit-intel', () => { + beforeEach(() => resetSecurity()); + + it('returns actionable Crit/High findings with KEV/EPSS joined and dismissed excluded', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'app:1', image_digest: 'sha256:app', scanned_at: now, + total_vulnerabilities: 3, critical_count: 2, high_count: 1, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 2, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + const d = (id: string, severity: 'CRITICAL' | 'HIGH', cvss: number | null, fixed: string | null) => ({ + vulnerability_id: id, pkg_name: `p-${id}`, installed_version: '1', fixed_version: fixed, + severity, title: null, description: null, primary_url: null, cvss_score: cvss, + }); + db().insertVulnerabilityDetails(scanId, [ + d('CVE-2024-AAAA', 'CRITICAL', 9.8, '2'), // actionable, has KEV + EPSS + d('CVE-2024-BBBB', 'HIGH', 7.2, null), // actionable, no intel yet + d('CVE-2024-CCCC', 'CRITICAL', 8.1, '3'), // dismissed -> excluded + ]); + db().replaceKev([{ cve_id: 'CVE-2024-AAAA', date_added: '2024-01-01' }], now); + db().upsertEpss([{ cve_id: 'CVE-2024-AAAA', epss_score: 0.6, epss_percentile: 0.97 }], now); + db().createCveSuppression({ + cve_id: 'CVE-2024-CCCC', pkg_name: null, image_pattern: null, reason: 'accepted', + created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted', + }); + + const res = await request(app).get('/api/security/overview/exploit-intel').set('Cookie', adminCookie); + expect(res.status).toBe(200); + const items = res.body.items as Array<{ vulnerability_id: string; cvss_score: number | null; epss_score: number | null; kev: boolean; severity: string; scan_id: number }>; + const ids = items.map((i) => i.vulnerability_id); + expect(ids).toContain('CVE-2024-AAAA'); + expect(ids).toContain('CVE-2024-BBBB'); + expect(ids).not.toContain('CVE-2024-CCCC'); // dismissed triage decision + expect(items.find((i) => i.vulnerability_id === 'CVE-2024-AAAA')).toMatchObject({ cvss_score: 9.8, epss_score: 0.6, kev: true, severity: 'CRITICAL', scan_id: scanId }); + expect(items.find((i) => i.vulnerability_id === 'CVE-2024-BBBB')).toMatchObject({ cvss_score: 7.2, epss_score: null, kev: false }); + expect(res.body.truncated).toBe(false); + }); + + it('requires authentication', async () => { + const res = await request(app).get('/api/security/overview/exploit-intel'); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index e630aa5f..d1ad3ba6 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -828,6 +828,61 @@ securityRouter.get('/overview/trend', authMiddleware, (req: Request, res: Respon } }); +// One actionable Critical/High finding for the overview exploit-intel charts. +interface ExploitIntelFinding { + vulnerability_id: string; + image_ref: string; + scan_id: number; + severity: VulnSeverity; + cvss_score: number | null; + epss_score: number | null; + epss_percentile: number | null; + kev: boolean; + fixed_version: string | null; +} + +// Node-scoped, auth-only (Community). Returns the latest-scan Critical/High +// findings that are still actionable (dismissed triage decisions filtered out), +// enriched at read time with KEV/EPSS intel. Powers the Top exploit-risk list +// and the CVSS-by-EPSS quadrant on the Security overview. Bounded; `truncated` +// flags a capped node. +securityRouter.get('/overview/exploit-intel', authMiddleware, (req: Request, res: Response): void => { + try { + const db = DatabaseService.getInstance(); + const found = db.getLatestCritHighFindingsWithCvssForNode(req.nodeId); + const suppressions = db.getCveSuppressions(); + const intel = db.getCveIntel(found.items.map((f) => f.vulnerability_id)); + const byImage = new Map(); + for (const f of found.items) { + const group = byImage.get(f.image_ref); + if (group) group.push(f); + else byImage.set(f.image_ref, [f]); + } + const items: ExploitIntelFinding[] = []; + for (const [imageRef, group] of byImage) { + for (const e of applySuppressions(group, imageRef, suppressions)) { + if (e.suppressed) continue; // decided findings are not part of the act-first view + const i = intel.get(e.vulnerability_id); + items.push({ + vulnerability_id: e.vulnerability_id, + image_ref: imageRef, + scan_id: e.scan_id, + severity: e.severity, + cvss_score: e.cvss_score, + epss_score: i?.epssScore ?? null, + epss_percentile: i?.epssPercentile ?? null, + kev: i?.kev ?? false, + fixed_version: e.fixed_version, + }); + } + } + res.json({ items, truncated: found.truncated }); + } catch (error) { + console.error('[Security] Failed to build exploit-intel overview:', error); + res.status(500).json({ error: 'Failed to build exploit-intel overview' }); + } +}); + // Static, read-only policy-pack catalog. Auth-only (Community), no DB, no // enforcement. The frontend fetches this with localOnly so the global catalog // is available regardless of which node is active. diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index a646edcc..908c214f 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -4775,6 +4775,57 @@ export class DatabaseService { return { items: truncated ? rows.slice(0, limit) : rows, truncated }; } + /** + * Critical/High findings from the latest completed scan per image, with the + * severity + CVSS the overview's exploit-intel charts need. Same bounded + * shape as getLatestCritHighVulnFindingsForNode (single latest-per-image + * JOIN, capped, `truncated` flagged). Intel (KEV/EPSS) and suppression + * filtering are applied by the caller at read time. + */ + public getLatestCritHighFindingsWithCvssForNode( + nodeId: number, + limit = 2000, + ): { + items: Array<{ + image_ref: string; + scan_id: number; + vulnerability_id: string; + pkg_name: string; + severity: VulnSeverity; + cvss_score: number | null; + fixed_version: string | null; + }>; + truncated: boolean; + } { + const rows = this.db + .prepare( + `SELECT vs.image_ref, vs.id AS scan_id, vd.vulnerability_id, vd.pkg_name, + vd.severity, vd.cvss_score, vd.fixed_version + FROM vulnerability_details vd + INNER JOIN vulnerability_scans vs ON vs.id = vd.scan_id + INNER JOIN ( + SELECT image_ref, MAX(scanned_at) AS max_scanned + FROM vulnerability_scans + WHERE node_id = ? AND status = 'completed' + GROUP BY image_ref + ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at + WHERE vs.node_id = ? AND vs.status = 'completed' + AND vd.severity IN ('CRITICAL', 'HIGH') + LIMIT ?`, + ) + .all(nodeId, nodeId, limit + 1) as Array<{ + image_ref: string; + scan_id: number; + vulnerability_id: string; + pkg_name: string; + severity: VulnSeverity; + cvss_score: number | null; + fixed_version: string | null; + }>; + const truncated = rows.length > limit; + return { items: truncated ? rows.slice(0, limit) : rows, truncated }; + } + /** * Distinct CVE ids present in stored findings, for the intel service to fetch * EPSS only for what exists (EPSS covers CVEs, not GHSA, so filter to CVE-*). diff --git a/docs/features/security.mdx b/docs/features/security.mdx index f6e2d9e1..be0c6dec 100644 --- a/docs/features/security.mdx +++ b/docs/features/security.mdx @@ -27,9 +27,14 @@ posture itself: a vulnerable component being present is not the same as a reacha The masthead carries a standing note to that effect, and posture weighs fix availability, exploit intelligence, and triage decisions rather than raw severity alone. -Below it, a signal rail summarizes the supporting numbers: scanned images, fixable findings, secrets, -Compose misconfigurations, stale scans, and failed scans. A status strip shows scanner health -(installed source and version, auto-update) and the active node's deploy enforcement posture. +Below it, the charts lead with prioritization rather than raw severity: a **risk trend** for context, +an **action posture** breakdown (fixable, known-exploited, needs-review, accepted, not-affected), a +**top exploit-risk** list ranking actionable findings by known-exploited status then EPSS, and a +**severity-by-exploitability** quadrant that separates high-severity-but-unlikely findings from the +ones to act on first. The exploit-risk charts populate once exploit intelligence is enabled and images +are scanned. A signal rail summarizes the supporting numbers (scanned images, fixable findings, +secrets, Compose misconfigurations, stale scans, failed scans), and a status strip shows scanner health +and the active node's deploy enforcement posture. If a node does not report an overview (for example an older remote node), the page falls back to a clear "overview unavailable" state and the other tabs keep working. diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index 249c67f4..d36a2d59 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -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([]); + const [exploitIntel, setExploitIntel] = useState([]); 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 = 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 ? ( + + + {overview.scannedImages} {overview.scannedImages === 1 ? 'image' : 'images'} scanned · scanner {overview.scanner.available ? 'ready' : 'not installed'} + + + + + ) : 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 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} /> -

- {SCANNER_DETECTIONS_NOTE} -

onTabChange(v as SecurityTab)}> diff --git a/frontend/src/components/security/OverviewTab.tsx b/frontend/src/components/security/OverviewTab.tsx index ac585bc6..a163341d 100644 --- a/frontend/src/components/security/OverviewTab.tsx +++ b/frontend/src/components/security/OverviewTab.tsx @@ -5,14 +5,13 @@ 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 { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding } from '@/types/security'; import type { SecurityTab } from '@/lib/events'; import { - SeverityDonutChart, RiskTrendChart, - TopExposedImagesChart, - FindingsByTypeChart, + ActionPostureChart, + TopExploitRiskList, + CvssEpssQuadrantChart, } from './SecurityCharts'; import { ScanNodeLauncher } from './ScanNodeLauncher'; @@ -20,8 +19,9 @@ interface OverviewTabProps { overview: SecurityOverview | null; /** 'unsupported' = node has no overview endpoint (benign); 'failed' = a real error. */ loadError: 'unsupported' | 'failed' | null; - summaries: Record; trend: SecurityRiskTrendPoint[]; + /** Actionable Critical/High findings with KEV/EPSS for the exploit-intel charts. */ + exploitIntel: ExploitIntelFinding[]; onNavigate: (tab: SecurityTab) => void; onInspect: (scanId: number) => void; /** Admin on a node with a ready scanner; enables the node-scan launcher. */ @@ -57,7 +57,7 @@ function ChartCard({ title, className, children }: { title: string; className?: ); } -export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) { +export function OverviewTab({ overview, loadError, trend, exploitIntel, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) { const isMobile = useIsMobile(); if (loadError === 'unsupported') { @@ -93,8 +93,6 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, ); } - const summaryList = Object.values(summaries); - const tiles: SignalTile[] = [ { kicker: 'Scanned images', value: String(overview.scannedImages) }, { kicker: 'Fixable', value: String(overview.fixable), tone: overview.fixable > 0 ? 'warn' : 'value' }, @@ -125,31 +123,27 @@ export function OverviewTab({ overview, loadError, summaries, trend, onNavigate, ) )} - {/* The masthead hides its stat cluster on a phone; restate it here, framed - as scanner detections rather than posture. */} - {isMobile && ( -
- -

{SCANNER_DETECTIONS_NOTE}

-
- )} + {/* The masthead hides its stat cluster on a phone; restate it here. The + scanner-detections note lives in the masthead's info affordance. */} + {isMobile && } - {/* Charts lead the dashboard. */} + {/* Charts lead the dashboard: the trend gives severity context, the rest + answer "what should I act on first?" from posture + exploit intel. */}
- - + +
- - + + - - + +
diff --git a/frontend/src/components/security/SecurityCharts.test.tsx b/frontend/src/components/security/SecurityCharts.test.tsx index 65f494cf..ce724ab3 100644 --- a/frontend/src/components/security/SecurityCharts.test.tsx +++ b/frontend/src/components/security/SecurityCharts.test.tsx @@ -1,10 +1,10 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -import { render, act, renderHook } from '@testing-library/react'; +import { render, act, renderHook, fireEvent } from '@testing-library/react'; import { useTheme } from '@/hooks/use-theme'; // recharts paints nothing at 0x0 in jsdom, so stub every export with a prop- // capturing element. The real ChartContainer still runs (it injects the -// --color-* vars from SEVERITY_CONFIG), so the token mapping is observable. +// --color-* vars), so the token mapping is observable. vi.mock('recharts', async () => { const React = await import('react'); const stub = (tag: string) => (props: Record) => @@ -21,76 +21,136 @@ vi.mock('recharts', async () => { }, props.children as React.ReactNode, ); - // Explicit named exports (vitest validates named imports against real keys, - // so a Proxy namespace will not do). Covers what SecurityCharts and the shared - // ChartContainer (ResponsiveContainer / Tooltip / Legend) reference. return { ResponsiveContainer: stub('ResponsiveContainer'), Tooltip: stub('Tooltip'), Legend: stub('Legend'), - PieChart: stub('PieChart'), - Pie: stub('Pie'), AreaChart: stub('AreaChart'), Area: stub('Area'), BarChart: stub('BarChart'), Bar: stub('Bar'), + Cell: stub('Cell'), + ScatterChart: stub('ScatterChart'), + Scatter: stub('Scatter'), XAxis: stub('XAxis'), YAxis: stub('YAxis'), + ZAxis: stub('ZAxis'), CartesianGrid: stub('CartesianGrid'), LabelList: stub('LabelList'), + ReferenceLine: stub('ReferenceLine'), }; }); -import { RiskTrendChart, FindingsByTypeChart, SeverityDonutChart } from './SecurityCharts'; -import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security'; +import { RiskTrendChart, ActionPostureChart, TopExploitRiskList, CvssEpssQuadrantChart } from './SecurityCharts'; +import type { SecurityOverview, SecurityRiskTrendPoint, ExploitIntelFinding } from '@/types/security'; const TREND: SecurityRiskTrendPoint[] = [ { date: '2026-06-01', critical: 2, high: 5 }, { date: '2026-06-02', critical: 1, high: 3 }, ]; -const SUMMARY: ScanSummary = { - image_ref: 'nginx:1.27', - highest_severity: 'CRITICAL', - scanned_at: 0, - scan_id: 1, - total: 11, - critical: 2, high: 5, medium: 3, low: 1, unknown: 0, - fixable: 3, - secret_count: 1, misconfig_count: 4, -}; +function overview(o: Partial): SecurityOverview { + return { + scannedImages: 0, critical: 0, high: 0, fixable: 0, secrets: 0, misconfigs: 0, + staleScans: 0, failedScans: 0, lastSuccessfulScanAt: null, + scanner: { available: true, version: '1', source: 'managed', autoUpdate: false }, + deployEnforcement: { honorSuppressionsOnDeploy: false, eligibleBlockPolicies: 0 }, + rawCritical: 0, rawHigh: 0, fixableCriticalHigh: 0, knownExploited: 0, publiclyExposed: 0, + dangerousCompose: 0, needsReview: 0, accepted: 0, notAffected: 0, actionable: 0, + posture: 'Secure', posturePartial: false, + ...o, + }; +} -function configureChart(opts: { chartStyle?: 'muted' | 'heat' | 'signature'; reducedEffects?: boolean; readability?: boolean } = {}) { +function finding(o: Partial): ExploitIntelFinding { + return { + vulnerability_id: 'CVE-0000-0000', image_ref: 'img:1', scan_id: 1, severity: 'HIGH', + cvss_score: null, epss_score: null, epss_percentile: null, kev: false, fixed_version: null, + ...o, + }; +} + +function configureChart(opts: { chartStyle?: 'muted' | 'heat' | 'signature'; reducedEffects?: boolean } = {}) { const { result } = renderHook(() => useTheme()); act(() => { result.current.setReadability(false); result.current.setVisualStyle('signature'); if (opts.chartStyle) result.current.setChartStyle(opts.chartStyle); if (opts.reducedEffects) result.current.setReducedEffects(true); - if (opts.readability) result.current.setReadability(true); }); } -describe('SecurityCharts palette routing', () => { +describe('ActionPostureChart', () => { beforeEach(() => configureChart()); - it('routes FindingsByType through --sev-* / neutral (no destructive/warning/brand)', () => { - const { container } = render(); + it('renders the five posture bars from the overview facts', () => { + const { container } = render( + , + ); const chart = container.querySelector('[data-rc="BarChart"]'); - const data = JSON.parse(chart!.getAttribute('data-chartdata')!) as { fill: string }[]; - expect(data.map((d) => d.fill)).toEqual(['var(--sev-vuln)', 'var(--sev-critical)', 'var(--stat-icon)']); - for (const d of data) { - expect(d.fill).not.toMatch(/--(destructive|warning|brand)\)/); - } + const data = JSON.parse(chart!.getAttribute('data-chartdata')!) as { label: string; value: number }[]; + expect(data.map((d) => [d.label, d.value])).toEqual([ + ['Fixable', 3], ['Known exploited', 1], ['Needs review', 2], ['Accepted', 1], ['Not affected', 0], + ]); + expect(container.textContent).toContain('known-exploited'); }); - it('maps the four donut severities to the --sev-* tokens', () => { - const { container } = render(); - const css = container.querySelector('style')?.textContent ?? ''; - expect(css).toContain('--color-critical: var(--sev-critical)'); - expect(css).toContain('--color-high: var(--sev-high)'); - expect(css).toContain('--color-medium: var(--sev-medium)'); - expect(css).toContain('--color-low: var(--sev-low)'); + it('shows an empty state with no Critical or High findings', () => { + const { container } = render(); + expect(container.textContent).toContain('No Critical or High findings'); + }); +}); + +describe('TopExploitRiskList', () => { + it('ranks KEV > high EPSS > unknown EPSS > low EPSS (assume automatable), and opens the scan', () => { + const items = [ + finding({ vulnerability_id: 'CVE-LOW', cvss_score: 5, epss_score: 0.01, scan_id: 10 }), + finding({ vulnerability_id: 'CVE-KEV', cvss_score: 6, kev: true, scan_id: 11 }), + finding({ vulnerability_id: 'CVE-EPSS', cvss_score: 5, epss_score: 0.8, scan_id: 12 }), + finding({ vulnerability_id: 'CVE-UNK', cvss_score: 5, epss_score: null, scan_id: 13 }), + ]; + const onInspect = vi.fn(); + const { container } = render(); + const buttons = [...container.querySelectorAll('button')]; + const order = buttons.map((b) => b.querySelector('.font-mono')?.textContent); + // Unknown-exploitability (CVE-UNK) outranks the evidenced-low one (CVE-LOW). + expect(order).toEqual(['CVE-KEV', 'CVE-EPSS', 'CVE-UNK', 'CVE-LOW']); + fireEvent.click(buttons[0]); + expect(onInspect).toHaveBeenCalledWith(11); + }); + + it('shows the severity-ranked hint when no intel is present', () => { + const { container } = render( + , + ); + expect(container.textContent).toContain('Enable exploit intelligence'); + }); + + it('shows an empty state with no actionable findings', () => { + const { container } = render(); + expect(container.textContent).toContain('No actionable'); + }); +}); + +describe('CvssEpssQuadrantChart', () => { + beforeEach(() => configureChart()); + + it('plots only findings with both CVSS and EPSS and notes the excluded ones', () => { + const items = [ + finding({ vulnerability_id: 'CVE-1', cvss_score: 9, epss_score: 0.5, kev: true }), + finding({ vulnerability_id: 'CVE-2', cvss_score: 7, epss_score: 0.2 }), + finding({ vulnerability_id: 'CVE-3', cvss_score: 8, epss_score: null }), // excluded + ]; + const { container } = render(); + const scatters = [...container.querySelectorAll('[data-rc="Scatter"]')]; + const plotted = scatters.flatMap((s) => JSON.parse(s.getAttribute('data-chartdata') ?? '[]') as { cve: string }[]); + expect(plotted.map((p) => p.cve).sort()).toEqual(['CVE-1', 'CVE-2']); + expect(container.textContent).toContain('unrated'); + }); + + it('shows an empty state when no finding has both scores', () => { + const { container } = render(); + expect(container.textContent).toContain('Enable exploit intelligence'); }); }); @@ -120,18 +180,6 @@ describe('RiskTrendChart gradient vs flat', () => { } }); - it('uses the heat fill (0.15) with no gradient under Heat', () => { - configureChart({ chartStyle: 'heat' }); - const { container } = render(); - const areas = [...container.querySelectorAll('[data-rc="Area"]')]; - expect(areas).toHaveLength(2); - for (const a of areas) { - expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/); - expect(a.getAttribute('data-fillopacity')).toBe('0.15'); - expect(a.getAttribute('data-strokewidth')).toBe('1.9'); - } - }); - it('dims the fill further and flattens under reduced effects, even in Signature', () => { configureChart({ chartStyle: 'signature', reducedEffects: true }); const { container } = render(); @@ -139,7 +187,6 @@ describe('RiskTrendChart gradient vs flat', () => { for (const a of areas) { expect(a.getAttribute('data-fill')).toMatch(/^var\(--color-/); expect(a.getAttribute('data-strokewidth')).toBe('1.9'); - // 0.30 * 0.62 expect(Number(a.getAttribute('data-fillopacity'))).toBeCloseTo(0.186, 5); } }); diff --git a/frontend/src/components/security/SecurityCharts.tsx b/frontend/src/components/security/SecurityCharts.tsx index 3e24bee9..3d9793fa 100644 --- a/frontend/src/components/security/SecurityCharts.tsx +++ b/frontend/src/components/security/SecurityCharts.tsx @@ -1,8 +1,11 @@ -import { useMemo } from 'react'; -import { PieChart, Pie, AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, LabelList } from 'recharts'; +import { + AreaChart, Area, BarChart, Bar, Cell, ScatterChart, Scatter, + XAxis, YAxis, ZAxis, CartesianGrid, LabelList, ReferenceLine, Tooltip, +} from 'recharts'; import { ChartContainer, ChartTooltip, ChartTooltipContent, type ChartConfig } from '@/components/ui/chart'; import { useChartStyle, type ChartStyle } from '@/hooks/use-theme'; -import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security'; +import { cn } from '@/lib/utils'; +import type { SecurityRiskTrendPoint, SecurityOverview, ExploitIntelFinding } from '@/types/security'; // Severity colours resolve through the --sev-* tokens, which the appearance // chart-style switches (Signature keeps today's saturated semantics; Muted and @@ -10,61 +13,23 @@ import type { ScanSummary, SecurityRiskTrendPoint } from '@/types/security'; const SEVERITY_CONFIG = { critical: { label: 'Critical', color: 'var(--sev-critical)' }, high: { label: 'High', color: 'var(--sev-high)' }, - medium: { label: 'Medium', color: 'var(--sev-medium)' }, - low: { label: 'Low', color: 'var(--sev-low)' }, } satisfies ChartConfig; -// Area fill opacity, gradient on/off, and stroke per chart-style. Colours stay in -// the --sev-* tokens; only these shape values vary. Reduced effects flattens -// (no gradient) and dims the fill, matching the calm material direction. +// Area fill opacity, gradient on/off, and stroke per chart-style. const TREND_SHAPE: Record = { signature: { fill: 0.30, gradient: true, stroke: 1.5 }, muted: { fill: 0.16, gradient: false, stroke: 1.9 }, heat: { fill: 0.15, gradient: false, stroke: 1.9 }, }; -// The trend and top-exposed charts both plot only the Critical + High slots. -const CRITICAL_HIGH_CONFIG = { - critical: SEVERITY_CONFIG.critical, - high: SEVERITY_CONFIG.high, -} satisfies ChartConfig; - function EmptyChart({ label, height }: { label: string; height: number }) { return ( -
+
{label}
); } -/** Donut of total findings by severity across the node's scanned images. */ -export function SeverityDonutChart({ summaries }: { summaries: ScanSummary[] }) { - const data = useMemo(() => { - const totals = { critical: 0, high: 0, medium: 0, low: 0 }; - for (const s of summaries) { - totals.critical += s.critical; - totals.high += s.high; - totals.medium += s.medium; - totals.low += s.low; - } - return (['critical', 'high', 'medium', 'low'] as const) - .map((k) => ({ key: k, label: SEVERITY_CONFIG[k].label, value: totals[k], fill: `var(--color-${k})` })) - .filter((d) => d.value > 0); - }, [summaries]); - - const total = data.reduce((sum, d) => sum + d.value, 0); - if (total === 0) return ; - - return ( - - - } /> - - - - ); -} - /** Stacked area of Critical + High findings by scan-day (days with no scans are omitted). */ export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) { const { chartStyle, reduced } = useChartStyle(); @@ -73,14 +38,12 @@ export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) { const fmtDate = (d: string) => d.slice(5); // MM-DD const shape = TREND_SHAPE[chartStyle]; - // Signature (gradient, stroke 1.5) is the no-op baseline. Flat styles and - // reduced effects drop the gradient for a solid low-opacity fill + thicker line. const gradient = shape.gradient && !reduced; const fillOpacity = reduced ? shape.fill * 0.62 : shape.fill; const stroke = reduced ? 1.9 : shape.stroke; return ( - + {gradient && ( @@ -119,91 +82,203 @@ export function RiskTrendChart({ trend }: { trend: SecurityRiskTrendPoint[] }) { ); } -interface TopImageDatum { name: string; critical: number; high: number; scanId: number } +// Action-posture bars. These are independent counts (a finding can be both +// fixable and known-exploited), so they are bars, not a part-of-whole donut. +const POSTURE_CONFIG = { value: { label: 'Findings' } } satisfies ChartConfig; -/** Horizontal stacked bars of the top images by Critical+High; click opens the scan. */ -export function TopExposedImagesChart({ - summaries, +/** Horizontal bars of the actionability facts, from the overview posture. */ +export function ActionPostureChart({ overview }: { overview: SecurityOverview }) { + const data = [ + { label: 'Fixable', value: overview.fixableCriticalHigh ?? 0, fill: 'var(--sev-high)' }, + { label: 'Known exploited', value: overview.knownExploited ?? 0, fill: 'var(--sev-critical)' }, + { label: 'Needs review', value: overview.needsReview ?? 0, fill: 'var(--sev-medium)' }, + { label: 'Accepted', value: overview.accepted ?? 0, fill: 'var(--stat-icon)' }, + { label: 'Not affected', value: overview.notAffected ?? 0, fill: 'var(--sev-low)' }, + ]; + const known = overview.knownExploited ?? 0; + const denom = (overview.rawCritical ?? 0) + (overview.rawHigh ?? 0); + const total = data.reduce((sum, d) => sum + d.value, 0); + + if (denom === 0 && total === 0) return ; + + return ( +
+

+ 0 ? 'text-destructive' : 'text-stat-value')}>{known} + {' of '} + {denom} + {' Critical+High '}{known === 1 ? 'is' : 'are'} known-exploited. +

+ + + + + + {data.map((d) => ())} + + + + +
+ ); +} + +// EPSS at or above this is treated as an elevated exploitation likelihood. +const HIGH_EPSS = 0.1; + +// Rank by exploitation risk under the "assume it's automatable" principle +// (CISA BOD 26-04): absence of EPSS evidence is NOT treated as low risk. Tiers: +// known-exploited (KEV) > known-elevated EPSS > unknown EPSS > known-low EPSS. +// A finding we have no exploitability evidence for outranks one we have +// evidence is unlikely. CVSS is only a within-tier tiebreaker. +function exploitTier(f: ExploitIntelFinding): number { + if (f.kev) return 0; + if (f.epss_score === null) return 2; // unknown: assume potentially automatable + if (f.epss_score >= HIGH_EPSS) return 1; + return 3; // evidence of low likelihood +} + +function exploitRank(a: ExploitIntelFinding, b: ExploitIntelFinding): number { + const ta = exploitTier(a); + const tb = exploitTier(b); + if (ta !== tb) return ta - tb; + const ae = a.epss_score ?? -1; + const be = b.epss_score ?? -1; + if (ae !== be) return be - ae; + return (b.cvss_score ?? -1) - (a.cvss_score ?? -1); +} + +function shortImage(ref: string): string { + return ref.length > 30 ? `…${ref.slice(-29)}` : ref; +} + +/** Ranked list of the highest exploit-risk actionable findings; row opens the scan. */ +export function TopExploitRiskList({ + items, onInspect, }: { - summaries: ScanSummary[]; + items: ExploitIntelFinding[]; onInspect: (scanId: number) => void; }) { - const data: TopImageDatum[] = useMemo( - () => - summaries - .filter((s) => !s.image_ref.startsWith('stack:') && s.critical + s.high > 0) - .sort((a, b) => b.critical + b.high - (a.critical + a.high)) - .slice(0, 6) - .map((s) => ({ - name: s.image_ref.length > 28 ? `…${s.image_ref.slice(-27)}` : s.image_ref, - critical: s.critical, - high: s.high, - scanId: s.scan_id, - })), - [summaries], - ); - - if (data.length === 0) return ; - - const handleBarClick = (d: unknown) => { - const dd = d as TopImageDatum; - if (dd?.scanId != null) onInspect(dd.scanId); - }; + if (items.length === 0) return ; + const ranked = [...items].sort(exploitRank).slice(0, 8); + const anyIntel = items.some((i) => i.epss_score !== null || i.kev); return ( - - - - - } /> - - - - +
+
+ {ranked.map((f) => ( + + ))} +
+ {!anyIntel && ( +

+ Ranked by severity. Enable exploit intelligence and re-scan to rank by known-exploited and EPSS. +

+ )} +
); } -/** Vertical bars comparing the three finding types. */ -export function FindingsByTypeChart({ summaries }: { summaries: ScanSummary[] }) { - const data = useMemo(() => { - let vulnerabilities = 0; - let secrets = 0; - let misconfigs = 0; - for (const s of summaries) { - vulnerabilities += s.total; - secrets += s.secret_count; - misconfigs += s.misconfig_count; - } - // Route every series through the severity ramp (or a neutral for misconfigs) - // so no two complementary hues sit adjacent (the old cyan-next-to-rose clash). - // --stat-icon is palette-invariant by design: misconfigs stay neutral across - // Muted/Heat rather than picking up a severity hue. - return [ - { type: 'Vulnerabilities', value: vulnerabilities, fill: 'var(--sev-vuln)' }, - { type: 'Secrets', value: secrets, fill: 'var(--sev-critical)' }, - { type: 'Misconfigs', value: misconfigs, fill: 'var(--stat-icon)' }, - ]; - }, [summaries]); - - const total = data.reduce((sum, d) => sum + d.value, 0); - if (total === 0) return ; - - const config = { - value: { label: 'Findings' }, - } satisfies ChartConfig; +interface QuadrantPoint { epssPct: number; cvss: number; cve: string; kev: boolean; image: string } +function QuadrantTooltip({ active, payload }: { active?: boolean; payload?: Array<{ payload: QuadrantPoint }> }) { + if (!active || !payload || payload.length === 0) return null; + const p = payload[0].payload; return ( - - - - - - } /> - - - - - +
+
{p.cve}{p.kev && KEV}
+
CVSS {p.cvss} · EPSS {Math.round(p.epssPct)}%
+
{p.image}
+
+ ); +} + +const QUADRANT_CONFIG = { cvss: { label: 'CVSS' } } satisfies ChartConfig; + +/** Scatter of CVSS (severity) by EPSS (exploitability) for actionable findings. + * Separates "scary but not exploitable" (high CVSS, low EPSS) from "act first" + * (high both). Only findings with both scores can be plotted. */ +export function CvssEpssQuadrantChart({ items }: { items: ExploitIntelFinding[] }) { + const plotted: QuadrantPoint[] = items + .filter((i) => i.cvss_score !== null && i.epss_score !== null) + .slice(0, 300) + .map((i) => ({ + epssPct: (i.epss_score as number) * 100, + cvss: i.cvss_score as number, + cve: i.vulnerability_id, + kev: i.kev, + image: i.image_ref, + })); + + if (plotted.length === 0) { + return ; + } + + const missing = items.length - plotted.length; + const kevPoints = plotted.filter((p) => p.kev); + const otherPoints = plotted.filter((p) => !p.kev); + + return ( +
+ + + + + + + + + } /> + + + + + {missing > 0 && ( +

+ {missing} finding{missing === 1 ? '' : 's'} unrated (missing CVSS or EPSS), not lower risk. +

+ )} +
); } diff --git a/frontend/src/components/ui/PageMasthead.tsx b/frontend/src/components/ui/PageMasthead.tsx index 8b1567d9..9e6bb687 100644 --- a/frontend/src/components/ui/PageMasthead.tsx +++ b/frontend/src/components/ui/PageMasthead.tsx @@ -10,7 +10,8 @@ export interface MastheadMetadataItem { } export interface PageMastheadProps { - kicker: string; + /** Small uppercase label above the state word. Omit to show only the state. */ + kicker?: string; state: string; tone: MastheadTone; pulsing?: boolean; @@ -95,9 +96,11 @@ export function PageMasthead({ )} />
- - {kicker} - + {kicker ? ( + + {kicker} + + ) : null}