fix: export the full vulnerability list to CSV (#1472)

The scan detail sheet fetches a capped page of vulnerabilities for
display, then told operators to "Export CSV for the complete list".
The CSV writer only serialized the rows already in memory, so for a
scan with more findings than the page cap the CSV silently dropped
everything past the cap: the recovery path the notice promised did not
exist.

Export now pages past the API's per-request cap and serializes every
row when the loaded set is short of the total, falling back to the
in-memory rows when they are already complete. The CSV action shows a
spinner and disables while the export runs.
This commit is contained in:
Anso
2026-06-26 15:34:57 -04:00
committed by GitHub
parent ca496c89dc
commit e3b3c3b857
3 changed files with 133 additions and 27 deletions
@@ -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<VulnerabilityDetail[]> {
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;
}
@@ -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<AckDialogState | null>(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',
@@ -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');
});
});