From c211f655c3144927e54bf7f47697c36146692bf9 Mon Sep 17 00:00:00 2001 From: Anso Date: Fri, 17 Apr 2026 13:53:53 -0400 Subject: [PATCH] fix(security): signal when scan comparison is truncated (#658) Compare endpoint loads up to 1000 findings per scan. When a scan exceeds this cap, the response now includes truncated=true and row_limit, and the comparison sheet surfaces a banner so users understand the diff may be incomplete. Also exposes total_vulnerabilities on scanA/scanB for UI use and logs a warning when truncation occurs. --- backend/src/__tests__/scan-compare.test.ts | 29 +++++++++++++++++++ backend/src/index.ts | 28 +++++++++++++++--- docs/features/vulnerability-scanning.mdx | 2 ++ .../src/components/ScanComparisonSheet.tsx | 12 ++++++++ .../__tests__/ScanComparisonSheet.test.tsx | 21 ++++++++++++++ frontend/src/types/security.ts | 6 ++-- 6 files changed, 92 insertions(+), 6 deletions(-) diff --git a/backend/src/__tests__/scan-compare.test.ts b/backend/src/__tests__/scan-compare.test.ts index 970a1094..267c0011 100644 --- a/backend/src/__tests__/scan-compare.test.ts +++ b/backend/src/__tests__/scan-compare.test.ts @@ -266,6 +266,35 @@ describe('GET /api/security/compare', () => { expect(added0102.suppressed).toBe(false); }); + it('flags truncated=true when either scan exceeds the 1000-row cap', async () => { + const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {}); + const a = seedScan({ scannedAt: 1000, totalVulnerabilities: 1500 }); + const b = seedScan({ scannedAt: 2000, totalVulnerabilities: 10 }); + + const res = await request(app) + .get(`/api/security/compare?scanId1=${a}&scanId2=${b}`) + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + expect(res.body.truncated).toBe(true); + expect(res.body.row_limit).toBe(1000); + expect(res.body.scanA.total_vulnerabilities).toBe(1500); + expect(warnSpy).toHaveBeenCalled(); + warnSpy.mockRestore(); + }); + + it('returns truncated=false when both scans fit within the row cap', async () => { + const a = seedScan({ scannedAt: 1000, totalVulnerabilities: 5 }); + const b = seedScan({ scannedAt: 2000, totalVulnerabilities: 10 }); + + const res = await request(app) + .get(`/api/security/compare?scanId1=${a}&scanId2=${b}`) + .set('Authorization', `Bearer ${adminToken()}`); + + expect(res.status).toBe(200); + expect(res.body.truncated).toBe(false); + }); + it('allows cross-image comparison on the same node and preserves distinct image refs', async () => { const a = seedScan({ imageRef: 'alpine:3.18', scannedAt: 1000 }); const b = seedScan({ imageRef: 'alpine:3.19', scannedAt: 2000 }); diff --git a/backend/src/index.ts b/backend/src/index.ts index 9d506104..5ca18f04 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -7976,8 +7976,16 @@ app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): if (!a || !b || a.node_id !== req.nodeId || b.node_id !== req.nodeId) { res.status(404).json({ error: 'One or both scans not found' }); return; } - const aVulns = db.getVulnerabilityDetails(scanId1, { limit: 1000 }).items; - const bVulns = db.getVulnerabilityDetails(scanId2, { limit: 1000 }).items; + const COMPARE_ROW_LIMIT = 1000; + const aVulns = db.getVulnerabilityDetails(scanId1, { limit: COMPARE_ROW_LIMIT }).items; + const bVulns = db.getVulnerabilityDetails(scanId2, { limit: COMPARE_ROW_LIMIT }).items; + const truncated = + a.total_vulnerabilities > COMPARE_ROW_LIMIT || b.total_vulnerabilities > COMPARE_ROW_LIMIT; + if (truncated) { + console.warn( + `[Compare] scan(s) exceed ${COMPARE_ROW_LIMIT}-row cap: scanA=${a.id}(${a.total_vulnerabilities}) scanB=${b.id}(${b.total_vulnerabilities})`, + ); + } const keyOf = (v: { vulnerability_id: string; pkg_name: string }) => `${v.vulnerability_id}::${v.pkg_name}`; const aMap = new Map(aVulns.map((v) => [keyOf(v), v])); @@ -7990,11 +7998,23 @@ app.get('/api/security/compare', authMiddleware, (req: Request, res: Response): const removed = applySuppressions(removedRaw, a.image_ref, suppressions); const unchanged = applySuppressions(unchangedRaw, b.image_ref, suppressions); res.json({ - scanA: { id: a.id, scanned_at: a.scanned_at, image_ref: a.image_ref }, - scanB: { id: b.id, scanned_at: b.scanned_at, image_ref: b.image_ref }, + scanA: { + id: a.id, + scanned_at: a.scanned_at, + image_ref: a.image_ref, + total_vulnerabilities: a.total_vulnerabilities, + }, + scanB: { + id: b.id, + scanned_at: b.scanned_at, + image_ref: b.image_ref, + total_vulnerabilities: b.total_vulnerabilities, + }, added, removed, unchanged, + truncated, + row_limit: COMPARE_ROW_LIMIT, }); }); diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 7aa8a7e9..360d275e 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -262,6 +262,8 @@ The comparison sheet shows: Cross-image comparisons (picking scans from two different image references) are allowed but flagged with a warning, since package-level changes may reflect image differences rather than CVE drift. +Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this limit, the sheet shows a banner indicating the comparison may be incomplete. + ## How it works 1. On startup, Sencho looks for the `trivy` binary on `PATH` and caches its availability. diff --git a/frontend/src/components/ScanComparisonSheet.tsx b/frontend/src/components/ScanComparisonSheet.tsx index 5e120142..a96131a5 100644 --- a/frontend/src/components/ScanComparisonSheet.tsx +++ b/frontend/src/components/ScanComparisonSheet.tsx @@ -175,6 +175,18 @@ export function ScanComparisonSheet({ )} + {data.truncated && ( +
+ + + Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete. + +
+ )} + {/* Delta ribbon */} {addedCounts && removedCounts && (
diff --git a/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx b/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx index daad20e8..9db1300f 100644 --- a/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx +++ b/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx @@ -150,6 +150,27 @@ describe('ScanComparisonSheet', () => { ); }); + it('renders the truncation banner when the response flags truncated', async () => { + mockedFetch.mockResolvedValueOnce( + jsonResponse(200, result({ truncated: true, row_limit: 1000 })), + ); + + render( {}} />); + + await waitFor(() => + expect(screen.getByText(/first 1000 findings per scan/i)).toBeInTheDocument(), + ); + }); + + it('hides the truncation banner when truncated is false', async () => { + mockedFetch.mockResolvedValueOnce(jsonResponse(200, result({ truncated: false }))); + + render( {}} />); + + await waitFor(() => expect(screen.getByText(/Baseline/i)).toBeInTheDocument()); + expect(screen.queryByText(/findings per scan/i)).toBeNull(); + }); + it('reloads when the scan ids change', async () => { mockedFetch.mockResolvedValue(jsonResponse(200, result())); diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 685fd298..7a3585ea 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -143,9 +143,11 @@ export interface ScanCompareVulnerability { } export interface ScanCompareResult { - scanA: { id: number; scanned_at: number; image_ref: string }; - scanB: { id: number; scanned_at: number; image_ref: string }; + scanA: { id: number; scanned_at: number; image_ref: string; total_vulnerabilities?: number }; + scanB: { id: number; scanned_at: number; image_ref: string; total_vulnerabilities?: number }; added: ScanCompareVulnerability[]; removed: ScanCompareVulnerability[]; unchanged: ScanCompareVulnerability[]; + truncated?: boolean; + row_limit?: number; }