From 1bca75a9990b5e9344fafffd58a27b3dca6a46e5 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 26 Jun 2026 21:12:18 -0400 Subject: [PATCH] fix: load the full vulnerability list in the scan detail sheet (#1483) The scan detail sheet fetched only the first 500 vulnerabilities for its interactive table, so severity filtering, row inspection, and suppression management could not reach findings beyond the first page on a scan with more than 500. The CSV export already paged the complete list, but that is not a substitute for working with the findings in the table. The sheet now loads every vulnerability via the same paged helper the CSV uses, so the table, filter, pagination, inspection, and suppression all operate over the complete set. The "showing first N of M, export CSV for the complete list" notice is removed because the table is no longer capped. The CSV export reuses the already-complete in-memory set rather than refetching. Secrets and misconfigurations keep their existing per-request cap; they are not the suppression-managed findings this blocker concerns and rarely exceed it. --- .../src/components/VulnerabilityScanSheet.tsx | 19 +++--- .../VulnerabilityScanSheet.load.test.tsx | 63 +++++++++++++++++++ 2 files changed, 70 insertions(+), 12 deletions(-) create mode 100644 frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index 6880814f..f26d5a31 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -231,21 +231,22 @@ export function VulnerabilityScanSheet({ if (scanId == null) return; setLoading(true); try { - const [scanRes, detailsRes, secretsRes, misconfigsRes] = await Promise.all([ + // The vulnerability list is fetched in full (paging past the per-request + // cap), not a single capped page, so severity filtering, inspection, and + // suppression reach every finding rather than only the first page. + const [scanRes, allVulns, secretsRes, misconfigsRes] = await Promise.all([ apiFetch(`/security/scans/${scanId}`), - apiFetch(`/security/scans/${scanId}/vulnerabilities?limit=${DETAIL_FETCH_LIMIT}`), + fetchAllScanVulnerabilities(scanId), apiFetch(`/security/scans/${scanId}/secrets?limit=${DETAIL_FETCH_LIMIT}`), apiFetch(`/security/scans/${scanId}/misconfigs?limit=${DETAIL_FETCH_LIMIT}`), ]); if (!scanRes.ok) throw new Error('Failed to fetch scan'); - if (!detailsRes.ok) throw new Error('Failed to fetch vulnerabilities'); const scanData = (await scanRes.json()) as VulnerabilityScan; - const detailsData = await detailsRes.json(); const secretsData = secretsRes.ok ? await secretsRes.json() : { items: [] }; const misconfigsData = misconfigsRes.ok ? await misconfigsRes.json() : { items: [] }; setScan(scanData); - setDetails(Array.isArray(detailsData.items) ? detailsData.items : []); - setTotalDetails(typeof detailsData.total === 'number' ? detailsData.total : 0); + setDetails(allVulns); + setTotalDetails(allVulns.length); setSecrets(Array.isArray(secretsData.items) ? secretsData.items : []); setMisconfigs(Array.isArray(misconfigsData.items) ? misconfigsData.items : []); setPage(0); @@ -785,12 +786,6 @@ export function VulnerabilityScanSheet({ )} - {totalDetails > details.length && ( -
- Showing first {details.length} of {totalDetails}. Export CSV for the complete list. -
- )} - {pageItems.length === 0 ? (
diff --git a/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx b/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx new file mode 100644 index 00000000..9b64f5a6 --- /dev/null +++ b/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx @@ -0,0 +1,63 @@ +/** + * The detail sheet must load EVERY vulnerability (paging past the per-request + * cap), not a single capped page, so severity filtering, inspection, and + * suppression reach findings beyond the first page. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, waitFor } from '@testing-library/react'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); +vi.mock('@/components/ui/toast-store', () => ({ toast: { error: vi.fn(), success: vi.fn() } })); + +import { apiFetch } from '@/lib/api'; +import { VulnerabilityScanSheet } from '../VulnerabilityScanSheet'; + +const mockedFetch = vi.mocked(apiFetch); +const ok = (body: unknown) => ({ ok: true, json: async () => body }) as Response; + +function vuln(i: number) { + return { + id: i, scan_id: 1, vulnerability_id: `CVE-2026-${i}`, pkg_name: 'pkg', installed_version: '1', + fixed_version: null, severity: 'HIGH', title: null, description: null, primary_url: null, + }; +} + +const SCAN = { + id: 1, node_id: 1, image_ref: 'img:1', image_digest: null, scanned_at: Date.now(), + total_vulnerabilities: 1500, critical_count: 0, high_count: 1500, 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: '0.50.0', scan_duration_ms: 1, triggered_by: 'manual', + status: 'completed', error: null, stack_context: null, policy_evaluation: null, +}; + +beforeEach(() => { + mockedFetch.mockReset(); + mockedFetch.mockImplementation(((url: string) => { + if (/\/vulnerabilities\?limit=1000&offset=0/.test(url)) { + return Promise.resolve(ok({ items: Array.from({ length: 1000 }, (_, i) => vuln(i)), total: 1500 })); + } + if (/\/vulnerabilities\?limit=1000&offset=1000/.test(url)) { + return Promise.resolve(ok({ items: Array.from({ length: 500 }, (_, i) => vuln(1000 + i)), total: 1500 })); + } + if (/\/secrets/.test(url) || /\/misconfigs/.test(url) || /\/security\/scans\?imageRef/.test(url)) { + return Promise.resolve(ok({ items: [] })); + } + if (/\/security\/scans\/1$/.test(url)) return Promise.resolve(ok(SCAN)); + return Promise.resolve(ok({ items: [] })); + }) as unknown as typeof apiFetch); +}); + +describe('VulnerabilityScanSheet load', () => { + it('pages the full vulnerability list instead of a single capped page', async () => { + render( {}} />); + await waitFor(() => { + const urls = mockedFetch.mock.calls.map((c) => String(c[0])); + // Both pages of the offset-based full fetch were requested. + expect(urls.some((u) => /vulnerabilities\?limit=1000&offset=0/.test(u))).toBe(true); + expect(urls.some((u) => /vulnerabilities\?limit=1000&offset=1000/.test(u))).toBe(true); + }); + // The old single capped page request is gone. + const urls = mockedFetch.mock.calls.map((c) => String(c[0])); + expect(urls.some((u) => /vulnerabilities\?limit=500/.test(u))).toBe(false); + }); +});