mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
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:
@@ -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' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -114,6 +114,10 @@ function parseScannersInput(raw: unknown): readonly ('vuln' | 'secret')[] | unde
|
||||
return Array.from(out) as readonly ('vuln' | 'secret')[];
|
||||
}
|
||||
|
||||
function optionalTrimmedQuery(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function shapeScanForResponse(scan: VulnerabilityScan): Omit<VulnerabilityScan, 'policy_evaluation'> & {
|
||||
policy_evaluation: ReturnType<typeof parsePolicyEvaluation>;
|
||||
} {
|
||||
@@ -556,10 +560,9 @@ securityRouter.post('/scan-node', authMiddleware, async (req: Request, res: Resp
|
||||
securityRouter.get('/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 imageRefLike = optionalTrimmedQuery(req.query.imageRefLike);
|
||||
const imageDigest = optionalTrimmedQuery(req.query.imageDigest);
|
||||
const imageIdentityLike = optionalTrimmedQuery(req.query.imageIdentityLike);
|
||||
const statusParam = typeof req.query.status === 'string' ? req.query.status : undefined;
|
||||
const status =
|
||||
statusParam === 'completed' || statusParam === 'in_progress' || statusParam === 'failed'
|
||||
@@ -570,6 +573,8 @@ securityRouter.get('/scans', authMiddleware, (req: Request, res: Response) => {
|
||||
const result = DatabaseService.getInstance().getVulnerabilityScans(req.nodeId, {
|
||||
imageRef,
|
||||
imageRefLike,
|
||||
imageDigest,
|
||||
imageIdentityLike,
|
||||
status,
|
||||
limit,
|
||||
offset,
|
||||
@@ -1579,12 +1584,14 @@ securityRouter.get('/compare', authMiddleware, (req: Request, res: Response): vo
|
||||
id: a.id,
|
||||
scanned_at: a.scanned_at,
|
||||
image_ref: a.image_ref,
|
||||
image_digest: a.image_digest,
|
||||
total_vulnerabilities: a.total_vulnerabilities,
|
||||
},
|
||||
scanB: {
|
||||
id: b.id,
|
||||
scanned_at: b.scanned_at,
|
||||
image_ref: b.image_ref,
|
||||
image_digest: b.image_digest,
|
||||
total_vulnerabilities: b.total_vulnerabilities,
|
||||
},
|
||||
added,
|
||||
|
||||
@@ -4818,10 +4818,31 @@ export class DatabaseService {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stable scan-history identity: prefer a non-empty digest, else image_ref.
|
||||
* TRIM so whitespace-only digests fall back to the reference (config scans,
|
||||
* legacy rows). Used for retention partitioning and cap metadata.
|
||||
*/
|
||||
private static readonly SCAN_IDENTITY_SQL =
|
||||
`COALESCE(NULLIF(TRIM(image_digest), ''), image_ref)`;
|
||||
|
||||
public getVulnerabilityScans(
|
||||
nodeId: number,
|
||||
opts: { imageRef?: string; imageRefLike?: string; status?: VulnScanStatus; limit?: number; offset?: number } = {},
|
||||
): { items: VulnerabilityScan[]; total: number; cappedImageRefs: string[]; perImageLimit: number } {
|
||||
opts: {
|
||||
imageRef?: string;
|
||||
imageRefLike?: string;
|
||||
imageDigest?: string;
|
||||
imageIdentityLike?: string;
|
||||
status?: VulnScanStatus;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
} = {},
|
||||
): {
|
||||
items: VulnerabilityScan[];
|
||||
total: number;
|
||||
cappedIdentities: Array<{ key: string; kind: 'digest' | 'ref'; displayRef: string }>;
|
||||
perImageLimit: number;
|
||||
} {
|
||||
const limit = Math.max(1, Math.min(opts.limit ?? 50, 500));
|
||||
const offset = Math.max(0, opts.offset ?? 0);
|
||||
const where = ['node_id = ?'];
|
||||
@@ -4830,20 +4851,32 @@ export class DatabaseService {
|
||||
where.push('image_ref = ?');
|
||||
params.push(opts.imageRef);
|
||||
}
|
||||
if (opts.imageDigest) {
|
||||
where.push('image_digest = ?');
|
||||
params.push(opts.imageDigest);
|
||||
}
|
||||
// Legacy reference-only substring filter (documented API; keep).
|
||||
if (opts.imageRefLike) {
|
||||
where.push('image_ref LIKE ?');
|
||||
params.push(`%${opts.imageRefLike}%`);
|
||||
}
|
||||
// Additive OR identity search: tag or digest fragment.
|
||||
if (opts.imageIdentityLike) {
|
||||
const like = `%${opts.imageIdentityLike}%`;
|
||||
where.push('(image_ref LIKE ? OR image_digest LIKE ?)');
|
||||
params.push(like, like);
|
||||
}
|
||||
if (opts.status) {
|
||||
where.push('status = ?');
|
||||
params.push(opts.status);
|
||||
}
|
||||
const whereSql = where.join(' AND ');
|
||||
const identitySql = DatabaseService.SCAN_IDENTITY_SQL;
|
||||
|
||||
// Grouped (history) view caps rows per image_ref so a hot image
|
||||
// cannot drown out the others. Single-image deep-dive (imageRef set)
|
||||
// bypasses the cap so users can drill past it.
|
||||
const applyPerImageCap = !opts.imageRef;
|
||||
// Grouped history caps rows per digest identity so a hot digest cannot
|
||||
// drown out others. Exact imageRef or imageDigest deep-dives bypass the
|
||||
// cap so operators can page past retention.
|
||||
const applyPerImageCap = !opts.imageRef && !opts.imageDigest;
|
||||
const parsedLimit = parseInt(this.getGlobalSettings()['scan_history_per_image_limit'] ?? '50', 10);
|
||||
const perImageLimit = parsedLimit > 0 ? parsedLimit : 50;
|
||||
|
||||
@@ -4858,11 +4891,11 @@ export class DatabaseService {
|
||||
`SELECT * FROM vulnerability_scans WHERE ${whereSql} ORDER BY scanned_at DESC LIMIT ? OFFSET ?`,
|
||||
)
|
||||
.all(...(params as never[]), limit, offset) as VulnerabilityScan[];
|
||||
return { items, total, cappedImageRefs: [], perImageLimit };
|
||||
return { items, total, cappedIdentities: [], perImageLimit };
|
||||
}
|
||||
|
||||
const rankedCte = `WITH ranked AS (
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY image_ref ORDER BY scanned_at DESC) AS rn
|
||||
SELECT *, ROW_NUMBER() OVER (PARTITION BY ${identitySql} ORDER BY scanned_at DESC) AS rn
|
||||
FROM vulnerability_scans
|
||||
WHERE ${whereSql}
|
||||
)`;
|
||||
@@ -4883,38 +4916,50 @@ export class DatabaseService {
|
||||
)
|
||||
.all(...(params as never[]), perImageLimit, limit, offset) as VulnerabilityScan[];
|
||||
|
||||
// Identify which image_refs sit at or above the cap so the UI can
|
||||
// flag them. `>=` (not `>`) is intentional: the daily prune keeps
|
||||
// each image at exactly perImageLimit rows, so by the time a user
|
||||
// opens the history sheet the underlying count rarely exceeds the
|
||||
// cap. Flagging at-cap groups still tells the truth (older scans
|
||||
// have been or will be pruned at this image's next scan).
|
||||
// Cap metadata keyed by digest identity. displayRef is the newest
|
||||
// image_ref in the bucket so UI copy stays accurate when multiple tags
|
||||
// share one digest. `>=` matches the daily prune keeping each identity
|
||||
// at exactly perImageLimit rows.
|
||||
const cappedRows = this.db
|
||||
.prepare(
|
||||
`SELECT image_ref FROM vulnerability_scans
|
||||
WHERE ${whereSql}
|
||||
GROUP BY image_ref HAVING COUNT(*) >= ?`,
|
||||
`WITH buckets AS (
|
||||
SELECT
|
||||
${identitySql} AS identity_key,
|
||||
image_ref,
|
||||
image_digest,
|
||||
COUNT(*) OVER (PARTITION BY ${identitySql}) AS cnt,
|
||||
ROW_NUMBER() OVER (PARTITION BY ${identitySql} ORDER BY scanned_at DESC) AS rn
|
||||
FROM vulnerability_scans
|
||||
WHERE ${whereSql}
|
||||
)
|
||||
SELECT identity_key AS key,
|
||||
CASE WHEN NULLIF(TRIM(image_digest), '') IS NOT NULL THEN 'digest' ELSE 'ref' END AS kind,
|
||||
image_ref AS displayRef
|
||||
FROM buckets
|
||||
WHERE rn = 1 AND cnt >= ?`,
|
||||
)
|
||||
.all(...(params as never[]), perImageLimit) as Array<{ image_ref: string }>;
|
||||
const cappedImageRefs = cappedRows.map((r) => r.image_ref);
|
||||
.all(...(params as never[]), perImageLimit) as Array<{
|
||||
key: string;
|
||||
kind: 'digest' | 'ref';
|
||||
displayRef: string;
|
||||
}>;
|
||||
|
||||
return { items, total, cappedImageRefs, perImageLimit };
|
||||
return { items, total, cappedIdentities: cappedRows, perImageLimit };
|
||||
}
|
||||
|
||||
/**
|
||||
* Per-image scan history pruner. For each (node_id, image_ref), keep the
|
||||
* newest N scans (ordered by scanned_at DESC) and delete older rows along
|
||||
* with their child findings. SQLite foreign-key cascade is not enabled
|
||||
* at the connection level here, so children are deleted explicitly. The
|
||||
* subquery is self-contained so we don't bind one parameter per ID. A
|
||||
* first-run backlog of thousands of stale scans would otherwise blow
|
||||
* past SQLITE_MAX_VARIABLE_NUMBER.
|
||||
* Per-identity scan history pruner. For each (node_id, digest-or-ref
|
||||
* identity), keep the newest N scans and delete older rows plus child
|
||||
* findings. SQLite FK cascade is not enabled here, so children are deleted
|
||||
* explicitly. The subquery is self-contained to stay under
|
||||
* SQLITE_MAX_VARIABLE_NUMBER on large first-run backlogs.
|
||||
*/
|
||||
public pruneScanHistoryPerImage(perImageLimit: number): number {
|
||||
const limit = Math.max(1, Math.floor(perImageLimit));
|
||||
const identitySql = DatabaseService.SCAN_IDENTITY_SQL;
|
||||
const overflowSubquery = `SELECT id FROM (
|
||||
SELECT id, ROW_NUMBER() OVER (
|
||||
PARTITION BY node_id, image_ref ORDER BY scanned_at DESC
|
||||
PARTITION BY node_id, ${identitySql} ORDER BY scanned_at DESC
|
||||
) AS rn
|
||||
FROM vulnerability_scans
|
||||
) WHERE rn > ?`;
|
||||
|
||||
Reference in New Issue
Block a user