diff --git a/frontend/src/components/VulnerabilityScanSheet.tsx b/frontend/src/components/VulnerabilityScanSheet.tsx
index 6880814f..f26d5a31 100644
--- a/frontend/src/components/VulnerabilityScanSheet.tsx
+++ b/frontend/src/components/VulnerabilityScanSheet.tsx
@@ -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({
)}
- {totalDetails > details.length && (
-
- Showing first {details.length} of {totalDetails}. Export CSV for the complete list.
-
- )}
-
{pageItems.length === 0 ? (
diff --git a/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx b/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx
new file mode 100644
index 00000000..9b64f5a6
--- /dev/null
+++ b/frontend/src/components/__tests__/VulnerabilityScanSheet.load.test.tsx
@@ -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( {}} />);
+ 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);
+ });
+});