feat(security): prefer digest identity in scan history (#1610)

Retain scans and History search by digest when available, keep imageRefLike for compatibility, and surface digests in compare.
This commit is contained in:
Anso
2026-07-10 22:10:09 -04:00
committed by GitHub
parent 8fd526ba05
commit 4834e2e51d
17 changed files with 561 additions and 97 deletions
+197 -26
View File
@@ -1,6 +1,7 @@
/**
* Coverage for `getVulnerabilityScans` filtering + pagination, used by
* the scan-history page's server-driven pagination.
* the scan-history page's server-driven pagination. Caps and prune partition
* by digest identity (fallback to image_ref when digest is null/empty).
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest';
import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb';
@@ -18,14 +19,19 @@ afterAll(() => cleanupTestDb(tmpDir));
function seedScan(overrides: Partial<{
node_id: number;
image_ref: string;
image_digest: string | null;
scanned_at: number;
status: 'completed' | 'in_progress' | 'failed';
}> = {}): number {
const db = DatabaseService.getInstance();
const digest =
overrides.image_digest === undefined
? `sha256:${Math.random().toString(16).slice(2)}`
: overrides.image_digest;
return db.createVulnerabilityScan({
node_id: overrides.node_id ?? 1,
image_ref: overrides.image_ref ?? 'alpine:3.19',
image_digest: `sha256:${Math.random().toString(16).slice(2)}`,
image_digest: digest,
scanned_at: overrides.scanned_at ?? Date.now(),
total_vulnerabilities: 0,
critical_count: 0,
@@ -69,17 +75,53 @@ describe('getVulnerabilityScans filters and pagination', () => {
expect(result.items[0].status).toBe('completed');
});
it('filters by imageRefLike substring, case-sensitive', () => {
it('filters by imageRefLike substring (reference-only, legacy)', () => {
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 });
seedScan({ image_ref: 'alpine:3.18', image_digest: 'sha256:aaa', scanned_at: 1 });
seedScan({ image_ref: 'alpine:3.19', image_digest: 'sha256:bbb', scanned_at: 2 });
seedScan({ image_ref: 'nginx:1.25', image_digest: 'sha256:ccc', 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('imageRefLike does not match digest-only fragments', () => {
const db = DatabaseService.getInstance();
seedScan({ image_ref: 'nginx:1', image_digest: 'sha256:alpinecafe', scanned_at: 1 });
seedScan({ image_ref: 'alpine:3.19', image_digest: 'sha256:other', scanned_at: 2 });
const result = db.getVulnerabilityScans(1, { imageRefLike: 'alpine' });
expect(result.total).toBe(1);
expect(result.items[0].image_ref).toBe('alpine:3.19');
});
it('imageIdentityLike matches image_ref OR image_digest', () => {
const db = DatabaseService.getInstance();
seedScan({ image_ref: 'nginx:1', image_digest: 'sha256:deadbeef01', scanned_at: 1 });
seedScan({ image_ref: 'redis:7', image_digest: 'sha256:cafef00d02', scanned_at: 2 });
seedScan({ image_ref: 'alpine:3.19', image_digest: 'sha256:other03', scanned_at: 3 });
const byDigest = db.getVulnerabilityScans(1, { imageIdentityLike: 'deadbeef' });
expect(byDigest.total).toBe(1);
expect(byDigest.items[0].image_ref).toBe('nginx:1');
const byRef = db.getVulnerabilityScans(1, { imageIdentityLike: 'alpine' });
expect(byRef.total).toBe(1);
expect(byRef.items[0].image_ref).toBe('alpine:3.19');
});
it('filters by exact imageDigest', () => {
const db = DatabaseService.getInstance();
const digest = 'sha256:exactmatch99';
seedScan({ image_ref: 'a:1', image_digest: digest, scanned_at: 1 });
seedScan({ image_ref: 'a:2', image_digest: 'sha256:other', scanned_at: 2 });
const result = db.getVulnerabilityScans(1, { imageDigest: digest });
expect(result.total).toBe(1);
expect(result.items[0].image_digest).toBe(digest);
});
it('returns total independent of limit for pagination', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 5; i++) seedScan({ scanned_at: i * 1000 });
@@ -95,43 +137,148 @@ describe('getVulnerabilityScans filters and pagination', () => {
});
});
describe('getVulnerabilityScans per-image cap', () => {
it('caps rows per image_ref when no imageRef filter is set', () => {
describe('getVulnerabilityScans per-digest identity cap', () => {
it('caps rows per digest identity and reports cappedIdentities', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '10');
const hotDigest = 'sha256:hotdigest00';
const coolDigest = 'sha256:cooldigest0';
for (let i = 0; i < 80; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
for (let i = 0; i < 80; i++) {
seedScan({ image_ref: 'hot:latest', image_digest: hotDigest, scanned_at: 1000 + i });
}
for (let i = 0; i < 5; i++) {
seedScan({ image_ref: 'cool:latest', image_digest: coolDigest, scanned_at: 1000 + i });
}
const result = db.getVulnerabilityScans(1, { limit: 500 });
const hotRows = result.items.filter((s) => s.image_ref === 'hot:latest');
const coolRows = result.items.filter((s) => s.image_ref === 'cool:latest');
const hotRows = result.items.filter((s) => s.image_digest === hotDigest);
const coolRows = result.items.filter((s) => s.image_digest === coolDigest);
expect(hotRows).toHaveLength(10);
expect(coolRows).toHaveLength(5);
expect(result.total).toBe(15);
expect(result.cappedImageRefs).toEqual(['hot:latest']);
expect(result.cappedIdentities).toEqual([
{ key: hotDigest, kind: 'digest', displayRef: 'hot:latest' },
]);
expect(result.perImageLimit).toBe(10);
});
it('shares one retention bucket across tags that share a digest', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '5');
const shared = 'sha256:sharedigest1';
for (let i = 0; i < 4; i++) {
seedScan({ image_ref: 'app:v1', image_digest: shared, scanned_at: 1000 + i });
}
for (let i = 0; i < 4; i++) {
seedScan({ image_ref: 'app:latest', image_digest: shared, scanned_at: 2000 + i });
}
const result = db.getVulnerabilityScans(1, { limit: 500 });
expect(result.total).toBe(5);
expect(result.items.every((s) => s.image_digest === shared)).toBe(true);
expect(result.cappedIdentities[0]?.displayRef).toBe('app:latest');
});
it('partitions null-digest rows by image_ref', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '3');
for (let i = 0; i < 6; i++) {
seedScan({ image_ref: 'stack:web', image_digest: null, scanned_at: 1000 + i });
}
const result = db.getVulnerabilityScans(1, { limit: 500 });
expect(result.total).toBe(3);
expect(result.cappedIdentities).toEqual([
{ key: 'stack:web', kind: 'ref', displayRef: 'stack:web' },
]);
});
it('treats empty and whitespace-only digests as ref identity', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '2');
for (let i = 0; i < 3; i++) {
seedScan({ image_ref: 'stack:empty', image_digest: '', scanned_at: 1000 + i });
}
for (let i = 0; i < 3; i++) {
seedScan({ image_ref: 'stack:ws', image_digest: ' ', scanned_at: 2000 + i });
}
const empty = db.getVulnerabilityScans(1, { imageRef: 'stack:empty', limit: 500 });
expect(empty.items).toHaveLength(3);
const listed = db.getVulnerabilityScans(1, { limit: 500 });
expect(listed.cappedIdentities).toEqual(
expect.arrayContaining([
{ key: 'stack:empty', kind: 'ref', displayRef: 'stack:empty' },
{ key: 'stack:ws', kind: 'ref', displayRef: 'stack:ws' },
]),
);
});
it('bypasses the cap when imageRef targets a single image', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '10');
const digest = 'sha256:hotdigest00';
for (let i = 0; i < 30; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
for (let i = 0; i < 30; i++) {
seedScan({ image_ref: 'hot:latest', image_digest: digest, scanned_at: 1000 + i });
}
const result = db.getVulnerabilityScans(1, { imageRef: 'hot:latest', limit: 500 });
expect(result.items).toHaveLength(30);
expect(result.total).toBe(30);
expect(result.cappedImageRefs).toEqual([]);
expect(result.cappedIdentities).toEqual([]);
});
it('bypasses the cap when imageDigest targets a single digest', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '10');
const digest = 'sha256:hotdigest00';
for (let i = 0; i < 30; i++) {
seedScan({ image_ref: 'hot:latest', image_digest: digest, scanned_at: 1000 + i });
}
const result = db.getVulnerabilityScans(1, { imageDigest: digest, limit: 500 });
expect(result.items).toHaveLength(30);
expect(result.cappedIdentities).toEqual([]);
});
it('never mixes identities across nodes in cappedIdentities', () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '2');
db.getDb()
.prepare(`INSERT OR IGNORE INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`)
.run(Date.now());
const digest = 'sha256:crossnode00';
for (let i = 0; i < 4; i++) {
seedScan({ node_id: 1, image_ref: 'a:1', image_digest: digest, scanned_at: 1000 + i });
}
for (let i = 0; i < 4; i++) {
seedScan({ node_id: 2, image_ref: 'a:1', image_digest: digest, scanned_at: 2000 + i });
}
const node1 = db.getVulnerabilityScans(1, { limit: 500 });
const node2 = db.getVulnerabilityScans(2, { limit: 500 });
expect(node1.total).toBe(2);
expect(node2.total).toBe(2);
expect(node1.items.every((s) => s.node_id === 1)).toBe(true);
expect(node2.items.every((s) => s.node_id === 2)).toBe(true);
});
});
describe('pruneScanHistoryPerImage', () => {
it('keeps the newest N rows per (node_id, image_ref) and deletes the rest', () => {
it('keeps the newest N rows per (node_id, digest identity) and deletes the rest', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 60; i++) seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i });
for (let i = 0; i < 5; i++) seedScan({ image_ref: 'cool:latest', scanned_at: 1000 + i });
const hotDigest = 'sha256:hotdigest00';
const coolDigest = 'sha256:cooldigest0';
for (let i = 0; i < 60; i++) {
seedScan({ image_ref: 'hot:latest', image_digest: hotDigest, scanned_at: 1000 + i });
}
for (let i = 0; i < 5; i++) {
seedScan({ image_ref: 'cool:latest', image_digest: coolDigest, scanned_at: 1000 + i });
}
const deleted = db.pruneScanHistoryPerImage(50);
expect(deleted).toBe(10);
@@ -145,23 +292,47 @@ describe('pruneScanHistoryPerImage', () => {
expect(cool.items).toHaveLength(5);
});
it('is a no-op when no image exceeds the cap', () => {
it('prunes shared-digest tags as one identity bucket', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 3; i++) seedScan({ image_ref: 'small:latest', scanned_at: 1000 + i });
const shared = 'sha256:sharedprune01';
for (let i = 0; i < 4; i++) {
seedScan({ image_ref: 'app:v1', image_digest: shared, scanned_at: 1000 + i });
}
for (let i = 0; i < 4; i++) {
seedScan({ image_ref: 'app:latest', image_digest: shared, scanned_at: 2000 + i });
}
const deleted = db.pruneScanHistoryPerImage(5);
expect(deleted).toBe(3);
const remaining = db.getVulnerabilityScans(1, { imageDigest: shared, limit: 500 });
expect(remaining.items).toHaveLength(5);
});
it('is a no-op when no identity exceeds the cap', () => {
const db = DatabaseService.getInstance();
for (let i = 0; i < 3; i++) {
seedScan({ image_ref: 'small:latest', image_digest: 'sha256:small00', scanned_at: 1000 + i });
}
const deleted = db.pruneScanHistoryPerImage(50);
expect(deleted).toBe(0);
});
it('partitions by node_id so two nodes scanning the same image keep independent histories', () => {
it('partitions by node_id so two nodes scanning the same digest keep independent histories', () => {
const db = DatabaseService.getInstance();
db.getDb()
.prepare(`INSERT INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
.prepare(`INSERT OR IGNORE INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`)
.run(Date.now());
const digest = 'sha256:alpine31900';
for (let i = 0; i < 60; i++) seedScan({ node_id: 1, image_ref: 'alpine:3.19', scanned_at: 1000 + i });
for (let i = 0; i < 60; i++) seedScan({ node_id: 2, image_ref: 'alpine:3.19', scanned_at: 2000 + i });
for (let i = 0; i < 60; i++) {
seedScan({ node_id: 1, image_ref: 'alpine:3.19', image_digest: digest, scanned_at: 1000 + i });
}
for (let i = 0; i < 60; i++) {
seedScan({ node_id: 2, image_ref: 'alpine:3.19', image_digest: digest, scanned_at: 2000 + i });
}
const deleted = db.pruneScanHistoryPerImage(50);
expect(deleted).toBe(20);
@@ -174,9 +345,10 @@ describe('pruneScanHistoryPerImage', () => {
it('deletes child vulnerability_details rows for pruned scans', () => {
const db = DatabaseService.getInstance();
const digest = 'sha256:hotdigest00';
const ids: number[] = [];
for (let i = 0; i < 60; i++) {
ids.push(seedScan({ image_ref: 'hot:latest', scanned_at: 1000 + i }));
ids.push(seedScan({ image_ref: 'hot:latest', image_digest: digest, scanned_at: 1000 + i }));
}
const oldestScanId = ids[0];
db.insertVulnerabilityDetails(oldestScanId, [{
@@ -228,7 +400,6 @@ describe('vulnerability_details enrichment round-trips', () => {
pkg_path: 'usr/lib/libssl.so',
layer_digest: 'sha256:cafe',
},
// A finding that omits enrichment stores nulls, not undefined (no crash).
{
vulnerability_id: 'CVE-2024-5678',
pkg_name: 'libbare',
@@ -187,6 +187,8 @@ describe('GET /api/security/compare', () => {
expect(res.status).toBe(200);
expect(res.body.scanA.id).toBe(baseline);
expect(res.body.scanB.id).toBe(current);
expect(res.body.scanA).toHaveProperty('image_digest');
expect(res.body.scanB).toHaveProperty('image_digest');
expect(res.body.added).toHaveLength(1);
expect(res.body.added[0].vulnerability_id).toBe('CVE-2024-0003');
expect(res.body.removed).toHaveLength(1);
@@ -0,0 +1,143 @@
/**
* Route wiring for GET /api/security/scans: auth, legacy imageRefLike,
* additive imageIdentityLike, exact imageDigest, node isolation, and
* cappedIdentities response shape.
*/
import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest';
import request from 'supertest';
import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb';
let tmpDir: string;
let app: import('express').Express;
let DatabaseService: typeof import('../services/DatabaseService').DatabaseService;
let adminCookie: string;
beforeAll(async () => {
tmpDir = await setupTestDb();
({ DatabaseService } = await import('../services/DatabaseService'));
const { LicenseService } = await import('../services/LicenseService');
vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community');
({ app } = await import('../index'));
adminCookie = await loginAsTestAdmin(app);
});
afterAll(() => cleanupTestDb(tmpDir));
function seedScan(overrides: {
node_id?: number;
image_ref?: string;
image_digest?: string | null;
scanned_at?: number;
} = {}): number {
const db = DatabaseService.getInstance();
return db.createVulnerabilityScan({
node_id: overrides.node_id ?? 1,
image_ref: overrides.image_ref ?? 'alpine:3.19',
image_digest: overrides.image_digest === undefined
? `sha256:${Math.random().toString(16).slice(2)}`
: overrides.image_digest,
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: 'completed',
error: null,
stack_context: null,
});
}
beforeEach(() => {
DatabaseService.getInstance().getDb().prepare('DELETE FROM vulnerability_scans').run();
});
describe('GET /api/security/scans query wiring', () => {
it('requires authentication', async () => {
const res = await request(app).get('/api/security/scans');
expect(res.status).toBe(401);
});
it('forwards legacy imageRefLike as reference-only', async () => {
seedScan({ image_ref: 'alpine:3.19', image_digest: 'sha256:aaa', scanned_at: 1 });
seedScan({ image_ref: 'nginx:1', image_digest: 'sha256:alpinezzz', scanned_at: 2 });
const res = await request(app)
.get('/api/security/scans?imageRefLike=alpine&status=completed')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.total).toBe(1);
expect(res.body.items[0].image_ref).toBe('alpine:3.19');
expect(Array.isArray(res.body.cappedIdentities)).toBe(true);
});
it('forwards imageIdentityLike matching ref or digest', async () => {
seedScan({ image_ref: 'nginx:1', image_digest: 'sha256:deadbeef99', scanned_at: 1 });
seedScan({ image_ref: 'redis:7', image_digest: 'sha256:other', scanned_at: 2 });
const byDigest = await request(app)
.get('/api/security/scans?imageIdentityLike=deadbeef&status=completed')
.set('Cookie', adminCookie);
expect(byDigest.status).toBe(200);
expect(byDigest.body.total).toBe(1);
expect(byDigest.body.items[0].image_ref).toBe('nginx:1');
const byRef = await request(app)
.get('/api/security/scans?imageIdentityLike=redis&status=completed')
.set('Cookie', adminCookie);
expect(byRef.body.total).toBe(1);
expect(byRef.body.items[0].image_ref).toBe('redis:7');
});
it('forwards exact imageDigest', async () => {
const digest = 'sha256:exactroute01';
seedScan({ image_ref: 'a:1', image_digest: digest, scanned_at: 1 });
seedScan({ image_ref: 'a:2', image_digest: 'sha256:other', scanned_at: 2 });
const res = await request(app)
.get(`/api/security/scans?imageDigest=${encodeURIComponent(digest)}&status=completed`)
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.total).toBe(1);
expect(res.body.items[0].image_digest).toBe(digest);
});
it('scopes listing and cappedIdentities to the request node', async () => {
const db = DatabaseService.getInstance();
db.updateGlobalSetting('scan_history_per_image_limit', '2');
db.getDb()
.prepare(`INSERT OR IGNORE INTO nodes (id, name, type, compose_dir, is_default, status, created_at)
VALUES (2, 'Peer', 'remote', '/tmp', 0, 'online', ?)`)
.run(Date.now());
const digest = 'sha256:noderoute00';
for (let i = 0; i < 4; i++) {
seedScan({ node_id: 1, image_ref: 'a:1', image_digest: digest, scanned_at: 1000 + i });
}
for (let i = 0; i < 4; i++) {
seedScan({ node_id: 2, image_ref: 'a:1', image_digest: digest, scanned_at: 2000 + i });
}
const res = await request(app)
.get('/api/security/scans?status=completed&limit=50')
.set('Cookie', adminCookie);
expect(res.status).toBe(200);
expect(res.body.total).toBe(2);
expect(res.body.items.every((s: { node_id: number }) => s.node_id === 1)).toBe(true);
expect(res.body.cappedIdentities).toEqual([
{ key: digest, kind: 'digest', displayRef: 'a:1' },
]);
});
});