fix(security): server-driven pagination for scan history (#661)

Scan history fetched a fixed 200 most-recent rows and paginated them
client-side, so older scans silently fell off mature nodes where
baselining is most valuable. The list now fetches one page at a time
via offset, with status=completed and imageRefLike filters applied
server-side. Search input is debounced to avoid per-keystroke fetches.
This commit is contained in:
Anso
2026-04-17 14:06:23 -04:00
committed by GitHub
parent c211f655c3
commit 2fce1d3baf
5 changed files with 174 additions and 25 deletions
@@ -0,0 +1,95 @@
/**
* Coverage for `getVulnerabilityScans` filtering + pagination, used by
* the scan-history page's server-driven pagination.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
let tmpDir: string;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
});
afterAll(() => cleanupTestDb(tmpDir));
function seedScan(overrides: Partial<{
image_ref: string;
scanned_at: number;
status: 'completed' | 'in_progress' | 'failed';
}> = {}): number {
const db = DatabaseService.getInstance();
return db.createVulnerabilityScan({
node_id: 1,
image_ref: overrides.image_ref ?? 'alpine:3.19',
image_digest: `sha256:${Math.random().toString(16).slice(2)}`,
scanned_at: overrides.scanned_at ?? Date.now(),
total_vulnerabilities: 0,
critical_count: 0,
high_count: 0,
medium_count: 0,
low_count: 0,
unknown_count: 0,
fixable_count: 0,
secret_count: 0,
misconfig_count: 0,
scanners_used: 'vuln',
highest_severity: null,
os_info: null,
trivy_version: null,
scan_duration_ms: null,
triggered_by: 'manual',
status: overrides.status ?? 'completed',
error: null,
stack_context: null,
});
}
function resetTable(): void {
(DatabaseService.getInstance() as unknown as {
db: { prepare: (s: string) => { run: () => void } };
}).db.prepare('DELETE FROM vulnerability_scans').run();
}
beforeEach(() => resetTable());
describe('getVulnerabilityScans filters and pagination', () => {
it('filters by status=completed', () => {
const db = DatabaseService.getInstance();
seedScan({ status: 'completed', scanned_at: 1 });
seedScan({ status: 'in_progress', scanned_at: 2 });
seedScan({ status: 'failed', scanned_at: 3 });
const result = db.getVulnerabilityScans(1, { status: 'completed' });
expect(result.total).toBe(1);
expect(result.items).toHaveLength(1);
expect(result.items[0].status).toBe('completed');
});
it('filters by imageRefLike substring, case-sensitive', () => {
const db = DatabaseService.getInstance();
seedScan({ image_ref: 'alpine:3.18', scanned_at: 1 });
seedScan({ image_ref: 'alpine:3.19', scanned_at: 2 });
seedScan({ image_ref: 'nginx:1.25', scanned_at: 3 });
const result = db.getVulnerabilityScans(1, { imageRefLike: 'alpine' });
expect(result.total).toBe(2);
expect(result.items.every((s) => s.image_ref.startsWith('alpine'))).toBe(true);
});
it('returns total independent of limit for pagination', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 5; i++) seedScan({ scanned_at: i * 1000 });
const page1 = db.getVulnerabilityScans(1, { limit: 2, offset: 0 });
const page2 = db.getVulnerabilityScans(1, { limit: 2, offset: 2 });
expect(page1.total).toBe(5);
expect(page2.total).toBe(5);
expect(page1.items).toHaveLength(2);
expect(page2.items).toHaveLength(2);
expect(page1.items[0].id).not.toBe(page2.items[0].id);
});
});
+11
View File
@@ -7544,10 +7544,21 @@ app.post('/api/security/scan/stack', authMiddleware, async (req: Request, res: R
app.get('/api/security/scans', authMiddleware, (req: Request, res: Response) => {
try {
const imageRef = typeof req.query.imageRef === 'string' ? req.query.imageRef : undefined;
const imageRefLike =
typeof req.query.imageRefLike === 'string' && req.query.imageRefLike.trim()
? req.query.imageRefLike.trim()
: undefined;
const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined;
const status =
statusParam === 'completed' || statusParam === 'in_progress' || statusParam === 'failed'
? statusParam
: undefined;
const limit = req.query.limit ? Number(req.query.limit) : undefined;
const offset = req.query.offset ? Number(req.query.offset) : undefined;
const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, {
imageRef,
imageRefLike,
status,
limit,
offset,
});
+9 -1
View File
@@ -2242,7 +2242,7 @@ export class DatabaseService {
public getVulnerabilityScans(
nodeId: number,
opts: { imageRef?: string; limit?: number; offset?: number } = {},
opts: { imageRef?: string; imageRefLike?: string; status?: VulnScanStatus; limit?: number; offset?: number } = {},
): { items: VulnerabilityScan[]; total: number } {
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
const offset = Math.max(0, opts.offset ?? 0);
@@ -2252,6 +2252,14 @@ export class DatabaseService {
where.push('image_ref = ?');
params.push(opts.imageRef);
}
if (opts.imageRefLike) {
where.push('image_ref LIKE ?');
params.push(`%${opts.imageRefLike}%`);
}
if (opts.status) {
where.push('status = ?');
params.push(opts.status);
}
const whereSql = where.join(' AND ');
const total = (
this.db