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.
This commit is contained in:
Anso
2026-06-26 21:12:18 -04:00
committed by GitHub
parent 7c9c640625
commit 1bca75a999
2 changed files with 70 additions and 12 deletions
@@ -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({
)}
</div>
{totalDetails > details.length && (
<div className="text-xs text-stat-subtitle font-mono mb-2">
Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
</div>
)}
<ScrollArea block className="flex-1 min-h-0">
{pageItems.length === 0 ? (
<div className="text-center text-sm text-muted-foreground py-12">
@@ -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(<VulnerabilityScanSheet scanId={1} onClose={() => {}} />);
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);
});
});