mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-11 19:26:56 +00:00
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:
@@ -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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user