diff --git a/backend/src/__tests__/database-scan-list.test.ts b/backend/src/__tests__/database-scan-list.test.ts index 4bced822..62339b99 100644 --- a/backend/src/__tests__/database-scan-list.test.ts +++ b/backend/src/__tests__/database-scan-list.test.ts @@ -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', diff --git a/backend/src/__tests__/scan-compare.test.ts b/backend/src/__tests__/scan-compare.test.ts index 1e260bd7..eadfb44b 100644 --- a/backend/src/__tests__/scan-compare.test.ts +++ b/backend/src/__tests__/scan-compare.test.ts @@ -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); diff --git a/backend/src/__tests__/security-scans-route.test.ts b/backend/src/__tests__/security-scans-route.test.ts new file mode 100644 index 00000000..5dfea32c --- /dev/null +++ b/backend/src/__tests__/security-scans-route.test.ts @@ -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' }, + ]); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 08865260..c60893be 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -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 & { policy_evaluation: ReturnType; } { @@ -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, diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 22ede216..0875d49d 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -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 > ?`; diff --git a/docs/api-reference/security.mdx b/docs/api-reference/security.mdx index 29b0c9fd..7decba17 100644 --- a/docs/api-reference/security.mdx +++ b/docs/api-reference/security.mdx @@ -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" \ diff --git a/docs/features/security.mdx b/docs/features/security.mdx index f8a3ee96..39c26863 100644 --- a/docs/features/security.mdx +++ b/docs/features/security.mdx @@ -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 diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index e6d98a8b..118bbf7c 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -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. - 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 + Security History tab listing completed scans with search, compare selection, and pagination ## 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 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 "" 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. - - 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. + + 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. 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. - 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. 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. diff --git a/docs/reference/settings.mdx b/docs/reference/settings.mdx index 8e812640..f5bf885a 100644 --- a/docs/reference/settings.mdx +++ b/docs/reference/settings.mdx @@ -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. | diff --git a/frontend/src/components/ScanComparisonSheet.tsx b/frontend/src/components/ScanComparisonSheet.tsx index 342a3caa..10e2fbfa 100644 --- a/frontend/src/components/ScanComparisonSheet.tsx +++ b/frontend/src/components/ScanComparisonSheet.tsx @@ -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 = { 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(() => { 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({
Baseline
-
{data.scanA.image_ref}
+
{scanCompareLabel(data.scanA)}
{new Date(data.scanA.scanned_at).toLocaleString()}
Current
-
{data.scanB.image_ref}
+
{scanCompareLabel(data.scanB)}
{new Date(data.scanB.scanned_at).toLocaleString()}
@@ -189,7 +209,7 @@ export function ScanComparisonSheet({
- 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.
)} diff --git a/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx b/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx index 7099aeec..db5155b5 100644 --- a/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx +++ b/frontend/src/components/__tests__/ScanComparisonSheet.test.tsx @@ -94,7 +94,7 @@ describe('ScanComparisonSheet', () => { render( {}} />); 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( {}} />); 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( {}} />); + + await waitFor(() => + expect(screen.getByText(/different image identities/i)).toBeInTheDocument(), + ); }); it('surfaces a toast and closes the sheet on fetch error', async () => { diff --git a/frontend/src/components/resources/ImageDetailsSheet.tsx b/frontend/src/components/resources/ImageDetailsSheet.tsx index eebb41d4..1088ce46 100644 --- a/frontend/src/components/resources/ImageDetailsSheet.tsx +++ b/frontend/src/components/resources/ImageDetailsSheet.tsx @@ -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(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)

- {shortDigest(inspect.Id)} + {formatShortDigest(inspect.Id)}