mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-29 05:09:10 +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 > ?`;
|
||||
|
||||
@@ -226,7 +226,16 @@ curl -X POST https://your-sencho-instance:1852/api/security/scan \
|
||||
|
||||
**License:** Community
|
||||
|
||||
Supports filters: `imageRef`, `imageRefLike`, `status`, `limit`, `offset`. Response is paginated with `total` and `scans` fields.
|
||||
Supports filters: `imageRef`, `imageRefLike` (reference-only substring), `imageDigest` (exact), `imageIdentityLike` (substring match on reference **or** digest), `status`, `limit`, `offset`.
|
||||
|
||||
Response is paginated with `items`, `total`, `perImageLimit`, and `cappedIdentities` (digest-or-ref buckets at the retention cap). Exact `imageRef` or `imageDigest` deep-dives bypass the per-identity cap.
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
"https://your-sencho-instance:1852/api/security/scans?imageIdentityLike=nginx&limit=20"
|
||||
```
|
||||
|
||||
Legacy clients may still use `imageRefLike` for reference-only search:
|
||||
|
||||
```bash
|
||||
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
|
||||
|
||||
@@ -88,9 +88,7 @@ export the fleet's triage decisions as an OpenVEX document for use with other to
|
||||
|
||||
## History
|
||||
|
||||
The History tab opens the scan history sheet, listing completed scans grouped by image with search and
|
||||
two-scan comparison. Closing the sheet leaves the tab in place so you can reopen it. The **Scan
|
||||
history** button in the [Resources Hub](/features/resources) is a shortcut to the same place.
|
||||
The History tab lists completed scans for the active node in an inline table. Prefer digest identity when a digest is stored (short digest as the primary label, image reference as the subtitle); config and other scans without a digest show the image reference. Search matches image references or digests. Select two scans to compare. The **Scan history** button in the [Resources Hub](/features/resources) is a shortcut to the same place.
|
||||
|
||||
## Scanner setup
|
||||
|
||||
|
||||
@@ -347,19 +347,19 @@ Typical upload flow for GitHub code scanning:
|
||||
|
||||
## Scan history
|
||||
|
||||
Every scan Sencho runs is stored with its full vulnerability detail. Scan records are pruned automatically after 90 days to keep the database compact. The history powers two things: digest caching (skip re-scanning a digest already scanned within 24 hours) and trend insight (compare a new scan to its predecessor to see what changed).
|
||||
Every scan Sencho runs is stored with its full vulnerability detail. History retention keeps a configurable number of scans per image digest (or per image reference when no digest is stored; default 50). Configure the cap under **Settings → Operations → Data Retention**. The history powers two things: digest caching (skip re-scanning a digest already scanned within 24 hours) and trend insight (compare a new scan to its predecessor to see what changed).
|
||||
|
||||
Open the **Security** page and select the **History** tab, then choose **Open scan history**, to browse completed scans. The sheet lists completed scans grouped by image, lets you search by image reference, and lets you tick two scans to compare. Close the sheet with Escape, by clicking the overlay, or by clicking the close button in the header; the History tab stays open behind it. The **Scan history** button at the top of the Resources Hub is a shortcut to the same place.
|
||||
Open the **Security** page and select the **History** tab to browse completed scans. The table prefers digest identity when available (short digest with the image reference as a subtitle), lets you search by image reference or digest, and lets you tick two scans to compare. The **Scan history** button at the top of the Resources Hub is a shortcut to the same place.
|
||||
|
||||
<Frame>
|
||||
<img src="/images/vulnerability-scanning/scan-history-sheet.png" alt="Scan history sheet over the Resources Hub showing 361 scans across 29 images, the Compare primary action enabled with two scans ticked, a search box, pagination, and scans grouped by image reference" />
|
||||
<img src="/images/vulnerability-scanning/scan-history-sheet.png" alt="Security History tab listing completed scans with search, compare selection, and pagination" />
|
||||
</Frame>
|
||||
|
||||
## Comparing scans
|
||||
|
||||
Compare any two completed scans for an image to see what changed.
|
||||
|
||||
**From the scan history sheet**: tick two scans (one baseline, one newer) and click **Compare**. Selecting a third scan replaces the oldest selection.
|
||||
**From the History tab**: tick two scans (one baseline, one newer) and click **Compare**. Selecting a third scan replaces the oldest selection.
|
||||
|
||||
**From an open scan**: click **Compare** in the drawer header, then pick a baseline scan from the dropdown. Only completed scans for the same image appear.
|
||||
|
||||
@@ -371,7 +371,7 @@ The comparison sheet shows:
|
||||
|
||||
Comparisons are scoped to a single node; scans taken on different nodes cannot be compared against each other.
|
||||
|
||||
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. In that mode, the **Unchanged** pill is relabeled to **Shared** to reflect that same CVE + package matches across different images are not necessarily the same finding.
|
||||
Cross-image comparisons (picking scans from two different digests, or different references when digests are absent) are allowed but flagged with a warning, since package-level changes may reflect image differences rather than CVE drift. In that mode, the **Unchanged** pill is relabeled to **Shared** to reflect that same CVE + package matches across different images are not necessarily the same finding.
|
||||
|
||||
Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this limit, the sheet shows a truncation banner indicating the comparison may be incomplete.
|
||||
|
||||
@@ -420,17 +420,17 @@ Up to 1000 findings per scan are loaded for comparison. When a scan exceeds this
|
||||
<Accordion title="Trivy is not installed and a deploy with a block policy went through">
|
||||
Sencho fails open when Trivy is not installed on the target node, so operators are never locked out by tooling state. A warning alert is dispatched through your configured notification channels with the message `Pre-deploy scan for "<stack>" skipped: Trivy not installed on this node`. Install Trivy from the **Security** page → **Scanner setup** tab to enforce the policy; see [Installing Trivy](/operations/trivy-setup) for options.
|
||||
</Accordion>
|
||||
<Accordion title="Compare button is disabled in the scan history sheet">
|
||||
The Compare primary action enables only after exactly two scans are ticked. Selecting zero, one, or three scans leaves it disabled. If you have only one scan for an image, trigger a second scan from the Resources Hub (or wait for a scheduled scan), then return to Scan history and tick both.
|
||||
<Accordion title="Compare button is disabled in Scan history">
|
||||
The Compare primary action enables only after exactly two scans are ticked. Selecting zero, one, or three scans leaves it disabled. If you have only one scan for an image, trigger a second scan from the Resources Hub (or wait for a scheduled scan), then return to History and tick both.
|
||||
</Accordion>
|
||||
<Accordion title="Comparison shows unexpected results">
|
||||
Two common causes:
|
||||
|
||||
- **Cross-image comparison.** When the Baseline and Current rows at the top of the sheet point at different image references, a warning banner appears and the "Unchanged" pill is labeled "Shared". Items in that bucket match on CVE + package name but may not be the same finding across two distinct images. Stick to scans of the same image reference for apples-to-apples drift analysis.
|
||||
- **Cross-identity comparison.** When the Baseline and Current rows point at different digests (or different image references when digests are absent), a warning banner appears and the "Unchanged" pill is labeled "Shared". Items in that bucket match on CVE + package name but may not be the same finding across two distinct images. Stick to scans of the same digest for apples-to-apples drift analysis.
|
||||
- **Truncated scans.** When either scan has more than 1000 stored findings, the sheet shows a truncation banner. Items past the 1000-row cap do not contribute to the Added / Removed / Unchanged buckets and totals may be misleading. Re-run the scan with a tighter image or scope the investigation to the most severe findings to avoid truncation.
|
||||
</Accordion>
|
||||
<Accordion title="An older scan is missing from Scan history">
|
||||
The Scan history sheet uses server-driven pagination. If you know the scan exists but cannot see it, use the search box to filter by image reference, or page forward with the arrows in the card header. Scans older than 90 days are pruned automatically to keep the database compact.
|
||||
History uses server-driven pagination and keeps a configurable number of scans per image digest (or per image reference when no digest is stored). If you know the scan exists but cannot see it, search by image reference or digest, or page forward with the arrows. Raise **Scan history per digest** under **Settings → Operations → Data Retention** if you need a longer per-identity window.
|
||||
</Accordion>
|
||||
<Accordion title="Scan policies are missing on one of my nodes">
|
||||
Scan policies are managed from the control Sencho instance and replicate to every remote. On a replica, the **Security** page → **Policies** tab shows a banner explaining that rules are managed upstream. See [Fleet Sync](/features/fleet-sync) for how replication works and how to investigate push failures.
|
||||
|
||||
@@ -526,7 +526,7 @@ How long Sencho keeps historical data on this node before pruning it.
|
||||
|---------|---------|-----|-------------|
|
||||
| **Container metrics** | 24 hrs | 8,760 (1 year) | How long to keep per-container CPU, RAM, and network history for dashboard charts. |
|
||||
| **Notification log** | 30 days | 365 | How long to keep alert and notification history. |
|
||||
| **Scan history per image** | 50 scans | 1,000 | How many vulnerability scans to keep per image. Older scans beyond the cap are pruned. |
|
||||
| **Scan history per digest** | 50 scans | 1,000 | How many vulnerability scans to keep per image digest (or per image reference when no digest is stored). Older scans beyond the cap are pruned. |
|
||||
| **Remove scans for deleted images and stacks** | On | - | When on, scan results are deleted once their image is gone from this node or their stack is deleted, so the Security Overview stays tied to what still exists. Turn it off to keep scan history for removed images and stacks. |
|
||||
| **Audit log** | 90 days | 365 | How long to keep audit trail entries. Requires Admiral. |
|
||||
|
||||
|
||||
@@ -26,10 +26,12 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { cveUrl } from '@/lib/cveUrl';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { SEVERITY_ROW_TINT } from '@/lib/severityStyles';
|
||||
import { SeverityChip } from './VulnerabilityScanSheet';
|
||||
import type {
|
||||
ScanCompareResult,
|
||||
ScanCompareSide,
|
||||
ScanCompareVulnerability,
|
||||
VulnSeverity,
|
||||
} from '@/types/security';
|
||||
@@ -44,6 +46,24 @@ type DiffFilter = 'added' | 'removed' | 'unchanged';
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
|
||||
function trimmedDigest(scan: ScanCompareSide): string | null {
|
||||
const digest = scan.image_digest?.trim();
|
||||
return digest || null;
|
||||
}
|
||||
|
||||
function scanCompareLabel(scan: ScanCompareSide): string {
|
||||
const digest = trimmedDigest(scan);
|
||||
if (digest) return `${formatShortDigest(digest)} · ${scan.image_ref}`;
|
||||
return scan.image_ref;
|
||||
}
|
||||
|
||||
function isCrossIdentity(a: ScanCompareSide, b: ScanCompareSide): boolean {
|
||||
const aDigest = trimmedDigest(a);
|
||||
const bDigest = trimmedDigest(b);
|
||||
if (aDigest && bDigest) return aDigest !== bDigest;
|
||||
return a.image_ref !== b.image_ref;
|
||||
}
|
||||
|
||||
const SEVERITY_ORDER: Record<VulnSeverity, number> = {
|
||||
CRITICAL: 0,
|
||||
HIGH: 1,
|
||||
@@ -130,7 +150,7 @@ export function ScanComparisonSheet({
|
||||
const addedCounts = useMemo(() => (data ? countBySeverity(data.added) : null), [data]);
|
||||
const removedCounts = useMemo(() => (data ? countBySeverity(data.removed) : null), [data]);
|
||||
|
||||
const crossImage = data != null && data.scanA.image_ref !== data.scanB.image_ref;
|
||||
const crossImage = data != null && isCrossIdentity(data.scanA, data.scanB);
|
||||
|
||||
const rows = useMemo<ScanCompareVulnerability[]>(() => {
|
||||
if (!data) return [];
|
||||
@@ -149,7 +169,7 @@ export function ScanComparisonSheet({
|
||||
: (loading ? 'Loading…' : '');
|
||||
|
||||
const footerContext = data
|
||||
? `${data.scanA.image_ref} → ${data.scanB.image_ref}`
|
||||
? `${scanCompareLabel(data.scanA)} → ${scanCompareLabel(data.scanB)}`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
@@ -174,13 +194,13 @@ export function ScanComparisonSheet({
|
||||
<div className="flex items-center gap-3 text-xs font-mono tabular-nums mb-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Baseline</div>
|
||||
<div className="text-stat-value truncate">{data.scanA.image_ref}</div>
|
||||
<div className="text-stat-value truncate">{scanCompareLabel(data.scanA)}</div>
|
||||
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanA.scanned_at).toLocaleString()}</div>
|
||||
</div>
|
||||
<ArrowRight className="w-4 h-4 text-muted-foreground shrink-0" strokeWidth={1.5} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-[10px] uppercase tracking-[0.18em] text-stat-subtitle">Current</div>
|
||||
<div className="text-stat-value truncate">{data.scanB.image_ref}</div>
|
||||
<div className="text-stat-value truncate">{scanCompareLabel(data.scanB)}</div>
|
||||
<div className="text-stat-subtitle tabular-nums">{new Date(data.scanB.scanned_at).toLocaleString()}</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -189,7 +209,7 @@ export function ScanComparisonSheet({
|
||||
<div className="flex items-start gap-2 rounded border border-warning/40 bg-warning/10 px-3 py-2 mb-3 text-xs text-warning">
|
||||
<AlertTriangle className="w-3.5 h-3.5 shrink-0 mt-[1px]" strokeWidth={1.5} />
|
||||
<span>
|
||||
You are comparing scans from two different image references. Package-level changes may reflect image differences rather than CVE drift.
|
||||
You are comparing scans from two different image identities. Package-level changes may reflect image differences rather than CVE drift.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -94,7 +94,7 @@ describe('ScanComparisonSheet', () => {
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/different image references/i)).toBeInTheDocument(),
|
||||
expect(screen.getByText(/different image identities/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -104,7 +104,22 @@ describe('ScanComparisonSheet', () => {
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() => expect(screen.getByText(/Baseline/i)).toBeInTheDocument());
|
||||
expect(screen.queryByText(/different image references/i)).toBeNull();
|
||||
expect(screen.queryByText(/different image identities/i)).toBeNull();
|
||||
});
|
||||
|
||||
it('shows cross-image warning when digests differ for the same tag', async () => {
|
||||
mockedFetch.mockResolvedValueOnce(
|
||||
jsonResponse(200, result({
|
||||
scanA: { id: 1, image_ref: 'app:latest', scanned_at: 1, image_digest: 'sha256:aaa' },
|
||||
scanB: { id: 2, image_ref: 'app:latest', scanned_at: 2, image_digest: 'sha256:bbb' },
|
||||
})),
|
||||
);
|
||||
|
||||
render(<ScanComparisonSheet baselineScanId={1} currentScanId={2} onClose={() => {}} />);
|
||||
|
||||
await waitFor(() =>
|
||||
expect(screen.getByText(/different image identities/i)).toBeInTheDocument(),
|
||||
);
|
||||
});
|
||||
|
||||
it('surfaces a toast and closes the sheet on fetch error', async () => {
|
||||
|
||||
@@ -6,6 +6,7 @@ import { toast } from '@/components/ui/toast-store';
|
||||
import { apiFetch } from '@/lib/api';
|
||||
import { formatBytes } from '@/lib/utils';
|
||||
import { copyToClipboard } from '@/lib/clipboard';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { Copy } from 'lucide-react';
|
||||
|
||||
interface ImageInspect {
|
||||
@@ -59,12 +60,6 @@ function formatRelativeAge(timestampSec: number): string {
|
||||
return `${Math.floor(diff / (86400 * 365))}y ago`;
|
||||
}
|
||||
|
||||
function shortDigest(id: string): string {
|
||||
const colon = id.indexOf(':');
|
||||
const hex = colon >= 0 ? id.slice(colon + 1) : id;
|
||||
return hex.substring(0, 12);
|
||||
}
|
||||
|
||||
export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps) {
|
||||
const [data, setData] = useState<ImageDetails | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
@@ -104,7 +99,7 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps)
|
||||
const history = data?.history ?? [];
|
||||
const totalLayers = history.length;
|
||||
|
||||
const name = inspect?.RepoTags?.[0] || (inspect ? shortDigest(inspect.Id) : 'Image details');
|
||||
const name = inspect?.RepoTags?.[0] || (inspect ? formatShortDigest(inspect.Id) : 'Image details');
|
||||
const meta = inspect
|
||||
? `${formatBytes(inspect.Size)} · ${inspect.Architecture ?? '?'}/${inspect.Os ?? '?'} · ${totalLayers} layers`
|
||||
: (loading ? 'Loading…' : '');
|
||||
@@ -138,7 +133,7 @@ export function ImageDetailsSheet({ imageId, onClose }: ImageDetailsSheetProps)
|
||||
<div className="grid grid-cols-2 gap-x-4 gap-y-2 text-sm">
|
||||
<Field label="ID">
|
||||
<p className="font-mono text-xs mt-0.5 flex items-center gap-1.5">
|
||||
{shortDigest(inspect.Id)}
|
||||
{formatShortDigest(inspect.Id)}
|
||||
<button
|
||||
className="text-muted-foreground hover:text-foreground transition-colors"
|
||||
onClick={async () => {
|
||||
|
||||
@@ -9,6 +9,7 @@ import { apiFetch } from '@/lib/api';
|
||||
import { toast } from '@/components/ui/toast-store';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useNodes } from '@/context/NodeContext';
|
||||
import { formatShortDigest } from '@/lib/formatDigest';
|
||||
import { FleetTabHeading } from '@/components/fleet/FleetEmptyState';
|
||||
import { SeverityChip } from '../VulnerabilityScanSheet';
|
||||
import { ScanComparisonSheet } from '../ScanComparisonSheet';
|
||||
@@ -17,6 +18,22 @@ import type { VulnerabilityScan, ScanDetailTab, VulnSeverity } from '@/types/sec
|
||||
const PAGE_SIZE = 100;
|
||||
const SEVERITY_RANK: Record<VulnSeverity, number> = { CRITICAL: 4, HIGH: 3, MEDIUM: 2, LOW: 1, UNKNOWN: 0 };
|
||||
|
||||
function scanIdentityLabel(scan: VulnerabilityScan): {
|
||||
primary: string;
|
||||
subtitle: string | null;
|
||||
title: string;
|
||||
} {
|
||||
const digest = scan.image_digest?.trim();
|
||||
if (digest) {
|
||||
return {
|
||||
primary: formatShortDigest(digest),
|
||||
subtitle: scan.image_ref,
|
||||
title: digest,
|
||||
};
|
||||
}
|
||||
return { primary: scan.image_ref, subtitle: null, title: scan.image_ref };
|
||||
}
|
||||
|
||||
type SortKey = 'scanned_at' | 'image_ref' | 'severity' | 'total';
|
||||
|
||||
/** Sortable column header. Module-scoped so it is a stable component. */
|
||||
@@ -72,7 +89,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
limit: String(PAGE_SIZE),
|
||||
offset: String(pageToLoad * PAGE_SIZE),
|
||||
});
|
||||
if (term.trim()) params.set('imageRefLike', term.trim());
|
||||
if (term.trim()) params.set('imageIdentityLike', term.trim());
|
||||
const res = await apiFetch(`/security/scans?${params.toString()}`);
|
||||
if (!res.ok) {
|
||||
setError(true);
|
||||
@@ -166,7 +183,7 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
<div className="relative max-w-sm">
|
||||
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground" strokeWidth={1.5} />
|
||||
<Input
|
||||
placeholder="Search by image..."
|
||||
placeholder="Search by image or digest..."
|
||||
value={searchDraft}
|
||||
onChange={(e) => setSearchDraft(e.target.value)}
|
||||
className="pl-8"
|
||||
@@ -191,12 +208,22 @@ export function HistoryTab({ onInspect }: HistoryTabProps) {
|
||||
<TableBody>
|
||||
{!loading && !error && sorted.map((scan) => {
|
||||
const isSelected = selected.includes(scan.id);
|
||||
const identity = scanIdentityLabel(scan);
|
||||
return (
|
||||
<TableRow key={scan.id} className={cn('hover:bg-muted/30 transition-colors', isSelected && 'bg-accent/30')}>
|
||||
<TableCell>
|
||||
<Checkbox checked={isSelected} onCheckedChange={() => toggleSelect(scan.id)} aria-label="Select scan to compare" />
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs truncate max-w-[280px]">{scan.image_ref}</TableCell>
|
||||
<TableCell className="max-w-[280px]">
|
||||
<div className="font-mono text-xs truncate" title={identity.title}>
|
||||
{identity.primary}
|
||||
</div>
|
||||
{identity.subtitle && (
|
||||
<div className="font-mono text-[10px] text-muted-foreground truncate" title={identity.subtitle}>
|
||||
{identity.subtitle}
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="font-mono text-xs text-stat-subtitle whitespace-nowrap">{new Date(scan.scanned_at).toLocaleString()}</TableCell>
|
||||
<TableCell className="font-mono text-xs capitalize text-stat-subtitle">{scan.triggered_by}</TableCell>
|
||||
<TableCell>
|
||||
|
||||
@@ -125,17 +125,30 @@ describe('HistoryTab', () => {
|
||||
expect(screen.getByRole('button', { name: /Compare \(2\/2\)/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('searches by image as you type (no Enter), adding imageRefLike to the request', async () => {
|
||||
it('searches by imageIdentityLike as you type (not legacy imageRefLike)', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([scan({ image_ref: 'alpine:3.19' })]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('alpine:3.19')).toBeInTheDocument());
|
||||
await userEvent.type(screen.getByPlaceholderText('Search by image...'), 'redis');
|
||||
await userEvent.type(screen.getByPlaceholderText('Search by image or digest...'), 'redis');
|
||||
await waitFor(() => {
|
||||
const calls = mockedFetch.mock.calls.map((c) => c[0] as string);
|
||||
expect(calls.some((u) => u.includes('imageRefLike=redis'))).toBe(true);
|
||||
expect(calls.some((u) => u.includes('imageIdentityLike=redis'))).toBe(true);
|
||||
expect(calls.every((u) => !u.includes('imageRefLike='))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows short digest as primary when image_digest is present', async () => {
|
||||
mockedFetch.mockResolvedValue(listResponse([
|
||||
scan({
|
||||
image_ref: 'nginx:1.25',
|
||||
image_digest: 'sha256:abcdef0123456789ffff',
|
||||
}),
|
||||
]));
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
await waitFor(() => expect(screen.getByText('abcdef012345')).toBeInTheDocument());
|
||||
expect(screen.getByText('nginx:1.25')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the error state when the load fails', async () => {
|
||||
mockedFetch.mockResolvedValue({ ok: false, status: 500, json: async () => ({}) } as unknown as Response);
|
||||
render(<HistoryTab onInspect={vi.fn()} />);
|
||||
|
||||
@@ -173,8 +173,8 @@ export function DataRetentionSection({ onDirtyChange }: DataRetentionSectionProp
|
||||
</SettingsField>
|
||||
|
||||
<SettingsField
|
||||
label="Scan history per image"
|
||||
helper="How many vulnerability scans to keep per image. Older scans beyond the cap are pruned."
|
||||
label="Scan history per digest"
|
||||
helper="How many vulnerability scans to keep per image digest (or per image reference when no digest is stored). Older scans beyond the cap are pruned."
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Shorten a Docker content digest or image ID for display.
|
||||
* Accepts `sha256:…` or bare hex; returns up to 12 hex characters.
|
||||
*/
|
||||
export function formatShortDigest(id: string | null | undefined): string {
|
||||
if (!id) return '';
|
||||
const colon = id.indexOf(':');
|
||||
const hex = colon >= 0 ? id.slice(colon + 1) : id;
|
||||
return hex.slice(0, 12);
|
||||
}
|
||||
@@ -224,9 +224,18 @@ export interface ScanCompareVulnerability {
|
||||
suppression_reason?: string;
|
||||
}
|
||||
|
||||
/** One side of a scan comparison (baseline or current). */
|
||||
export interface ScanCompareSide {
|
||||
id: number;
|
||||
scanned_at: number;
|
||||
image_ref: string;
|
||||
image_digest?: string | null;
|
||||
total_vulnerabilities?: number;
|
||||
}
|
||||
|
||||
export interface ScanCompareResult {
|
||||
scanA: { id: number; scanned_at: number; image_ref: string; total_vulnerabilities?: number };
|
||||
scanB: { id: number; scanned_at: number; image_ref: string; total_vulnerabilities?: number };
|
||||
scanA: ScanCompareSide;
|
||||
scanB: ScanCompareSide;
|
||||
added: ScanCompareVulnerability[];
|
||||
removed: ScanCompareVulnerability[];
|
||||
unchanged: ScanCompareVulnerability[];
|
||||
|
||||
Reference in New Issue
Block a user