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}