diff --git a/backend/src/__tests__/database-security-overview-helpers.test.ts b/backend/src/__tests__/database-security-overview-helpers.test.ts index 3792fb01..1b628e5c 100644 --- a/backend/src/__tests__/database-security-overview-helpers.test.ts +++ b/backend/src/__tests__/database-security-overview-helpers.test.ts @@ -284,6 +284,43 @@ describe('getLatestKevFindingsForNode', () => { }); }); +describe('getLatestCritHighFindingsWithCvssForNode ranking', () => { + function rawDb2() { + return (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; + } + beforeEach(() => { + rawDb2().prepare('DELETE FROM vulnerability_details').run(); + rawDb2().prepare('DELETE FROM cve_intel').run(); + rawDb2().prepare('DELETE FROM vulnerability_scans').run(); + }); + + it('keeps the highest-risk findings (KEV, then EPSS, then CVSS) when the cap truncates', () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'app:1', image_digest: 'sha256:rank', scanned_at: now, + total_vulnerabilities: 3, critical_count: 0, high_count: 3, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 0, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'HIGH', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + const d = (id: string, cvss: number) => ({ + vulnerability_id: id, pkg_name: `p-${id}`, installed_version: '1', fixed_version: null, + severity: 'HIGH' as const, title: null, description: null, primary_url: null, cvss_score: cvss, + }); + // Insert the lowest-risk finding FIRST so an unordered LIMIT would wrongly keep it. + db().insertVulnerabilityDetails(scanId, [d('CVE-PLAIN-LOWCVSS', 1.0), d('CVE-KEV-LOWCVSS', 4.0), d('CVE-PLAIN-HIGHCVSS', 9.0)]); + db().replaceKev([{ cve_id: 'CVE-KEV-LOWCVSS', date_added: '2024-01-01' }], now); + + // Cap below the finding count: the dropped row must be the lowest-risk one. + const res = db().getLatestCritHighFindingsWithCvssForNode(1, 2); + const ids = res.items.map((i) => i.vulnerability_id); + expect(res.truncated).toBe(true); + expect(ids).toContain('CVE-KEV-LOWCVSS'); // KEV ranks first despite low CVSS + expect(ids).toContain('CVE-PLAIN-HIGHCVSS'); // then highest CVSS + expect(ids).not.toContain('CVE-PLAIN-LOWCVSS'); // lowest-risk is the one dropped + }); +}); + describe('getDailyRiskTrend', () => { it('sums latest-per-image critical/high per day and orders days ascending', () => { const day1 = dayStartMs(3); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 33c68034..2c7c85d8 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -5193,6 +5193,7 @@ export class DatabaseService { ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders}) AND (vd.severity IN ('CRITICAL', 'HIGH') OR ci.kev = 1) + ORDER BY COALESCE(ci.kev, 0) DESC, COALESCE(ci.epss_score, -1) DESC, COALESCE(vd.cvss_score, -1) DESC LIMIT ?`, ) .all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{ diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index b86ea493..baa381c0 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -76,6 +76,9 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security const [summariesError, setSummariesError] = useState(false); const [trend, setTrend] = useState([]); const [exploitIntel, setExploitIntel] = useState([]); + // True when the exploit-intel query hit its row cap: the list shows the + // highest-risk findings but not every one, so the UI discloses it. + const [exploitTruncated, setExploitTruncated] = useState(false); const [isReplica, setIsReplica] = useState(false); // Bumped after a node-wide scan completes to refetch the active node's posture. const [reloadToken, setReloadToken] = useState(0); @@ -117,10 +120,13 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security .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(() => []); + const exploitIntelPromise: Promise<{ items: ExploitIntelFinding[]; truncated: boolean }> = apiFetch('/security/overview/exploit-intel') + .then((r) => (r.ok ? r.json() : { items: [], truncated: false })) + .then((b) => ({ + items: b && Array.isArray(b.items) ? b.items : [], + truncated: b?.truncated === true, + })) + .catch(() => ({ items: [], truncated: false })); try { const [overviewRes, summariesRes] = await Promise.all([ apiFetch('/security/overview'), @@ -164,7 +170,8 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security const [trendData, intelData] = await Promise.all([trendPromise, exploitIntelPromise]); if (!cancelled) { setTrend(trendData); - setExploitIntel(intelData); + setExploitIntel(intelData.items); + setExploitTruncated(intelData.truncated); } })(); return () => { cancelled = true; }; @@ -246,6 +253,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security loadError={overviewLoadError} trend={trend} exploitIntel={exploitIntel} + exploitTruncated={exploitTruncated} onNavigate={onTabChange} onInspect={onInspect} canScan={canScan} diff --git a/frontend/src/components/security/OverviewTab.tsx b/frontend/src/components/security/OverviewTab.tsx index e310d5d4..c6b5b23b 100644 --- a/frontend/src/components/security/OverviewTab.tsx +++ b/frontend/src/components/security/OverviewTab.tsx @@ -22,6 +22,8 @@ interface OverviewTabProps { trend: SecurityRiskTrendPoint[]; /** Actionable Critical/High findings with KEV/EPSS for the exploit-intel charts. */ exploitIntel: ExploitIntelFinding[]; + /** True when the exploit-intel set hit its row cap (highest-risk shown, not all). */ + exploitTruncated: boolean; onNavigate: (tab: SecurityTab) => void; onInspect: (scanId: number) => void; /** Admin on a node with a ready scanner; enables the node-scan launcher. */ @@ -124,7 +126,7 @@ function ReviewQueueCard({ ); } -export function OverviewTab({ overview, loadError, trend, exploitIntel, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) { +export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete, isPaid }: OverviewTabProps) { const isMobile = useIsMobile(); if (loadError === 'unsupported') { @@ -218,7 +220,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, onNaviga card never stretches to a taller exploit table (which left dead space under the chart). The exploit-risk table owns its own card chrome. */}
- + diff --git a/frontend/src/components/security/SecurityCharts.test.tsx b/frontend/src/components/security/SecurityCharts.test.tsx index dd723722..7a432a45 100644 --- a/frontend/src/components/security/SecurityCharts.test.tsx +++ b/frontend/src/components/security/SecurityCharts.test.tsx @@ -132,6 +132,22 @@ describe('TopExploitRiskList', () => { expect(onInspect).toHaveBeenCalledWith(11); }); + it('colors the severity dot by the finding severity (a Medium KEV is not shown as High)', () => { + const { container } = render( + , + ); + const dot = container.querySelector('li[role="button"] span[aria-hidden]') as HTMLElement; + expect(dot.style.background).toContain('sev-medium'); + }); + + it('discloses truncation only when the result was capped', () => { + const item = finding({ vulnerability_id: 'CVE-A', cvss_score: 8 }); + const capped = render(); + expect(capped.container.textContent).toContain('more exist than can be listed'); + const full = render(); + expect(full.container.textContent).not.toContain('more exist than can be listed'); + }); + it('paginates beyond the page size and advances and rewinds pages', () => { const items = Array.from({ length: 9 }, (_, i) => finding({ vulnerability_id: `CVE-${i}`, cvss_score: 9 - i * 0.1, epss_score: 0.5, scan_id: i }), diff --git a/frontend/src/components/security/SecurityCharts.tsx b/frontend/src/components/security/SecurityCharts.tsx index 71614b04..c53f3651 100644 --- a/frontend/src/components/security/SecurityCharts.tsx +++ b/frontend/src/components/security/SecurityCharts.tsx @@ -162,14 +162,29 @@ const EXPLOIT_PAGE_SIZE = 8; // horizontally instead; desktop is untouched by the `max-md:` prefix. const EXPLOIT_GRID = 'grid-cols-[10px_minmax(0,1.4fr)_minmax(0,1fr)_56px_52px] max-md:min-w-[480px]'; +// Per-severity dot color. A KEV finding can now be any severity, so the dot must +// reflect the row's real severity rather than collapsing everything non-Critical +// to the High color. UNKNOWN gets the neutral subtitle tone (matching SeverityChip), +// not the low color, so an UNKNOWN-severity finding is not understated as low risk. +const SEV_DOT_VAR: Record = { + CRITICAL: 'var(--sev-critical)', + HIGH: 'var(--sev-high)', + MEDIUM: 'var(--sev-medium)', + LOW: 'var(--sev-low)', + UNKNOWN: 'var(--stat-subtitle)', +}; + /** Ranked, paginated table of the highest exploit-risk actionable findings; a row opens the scan. * Renders its own card chrome (header + pagination + column headers) so the Overview reads as a * table, mirroring the dashboard Stack-health table. */ export function TopExploitRiskList({ items, + truncated = false, onInspect, }: { items: ExploitIntelFinding[]; + /** The backend capped the result; the highest-risk findings are shown, not all. */ + truncated?: boolean; onInspect: (scanId: number) => void; }) { const [page, setPage] = useState(0); @@ -231,7 +246,7 @@ export function TopExploitRiskList({ > @@ -254,6 +269,11 @@ export function TopExploitRiskList({ ))} + {truncated && ( +

+ Showing the highest-risk findings; more exist than can be listed here. +

+ )} {!anyIntel && (

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