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.
This commit is contained in:
Anso
2026-04-17 13:53:53 -04:00
committed by GitHub
parent f4e3c267cd
commit c211f655c3
6 changed files with 92 additions and 6 deletions
@@ -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 });
+24 -4
View File
@@ -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,
});
});
+2
View File
@@ -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.
@@ -175,6 +175,18 @@ export function ScanComparisonSheet({
</div>
)}
{data.truncated && (
<div
role="alert"
className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 text-xs text-warning"
>
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
<span>
Showing the first {data.row_limit ?? 1000} findings per scan. One or both scans exceed this limit, so the comparison may be incomplete.
</span>
</div>
)}
{/* Delta ribbon */}
{addedCounts && removedCounts && (
<div className="flex flex-wrap gap-2">
@@ -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(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
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(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
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()));
+4 -2
View File
@@ -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;
}