diff --git a/frontend/src/components/VulnerabilityScanSheet.export.ts b/frontend/src/components/VulnerabilityScanSheet.export.ts new file mode 100644 index 00000000..b791b545 --- /dev/null +++ b/frontend/src/components/VulnerabilityScanSheet.export.ts @@ -0,0 +1,28 @@ +import { apiFetch } from '@/lib/api'; +import type { VulnerabilityDetail } from '@/types/security'; + +/** + * Every vulnerability row for a scan, paging past the API's per-request cap + * (the backend clamps a single page to 1000 rows). The detail table renders + * only a capped page, so the CSV export relies on this to make good on its + * "complete list" promise for scans with thousands of findings. Unfiltered by + * severity on purpose: the CSV is the full record, not the current view. A + * short page also ends the loop, so an over-reported `total` cannot spin it. + */ +export async function fetchAllScanVulnerabilities( + scanId: number, +): Promise { + const all: VulnerabilityDetail[] = []; + const pageSize = 1000; + for (let offset = 0; ; offset += pageSize) { + const res = await apiFetch( + `/security/scans/${scanId}/vulnerabilities?limit=${pageSize}&offset=${offset}`, + ); + if (!res.ok) throw new Error('Failed to fetch the full vulnerability list'); + const data = await res.json(); + const items: VulnerabilityDetail[] = Array.isArray(data.items) ? data.items : []; + all.push(...items); + if (items.length < pageSize || all.length >= (data.total ?? all.length)) break; + } + return all; +} diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx index e01aafc3..32417728 100644 --- a/frontend/src/components/VulnerabilityScanSheet.tsx +++ b/frontend/src/components/VulnerabilityScanSheet.tsx @@ -40,6 +40,7 @@ import { import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { ScanComparisonSheet } from './ScanComparisonSheet'; +import { fetchAllScanVulnerabilities } from './VulnerabilityScanSheet.export'; import { apiFetch } from '@/lib/api'; import { toast } from '@/components/ui/toast-store'; import { cn } from '@/lib/utils'; @@ -221,6 +222,7 @@ export function VulnerabilityScanSheet({ const [savingSuppression, setSavingSuppression] = useState(false); const [ackForm, setAckForm] = useState(null); const [savingAck, setSavingAck] = useState(false); + const [exportingCsv, setExportingCsv] = useState(false); const DETAIL_FETCH_LIMIT = 500; @@ -477,32 +479,47 @@ export function VulnerabilityScanSheet({ } }, [ackForm, load]); - const exportCsv = useCallback(() => { + const exportCsv = useCallback(async () => { if (!scan || details.length === 0) return; - const header = 'CVE,Package,Severity,Installed,Fixed,URL\n'; - const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; - const rows = details - .map((d) => - [ - escape(d.vulnerability_id), - escape(d.pkg_name), - escape(d.severity), - escape(d.installed_version), - escape(d.fixed_version ?? ''), - escape(d.primary_url ?? ''), - ].join(','), - ) - .join('\n'); - const blob = new Blob([header + rows], { type: 'text/csv;charset=utf-8' }); - const url = URL.createObjectURL(blob); - const a = document.createElement('a'); - a.href = url; - a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-vulnerabilities.csv`; - document.body.appendChild(a); - a.click(); - a.remove(); - URL.revokeObjectURL(url); - }, [scan, details]); + setExportingCsv(true); + try { + // The table renders a capped page; the CSV is the complete-list recovery + // path the in-sheet notice promises, so fetch every row when the loaded + // set is short of the total. Otherwise reuse what is already in memory. + const rows = + details.length < totalDetails + ? await fetchAllScanVulnerabilities(scan.id) + : details; + const escape = (v: string) => `"${v.replace(/"/g, '""')}"`; + const csv = + 'CVE,Package,Severity,Installed,Fixed,URL\n' + + rows + .map((d) => + [ + escape(d.vulnerability_id), + escape(d.pkg_name), + escape(d.severity), + escape(d.installed_version), + escape(d.fixed_version ?? ''), + escape(d.primary_url ?? ''), + ].join(','), + ) + .join('\n'); + const url = URL.createObjectURL(new Blob([csv], { type: 'text/csv;charset=utf-8' })); + const a = document.createElement('a'); + a.href = url; + a.download = `${scan.image_ref.replace(/[^a-z0-9]+/gi, '_')}-vulnerabilities.csv`; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + toast.success(`Exported ${rows.length} ${rows.length === 1 ? 'vulnerability' : 'vulnerabilities'}`); + } catch (err) { + toast.error((err as Error)?.message || 'CSV export failed'); + } finally { + setExportingCsv(false); + } + }, [scan, details, totalDetails]); const exportSarif = useCallback(async () => { if (!scan) return; @@ -550,8 +567,9 @@ export function VulnerabilityScanSheet({ }] : []), ...(details.length > 0 ? [{ label: 'CSV', - icon: Download, - onClick: exportCsv, + icon: exportingCsv ? Loader2 : Download, + onClick: () => { void exportCsv(); }, + disabled: exportingCsv, }] : []), ...(canExportSarif && scan.status === 'completed' ? [{ label: 'SARIF', diff --git a/frontend/src/components/__tests__/VulnerabilityScanSheet.export.test.ts b/frontend/src/components/__tests__/VulnerabilityScanSheet.export.test.ts new file mode 100644 index 00000000..7ff91735 --- /dev/null +++ b/frontend/src/components/__tests__/VulnerabilityScanSheet.export.test.ts @@ -0,0 +1,60 @@ +/** + * fetchAllScanVulnerabilities backs the scan sheet's CSV export. The detail + * table renders a capped page, but the CSV promises the complete list, so this + * helper must page past the backend's per-request cap (1000 rows) and collect + * every finding. A scan with thousands of CVEs is the case this guards. + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })); + +import { apiFetch } from '@/lib/api'; +import { fetchAllScanVulnerabilities } from '../VulnerabilityScanSheet.export'; +import type { VulnerabilityDetail } from '@/types/security'; + +const mockedFetch = vi.mocked(apiFetch); + +function response(ok: boolean, body: unknown): Response { + return { ok, json: async () => body } as unknown as Response; +} + +function rows(n: number, startId = 0): VulnerabilityDetail[] { + return Array.from({ length: n }, (_, i) => ({ id: startId + i }) as VulnerabilityDetail); +} + +beforeEach(() => mockedFetch.mockReset()); + +describe('fetchAllScanVulnerabilities', () => { + it('fetches a single page when the total fits under the per-request cap', async () => { + mockedFetch.mockResolvedValueOnce(response(true, { items: rows(42), total: 42 })); + const result = await fetchAllScanVulnerabilities(7); + expect(result).toHaveLength(42); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith('/security/scans/7/vulnerabilities?limit=1000&offset=0'); + }); + + it('pages past the cap until every row is collected', async () => { + mockedFetch + .mockResolvedValueOnce(response(true, { items: rows(1000, 0), total: 2500 })) + .mockResolvedValueOnce(response(true, { items: rows(1000, 1000), total: 2500 })) + .mockResolvedValueOnce(response(true, { items: rows(500, 2000), total: 2500 })); + const result = await fetchAllScanVulnerabilities(3); + expect(result).toHaveLength(2500); + expect(mockedFetch).toHaveBeenCalledTimes(3); + expect(mockedFetch).toHaveBeenNthCalledWith(1, '/security/scans/3/vulnerabilities?limit=1000&offset=0'); + expect(mockedFetch).toHaveBeenNthCalledWith(2, '/security/scans/3/vulnerabilities?limit=1000&offset=1000'); + expect(mockedFetch).toHaveBeenNthCalledWith(3, '/security/scans/3/vulnerabilities?limit=1000&offset=2000'); + }); + + it('stops on a short page even when total over-reports (no infinite loop)', async () => { + mockedFetch.mockResolvedValueOnce(response(true, { items: rows(10), total: 99999 })); + const result = await fetchAllScanVulnerabilities(1); + expect(result).toHaveLength(10); + expect(mockedFetch).toHaveBeenCalledTimes(1); + }); + + it('throws when a page request fails rather than returning a partial list', async () => { + mockedFetch.mockResolvedValueOnce(response(false, {})); + await expect(fetchAllScanVulnerabilities(1)).rejects.toThrow('full vulnerability list'); + }); +});