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
@@ -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);
});
});