From fcd44f56934ed038080c5832b45062809472a3a4 Mon Sep 17 00:00:00 2001 From: Anso Date: Wed, 12 Aug 2026 15:02:14 -0400 Subject: [PATCH] fix(security): tie fixable CVE posture to image-update evidence (#1815) * fix(security): tie fixable CVE posture to image-update evidence Stop treating Trivy fixed_version alone as an Update affected images CTA. Reuse persisted ImageUpdateService status so Security only offers Review update when an applicable image update is confirmed, and otherwise surfaces waiting or uncertain remediation with truthful affordances. * fix(security): move image-update recheck helper out of OverviewTab Satisfy react-refresh/only-export-components so Frontend lint passes. * fix(security): preserve posture reason image targets in Images drill-down Carry affected image refs on overview reasons so public exposure and related CTAs open a clearable targeted Images list instead of an unfiltered hunt. * fix(security): attach Networking exposure intent to posture targets Preserve stack/service context and intentional classification on network-exposed Security reasons without suppressing risk or claiming Internet reachability. * fix(security): persist Images exposure intent and triage scope Standing image summaries carry Networking intent context with cap-safe aggregates, Anatomy Networking links, and scan-sheet triage that defaults to the current image. * fix(security): clear CI lint errors for exposure helpers * fix(security): stop intentional exposure from forcing Action needed Separate exposure fact, intent correctness, and vulnerability drivers so package fixed_version cannot recreate a permanent public_exposure blocker. * fix(security): define Monitoring residual-risk narrative * fix(security): define Secure via residual Crit/High triage Replace triage-blind raw Crit/High Secure gating with residual material risk so accepted and ignored stay Monitoring, while not_affected, false positive, and fixed can clear residual without claiming no detections. * fix(security): exclude rollback-hold images from Security scans Hold-only sencho-rb tags are recovery state; keep them out of Trivy node scans, Security inventory, and Overview posture while dual-tagged images remain under their registry tag. * fix(security): keep authoritative no-update rows after preview Opening a stack page must not delete ok+false stack_update_status evidence; Security treats a missing row as uncertain and would flip waiting-upstream to unknown. --- ...database-security-overview-helpers.test.ts | 103 +++++ backend/src/__tests__/exposure-cache.test.ts | 69 +++ backend/src/__tests__/exposure.test.ts | 41 ++ .../security-image-summaries-route.test.ts | 149 +++++++ .../__tests__/security-overview-route.test.ts | 382 +++++++++++++++++ .../__tests__/security-scans-route.test.ts | 17 + .../securityExposureClassification.test.ts | 196 +++++++++ .../__tests__/securityExposureTargets.test.ts | 216 ++++++++++ .../securityImageRemediation.test.ts | 269 ++++++++++++ backend/src/__tests__/securityPosture.test.ts | 332 +++++++++++++-- .../__tests__/sencho-rollback-hold.test.ts | 43 ++ .../src/__tests__/suppression-filter.test.ts | 21 +- backend/src/__tests__/trivy-scan-node.test.ts | 23 + .../update-preview-reconcile.test.ts | 70 +++ backend/src/routes/security.ts | 251 +++++++++-- backend/src/services/DatabaseService.ts | 11 +- backend/src/services/DockerController.ts | 11 +- backend/src/services/ImageUpdateService.ts | 48 ++- backend/src/services/TrivyService.ts | 8 +- .../securityExposureClassification.ts | 206 +++++++++ .../src/services/securityExposureTargets.ts | 316 ++++++++++++++ .../src/services/securityImageRemediation.ts | 188 +++++++++ backend/src/services/securityPosture.ts | 398 +++++++++++++++--- backend/src/utils/senchoRollbackHold.ts | 26 ++ backend/src/utils/suppression-filter.ts | 30 ++ docs/features/cve-suppressions.mdx | 11 +- docs/features/health-gated-updates.mdx | 2 +- docs/features/security.mdx | 83 +++- docs/openapi.yaml | 7 +- frontend/src/components/SecurityView.tsx | 128 +++++- .../src/components/VulnerabilityScanSheet.tsx | 91 +++- .../VulnerabilityScanSheet.suppress.test.tsx | 13 +- .../security/ExposureNetworking.tsx | 144 +++++++ .../src/components/security/ImagesTab.tsx | 334 +++++++++++++-- .../src/components/security/OverviewTab.tsx | 162 +++++-- .../components/security/ScanPolicyManager.tsx | 8 +- .../components/security/SecurityMobile.tsx | 31 +- .../security/__tests__/ImagesTab.test.tsx | 368 +++++++++++++++- .../security/__tests__/OverviewTab.test.tsx | 241 +++++++++++ .../__tests__/ScanPolicyManager.test.tsx | 6 +- .../__tests__/SecurityMobile.test.tsx | 83 +++- .../security/__tests__/deriveMasthead.test.ts | 7 +- .../__tests__/imagesTargeting.test.ts | 272 ++++++++++++ .../__tests__/postureNavigation.test.ts | 15 +- .../components/security/imageUpdateRecheck.ts | 21 + .../components/security/imagesTargeting.ts | 318 ++++++++++++++ .../components/security/postureNavigation.ts | 19 +- .../components/security/securityMasthead.ts | 14 +- frontend/src/types/security.ts | 102 ++++- 49 files changed, 5596 insertions(+), 308 deletions(-) create mode 100644 backend/src/__tests__/exposure-cache.test.ts create mode 100644 backend/src/__tests__/security-image-summaries-route.test.ts create mode 100644 backend/src/__tests__/securityExposureClassification.test.ts create mode 100644 backend/src/__tests__/securityExposureTargets.test.ts create mode 100644 backend/src/__tests__/securityImageRemediation.test.ts create mode 100644 backend/src/__tests__/sencho-rollback-hold.test.ts create mode 100644 backend/src/services/securityExposureClassification.ts create mode 100644 backend/src/services/securityExposureTargets.ts create mode 100644 backend/src/services/securityImageRemediation.ts create mode 100644 backend/src/utils/senchoRollbackHold.ts create mode 100644 frontend/src/components/security/ExposureNetworking.tsx create mode 100644 frontend/src/components/security/__tests__/OverviewTab.test.tsx create mode 100644 frontend/src/components/security/__tests__/imagesTargeting.test.ts create mode 100644 frontend/src/components/security/imageUpdateRecheck.ts create mode 100644 frontend/src/components/security/imagesTargeting.ts diff --git a/backend/src/__tests__/database-security-overview-helpers.test.ts b/backend/src/__tests__/database-security-overview-helpers.test.ts index cf7357cd..f626d84a 100644 --- a/backend/src/__tests__/database-security-overview-helpers.test.ts +++ b/backend/src/__tests__/database-security-overview-helpers.test.ts @@ -220,6 +220,109 @@ describe('getImageScanSummaries', () => { // No vuln-bearing scan exists, so scan_id falls back to the latest scan overall. expect(summary.scan_id).toBe(secretScan); }); + + it('excludes Sencho rollback-hold image refs from Security summaries', () => { + seedScan({ + imageRef: 'sencho-rb/aaaaaaaaaaaa/web:hold', + scannersUsed: 'vuln', + scannedAt: 1000, + critical: 9, + high: 9, + }); + seedScan({ imageRef: 'app:1', scannersUsed: 'vuln', scannedAt: 1000, critical: 1 }); + const summaries = db().getImageScanSummaries(1); + expect(summaries['sencho-rb/aaaaaaaaaaaa/web:hold']).toBeUndefined(); + expect(summaries['app:1']?.critical).toBe(1); + }); +}); + +describe('rollback-hold exclusion from posture helpers', () => { + function rawDbHold() { + return (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; + } + beforeEach(() => { + rawDbHold().prepare('DELETE FROM vulnerability_details').run(); + rawDbHold().prepare('DELETE FROM cve_intel').run(); + rawDbHold().prepare('DELETE FROM vulnerability_scans').run(); + }); + + it('omits hold Crit/High, KEV, and risk-trend contributions while keeping registry-tag findings', () => { + const now = Date.now(); + const holdScan = db().createVulnerabilityScan({ + node_id: 1, + image_ref: 'sencho-rb/aaaaaaaaaaaa/web:hold', + image_digest: 'sha256:hold', + scanned_at: now, + total_vulnerabilities: 2, + critical_count: 1, + high_count: 1, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: 'CRITICAL', + os_info: null, + trivy_version: null, + scan_duration_ms: null, + triggered_by: 'manual', + status: 'completed', + error: null, + stack_context: null, + }); + const appScan = db().createVulnerabilityScan({ + node_id: 1, + image_ref: 'app:1', + image_digest: 'sha256:app', + scanned_at: now, + total_vulnerabilities: 1, + critical_count: 0, + high_count: 1, + medium_count: 0, + low_count: 0, + unknown_count: 0, + fixable_count: 0, + secret_count: 0, + misconfig_count: 0, + scanners_used: 'vuln', + highest_severity: 'HIGH', + os_info: null, + trivy_version: null, + scan_duration_ms: null, + triggered_by: 'manual', + status: 'completed', + error: null, + stack_context: null, + }); + const detail = (id: string, severity: 'CRITICAL' | 'HIGH') => ({ + vulnerability_id: id, + pkg_name: `p-${id}`, + installed_version: '1', + fixed_version: null, + severity, + title: null, + description: null, + primary_url: null, + cvss_score: 9.0, + }); + db().insertVulnerabilityDetails(holdScan, [detail('CVE-HOLD-CRIT', 'CRITICAL'), detail('CVE-HOLD-KEV', 'HIGH')]); + db().insertVulnerabilityDetails(appScan, [detail('CVE-APP-HIGH', 'HIGH')]); + db().replaceKev([{ cve_id: 'CVE-HOLD-KEV', date_added: '2024-01-01' }], now); + + const critIds = db().getLatestCritHighVulnFindingsForNode(1).items.map((i) => i.vulnerability_id); + expect(critIds).toContain('CVE-APP-HIGH'); + expect(critIds).not.toContain('CVE-HOLD-CRIT'); + expect(critIds).not.toContain('CVE-HOLD-KEV'); + expect(db().getLatestKevFindingsForNode(1).items).toHaveLength(0); + const cvssIds = db().getLatestCritHighFindingsWithCvssForNode(1).items.map((i) => i.vulnerability_id); + expect(cvssIds).toContain('CVE-APP-HIGH'); + expect(cvssIds).not.toContain('CVE-HOLD-CRIT'); + const trend = db().getDailyRiskTrend(1, 30); + expect(trend).toHaveLength(1); + expect(trend[0]).toMatchObject({ critical: 0, high: 1 }); + }); }); describe('getLatestKevFindingsForNode', () => { diff --git a/backend/src/__tests__/exposure-cache.test.ts b/backend/src/__tests__/exposure-cache.test.ts new file mode 100644 index 00000000..71c889dd --- /dev/null +++ b/backend/src/__tests__/exposure-cache.test.ts @@ -0,0 +1,69 @@ +/** + * Exposure cache refresh success path via DatabaseService upsert + buildExposedImageMap. + * ComposeService.refreshExposureCache is best-effort: on failure it returns without + * upserting, so the prior beyond-loopback descriptor stays in stack_exposure. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import { buildExposedImageMap, type StackExposure } from '../services/preflight/exposure'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +function db() { + return DatabaseService.getInstance(); +} + +function reset(): void { + const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; + raw.prepare('DELETE FROM stack_exposure').run(); +} + +function parseExposures(nodeId: number): StackExposure[] { + return db().getStackExposures(nodeId).map((row) => JSON.parse(row.descriptor) as StackExposure); +} + +describe('exposure cache refresh (DatabaseService)', () => { + beforeEach(() => reset()); + + it('clears prior beyond-loopback exposure after a successful descriptor replace', () => { + const now = Date.now(); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [{ + service: 'api', + image: 'web:1', + publiclyExposed: true, + reason: 'published-port', + bindings: ['0.0.0.0:80/tcp'], + }], + } satisfies StackExposure), now); + + expect(buildExposedImageMap(parseExposures(1)).get('web:1')).toBe(true); + + // Successful ComposeService.refreshExposureCache upserts the corrected descriptor. + // Refresh failure retains the prior row (ComposeService best-effort); not asserted here. + const correctedAt = now + 1; + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: correctedAt, + services: [{ + service: 'api', + image: 'web:1', + publiclyExposed: false, + reason: null, + bindings: ['127.0.0.1:80/tcp'], + }], + } satisfies StackExposure), correctedAt); + + expect(buildExposedImageMap(parseExposures(1)).get('web:1')).toBe(false); + }); +}); diff --git a/backend/src/__tests__/exposure.test.ts b/backend/src/__tests__/exposure.test.ts index 08fc6c9b..26d68e34 100644 --- a/backend/src/__tests__/exposure.test.ts +++ b/backend/src/__tests__/exposure.test.ts @@ -306,3 +306,44 @@ describe('buildExposedImageMap', () => { expect(map.get('backend:1')).toBe(false); }); }); + +/** + * Mirrors ComposeService.refreshExposureCache success path: + * deriveStackExposure → upsertStackExposure (replace row) → buildExposedImageMap. + * On refresh failure ComposeService returns without upserting, so the prior + * beyond-loopback descriptor is retained (best-effort; not asserted here). + */ +describe('exposure cache refresh success path', () => { + it('clears prior beyond-loopback exposure after Compose correction', () => { + const prior = deriveStackExposure( + model({ + services: [ + svc({ + image: 'web:1', + ports: [{ startPort: 80, endPort: 80, hostIp: '0.0.0.0', protocol: 'tcp' }], + }), + ], + }), + 'web', + NOW, + ); + expect(prior.services[0].publiclyExposed).toBe(true); + expect(buildExposedImageMap([prior]).get('web:1')).toBe(true); + + // Successful refresh replaces the cached descriptor for the stack. + const corrected = deriveStackExposure( + model({ + services: [ + svc({ + image: 'web:1', + ports: [{ startPort: 80, endPort: 80, hostIp: '127.0.0.1', protocol: 'tcp' }], + }), + ], + }), + 'web', + NOW + 1, + ); + expect(corrected.services[0].publiclyExposed).toBe(false); + expect(buildExposedImageMap([corrected]).get('web:1')).toBe(false); + }); +}); diff --git a/backend/src/__tests__/security-image-summaries-route.test.ts b/backend/src/__tests__/security-image-summaries-route.test.ts new file mode 100644 index 00000000..5e58ebf0 --- /dev/null +++ b/backend/src/__tests__/security-image-summaries-route.test.ts @@ -0,0 +1,149 @@ + +/** + * GET /api/security/image-summaries — route-level publicly_exposed + exposure context enrichment. + */ +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 adminCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let LicenseService: typeof import('../services/LicenseService').LicenseService; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ LicenseService } = await import('../services/LicenseService')); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +function db() { + return DatabaseService.getInstance(); +} + +function reset(): void { + const raw = (db() as unknown as { db: { prepare: (s: string) => { run: () => void } } }).db; + raw.prepare('DELETE FROM vulnerability_scans').run(); + raw.prepare('DELETE FROM stack_exposure').run(); + raw.prepare('DELETE FROM stack_exposure_intent').run(); +} + +function seedScan(ref: string, now = Date.now()): void { + db().createVulnerabilityScan({ + node_id: 1, image_ref: ref, image_digest: `sha256:${ref}`, scanned_at: 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, + }); +} + +describe('GET /api/security/image-summaries', () => { + beforeEach(() => reset()); + + it('attaches publicly_exposed true/false/null without changing existing fields', async () => { + const now = Date.now(); + for (const ref of ['pub:1', 'int:1', 'unknown:1']) seedScan(ref, now); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'a', image: 'pub:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + { service: 'b', image: 'int:1', publiclyExposed: false, reason: null, bindings: [] }, + ], + }), now); + + const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body['pub:1']).toMatchObject({ image_ref: 'pub:1', publicly_exposed: true, scan_id: expect.any(Number) }); + expect(res.body['int:1']).toMatchObject({ image_ref: 'int:1', publicly_exposed: false }); + expect(res.body['int:1'].exposure_contexts).toBeUndefined(); + expect(res.body['unknown:1']).toMatchObject({ image_ref: 'unknown:1', publicly_exposed: null }); + }); + + it('attaches exposure contexts and summary for publicly exposed images', async () => { + const now = Date.now(); + seedScan('pub:1', now); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'api', image: 'pub:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + ], + }), now); + db().setStackExposureIntent(1, 'web', '', 'public', 'admin'); + + const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body['pub:1']).toMatchObject({ + publicly_exposed: true, + exposure_context_count: 1, + exposure_contexts_truncated: false, + exposure_context_summary: { + hasConflict: false, + hasUnclassified: false, + hasUnavailable: false, + allKnownIntentional: true, + }, + }); + expect(res.body['pub:1'].exposure_contexts[0]).toMatchObject({ + stackName: 'web', + serviceName: 'api', + intentStatus: 'set', + exposureIntent: 'public', + }); + }); + + it('sets hasConflict from hidden truncated contexts', async () => { + const now = Date.now(); + seedScan('many:1', now); + const services = []; + for (let i = 0; i < 20; i += 1) { + services.push({ + service: `svc${i}`, + image: 'many:1', + publiclyExposed: true, + reason: 'published-port', + bindings: ['0.0.0.0:80/tcp'], + }); + } + services.push({ + service: 'bad', + image: 'many:1', + publiclyExposed: true, + reason: 'published-port', + bindings: ['0.0.0.0:81/tcp'], + }); + db().upsertStackExposure(1, 'web', JSON.stringify({ stack: 'web', computedAt: now, services }), now); + db().setStackExposureIntent(1, 'web', '', 'public', 'admin'); + db().setStackExposureIntent(1, 'web', 'bad', 'internal', 'admin'); + + const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body['many:1'].exposure_contexts_truncated).toBe(true); + expect(res.body['many:1'].exposure_context_count).toBe(21); + expect(res.body['many:1'].exposure_context_summary.hasConflict).toBe(true); + expect(res.body['many:1'].exposure_contexts[0].intentConflict).toBe(true); + }); + + it('does not 500 when a stack exposure descriptor is malformed', async () => { + const now = Date.now(); + seedScan('ok:1', now); + db().upsertStackExposure(1, 'bad', 'not-json{{{', now); + + const res = await request(app).get('/api/security/image-summaries').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body['ok:1']).toMatchObject({ image_ref: 'ok:1', publicly_exposed: null }); + }); + + it('requires authentication', async () => { + const res = await request(app).get('/api/security/image-summaries'); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/__tests__/security-overview-route.test.ts b/backend/src/__tests__/security-overview-route.test.ts index ad02c13f..6fd10705 100644 --- a/backend/src/__tests__/security-overview-route.test.ts +++ b/backend/src/__tests__/security-overview-route.test.ts @@ -89,6 +89,8 @@ function resetSecurity(): void { raw.prepare('DELETE FROM cve_suppressions').run(); raw.prepare('DELETE FROM misconfig_acknowledgements').run(); raw.prepare('DELETE FROM cve_intel').run(); + raw.prepare('DELETE FROM stack_exposure').run(); + raw.prepare('DELETE FROM stack_exposure_intent').run(); } describe('GET /api/security/overview', () => { @@ -318,6 +320,118 @@ describe('GET /api/security/overview', () => { expect(res.body.knownExploited).toBe(1); }); + it('stays Monitoring when Crit/High are accepted residual risk', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'accepted:1', image_digest: 'sha256:accepted', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [ + { vulnerability_id: 'CVE-2024-ACCEPT', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null }, + ]); + db().createCveSuppression({ + cve_id: 'CVE-2024-ACCEPT', pkg_name: null, image_pattern: null, reason: 'accepted residual', + created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted', + }); + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.rawCritical).toBe(1); + expect(res.body.accepted).toBe(1); + expect(res.body.posture).toBe('Monitoring'); + }); + + it('reads Secure when Crit/High are not_affected (raw detections may remain)', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'na:1', image_digest: 'sha256:na', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [ + { vulnerability_id: 'CVE-2024-NA', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null }, + ]); + db().createCveSuppression({ + cve_id: 'CVE-2024-NA', pkg_name: null, image_pattern: null, reason: 'not in execute path', + created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'not_affected', + }); + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.rawCritical).toBe(1); + expect(res.body.notAffected).toBe(1); + expect(res.body.posture).toBe('Secure'); + }); + + it('stays Monitoring when Crit/High are ignored residual risk', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'ignored:1', image_digest: 'sha256:ignored', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [ + { vulnerability_id: 'CVE-2024-IGN', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null }, + ]); + db().createCveSuppression({ + cve_id: 'CVE-2024-IGN', pkg_name: null, image_pattern: null, reason: 'ignored until patch', + created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'ignored', + }); + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.rawCritical).toBe(1); + expect(res.body.posture).toBe('Monitoring'); + }); + + it('reads Secure when Crit/High are false_positive (raw detections may remain)', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'fp:1', image_digest: 'sha256:fp', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [ + { vulnerability_id: 'CVE-2024-FP', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null }, + ]); + db().createCveSuppression({ + cve_id: 'CVE-2024-FP', pkg_name: null, image_pattern: null, reason: 'scanner false positive', + created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'false_positive', + }); + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.rawCritical).toBe(1); + expect(res.body.posture).toBe('Secure'); + }); + + it('reads Secure when Crit/High are fixed (raw detections may remain)', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'fixed:1', image_digest: 'sha256:fixed', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [ + { vulnerability_id: 'CVE-2024-FIX', pkg_name: 'x', installed_version: '1', fixed_version: null, severity: 'CRITICAL', title: null, description: null, primary_url: null }, + ]); + db().createCveSuppression({ + cve_id: 'CVE-2024-FIX', pkg_name: null, image_pattern: null, reason: 'patched in rebuild', + created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'fixed', + }); + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.rawCritical).toBe(1); + expect(res.body.posture).toBe('Secure'); + }); + it('reads Secure when a scan completed with nothing actionable or severe', async () => { db().createVulnerabilityScan({ node_id: 1, image_ref: 'clean:1', image_digest: 'sha256:clean', scanned_at: Date.now(), @@ -340,6 +454,274 @@ describe('GET /api/security/overview', () => { const res = await request(app).get('/api/security/overview'); expect(res.status).toBe(401); }); + + it('treats exposed + fixed_version without intent as review/monitoring, not public_exposure blocker', async () => { + const now = Date.now(); + for (const ref of ['exp-a:1', 'exp-b:1', 'safe:1']) { + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: ref, image_digest: `sha256:${ref}`, scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: `CVE-2024-${ref}`, pkg_name: 'x', installed_version: '1', fixed_version: '2', + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + } + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'a', image: 'exp-a:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + { service: 'b', image: 'exp-b:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:81/tcp'] }, + { service: 'c', image: 'safe:1', publiclyExposed: false, reason: null, bindings: [] }, + ], + }), now); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.posture).toBe('Monitoring'); + const reasons = res.body.postureReasons as Array<{ kind: string; severity: string; targets?: Array<{ imageRef: string }> }>; + expect(reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker')).toBeUndefined(); + expect(reasons.find((r) => r.kind === 'elevated_exploit_risk')).toBeUndefined(); + const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review'); + const uncertain = reasons.find((r) => r.kind === 'update_check_uncertain' || r.kind === 'waiting_upstream'); + expect(review || uncertain).toBeTruthy(); + if (review) { + expect(review.targets?.map((t) => t.imageRef).sort()).toEqual(['exp-a:1', 'exp-b:1']); + } + expect(res.body.primaryAction).toBeNull(); + }); + + it('intentional public + fixed_version without KEV/EPSS/image-update stays Monitoring', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'intent-fix:1', image_digest: 'sha256:intent-fix', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: 'CVE-2024-INTENT', pkg_name: 'x', installed_version: '1', fixed_version: '2', + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'api', image: 'intent-fix:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + ], + }), now); + db().setStackExposureIntent(1, 'web', '', 'public', 'admin'); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.posture).toBe('Monitoring'); + expect(res.body.publiclyExposed).toBe(1); + const reasons = res.body.postureReasons as Array<{ kind: string; severity: string }>; + expect(reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker')).toBeUndefined(); + expect(reasons.find((r) => r.kind === 'elevated_exploit_risk')).toBeUndefined(); + expect(res.body.actionable).toBe(0); + }); + + it('intentional exposure + KEV yields known_exploited blocker with drivers', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'intent-kev:1', image_digest: 'sha256:intent-kev', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: 'CVE-2024-IKEV', pkg_name: 'lib', installed_version: '1', fixed_version: null, + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + db().replaceKev([{ cve_id: 'CVE-2024-IKEV', date_added: '2024-01-01' }], now); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'api', image: 'intent-kev:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + ], + }), now); + db().setStackExposureIntent(1, 'web', '', 'public', 'admin'); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.posture).toBe('Action needed'); + const kev = (res.body.postureReasons as Array<{ + kind: string; + drivers?: Array<{ vulnerabilityId: string; imageRef: string }>; + }>).find((r) => r.kind === 'known_exploited'); + expect(kev?.drivers).toEqual([{ vulnerabilityId: 'CVE-2024-IKEV', imageRef: 'intent-kev:1' }]); + expect((res.body.postureReasons as Array<{ kind: string; severity: string }>) + .find((r) => r.kind === 'public_exposure' && r.severity === 'blocker')).toBeUndefined(); + }); + + it('intentional exposure + EPSS >= 0.1 yields elevated_exploit_risk blocker with drivers', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'intent-epss:1', image_digest: 'sha256:intent-epss', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: 'CVE-2024-IEPSS', pkg_name: 'x', installed_version: '1', fixed_version: '2', + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + db().upsertEpss([{ cve_id: 'CVE-2024-IEPSS', epss_score: 0.15, epss_percentile: 0.9 }], now); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'api', image: 'intent-epss:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + ], + }), now); + db().setStackExposureIntent(1, 'web', '', 'public', 'admin'); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.posture).toBe('Action needed'); + const elevated = (res.body.postureReasons as Array<{ + kind: string; + severity: string; + drivers?: Array<{ vulnerabilityId: string; imageRef: string }>; + targets?: Array<{ imageRef: string }>; + }>).find((r) => r.kind === 'elevated_exploit_risk'); + expect(elevated?.severity).toBe('blocker'); + expect(elevated?.drivers).toEqual([{ vulnerabilityId: 'CVE-2024-IEPSS', imageRef: 'intent-epss:1' }]); + expect(elevated?.targets?.map((t) => t.imageRef)).toEqual(['intent-epss:1']); + expect(res.body.primaryAction.kind).toBe('elevated_exploit_risk'); + }); + + it('internal intent + exposed yields public_exposure blocker with Review networking CTA', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'conflict:1', image_digest: 'sha256:conflict', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: 'CVE-2024-CONFLICT', pkg_name: 'x', installed_version: '1', fixed_version: '2', + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'api', image: 'conflict:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + ], + }), now); + db().setStackExposureIntent(1, 'web', '', 'internal', 'admin'); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.posture).toBe('Action needed'); + const blocker = (res.body.postureReasons as Array<{ + kind: string; + severity: string; + label: string; + targets?: Array<{ imageRef: string; intentConflict?: boolean }>; + }>).find((r) => r.kind === 'public_exposure' && r.severity === 'blocker'); + expect(blocker?.label).toBe('Exposure conflicts with declared intent'); + expect(blocker?.targets?.[0]).toMatchObject({ imageRef: 'conflict:1', intentConflict: true }); + expect(res.body.primaryAction).toMatchObject({ + kind: 'public_exposure', + label: 'Review networking', + }); + }); + + it('actionable excludes publiclyExposed-only intentional images', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'pub-only:1', image_digest: 'sha256:pub-only', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: 'CVE-2024-PUBONLY', pkg_name: 'x', installed_version: '1', fixed_version: null, + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'api', image: 'pub-only:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + ], + }), now); + db().setStackExposureIntent(1, 'web', '', 'public', 'admin'); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.publiclyExposed).toBe(1); + expect(res.body.actionable).toBe(0); + expect(res.body.posture).toBe('Monitoring'); + }); + + it('excludes fully suppressed exposed images from blocker and review targets', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'suppressed-exp:1', image_digest: 'sha256:se', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, high_count: 0, medium_count: 0, low_count: 0, + unknown_count: 0, fixable_count: 1, secret_count: 0, misconfig_count: 0, scanners_used: 'vuln', + highest_severity: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: 'CVE-2024-SUPP', pkg_name: 'x', installed_version: '1', fixed_version: '2', + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + db().createCveSuppression({ + cve_id: 'CVE-2024-SUPP', pkg_name: null, image_pattern: null, reason: 'accepted', + created_by: 'admin', created_at: now, expires_at: null, replicated_from_control: 0, status: 'accepted', + }); + db().upsertStackExposure(1, 'web', JSON.stringify({ + stack: 'web', + computedAt: now, + services: [ + { service: 'a', image: 'suppressed-exp:1', publiclyExposed: true, reason: 'published-port', bindings: ['0.0.0.0:80/tcp'] }, + ], + }), now); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.publiclyExposed).toBe(1); + const exposureReasons = (res.body.postureReasons as Array<{ kind: string; targets?: unknown[] }>) + .filter((r) => r.kind === 'public_exposure'); + expect(exposureReasons).toHaveLength(0); + }); + + it('attaches known_exploited targets as distinct image refs', async () => { + const now = Date.now(); + const scanId = db().createVulnerabilityScan({ + node_id: 1, image_ref: 'kev-img:1', image_digest: 'sha256:kevimg', scanned_at: now, + total_vulnerabilities: 1, critical_count: 1, 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: 'CRITICAL', os_info: null, trivy_version: null, scan_duration_ms: null, + triggered_by: 'manual', status: 'completed', error: null, stack_context: null, + }); + db().insertVulnerabilityDetails(scanId, [{ + vulnerability_id: 'CVE-2024-KEVT', pkg_name: 'lib', installed_version: '1', fixed_version: null, + severity: 'CRITICAL', title: null, description: null, primary_url: null, + }]); + db().replaceKev([{ cve_id: 'CVE-2024-KEVT', date_added: '2024-01-01' }], now); + + const res = await request(app).get('/api/security/overview').set('Cookie', adminCookie); + expect(res.status).toBe(200); + const kev = (res.body.postureReasons as Array<{ kind: string; targets?: Array<{ imageRef: string }> }>) + .find((r) => r.kind === 'known_exploited'); + expect(kev?.targets).toEqual([{ imageRef: 'kev-img:1' }]); + }); }); describe('GET /api/security/overview/trend', () => { diff --git a/backend/src/__tests__/security-scans-route.test.ts b/backend/src/__tests__/security-scans-route.test.ts index 5dfea32c..08c96a83 100644 --- a/backend/src/__tests__/security-scans-route.test.ts +++ b/backend/src/__tests__/security-scans-route.test.ts @@ -141,3 +141,20 @@ describe('GET /api/security/scans query wiring', () => { ]); }); }); + +describe('POST /api/security/scan hold refs', () => { + it('rejects Sencho rollback-hold image refs with 400 before starting a scan', async () => { + const { default: TrivyService } = await import('../services/TrivyService'); + vi.spyOn(TrivyService.getInstance(), 'isTrivyAvailable').mockReturnValue(true); + const begin = vi.spyOn(TrivyService.getInstance(), 'beginScan'); + + const res = await request(app) + .post('/api/security/scan') + .set('Cookie', adminCookie) + .send({ imageRef: 'sencho-rb/aaaaaaaaaaaa/web:hold' }); + + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/rollback-hold/i); + expect(begin).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/__tests__/securityExposureClassification.test.ts b/backend/src/__tests__/securityExposureClassification.test.ts new file mode 100644 index 00000000..da2905c9 --- /dev/null +++ b/backend/src/__tests__/securityExposureClassification.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from 'vitest'; +import { + classifyExposedImages, + classifyImageExposureBucket, + collectKevDrivers, + POSTURE_DRIVER_CAP, + type ExposedFindingRow, +} from '../services/securityExposureClassification'; +import type { PostureTarget } from '../services/securityPosture'; + +function intentionalTarget(imageRef: string, intent: 'public' | 'lan' | 'reverse-proxy' | 'temporary' = 'public'): PostureTarget { + return { + imageRef, + stackName: 'web', + serviceName: 'api', + intentStatus: 'set', + exposureIntent: intent, + intentConflict: false, + }; +} + +function conflictTarget(imageRef: string): PostureTarget { + return { + imageRef, + stackName: 'web', + serviceName: 'api', + intentStatus: 'set', + exposureIntent: 'internal', + intentConflict: true, + }; +} + +function unsetTarget(imageRef: string): PostureTarget { + return { imageRef, stackName: 'web', serviceName: 'api', intentStatus: 'unset' }; +} + +function mapsFor(imageRef: string, opts: { + findings: ExposedFindingRow[]; + targets: PostureTarget[]; + exposed?: boolean; + unsuppressed?: ExposedFindingRow[]; + intel?: Map; +}) { + const critHighByImage = new Map([[imageRef, opts.findings]]); + const exposedMap = new Map([[imageRef, opts.exposed ?? true]]); + const targetsByImage = new Map([[imageRef, opts.targets]]); + const unsuppressedByImage = new Map([[imageRef, opts.unsuppressed ?? opts.findings]]); + return { + critHighByImage, + exposedMap, + targetsByImage, + unsuppressedByImage, + intel: opts.intel ?? new Map(), + }; +} + +describe('classifyImageExposureBucket', () => { + it('returns conflict when any target conflicts', () => { + expect(classifyImageExposureBucket([ + intentionalTarget('a:1'), + conflictTarget('a:1'), + ])).toBe('conflict'); + }); + + it('returns intentional when all complete contexts are intentional', () => { + expect(classifyImageExposureBucket([ + intentionalTarget('a:1', 'public'), + intentionalTarget('a:1', 'lan'), + intentionalTarget('a:1', 'reverse-proxy'), + ])).toBe('intentional'); + }); + + it('returns unclassified for unset or empty targets', () => { + expect(classifyImageExposureBucket([unsetTarget('a:1')])).toBe('unclassified'); + expect(classifyImageExposureBucket([])).toBe('unclassified'); + }); +}); + +describe('classifyExposedImages', () => { + it('intentional public/lan/reverse-proxy + fixed_version only → no conflict, no elevated, unclassified=0', () => { + for (const intent of ['public', 'lan', 'reverse-proxy'] as const) { + const imageRef = `img-${intent}:1`; + const result = classifyExposedImages(mapsFor(imageRef, { + findings: [{ vulnerability_id: 'CVE-1' }], + targets: [intentionalTarget(imageRef, intent)], + })); + expect(result).toMatchObject({ + publiclyExposed: 1, + exposureIntentConflict: 0, + exposedUnclassified: 0, + elevatedExploitRisk: 0, + }); + expect(result.elevatedExploitRiskDrivers).toEqual([]); + } + }); + + it('intentional + high EPSS → elevatedExploitRisk', () => { + const imageRef = 'exp:1'; + const result = classifyExposedImages(mapsFor(imageRef, { + findings: [{ vulnerability_id: 'CVE-EPSS' }], + targets: [intentionalTarget(imageRef)], + intel: new Map([['CVE-EPSS', { epssScore: 0.42 }]]), + })); + expect(result.elevatedExploitRisk).toBe(1); + expect(result.exposureIntentConflict).toBe(0); + expect(result.exposedUnclassified).toBe(0); + expect(result.elevatedExploitRiskDrivers).toEqual([ + { vulnerabilityId: 'CVE-EPSS', imageRef }, + ]); + }); + + it('conflict (internal) + unsuppressed → exposureIntentConflict', () => { + const imageRef = 'bad:1'; + const result = classifyExposedImages(mapsFor(imageRef, { + findings: [{ vulnerability_id: 'CVE-1' }], + targets: [conflictTarget(imageRef)], + })); + expect(result.exposureIntentConflict).toBe(1); + expect(result.exposedUnclassified).toBe(0); + expect(result.elevatedExploitRisk).toBe(0); + expect(result.exposureIntentConflictTargets[0]?.intentConflict).toBe(true); + }); + + it('unset intent → exposedUnclassified, not conflict', () => { + const imageRef = 'unset:1'; + const result = classifyExposedImages(mapsFor(imageRef, { + findings: [{ vulnerability_id: 'CVE-1' }], + targets: [unsetTarget(imageRef)], + })); + expect(result.exposedUnclassified).toBe(1); + expect(result.exposureIntentConflict).toBe(0); + expect(result.elevatedExploitRisk).toBe(0); + }); + + it('fully empty unsuppressed → only publiclyExposed count', () => { + const imageRef = 'supp:1'; + const result = classifyExposedImages(mapsFor(imageRef, { + findings: [{ vulnerability_id: 'CVE-1' }], + targets: [unsetTarget(imageRef)], + unsuppressed: [], + })); + expect(result).toMatchObject({ + publiclyExposed: 1, + exposureIntentConflict: 0, + exposedUnclassified: 0, + elevatedExploitRisk: 0, + }); + }); + + it('caps elevated drivers at POSTURE_DRIVER_CAP', () => { + const imageRef = 'many:1'; + const findings: ExposedFindingRow[] = []; + const intel = new Map(); + for (let i = 0; i < POSTURE_DRIVER_CAP + 5; i += 1) { + const id = `CVE-${i}`; + findings.push({ vulnerability_id: id }); + intel.set(id, { epssScore: 0.5 }); + } + const result = classifyExposedImages(mapsFor(imageRef, { + findings, + targets: [intentionalTarget(imageRef)], + intel, + })); + expect(result.elevatedExploitRisk).toBe(1); + expect(result.elevatedExploitRiskDrivers).toHaveLength(POSTURE_DRIVER_CAP); + }); +}); + +describe('collectKevDrivers', () => { + it('collects unsuppressed KEV drivers and skips suppressed', () => { + const capped = collectKevDrivers([ + { imageRef: 'a:1', vulnerability_id: 'CVE-A', suppressed: false }, + { imageRef: 'a:1', vulnerability_id: 'CVE-B', suppressed: true }, + { imageRef: 'b:1', vulnerability_id: 'CVE-C' }, + ]); + expect(capped).toEqual({ + drivers: [ + { vulnerabilityId: 'CVE-A', imageRef: 'a:1' }, + { vulnerabilityId: 'CVE-C', imageRef: 'b:1' }, + ], + driverCount: 2, + driversTruncated: false, + }); + }); + + it('caps KEV drivers at POSTURE_DRIVER_CAP', () => { + const rows: Array<{ imageRef: string; vulnerability_id: string }> = []; + for (let i = 0; i < POSTURE_DRIVER_CAP + 3; i += 1) { + rows.push({ imageRef: 'img:1', vulnerability_id: `CVE-K${i}` }); + } + const capped = collectKevDrivers(rows); + expect(capped.drivers).toHaveLength(POSTURE_DRIVER_CAP); + expect(capped.driverCount).toBe(POSTURE_DRIVER_CAP + 3); + expect(capped.driversTruncated).toBe(true); + }); +}); diff --git a/backend/src/__tests__/securityExposureTargets.test.ts b/backend/src/__tests__/securityExposureTargets.test.ts new file mode 100644 index 00000000..4ae57944 --- /dev/null +++ b/backend/src/__tests__/securityExposureTargets.test.ts @@ -0,0 +1,216 @@ + +import { describe, it, expect, vi } from 'vitest'; +import { + buildSecurityExposureTargets, + buildImageExposureContextRows, + packageImageExposureContexts, + allTargetsIntentionallyClassified, + allContextsAbsolutelyIntentional, + partialIntentionalWithUnavailable, + summarizeExposureContexts, + IMAGE_EXPOSURE_CONTEXT_CAP, + type ImageExposureContext, +} from '../services/securityExposureTargets'; +import type { StackExposure } from '../services/preflight/exposure'; +import type { ExposureContext } from '../services/network/exposureContext'; +import type { PostureTarget } from '../services/securityPosture'; + +function exposure(stack: string, services: StackExposure['services']): StackExposure { + return { stack, services, computedAt: 1 }; +} + +function svc( + name: string, + image: string, + publiclyExposed = true, + reason: 'published-port' | 'host-network' | null = 'published-port', +): StackExposure['services'][number] { + return { service: name, image, publiclyExposed, reason, bindings: publiclyExposed ? ['0.0.0.0:80/tcp'] : [] }; +} + +describe('buildSecurityExposureTargets', () => { + it('emits public intent when stack intent is public', () => { + const getContext = vi.fn((): ExposureContext => ({ + available: true, + stackIntent: 'public', + serviceIntents: {}, + accessUrlPorts: new Set(), + hasAccessUrls: false, + })); + const targets = buildSecurityExposureTargets({ + nodeId: 1, + exposures: [exposure('web', [svc('api', 'nginx:1')])], + qualifyingImageRefs: new Set(['nginx:1']), + getContext, + }); + expect(getContext).toHaveBeenCalledTimes(1); + expect(targets).toEqual([{ + imageRef: 'nginx:1', + stackName: 'web', + serviceName: 'api', + exposureReason: 'published-port', + intentStatus: 'set', + exposureIntent: 'public', + }]); + }); + + it('flags internal conflict when published beyond loopback', () => { + const targets = buildSecurityExposureTargets({ + nodeId: 1, + exposures: [exposure('web', [svc('api', 'app:1')])], + qualifyingImageRefs: new Set(['app:1']), + getContext: () => ({ + available: true, + stackIntent: 'internal', + serviceIntents: {}, + accessUrlPorts: new Set(), + hasAccessUrls: false, + }), + }); + expect(targets[0]).toMatchObject({ + exposureIntent: 'internal', + intentStatus: 'set', + intentConflict: true, + }); + }); + + it('treats null intent as unset', () => { + const targets = buildSecurityExposureTargets({ + nodeId: 1, + exposures: [exposure('web', [svc('api', 'app:1')])], + qualifyingImageRefs: new Set(['app:1']), + getContext: () => ({ + available: true, + stackIntent: null, + serviceIntents: {}, + accessUrlPorts: new Set(), + hasAccessUrls: false, + }), + }); + expect(targets[0].intentStatus).toBe('unset'); + }); + + it('marks unavailable when context is unavailable', () => { + const targets = buildSecurityExposureTargets({ + nodeId: 1, + exposures: [exposure('web', [svc('api', 'app:1')])], + qualifyingImageRefs: new Set(['app:1']), + getContext: () => ({ available: false }), + }); + expect(targets[0].intentStatus).toBe('unavailable'); + }); + + it('prefers service override over stack intent', () => { + const targets = buildSecurityExposureTargets({ + nodeId: 1, + exposures: [exposure('web', [svc('api', 'app:1'), svc('worker', 'app:1')])], + qualifyingImageRefs: new Set(['app:1']), + getContext: () => ({ + available: true, + stackIntent: 'public', + serviceIntents: { worker: 'internal' }, + accessUrlPorts: new Set(), + hasAccessUrls: false, + }), + }); + expect(targets.find((t) => t.serviceName === 'api')?.exposureIntent).toBe('public'); + expect(targets.find((t) => t.serviceName === 'worker')?.intentConflict).toBe(true); + }); +}); + +describe('intentional helpers and packaging', () => { + it('absolute intentional requires every context complete', () => { + const intentional: ImageExposureContext[] = [ + { stackName: 'a', serviceName: 's', exposureReason: 'published-port', intentStatus: 'set', exposureIntent: 'public' }, + ]; + expect(allContextsAbsolutelyIntentional(intentional)).toBe(true); + + const withUnavailable: ImageExposureContext[] = [ + ...intentional, + { stackName: 'b', serviceName: 's', exposureReason: 'published-port', intentStatus: 'unavailable' }, + ]; + expect(allContextsAbsolutelyIntentional(withUnavailable)).toBe(false); + expect(partialIntentionalWithUnavailable(withUnavailable)).toEqual({ + partial: true, + unavailableCount: 1, + }); + }); + + it('allTargetsIntentionallyClassified is false when any unavailable', () => { + const targets: PostureTarget[] = [ + { imageRef: 'a', stackName: 's', serviceName: 'a', intentStatus: 'set', exposureIntent: 'public' }, + { imageRef: 'a', stackName: 's', serviceName: 'b', intentStatus: 'unavailable' }, + ]; + expect(allTargetsIntentionallyClassified(targets)).toBe(false); + }); + + it('allTargetsIntentionallyClassified is true for a complete public posture target', () => { + expect(allTargetsIntentionallyClassified([{ + imageRef: 'a:1', + stackName: 'web', + serviceName: 'api', + intentStatus: 'set', + exposureIntent: 'public', + }])).toBe(true); + }); + + it('packageImageExposureContexts aggregates before cap and prefers conflict in display', () => { + const contexts: ImageExposureContext[] = []; + for (let i = 0; i < IMAGE_EXPOSURE_CONTEXT_CAP; i += 1) { + contexts.push({ + stackName: `s${i}`, + serviceName: 'svc', + exposureReason: 'published-port', + intentStatus: 'set', + exposureIntent: 'public', + }); + } + contexts.push({ + stackName: 'hidden', + serviceName: 'svc', + exposureReason: 'published-port', + intentStatus: 'set', + exposureIntent: 'internal', + intentConflict: true, + }); + const packaged = packageImageExposureContexts(contexts); + expect(packaged.exposure_contexts_truncated).toBe(true); + expect(packaged.exposure_context_count).toBe(IMAGE_EXPOSURE_CONTEXT_CAP + 1); + expect(packaged.exposure_context_summary.hasConflict).toBe(true); + expect(packaged.exposure_context_summary.allKnownIntentional).toBe(false); + expect(packaged.exposure_contexts[0].intentConflict).toBe(true); + expect(allContextsAbsolutelyIntentional(contexts, packaged.exposure_contexts_truncated)).toBe(false); + }); + + it('summarizeExposureContexts sets allKnownIntentional when only intentional set contexts exist among available', () => { + const summary = summarizeExposureContexts([ + { stackName: 'a', serviceName: 's', exposureReason: null, intentStatus: 'set', exposureIntent: 'lan' }, + { stackName: 'b', serviceName: 's', exposureReason: null, intentStatus: 'unavailable' }, + ]); + expect(summary).toMatchObject({ + hasUnavailable: true, + allKnownIntentional: true, + hasConflict: false, + hasUnclassified: false, + }); + }); +}); + +describe('buildImageExposureContextRows', () => { + it('batches getContext once per stack', () => { + const getContext = vi.fn((): ExposureContext => ({ + available: true, + stackIntent: 'public', + serviceIntents: {}, + accessUrlPorts: new Set(), + hasAccessUrls: false, + })); + buildImageExposureContextRows({ + nodeId: 1, + exposures: [exposure('web', [svc('a', 'i:1'), svc('b', 'i:2')])], + qualifyingImageRefs: new Set(['i:1', 'i:2']), + getContext, + }); + expect(getContext).toHaveBeenCalledTimes(1); + }); +}); diff --git a/backend/src/__tests__/securityImageRemediation.test.ts b/backend/src/__tests__/securityImageRemediation.test.ts new file mode 100644 index 00000000..d9ab209d --- /dev/null +++ b/backend/src/__tests__/securityImageRemediation.test.ts @@ -0,0 +1,269 @@ +import { describe, it, expect } from 'vitest'; +import { + classifyImageRemediation, + buildUpdateServiceIndex, +} from '../services/securityImageRemediation'; +import type { StackServiceStatus, StackUpdateDetail } from '../services/DatabaseService'; + +const HOUR = 60 * 60 * 1000; +const NOW = 1_700_000_000_000; +const FRESH_WINDOW = 4 * HOUR; + +function service(partial: Partial & Pick): StackServiceStatus { + return { + image: 'nginx:1.25', + hasUpdate: false, + checkStatus: 'ok', + lastError: null, + ...partial, + }; +} + +function detail( + services: StackServiceStatus[], + opts: { checkedAt?: number; hasUpdate?: boolean; checkStatus?: 'ok' | 'partial' | 'failed' } = {}, +): StackUpdateDetail { + return { + hasUpdate: opts.hasUpdate ?? services.some((s) => s.hasUpdate), + checkStatus: opts.checkStatus ?? 'ok', + lastError: null, + checkedAt: opts.checkedAt ?? NOW - HOUR, + services, + }; +} + +describe('buildUpdateServiceIndex', () => { + it('indexes declared image and runtimeImages through normalizeImageRef', () => { + const index = buildUpdateServiceIndex({ + web: detail([ + service({ + service: 'app', + image: 'docker.io/library/nginx', + runtimeImages: ['nginx:1.25'], + }), + ]), + }); + expect(index.has('nginx:latest')).toBe(true); + expect(index.has('nginx:1.25')).toBe(true); + }); +}); + +describe('classifyImageRemediation', () => { + it('counts confirmed updates only when hasUpdate and checkStatus are ok', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 3 }], + details: { + web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts).toEqual({ + fixableWithImageUpdate: 3, + fixableWaitingUpstream: 0, + fixableUpdateUnknown: 0, + updateChecksDisabled: false, + imageRefsUpdateAvailable: ['nginx:1.25'], + imageRefsWaitingUpstream: [], + imageRefsUpdateUnknown: [], + }); + }); + + it('treats sticky partial hasUpdate as uncertain, never update_available', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 2 }], + details: { + web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWithImageUpdate).toBe(0); + expect(facts.fixableUpdateUnknown).toBe(2); + expect(facts.fixableWaitingUpstream).toBe(0); + }); + + it('treats not_checkable as uncertain, never waiting_upstream', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'myapp:local', count: 1 }], + details: { + web: detail([service({ + service: 'app', + image: 'myapp:local', + hasUpdate: false, + checkStatus: 'not_checkable', + })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWaitingUpstream).toBe(0); + expect(facts.fixableUpdateUnknown).toBe(1); + }); + + it('authoritative negative becomes waiting_upstream when fresh', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 4 }], + details: { + web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWaitingUpstream).toBe(4); + expect(facts.fixableWithImageUpdate).toBe(0); + expect(facts.fixableUpdateUnknown).toBe(0); + }); + + it('stale stack-level checkedAt yields uncertain, not waiting (R4)', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 1 }], + details: { + web: detail( + [service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })], + { checkedAt: NOW - (FRESH_WINDOW + 1) }, + ), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWaitingUpstream).toBe(0); + expect(facts.fixableUpdateUnknown).toBe(1); + }); + + it('digest-pinned finding does not match tag-declared service (uncertain)', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx@sha256:abc', count: 1 }], + details: { + web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWaitingUpstream).toBe(0); + expect(facts.fixableUpdateUnknown).toBe(1); + }); + + it('disabled checks put all package-fix findings in uncertain', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 5 }], + details: { + web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]), + }, + checksEnabled: false, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts).toEqual({ + fixableWithImageUpdate: 0, + fixableWaitingUpstream: 0, + fixableUpdateUnknown: 5, + updateChecksDisabled: true, + imageRefsUpdateAvailable: [], + imageRefsWaitingUpstream: [], + imageRefsUpdateUnknown: ['nginx:1.25'], + }); + }); + + it('missing stack membership is uncertain', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'orphan:1', count: 2 }], + details: {}, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableUpdateUnknown).toBe(2); + }); + + it('matches via runtimeImages when declared image differs', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25.3', count: 1 }], + details: { + web: detail([service({ + service: 'app', + image: 'nginx:1.25', + runtimeImages: ['nginx:1.25.3'], + hasUpdate: true, + checkStatus: 'ok', + })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWithImageUpdate).toBe(1); + }); + + it('cron-sized freshness window still allows authoritative negatives', () => { + // Daily cron → 2×24h = 48h window (clamped max). A check from 12h ago is fresh. + const cronWindow = 48 * HOUR; + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 1 }], + details: { + web: detail( + [service({ service: 'app', image: 'nginx:1.25', hasUpdate: false, checkStatus: 'ok' })], + { checkedAt: NOW - 12 * HOUR }, + ), + }, + checksEnabled: true, + freshnessWindowMs: cronWindow, + now: NOW, + }); + expect(facts.fixableWaitingUpstream).toBe(1); + }); + + it('confirmed update on one stack wins over sibling partial sticky hasUpdate', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 1 }], + details: { + web: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'ok' })]), + api: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWithImageUpdate).toBe(1); + expect(facts.fixableUpdateUnknown).toBe(0); + }); + + it('partial-only sticky hasUpdate stays uncertain', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.25', count: 1 }], + details: { + api: detail([service({ service: 'app', image: 'nginx:1.25', hasUpdate: true, checkStatus: 'partial' })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWithImageUpdate).toBe(0); + expect(facts.fixableUpdateUnknown).toBe(1); + }); + + it('returns raw finding image_ref even when stack match used normalizeImageRef', () => { + const facts = classifyImageRemediation({ + findings: [{ image_ref: 'nginx:1.14', count: 2 }], + details: { + web: detail([service({ + service: 'app', + image: 'docker.io/library/nginx:1.14', + hasUpdate: true, + checkStatus: 'ok', + })]), + }, + checksEnabled: true, + freshnessWindowMs: FRESH_WINDOW, + now: NOW, + }); + expect(facts.fixableWithImageUpdate).toBe(2); + expect(facts.imageRefsUpdateAvailable).toEqual(['nginx:1.14']); + }); +}); diff --git a/backend/src/__tests__/securityPosture.test.ts b/backend/src/__tests__/securityPosture.test.ts index 69664ca8..f9add1f7 100644 --- a/backend/src/__tests__/securityPosture.test.ts +++ b/backend/src/__tests__/securityPosture.test.ts @@ -6,14 +6,20 @@ function facts(o: Partial = {}): SecurityPostureFacts { scannerAvailable: true, hasCompletedScan: true, fixableCriticalHigh: 0, + fixableWithImageUpdate: 0, + fixableWaitingUpstream: 0, + fixableUpdateUnknown: 0, + updateChecksDisabled: false, secrets: 0, dangerousCompose: 0, knownExploited: 0, publiclyExposed: 0, - exposedBlocker: 0, - exposedReview: 0, + exposureIntentConflict: 0, + exposedUnclassified: 0, + elevatedExploitRisk: 0, rawCritical: 0, rawHigh: 0, + residualCriticalHigh: 0, staleScans: 0, failedScans: 0, needsReview: 0, @@ -21,6 +27,14 @@ function facts(o: Partial = {}): SecurityPostureFacts { }; } +function allCopy(f: SecurityPostureFacts): string { + const { reasons, primaryAction } = derivePostureReasons(f); + return [ + ...reasons.map((r) => `${r.label} ${r.description}`), + primaryAction?.label ?? '', + ].join(' | '); +} + describe('deriveSecurityPosture', () => { it('is Unknown when the scanner is unavailable', () => { expect(deriveSecurityPosture(facts({ scannerAvailable: false, rawCritical: 9 }))).toBe('Unknown'); @@ -30,8 +44,21 @@ describe('deriveSecurityPosture', () => { expect(deriveSecurityPosture(facts({ hasCompletedScan: false, rawCritical: 9 }))).toBe('Unknown'); }); - it('is Action needed when a Critical/High is fixable', () => { - expect(deriveSecurityPosture(facts({ fixableCriticalHigh: 1, rawCritical: 5, rawHigh: 5 }))).toBe('Action needed'); + it('is Monitoring when package-fix exists but no confirmed image update', () => { + expect(deriveSecurityPosture(facts({ + fixableCriticalHigh: 4, + fixableWaitingUpstream: 4, + rawCritical: 5, + rawHigh: 5, + }))).toBe('Monitoring'); + }); + + it('is Action needed when a confirmed image update is available', () => { + expect(deriveSecurityPosture(facts({ + fixableCriticalHigh: 1, + fixableWithImageUpdate: 1, + rawCritical: 5, + }))).toBe('Action needed'); }); it('is Action needed for a detected secret', () => { @@ -46,23 +73,52 @@ describe('deriveSecurityPosture', () => { expect(deriveSecurityPosture(facts({ knownExploited: 1, fixableCriticalHigh: 0, rawCritical: 1 }))).toBe('Action needed'); }); - it('is Action needed when exposedBlocker > 0 (KEV, fixable, or elevated EPSS on a public interface)', () => { - expect(deriveSecurityPosture(facts({ exposedBlocker: 1 }))).toBe('Action needed'); + it('is Action needed when exposureIntentConflict > 0', () => { + expect(deriveSecurityPosture(facts({ exposureIntentConflict: 1 }))).toBe('Action needed'); }); - it('is Monitoring when publiclyExposed > 0 but exposedBlocker is 0 (review-only exposure)', () => { - expect(deriveSecurityPosture(facts({ publiclyExposed: 3, exposedReview: 3, rawCritical: 2 }))).toBe('Monitoring'); + it('is Action needed when elevatedExploitRisk > 0', () => { + expect(deriveSecurityPosture(facts({ elevatedExploitRisk: 1, publiclyExposed: 1 }))).toBe('Action needed'); }); - it('is Monitoring when Critical/High exist but nothing is actionable', () => { - expect(deriveSecurityPosture(facts({ rawCritical: 3, rawHigh: 7 }))).toBe('Monitoring'); + it('keeps Action needed for intent conflict even with authoritative no-update (R3)', () => { + expect(deriveSecurityPosture(facts({ + fixableCriticalHigh: 2, + fixableWaitingUpstream: 2, + exposureIntentConflict: 1, + }))).toBe('Action needed'); + }); + + it('is Monitoring when publiclyExposed > 0 but only unclassified review (no conflict/elevated)', () => { + expect(deriveSecurityPosture(facts({ + publiclyExposed: 3, + exposedUnclassified: 3, + rawCritical: 2, + }))).toBe('Monitoring'); + }); + + it('is Monitoring for intentional exposure with package-fix only (no elevated/conflict)', () => { + expect(deriveSecurityPosture(facts({ + publiclyExposed: 1, + fixableCriticalHigh: 2, + fixableWaitingUpstream: 2, + rawCritical: 2, + }))).toBe('Monitoring'); + }); + + it('is Monitoring when residual Critical/High remain (including accepted risk)', () => { + expect(deriveSecurityPosture(facts({ residualCriticalHigh: 5 }))).toBe('Monitoring'); + }); + + it('is Secure when raw Crit/High remain but residual is cleared', () => { + expect(deriveSecurityPosture(facts({ rawCritical: 5, rawHigh: 2, residualCriticalHigh: 0 }))).toBe('Secure'); }); it('is Monitoring when only review/info reasons exist', () => { - expect(deriveSecurityPosture(facts({ exposedReview: 1, needsReview: 2, staleScans: 1 }))).toBe('Monitoring'); + expect(deriveSecurityPosture(facts({ exposedUnclassified: 1, needsReview: 2, staleScans: 1 }))).toBe('Monitoring'); }); - it('is Secure when a scan completed and nothing is actionable or severe', () => { + it('is Secure when a scan completed and nothing is actionable or residual', () => { expect(deriveSecurityPosture(facts())).toBe('Secure'); }); }); @@ -74,8 +130,18 @@ describe('derivePostureReasons', () => { expect(primaryAction).toBeNull(); }); - it('returns a blocker reason for fixable findings', () => { - const { reasons } = derivePostureReasons(facts({ fixableCriticalHigh: 4 })); + it('does not emit fixable_cve blocker from package-fix alone', () => { + const { reasons, primaryAction } = derivePostureReasons(facts({ + fixableCriticalHigh: 4, + fixableWaitingUpstream: 4, + })); + expect(reasons.find((r) => r.kind === 'fixable_cve')).toBeUndefined(); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'waiting_upstream', count: 4, severity: 'review' })); + expect(primaryAction).toBeNull(); + }); + + it('returns a blocker reason for confirmed image updates', () => { + const { reasons } = derivePostureReasons(facts({ fixableWithImageUpdate: 4, fixableCriticalHigh: 4 })); expect(reasons).toContainEqual(expect.objectContaining({ kind: 'fixable_cve', count: 4, severity: 'blocker' })); }); @@ -84,6 +150,16 @@ describe('derivePostureReasons', () => { expect(reasons).toContainEqual(expect.objectContaining({ kind: 'known_exploited', count: 2, severity: 'blocker' })); }); + it('returns a blocker reason for elevated exploit risk', () => { + const { reasons } = derivePostureReasons(facts({ elevatedExploitRisk: 1 })); + expect(reasons).toContainEqual(expect.objectContaining({ + kind: 'elevated_exploit_risk', + count: 1, + severity: 'blocker', + label: 'Elevated exploit risk on network-exposed workload', + })); + }); + it('returns a blocker reason for secrets', () => { const { reasons } = derivePostureReasons(facts({ secrets: 3 })); expect(reasons).toContainEqual(expect.objectContaining({ kind: 'secret', count: 3, severity: 'blocker' })); @@ -94,14 +170,30 @@ describe('derivePostureReasons', () => { expect(reasons).toContainEqual(expect.objectContaining({ kind: 'dangerous_compose', count: 5, severity: 'blocker' })); }); - it('returns a blocker reason for exposedBlocker', () => { - const { reasons } = derivePostureReasons(facts({ exposedBlocker: 1 })); - expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 1, severity: 'blocker' })); + it('returns a public_exposure blocker for exposureIntentConflict only', () => { + const { reasons, primaryAction } = derivePostureReasons(facts({ exposureIntentConflict: 1 })); + expect(reasons).toContainEqual(expect.objectContaining({ + kind: 'public_exposure', + count: 1, + severity: 'blocker', + label: 'Exposure conflicts with declared intent', + })); + expect(primaryAction).toEqual({ + label: 'Review networking', + targetTab: 'images', + kind: 'public_exposure', + }); }); - it('returns a review reason for exposedReview', () => { - const { reasons } = derivePostureReasons(facts({ exposedReview: 2 })); - expect(reasons).toContainEqual(expect.objectContaining({ kind: 'public_exposure', count: 2, severity: 'review' })); + it('returns a public_exposure review for exposedUnclassified only', () => { + const { reasons, primaryAction } = derivePostureReasons(facts({ exposedUnclassified: 2 })); + expect(reasons).toContainEqual(expect.objectContaining({ + kind: 'public_exposure', + count: 2, + severity: 'review', + label: 'Network-exposed images not yet classified', + })); + expect(primaryAction).toBeNull(); }); it('returns a review reason for needsReview', () => { @@ -115,11 +207,27 @@ describe('derivePostureReasons', () => { expect(reasons).toContainEqual(expect.objectContaining({ kind: 'failed_scan', count: 1, severity: 'info' })); }); + it('returns uncertain review reason for unknown remediation', () => { + const { reasons } = derivePostureReasons(facts({ + fixableCriticalHigh: 2, + fixableUpdateUnknown: 2, + })); + expect(reasons).toContainEqual(expect.objectContaining({ kind: 'update_check_uncertain', count: 2, severity: 'review' })); + }); + + it('explains disabled checks in uncertain description', () => { + const { reasons } = derivePostureReasons(facts({ + fixableUpdateUnknown: 1, + updateChecksDisabled: true, + })); + const uncertain = reasons.find((r) => r.kind === 'update_check_uncertain'); + expect(uncertain?.description).toMatch(/disabled/i); + }); + it('returns ALL reasons regardless of posture state', () => { - // Even with no scanner (Unknown posture), the facts produce reasons. const { reasons } = derivePostureReasons(facts({ scannerAvailable: false, - fixableCriticalHigh: 4, + fixableWithImageUpdate: 4, staleScans: 1, })); expect(reasons).toHaveLength(2); @@ -127,13 +235,34 @@ describe('derivePostureReasons', () => { expect(reasons[1].kind).toBe('stale_scan'); }); - it('sets primaryAction to the first blocker (fixable_cve priority)', () => { + it('sets primaryAction to Review update when image update is confirmed', () => { const { primaryAction } = derivePostureReasons(facts({ - fixableCriticalHigh: 3, + fixableWithImageUpdate: 3, knownExploited: 1, secrets: 2, })); - expect(primaryAction).toEqual({ label: 'Update affected images', targetTab: 'images', kind: 'fixable_cve' }); + expect(primaryAction).toEqual({ label: 'Review update', targetTab: 'images', kind: 'fixable_cve' }); + }); + + it('falls through to KEV when only waiting upstream for package fixes', () => { + const { primaryAction, reasons } = derivePostureReasons(facts({ + fixableCriticalHigh: 3, + fixableWaitingUpstream: 3, + knownExploited: 1, + })); + expect(primaryAction).toEqual({ label: 'Review exploited findings', targetTab: 'images', kind: 'known_exploited' }); + expect(reasons.some((r) => r.kind === 'waiting_upstream')).toBe(true); + }); + + it('R3: intent-conflict primary action when waiting upstream, never Update affected images', () => { + const { primaryAction, reasons } = derivePostureReasons(facts({ + fixableCriticalHigh: 2, + fixableWaitingUpstream: 2, + exposureIntentConflict: 1, + })); + expect(primaryAction).toEqual({ label: 'Review networking', targetTab: 'images', kind: 'public_exposure' }); + expect(reasons.some((r) => r.label === 'Update affected images' || r.description.includes('Update affected images'))).toBe(false); + expect(primaryAction?.label).not.toBe('Update affected images'); }); it('falls through to the next blocker when the first is absent', () => { @@ -143,29 +272,148 @@ describe('derivePostureReasons', () => { it('returns null primaryAction when no blockers exist', () => { const { primaryAction } = derivePostureReasons(facts({ - exposedReview: 1, needsReview: 2, staleScans: 1, failedScans: 0, + exposedUnclassified: 1, needsReview: 2, staleScans: 1, failedScans: 0, })); expect(primaryAction).toBeNull(); }); - it('each blocker reason has a targetTab matching a valid Security tab', () => { - const validTabs = ['images', 'secrets', 'compose', 'history', 'suppressions', 'scanner']; - const { reasons } = derivePostureReasons(facts({ - fixableCriticalHigh: 1, secrets: 1, dangerousCompose: 1, - knownExploited: 1, exposedBlocker: 1, + it('never claims a security fix or that an update fixes findings', () => { + const copy = allCopy(facts({ + fixableWithImageUpdate: 2, + fixableWaitingUpstream: 1, + fixableUpdateUnknown: 1, })); - for (const r of reasons) { - expect(validTabs).toContain(r.targetTab); - } + expect(copy.toLowerCase()).not.toContain('security fix available'); + expect(copy.toLowerCase()).not.toMatch(/fixes the/); + expect(copy).not.toContain('Update affected images'); }); - // Invariant: Action needed posture always has at least one blocker reason. - it('Action needed posture always has at least one blocker reason', () => { - const f = facts({ fixableCriticalHigh: 1 }); - const posture = deriveSecurityPosture(f); - const { reasons } = derivePostureReasons(f); - if (posture === 'Action needed') { - expect(reasons.some((r) => r.severity === 'blocker')).toBe(true); - } + it('attaches targets to reasons and omits them when empty', () => { + const { reasons, primaryAction } = derivePostureReasons(facts({ + exposureIntentConflict: 2, + exposureIntentConflictTargets: [{ imageRef: 'a:1' }, { imageRef: 'b:1' }], + exposedUnclassified: 1, + exposedUnclassifiedTargets: [{ imageRef: 'c:1' }], + knownExploited: 3, + knownExploitedTargets: ['kev:1'], + })); + const blocker = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker'); + const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review'); + const kev = reasons.find((r) => r.kind === 'known_exploited'); + expect(blocker?.targets).toEqual([{ imageRef: 'a:1' }, { imageRef: 'b:1' }]); + expect(review?.targets).toEqual([{ imageRef: 'c:1' }]); + expect(kev?.targets).toEqual([{ imageRef: 'kev:1' }]); + expect(primaryAction?.kind).toBe('known_exploited'); + expect(primaryAction?.targets).toEqual([{ imageRef: 'kev:1' }]); + }); + + it('omits targets field when target arrays are empty', () => { + const { reasons } = derivePostureReasons(facts({ + exposureIntentConflict: 1, + exposureIntentConflictTargets: [], + })); + const blocker = reasons.find((r) => r.kind === 'public_exposure'); + expect(blocker?.targets).toBeUndefined(); + }); + + it('primaryAction for public_exposure copies conflict targets, not unclassified review', () => { + const { primaryAction } = derivePostureReasons(facts({ + exposureIntentConflict: 1, + exposureIntentConflictTargets: [{ imageRef: 'block:1' }], + exposedUnclassified: 2, + exposedUnclassifiedTargets: [{ imageRef: 'rev:1' }, { imageRef: 'rev:2' }], + })); + expect(primaryAction).toEqual({ + label: 'Review networking', + targetTab: 'images', + kind: 'public_exposure', + targets: [{ imageRef: 'block:1' }], + }); + }); + + it('attaches elevated exploit risk targets and drivers', () => { + const { reasons, primaryAction } = derivePostureReasons(facts({ + elevatedExploitRisk: 1, + elevatedExploitRiskTargets: [{ imageRef: 'exp:1', intentStatus: 'set', exposureIntent: 'public' }], + elevatedExploitRiskDrivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'exp:1' }], + })); + const elevated = reasons.find((r) => r.kind === 'elevated_exploit_risk'); + expect(elevated?.targets).toEqual([{ imageRef: 'exp:1', intentStatus: 'set', exposureIntent: 'public' }]); + expect(elevated?.drivers).toEqual([{ vulnerabilityId: 'CVE-1', imageRef: 'exp:1' }]); + expect(elevated?.driverCount).toBe(1); + expect(elevated?.driversTruncated).toBe(false); + expect(primaryAction).toEqual({ + label: 'Review driving findings', + targetTab: 'images', + kind: 'elevated_exploit_risk', + targets: [{ imageRef: 'exp:1', intentStatus: 'set', exposureIntent: 'public' }], + drivers: [{ vulnerabilityId: 'CVE-1', imageRef: 'exp:1' }], + driverCount: 1, + driversTruncated: false, + }); + }); + + it('conflict description names intent mismatch and never claims Internet reachability', () => { + const { reasons, primaryAction } = derivePostureReasons(facts({ + exposureIntentConflict: 1, + exposureIntentConflictTargets: [{ + imageRef: 'a:1', + stackName: 'web', + serviceName: 'api', + intentStatus: 'set', + exposureIntent: 'internal', + intentConflict: true, + }], + })); + const blocker = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'blocker'); + expect(blocker?.label).toBe('Exposure conflicts with declared intent'); + expect(blocker?.description).toContain('conflicts with declared Networking intent'); + expect(blocker?.description).toContain('Review networking'); + expect(blocker?.description.toLowerCase()).not.toContain('internet'); + expect(primaryAction?.label).toBe('Review networking'); + }); + + it('unclassified review description prompts classification when intent is unset', () => { + const { reasons } = derivePostureReasons(facts({ + exposedUnclassified: 1, + exposedUnclassifiedTargets: [{ imageRef: 'a:1', intentStatus: 'unset' }], + })); + const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review'); + expect(review?.description).toContain('not yet classified'); + expect(review?.description).toContain('Set exposure intent in Networking'); + }); + + it('KEV plus intentional exposure context still Action needed via known_exploited', () => { + expect(deriveSecurityPosture(facts({ + knownExploited: 1, + publiclyExposed: 1, + }))).toBe('Action needed'); + }); + + it('unavailable-only unclassified does not add the unset classification sentence', () => { + const { reasons } = derivePostureReasons(facts({ + exposedUnclassified: 1, + exposedUnclassifiedTargets: [{ + imageRef: 'a:1', + stackName: 's', + serviceName: 'a', + intentStatus: 'unavailable', + }], + })); + const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review'); + expect(review?.description).not.toContain('Set exposure intent in Networking'); + expect(review?.description).toContain('not yet classified'); + }); + + it('notes unverified services when intentional contexts mix with unavailable', () => { + const { reasons } = derivePostureReasons(facts({ + exposedUnclassified: 1, + exposedUnclassifiedTargets: [ + { imageRef: 'a:1', stackName: 's', serviceName: 'a', intentStatus: 'set', exposureIntent: 'public' }, + { imageRef: 'a:1', stackName: 's', serviceName: 'b', intentStatus: 'unavailable' }, + ], + })); + const review = reasons.find((r) => r.kind === 'public_exposure' && r.severity === 'review'); + expect(review?.description).toContain('could not be verified'); }); }); diff --git a/backend/src/__tests__/sencho-rollback-hold.test.ts b/backend/src/__tests__/sencho-rollback-hold.test.ts new file mode 100644 index 00000000..7e83ff7b --- /dev/null +++ b/backend/src/__tests__/sencho-rollback-hold.test.ts @@ -0,0 +1,43 @@ +import { describe, it, expect } from 'vitest'; +import { + isFullySyntheticHoldImage, + isSenchoRollbackHoldRef, + SENCHO_ROLLBACK_HOLD_PREFIX, + SENCHO_ROLLBACK_HOLD_SQL_LIKE, +} from '../utils/senchoRollbackHold'; + +describe('senchoRollbackHold constants', () => { + it('keeps the SQL LIKE pattern aligned with the JS prefix', () => { + expect(SENCHO_ROLLBACK_HOLD_SQL_LIKE).toBe(`${SENCHO_ROLLBACK_HOLD_PREFIX}%`); + }); +}); + +describe('isSenchoRollbackHoldRef', () => { + it('matches Sencho synthetic rollback-hold tags', () => { + expect(isSenchoRollbackHoldRef('sencho-rb/aaaaaaaaaaaa/web:hold')).toBe(true); + expect(isSenchoRollbackHoldRef('sencho-rb/x/svc:hold')).toBe(true); + }); + + it('rejects ordinary registry refs', () => { + expect(isSenchoRollbackHoldRef('nginx:1.25')).toBe(false); + expect(isSenchoRollbackHoldRef('ghcr.io/org/app:latest')).toBe(false); + expect(isSenchoRollbackHoldRef('stack:web')).toBe(false); + }); +}); + +describe('isFullySyntheticHoldImage', () => { + it('is true when every tag is a hold tag', () => { + expect(isFullySyntheticHoldImage(['sencho-rb/aaaaaaaaaaaa/web:hold'])).toBe(true); + }); + + it('is false when a registry tag is present alongside a hold tag', () => { + expect(isFullySyntheticHoldImage([ + 'myregistry/app:1.4', + 'sencho-rb/aaaaaaaaaaaa/app:hold', + ])).toBe(false); + }); + + it('is false for empty tag lists', () => { + expect(isFullySyntheticHoldImage([])).toBe(false); + }); +}); diff --git a/backend/src/__tests__/suppression-filter.test.ts b/backend/src/__tests__/suppression-filter.test.ts index c2a1db74..22d3117a 100644 --- a/backend/src/__tests__/suppression-filter.test.ts +++ b/backend/src/__tests__/suppression-filter.test.ts @@ -2,7 +2,7 @@ * Unit tests for the read-time CVE suppression filter. */ import { describe, it, expect } from 'vitest'; -import { applySuppressions, findSuppression } from '../utils/suppression-filter'; +import { applySuppressions, countsTowardResidualCriticalHigh, findSuppression } from '../utils/suppression-filter'; import type { CveSuppression } from '../services/DatabaseService'; const NOW = 1_700_000_000_000; @@ -227,3 +227,22 @@ describe('applySuppressions', () => { expect(elapsed).toBeLessThan(1500); }); }); + +describe('countsTowardResidualCriticalHigh', () => { + it('counts unsuppressed findings as residual', () => { + expect(countsTowardResidualCriticalHigh({ suppressed: false })).toBe(true); + expect(countsTowardResidualCriticalHigh({ suppressed: false, triage_status: 'needs_review' })).toBe(true); + expect(countsTowardResidualCriticalHigh({ suppressed: false, triage_status: 'affected' })).toBe(true); + }); + + it('keeps accepted and ignored residual (do not turn Secure green)', () => { + expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'accepted' })).toBe(true); + expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'ignored' })).toBe(true); + }); + + it('clears not_affected, false_positive, and fixed from residual', () => { + expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'not_affected' })).toBe(false); + expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'false_positive' })).toBe(false); + expect(countsTowardResidualCriticalHigh({ suppressed: true, triage_status: 'fixed' })).toBe(false); + }); +}); diff --git a/backend/src/__tests__/trivy-scan-node.test.ts b/backend/src/__tests__/trivy-scan-node.test.ts index 011c3d10..4ec9fc9d 100644 --- a/backend/src/__tests__/trivy-scan-node.test.ts +++ b/backend/src/__tests__/trivy-scan-node.test.ts @@ -76,6 +76,29 @@ describe('TrivyService.scanNode', () => { expect(result.severity).toMatchObject({ critical: 1, high: 2 }); }); + it('skips Sencho rollback-hold tags while still scanning dual-tagged images via the registry tag', async () => { + vi.spyOn(DockerController, 'getInstance').mockReturnValue({ + getImages: async () => [ + { RepoTags: ['sencho-rb/aaaaaaaaaaaa/web:hold'] }, + { RepoTags: ['myregistry/app:1.4', 'sencho-rb/bbbbbbbbbbbb/app:hold'] }, + ], + } as never); + const run = vi.spyOn(svc(), 'runScanAndPersist').mockResolvedValue(fakeRow({ image_ref: 'myregistry/app:1.4' })); + + await svc().scanNode(1, { vulns: true, secrets: false, misconfig: false }); + + expect(run).toHaveBeenCalledTimes(1); + expect(run).toHaveBeenCalledWith('myregistry/app:1.4', 1, 'manual', null, { scanners: ['vuln'] }); + }); + + it('rejects scanImage for Sencho rollback-hold refs before touching Docker or Trivy', async () => { + const digest = vi.spyOn(svc() as unknown as { getImageDigest: (r: string, n: number) => Promise }, 'getImageDigest'); + await expect(svc().scanImage('sencho-rb/aaaaaaaaaaaa/web:hold', 1)).rejects.toThrow( + /rollback-hold images are recovery state/i, + ); + expect(digest).not.toHaveBeenCalled(); + }); + it('scans secrets only and keys the digest cache on the scanner set', async () => { vi.spyOn(DockerController, 'getInstance').mockReturnValue({ getImages: async () => [{ RepoTags: ['a:1'] }] } as never); // Force a digest so the cache lookup runs; return null so the scan proceeds. diff --git a/backend/src/__tests__/update-preview-reconcile.test.ts b/backend/src/__tests__/update-preview-reconcile.test.ts index dcab0d37..c84498c5 100644 --- a/backend/src/__tests__/update-preview-reconcile.test.ts +++ b/backend/src/__tests__/update-preview-reconcile.test.ts @@ -118,6 +118,43 @@ describe('ImageUpdateService.commitPreviewClear', () => { expect(db.getStackUpdateDetail(nodeId)['restart-web']).toBeUndefined(); }); + it('keeps an ok+false row', async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id!; + db.upsertStackUpdateStatus(nodeId, 'web', false, 1000, 'ok', null, [ + { service: 'web', image: 'nginx:1.2.3', hasUpdate: false, checkStatus: 'ok', lastError: null }, + ]); + const svc = ImageUpdateService.getInstance(); + const observedMem = svc.peekStackWriteGeneration(nodeId, 'web'); + const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web'); + expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('absent'); + expect(db.getStackUpdateDetail(nodeId).web).toMatchObject({ + hasUpdate: false, + checkStatus: 'ok', + services: [ + expect.objectContaining({ + service: 'web', + image: 'nginx:1.2.3', + hasUpdate: false, + checkStatus: 'ok', + }), + ], + }); + }); + + it('clears a failed row even when hasUpdate is false', async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id!; + db.upsertStackUpdateStatus(nodeId, 'web', false, 1000, 'failed', 'timeout', [ + { service: 'web', image: 'nginx:1.2.3', hasUpdate: false, checkStatus: 'failed', lastError: 'timeout' }, + ]); + const svc = ImageUpdateService.getInstance(); + const observedMem = svc.peekStackWriteGeneration(nodeId, 'web'); + const observedRow = db.getStackUpdateWriteGeneration(nodeId, 'web'); + expect(await svc.commitPreviewClear(nodeId, 'web', observedMem, observedRow)).toBe('cleared'); + expect(db.getStackUpdateDetail(nodeId).web).toBeUndefined(); + }); + it('returns absent when no row exists', async () => { const db = DatabaseService.getInstance(); const nodeId = db.getDefaultNode()!.id!; @@ -303,6 +340,39 @@ describe('GET/POST /api/stacks/:stackName/update-preview reconcile', () => { })); }); + it('POST keeps an ok+false scanner row', async () => { + const db = DatabaseService.getInstance(); + const nodeId = db.getDefaultNode()!.id!; + db.upsertStackUpdateStatus(nodeId, 'web', false, 1000, 'ok', null, [ + { service: 'web', image: 'nginx:1.2.3', hasUpdate: false, checkStatus: 'ok', lastError: null }, + ]); + + vi.spyOn(UpdatePreviewService.getInstance(), 'getPreview').mockResolvedValue(negativeOkPreview('web')); + const broadcast = vi.spyOn(NotificationService.getInstance(), 'broadcastEvent').mockImplementation(() => undefined); + const invalidate = vi.spyOn(CacheService.getInstance(), 'invalidate').mockImplementation(() => undefined); + + const res = await request(app) + .post('/api/stacks/web/update-preview') + .set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.reconciled).toBe(false); + expect(db.getStackUpdateDetail(nodeId).web).toMatchObject({ + hasUpdate: false, + checkStatus: 'ok', + services: [ + expect.objectContaining({ + service: 'web', + image: 'nginx:1.2.3', + hasUpdate: false, + checkStatus: 'ok', + }), + ], + }); + expect(broadcast).not.toHaveBeenCalled(); + expect(invalidate).not.toHaveBeenCalled(); + }); + it('POST does not mutate on partial negative preview', async () => { const db = DatabaseService.getInstance(); const nodeId = db.getDefaultNode()!.id!; diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 38e15385..37b9d524 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -11,12 +11,31 @@ import { isValidStackName } from '../utils/validation'; import { FleetSyncService } from '../services/FleetSyncService'; import { LicenseService } from '../services/LicenseService'; import { validateImageRef } from '../utils/image-ref'; -import { applySuppressions, isTriageStatus, isTriageJustification } from '../utils/suppression-filter'; +import { isSenchoRollbackHoldRef } from '../utils/senchoRollbackHold'; +import { + applySuppressions, + countsTowardResidualCriticalHigh, + isTriageStatus, + isTriageJustification, +} from '../utils/suppression-filter'; import { applyMisconfigAcknowledgements } from '../utils/misconfig-ack-filter'; import { generateSarif } from '../services/SarifExporter'; import { generateOpenVex } from '../services/OpenVexExporter'; -import { deriveSecurityPosture, derivePostureReasons, HIGH_EPSS_THRESHOLD, type SecurityPostureFacts, type SecurityPostureState, type PostureReason, type PostureAction } from '../services/securityPosture'; -import { buildExposedImageMap } from '../services/preflight/exposure'; +import { deriveSecurityPosture, derivePostureReasons, type SecurityPostureFacts, type SecurityPostureState, type PostureReason, type PostureAction, type PostureTarget } from '../services/securityPosture'; +import { classifyImageRemediation, type RemediationFindingInput } from '../services/securityImageRemediation'; +import { ImageUpdateService } from '../services/ImageUpdateService'; +import { buildExposedImageMap, type StackExposure } from '../services/preflight/exposure'; +import { + buildImageExposureContextRows, + buildSecurityExposureTargets, + packageExposureContextsByImage, + type PackagedImageExposureContexts, +} from '../services/securityExposureTargets'; +import { + classifyExposedImages, + collectKevDrivers, + collectPackageFixDrivers, +} from '../services/securityExposureClassification'; import { sanitizeForLog } from '../utils/safeLog'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; @@ -187,7 +206,10 @@ interface SecurityOverviewResponse { needsReview: number; accepted: number; notAffected: number; - /** Total actionable items, for the "N actions" affordance. */ + /** + * Legacy mixed-unit sum of canonical blocker counts (image updates, secrets, + * Compose, KEV, elevated EPSS, intent conflict). Prefer posture / reasons. + */ actionable: number; posture: SecurityPostureState; /** True when the bounded posture pass hit its row cap on this node. */ @@ -196,6 +218,8 @@ interface SecurityOverviewResponse { postureReasons: PostureReason[]; /** Highest-priority action for the masthead CTA, or null when no blockers. */ primaryAction: PostureAction | null; + /** True when image-update checks are disabled and uncertain remediation exists. */ + updateChecksDisabled?: boolean; } export const securityRouter = Router(); @@ -442,6 +466,10 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void res.status(400).json({ error: 'Invalid imageRef format' }); return; } + if (isSenchoRollbackHoldRef(rawImageRef)) { + res.status(400).json({ error: 'Sencho rollback-hold images are recovery state and cannot be scanned' }); + return; + } const imageRef = rawImageRef; const stackContext = typeof req.body?.stackName === 'string' ? req.body.stackName : null; const force = req.body?.force === true; @@ -711,8 +739,52 @@ securityRouter.get( securityRouter.get('/image-summaries', authMiddleware, (req: Request, res: Response) => { try { - const summaries = DatabaseService.getInstance().getImageScanSummaries(req.nodeId); - res.json(summaries); + const db = DatabaseService.getInstance(); + const summaries = db.getImageScanSummaries(req.nodeId); + // Route-level enrichment only: leave DatabaseService ScanSummary untouched. + // Fail soft on exposure read/parse so Resources and post-scan refresh stay up. + let exposedMap: Map | null = null; + let packagedByImage: Map | null = null; + try { + const exposures = db.getStackExposures(req.nodeId); + const parsed = exposures.map((r) => { + try { return JSON.parse(r.descriptor) as StackExposure; } catch { return null; } + }).filter((e): e is StackExposure => e !== null); + exposedMap = buildExposedImageMap(parsed); + const exposedRefs = new Set( + [...exposedMap].filter(([, exposed]) => exposed).map(([ref]) => ref), + ); + if (exposedRefs.size > 0) { + packagedByImage = packageExposureContextsByImage( + buildImageExposureContextRows({ + nodeId: req.nodeId, + exposures: parsed, + qualifyingImageRefs: exposedRefs, + }), + ); + } + } catch (err) { + console.error('[Security] Failed to load stack exposures for image summaries:', err); + exposedMap = null; + packagedByImage = null; + } + type EnrichedSummary = (typeof summaries)[string] & { + publicly_exposed: boolean | null; + } & Partial; + const out: Record = {}; + for (const [key, summary] of Object.entries(summaries)) { + const exposed = exposedMap?.get(summary.image_ref); + const publicly_exposed = exposed === undefined ? null : exposed; + const packaged = publicly_exposed === true + ? packagedByImage?.get(summary.image_ref) + : undefined; + out[key] = { + ...summary, + publicly_exposed, + ...packaged, + }; + } + res.json(out); } catch (error) { console.error('[Security] Failed to fetch image summaries:', error); res.status(500).json({ error: 'Failed to fetch image summaries' }); @@ -772,12 +844,18 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v else critHighByImage.set(f.image_ref, [f]); } let fixableCriticalHigh = 0; + let residualCriticalHigh = 0; let accepted = 0; let notAffected = 0; let needsReview = 0; + const remediationByImage = new Map(); + const packageFixByImage = new Map(); for (const [imageRef, group] of critHighByImage) { for (const e of applySuppressions(group, imageRef, cveSuppressions)) { if (e.triage_status === 'needs_review') needsReview += 1; + if (countsTowardResidualCriticalHigh(e)) { + residualCriticalHigh += 1; + } if (e.suppressed) { // A dismissing decision: not_affected is its own fact, the rest are "accepted". if (e.triage_status === 'not_affected') notAffected += 1; @@ -785,10 +863,41 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v continue; } // Not dismissed (no decision, needs_review, or affected): still actionable. - if (e.fixed_version) fixableCriticalHigh += 1; + if (e.fixed_version) { + fixableCriticalHigh += 1; + remediationByImage.set(imageRef, (remediationByImage.get(imageRef) ?? 0) + 1); + const vids = packageFixByImage.get(imageRef); + if (vids) vids.push(e.vulnerability_id); + else packageFixByImage.set(imageRef, [e.vulnerability_id]); + } } } + const remediationFindings: RemediationFindingInput[] = []; + for (const [image_ref, count] of remediationByImage) { + remediationFindings.push({ image_ref, count }); + } + const imageUpdateSvc = ImageUpdateService.getInstance(); + const remediation = classifyImageRemediation({ + findings: remediationFindings, + details: db.getStackUpdateDetail(req.nodeId), + checksEnabled: ImageUpdateService.isChecksEnabled(), + freshnessWindowMs: imageUpdateSvc.getRemediationFreshnessWindowMs(), + now: Date.now(), + }); + const updateAvailableDrivers = collectPackageFixDrivers( + packageFixByImage, + remediation.imageRefsUpdateAvailable, + ); + const waitingUpstreamDrivers = collectPackageFixDrivers( + packageFixByImage, + remediation.imageRefsWaitingUpstream, + ); + const updateUnknownDrivers = collectPackageFixDrivers( + packageFixByImage, + remediation.imageRefsUpdateUnknown, + ); + // A known-exploited (KEV) finding gates a deploy at ANY severity, so the // posture fact counts non-suppressed KEV findings across all severities, not // just Critical/High. Sourced from its own latest-scan query so a Low/Medium @@ -801,11 +910,19 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v else kevByImage.set(f.image_ref, [f]); } let knownExploited = 0; + const knownExploitedTargets: string[] = []; + const kevDriverRows: Array<{ imageRef: string; vulnerability_id: string }> = []; for (const [imageRef, group] of kevByImage) { + let imageHasKev = false; for (const e of applySuppressions(group, imageRef, cveSuppressions)) { - if (!e.suppressed) knownExploited += 1; + if (e.suppressed) continue; + knownExploited += 1; + imageHasKev = true; + kevDriverRows.push({ imageRef, vulnerability_id: e.vulnerability_id }); } + if (imageHasKev) knownExploitedTargets.push(imageRef); } + const knownExploitedDrivers = collectKevDrivers(kevDriverRows); const acks = db.getMisconfigAcknowledgements(); const highMisconfigs = db.getLatestHighMisconfigFindingsForNode(req.nodeId); @@ -822,61 +939,114 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v } } - // Count distinct images that are publicly exposed AND have at least one - // non-suppressed Critical/High finding. The exposure descriptor is cached - // at deploy/update time, so this is O(stacks) + O(images), zero subprocess. + // Network-exposed image classification from cached Compose descriptors. + // Intentional exposure is risk context, never an independent Action-needed + // blocker. Package fixed_version alone cannot manufacture exposed blockers. const exposures = db.getStackExposures(req.nodeId); - const exposedMap = buildExposedImageMap( - exposures.map((r) => { - try { return JSON.parse(r.descriptor); } catch { return null; } - }).filter(Boolean), - ); - let publiclyExposed = 0; - let exposedBlocker = 0; - let exposedReview = 0; + const parsedExposures: StackExposure[] = exposures.map((r) => { + try { return JSON.parse(r.descriptor) as StackExposure; } catch { return null; } + }).filter((e): e is StackExposure => e !== null); + const exposedMap = buildExposedImageMap(parsedExposures); + + const unsuppressedByImage = new Map>(); + const exposedImageRefs = new Set(); for (const [imageRef, group] of critHighByImage) { if (exposedMap.get(imageRef) !== true) continue; - publiclyExposed += 1; - let hasUnsuppressedFinding = false; - let hasKevOrFixOrHighEpss = false; + exposedImageRefs.add(imageRef); + const kept: Array<{ vulnerability_id: string }> = []; for (const e of applySuppressions(group, imageRef, cveSuppressions)) { - if (e.suppressed) continue; - hasUnsuppressedFinding = true; - if ( - e.fixed_version - || intel.get(e.vulnerability_id)?.kev - || (intel.get(e.vulnerability_id)?.epssScore ?? 0) >= HIGH_EPSS_THRESHOLD - ) { - hasKevOrFixOrHighEpss = true; - break; - } + if (!e.suppressed) kept.push({ vulnerability_id: e.vulnerability_id }); } - if (!hasUnsuppressedFinding) continue; // fully dismissed - if (hasKevOrFixOrHighEpss) exposedBlocker += 1; - else exposedReview += 1; + unsuppressedByImage.set(imageRef, kept); } + const allExposedTargets = buildSecurityExposureTargets({ + nodeId: req.nodeId, + exposures: parsedExposures, + qualifyingImageRefs: exposedImageRefs, + }); + const targetsByImage = new Map(); + for (const t of allExposedTargets) { + const group = targetsByImage.get(t.imageRef); + if (group) group.push(t); + else targetsByImage.set(t.imageRef, [t]); + } + + const { + publiclyExposed, + exposureIntentConflict, + exposureIntentConflictTargets, + exposedUnclassified, + exposedUnclassifiedTargets, + elevatedExploitRisk, + elevatedExploitRiskTargets, + elevatedExploitRiskDrivers, + elevatedExploitRiskDriverCount, + elevatedExploitRiskDriversTruncated, + } = classifyExposedImages({ + critHighByImage, + exposedMap, + targetsByImage, + unsuppressedByImage, + intel, + }); + const failedScans = db.countScansByStatus(req.nodeId, 'failed'); const postureFacts: SecurityPostureFacts = { scannerAvailable: svc.isTrivyAvailable(), hasCompletedScan: lastSuccessfulScanAt !== null, fixableCriticalHigh, + fixableWithImageUpdate: remediation.fixableWithImageUpdate, + fixableWaitingUpstream: remediation.fixableWaitingUpstream, + fixableUpdateUnknown: remediation.fixableUpdateUnknown, + updateChecksDisabled: remediation.updateChecksDisabled, secrets, dangerousCompose, knownExploited, publiclyExposed, - exposedBlocker, - exposedReview, + exposureIntentConflict, + exposedUnclassified, + elevatedExploitRisk, rawCritical: critical, rawHigh: high, + residualCriticalHigh, staleScans, failedScans, needsReview, + fixableWithImageUpdateTargets: remediation.imageRefsUpdateAvailable, + fixableWaitingUpstreamTargets: remediation.imageRefsWaitingUpstream, + fixableUpdateUnknownTargets: remediation.imageRefsUpdateUnknown, + knownExploitedTargets, + knownExploitedDrivers: knownExploitedDrivers.drivers, + knownExploitedDriverCount: knownExploitedDrivers.driverCount, + knownExploitedDriversTruncated: knownExploitedDrivers.driversTruncated, + exposureIntentConflictTargets, + exposedUnclassifiedTargets, + elevatedExploitRiskTargets, + elevatedExploitRiskDrivers, + elevatedExploitRiskDriverCount, + elevatedExploitRiskDriversTruncated, + fixableWithImageUpdateDrivers: updateAvailableDrivers.drivers, + fixableWithImageUpdateDriverCount: updateAvailableDrivers.driverCount, + fixableWithImageUpdateDriversTruncated: updateAvailableDrivers.driversTruncated, + fixableWaitingUpstreamDrivers: waitingUpstreamDrivers.drivers, + fixableWaitingUpstreamDriverCount: waitingUpstreamDrivers.driverCount, + fixableWaitingUpstreamDriversTruncated: waitingUpstreamDrivers.driversTruncated, + fixableUpdateUnknownDrivers: updateUnknownDrivers.drivers, + fixableUpdateUnknownDriverCount: updateUnknownDrivers.driverCount, + fixableUpdateUnknownDriversTruncated: updateUnknownDrivers.driversTruncated, }; const posture = deriveSecurityPosture(postureFacts); - const { reasons: postureReasons, primaryAction } = derivePostureReasons(postureFacts); - const actionable = fixableCriticalHigh + secrets + dangerousCompose + knownExploited + publiclyExposed; + const { reasons: postureReasons, primaryAction, targetsTruncated } = derivePostureReasons(postureFacts); + // Legacy mixed-unit sum of blocker counts (not a distinct product metric). + // Prefer posture / postureReasons. Intentional exposure alone is excluded. + const actionable = remediation.fixableWithImageUpdate + + secrets + + dangerousCompose + + knownExploited + + elevatedExploitRisk + + exposureIntentConflict; const overview: SecurityOverviewResponse = { scannedImages, @@ -913,9 +1083,10 @@ securityRouter.get('/overview', authMiddleware, (req: Request, res: Response): v notAffected, actionable, posture, - posturePartial: critHigh.truncated || kevFindings.truncated || highMisconfigs.truncated, + posturePartial: critHigh.truncated || kevFindings.truncated || highMisconfigs.truncated || targetsTruncated, postureReasons, primaryAction, + updateChecksDisabled: remediation.updateChecksDisabled, }; res.json(overview); } catch (error) { diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 9dd76eed..0c2998bb 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -5,6 +5,7 @@ import { CryptoService } from './CryptoService'; import { isSeverityAtLeast } from '../utils/severity'; import { evaluatePolicyRisk, policyInputs, type PolicyBlockReason } from '../utils/policy-risk'; import { applySuppressions } from '../utils/suppression-filter'; +import { SENCHO_ROLLBACK_HOLD_SQL_LIKE } from '../utils/senchoRollbackHold'; import type { AuditStatsInput } from './AuditAnomalyService'; import { EXPOSURE_INTENTS, type ExposureIntent } from './network/types'; import { HIGH_EPSS_THRESHOLD } from './securityPosture'; @@ -7101,7 +7102,8 @@ export class DatabaseService { ) lv ON lv.image_ref = v.image_ref AND lv.max_scanned = v.scanned_at WHERE v.node_id = ? AND v.status = 'completed' AND v.scanners_used IN (${placeholders}) ) vuln ON vuln.image_ref = base.image_ref - WHERE base.node_id = ? AND base.status = 'completed'`, + WHERE base.node_id = ? AND base.status = 'completed' + AND base.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}'`, ) .all(nodeId, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId) as Array<{ image_ref: string; @@ -7165,9 +7167,11 @@ export class DatabaseService { SELECT image_ref, MAX(scanned_at) AS max_scanned FROM vulnerability_scans WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders}) + AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}' GROUP BY image_ref ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders}) + AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}' AND vd.severity IN ('CRITICAL', 'HIGH') LIMIT ?`, ) @@ -7207,9 +7211,11 @@ export class DatabaseService { SELECT image_ref, MAX(scanned_at) AS max_scanned FROM vulnerability_scans WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders}) + AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}' GROUP BY image_ref ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders}) + AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}' LIMIT ?`, ) .all(nodeId, ...VULN_BEARING_SCANNER_SETS, nodeId, ...VULN_BEARING_SCANNER_SETS, limit + 1) as Array<{ @@ -7288,9 +7294,11 @@ export class DatabaseService { SELECT image_ref, MAX(scanned_at) AS max_scanned FROM vulnerability_scans WHERE node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders}) + AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}' GROUP BY image_ref ) latest ON latest.image_ref = vs.image_ref AND latest.max_scanned = vs.scanned_at WHERE vs.node_id = ? AND vs.status = 'completed' AND vs.scanners_used IN (${placeholders}) + AND vs.image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}' AND (vd.severity IN ('CRITICAL', 'HIGH') OR ci.kev = 1) -- Rank by the same exploitation-risk tiers the overview list -- displays (SecurityCharts exploitTier, "assume it's automatable"): @@ -7450,6 +7458,7 @@ export class DatabaseService { ) AS rn FROM vulnerability_scans WHERE node_id = ? AND status = 'completed' AND scanned_at >= ? + AND image_ref NOT LIKE '${SENCHO_ROLLBACK_HOLD_SQL_LIKE}' ) SELECT day, SUM(critical_count) AS critical, diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index 28117a2d..7dfbabec 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -30,6 +30,7 @@ import { getErrorMessage } from '../utils/errors'; import { sanitizeForLog } from '../utils/safeLog'; import { describeSpawnError } from '../utils/spawnErrors'; import { authoredComposeFileArgs, authoredComposeEnvFileArgs } from '../utils/authoredComposeArgs'; +import { isFullySyntheticHoldImage } from '../utils/senchoRollbackHold'; import { isValidDockerNetworkName } from './network/dockerNetworkName'; export type { @@ -619,13 +620,9 @@ class DockerController { return undefined; }; - // Only hide an image from the generic inventory when every visible tag is - // a synthetic sencho-rb hold tag; an image that also carries a normal - // registry tag stays visible here (with the badge below) so the generic - // inventory stays complete. Its generation still surfaces in the Rollback tab. - const isFullySyntheticHoldImage = (repoTags: string[]): boolean => - repoTags.length > 0 && repoTags.every((tag) => tag.startsWith('sencho-rb/')); - + // Hide hold-only images from generic inventory. Dual-tagged images (registry + // tag + hold) stay visible here with the badge below; generations still + // surface in the Rollback tab. const images: ClassifiedImage[] = this.validateApiData(rawImages) .map((img: any) => { const usedByStacks = [...(imageToStacks.get(img.Id) ?? [])].sort((a, b) => a.localeCompare(b)); diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index c3f66bd0..d352512c 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -778,6 +778,44 @@ export class ImageUpdateService { }; } + private static readonly MIN_REMEDIATION_FRESHNESS_MS = 30 * 60 * 1000; + private static readonly MAX_REMEDIATION_FRESHNESS_MS = 48 * 60 * 60 * 1000; + private static readonly CRON_CADENCE_FALLBACK_MS = 24 * 60 * 60 * 1000; + + /** + * Freshness window for Security remediation classification: 2× the + * expected check cadence. Interval mode uses the live intervalMs. Cron + * mode uses the gap between two successive fire times (not the unused + * interval setting). Missing/unparseable cron falls back to 24h. Clamped + * to [30m, 48h]. + */ + public getRemediationFreshnessWindowMs(): number { + let cadenceMs = this.intervalMs; + if (this.mode === 'cron') { + // Default to 24h; override only when two successive fire times yield a positive gap. + cadenceMs = ImageUpdateService.CRON_CADENCE_FALLBACK_MS; + if (this.cronExpression) { + try { + const expr = CronExpressionParser.parse(this.cronExpression); + const first = expr.next().toDate().getTime(); + const second = expr.next().toDate().getTime(); + const gap = second - first; + if (gap > 0) cadenceMs = gap; + } catch (e) { + console.warn( + '[ImageUpdateService] Could not derive cron cadence for remediation freshness; using 24h fallback:', + getErrorMessage(e, String(e)), + ); + } + } + } + const windowMs = 2 * cadenceMs; + return Math.min( + ImageUpdateService.MAX_REMEDIATION_FRESHNESS_MS, + Math.max(ImageUpdateService.MIN_REMEDIATION_FRESHNESS_MS, windowMs), + ); + } + // ─── Core check ────────────────────────────────────────────────────────── private async check() { @@ -1230,11 +1268,14 @@ export class ImageUpdateService { * - If memory generation advanced after observation, abort (stale). * - If memory generation still equals the observation watermark, advance * (tombstone) so an equal-generation writer reserved before observation - * cannot commit after the clear (SF-4). + * cannot commit after the clear. * - If the persisted row generation advanced after observation, keep the row. - * - Otherwise delete partial, failed, and confirmed ok+true rows. + * - If the row is already ok with no update, keep it. Deleting that row + * would make Security treat a confirmed no-update as unknown. + * - Otherwise delete partial, failed, and ok+true rows. * - * Returns cleared | stale | absent. + * Returns cleared (row deleted), stale (memory generation raced), or + * absent (no row, generation advanced, or the row is already ok+false). */ public async commitPreviewClear( nodeId: number, @@ -1270,6 +1311,7 @@ export class ImageUpdateService { // pre-preview snapshot). Memory peek resets on restart; SQLite does not. const rowGeneration = db.getStackUpdateWriteGeneration(nodeId, stackName); if (rowGeneration > observedRowGeneration) return; + if (detail.checkStatus === 'ok' && !detail.hasUpdate) return; deleted = db.clearStackUpdateStatus(nodeId, stackName); }); if (!committed) return 'stale'; diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index 3d549893..12051e3a 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -20,6 +20,7 @@ import { FleetSyncService } from './FleetSyncService'; import { getErrorMessage } from '../utils/errors'; import { isDebugEnabled } from '../utils/debug'; import { SEVERITY_ORDER } from '../utils/severity'; +import { isSenchoRollbackHoldRef } from '../utils/senchoRollbackHold'; import type { PolicyBlockReason } from '../utils/policy-risk'; const execFileAsync = promisify(execFile); @@ -621,6 +622,9 @@ class TrivyService { scanners?: readonly TrivyScanner[]; } = {}, ): Promise { + if (isSenchoRollbackHoldRef(imageRef)) { + throw new Error('Sencho rollback-hold images are recovery state and cannot be scanned'); + } const binary = this.binaryPath; if (!binary) { throw new Error('Trivy is not available on this host'); @@ -1171,7 +1175,9 @@ class TrivyService { const imageRefs = new Set(); for (const img of images as Array<{ RepoTags?: string[] }>) { for (const tag of img.RepoTags ?? []) { - if (tag && tag !== ':') imageRefs.add(tag); + // Skip hold tags; dual-tagged images are scanned via the registry tag. + if (!tag || tag === ':' || isSenchoRollbackHoldRef(tag)) continue; + imageRefs.add(tag); } } const refs = Array.from(imageRefs); diff --git a/backend/src/services/securityExposureClassification.ts b/backend/src/services/securityExposureClassification.ts new file mode 100644 index 00000000..456dfa42 --- /dev/null +++ b/backend/src/services/securityExposureClassification.ts @@ -0,0 +1,206 @@ +/** + * Classifies network-exposed Crit/High images into posture buckets. + * + * Exposure fact (Compose beyond loopback) is separate from exposure correctness + * (intent match) and from Security consequence (KEV, elevated EPSS, confirmed + * image update). Intentional exposure never independently manufactures an + * Action-needed exposure blocker. Package fixed_version alone never recreates + * that blocker through this path. + */ +import { HIGH_EPSS_THRESHOLD, type PostureDriverFinding, type PostureTarget } from './securityPosture'; + +/** Bounded driver findings attached to vulnerability-derived posture reasons. */ +export const POSTURE_DRIVER_CAP = 50; + +export interface CappedDrivers { + drivers: PostureDriverFinding[]; + /** Full contributing count before the display cap. */ + driverCount: number; + driversTruncated: boolean; +} + +export function capDriverFindings(drivers: PostureDriverFinding[]): CappedDrivers { + return { + drivers: drivers.slice(0, POSTURE_DRIVER_CAP), + driverCount: drivers.length, + driversTruncated: drivers.length > POSTURE_DRIVER_CAP, + }; +} + +export type CveIntelLookup = Map; + +export interface ExposedFindingRow { + vulnerability_id: string; + /** Present when the finding survived suppression filtering as actionable. */ + suppressed?: boolean; +} + +export interface ClassifyExposedImagesInput { + /** Crit/High findings keyed by image_ref (raw, before suppression). */ + critHighByImage: Map; + /** image_ref → true when Compose declares beyond-loopback / host-network. */ + exposedMap: Map; + /** Per-image exposure targets already enriched with Networking intent. */ + targetsByImage: Map; + /** Unsuppressed findings per image (caller applies applySuppressions). */ + unsuppressedByImage: Map; + intel: CveIntelLookup; +} + +export interface ClassifyExposedImagesResult { + /** Distinct exposed images in the Crit/High index (incl. fully suppressed). */ + publiclyExposed: number; + /** Intent mismatch (internal/same-node while exposed) with unsuppressed Crit/High. */ + exposureIntentConflict: number; + exposureIntentConflictTargets: PostureTarget[]; + /** Intent unset/unavailable (not intentional) with unsuppressed Crit/High. Review only. */ + exposedUnclassified: number; + exposedUnclassifiedTargets: PostureTarget[]; + /** + * Network-exposed images with unsuppressed elevated-EPSS Crit/High. + * Independent of intentional vs unclassified; never uses fixed_version alone. + */ + elevatedExploitRisk: number; + elevatedExploitRiskTargets: PostureTarget[]; + elevatedExploitRiskDrivers: PostureDriverFinding[]; + elevatedExploitRiskDriverCount: number; + elevatedExploitRiskDriversTruncated: boolean; +} + +function isIntentionalTarget(t: PostureTarget): boolean { + return ( + t.intentStatus === 'set' + && !t.intentConflict + && (t.exposureIntent === 'public' + || t.exposureIntent === 'lan' + || t.exposureIntent === 'reverse-proxy' + || t.exposureIntent === 'temporary') + ); +} + +/** + * Bucket an image's exposure contexts. + * Conflict wins. Any unset (or only-unavailable / non-intentional) is unclassified. + * All complete contexts intentional → intentional. + */ +export function classifyImageExposureBucket( + targets: PostureTarget[], +): 'conflict' | 'intentional' | 'unclassified' { + if (targets.some((t) => t.intentConflict)) return 'conflict'; + if (targets.length === 0) return 'unclassified'; + if (targets.some((t) => t.intentStatus === 'unset')) return 'unclassified'; + + const complete = targets.filter((t) => t.intentStatus !== 'unavailable'); + if (complete.length === 0) return 'unclassified'; + if (complete.every(isIntentionalTarget)) return 'intentional'; + return 'unclassified'; +} + +function targetKey(t: PostureTarget): string { + return `${t.imageRef}\0${t.stackName ?? ''}\0${t.serviceName ?? ''}`; +} + +function pushUniqueTargets(into: PostureTarget[], rows: PostureTarget[]): void { + const seen = new Set(into.map(targetKey)); + for (const t of rows) { + const key = targetKey(t); + if (seen.has(key)) continue; + seen.add(key); + into.push(t); + } +} + +/** + * Split exposed Crit/High images into conflict / unclassified review / elevated EPSS. + * Intentional exposure contributes only via elevated EPSS (or other independent + * drivers computed outside this function: KEV, confirmed image update). + */ +export function classifyExposedImages(input: ClassifyExposedImagesInput): ClassifyExposedImagesResult { + const { + critHighByImage, + exposedMap, + targetsByImage, + unsuppressedByImage, + intel, + } = input; + + let publiclyExposed = 0; + const conflictTargets: PostureTarget[] = []; + const unclassifiedTargets: PostureTarget[] = []; + const elevatedTargets: PostureTarget[] = []; + const elevatedDrivers: PostureDriverFinding[] = []; + const conflictImages = new Set(); + const unclassifiedImages = new Set(); + const elevatedImages = new Set(); + + for (const imageRef of critHighByImage.keys()) { + if (exposedMap.get(imageRef) !== true) continue; + publiclyExposed += 1; + + const unsuppressed = unsuppressedByImage.get(imageRef) ?? []; + if (unsuppressed.length === 0) continue; + + const targets = targetsByImage.get(imageRef) ?? [{ imageRef }]; + const bucket = classifyImageExposureBucket(targets); + + if (bucket === 'conflict') { + conflictImages.add(imageRef); + pushUniqueTargets(conflictTargets, targets); + } else if (bucket === 'unclassified') { + unclassifiedImages.add(imageRef); + pushUniqueTargets(unclassifiedTargets, targets); + } + + let hasHighEpss = false; + for (const e of unsuppressed) { + const epss = intel.get(e.vulnerability_id)?.epssScore ?? 0; + if (epss < HIGH_EPSS_THRESHOLD) continue; + hasHighEpss = true; + elevatedDrivers.push({ vulnerabilityId: e.vulnerability_id, imageRef }); + } + if (hasHighEpss) { + elevatedImages.add(imageRef); + pushUniqueTargets(elevatedTargets, targets); + } + } + + const elevated = capDriverFindings(elevatedDrivers); + return { + publiclyExposed, + exposureIntentConflict: conflictImages.size, + exposureIntentConflictTargets: conflictTargets, + exposedUnclassified: unclassifiedImages.size, + exposedUnclassifiedTargets: unclassifiedTargets, + elevatedExploitRisk: elevatedImages.size, + elevatedExploitRiskTargets: elevatedTargets, + elevatedExploitRiskDrivers: elevated.drivers, + elevatedExploitRiskDriverCount: elevated.driverCount, + elevatedExploitRiskDriversTruncated: elevated.driversTruncated, + }; +} + +/** Cap unsuppressed KEV driver rows for posture attachment. */ +export function collectKevDrivers( + drivers: Array<{ imageRef: string; vulnerability_id: string; suppressed?: boolean }>, +): CappedDrivers { + const out: PostureDriverFinding[] = []; + for (const e of drivers) { + if (e.suppressed) continue; + out.push({ vulnerabilityId: e.vulnerability_id, imageRef: e.imageRef }); + } + return capDriverFindings(out); +} + +/** Package-fix finding drivers for the given image refs (order preserved per image list). */ +export function collectPackageFixDrivers( + packageFixByImage: Map, + imageRefs: string[], +): CappedDrivers { + const out: PostureDriverFinding[] = []; + for (const imageRef of imageRefs) { + for (const vulnerabilityId of packageFixByImage.get(imageRef) ?? []) { + out.push({ vulnerabilityId, imageRef }); + } + } + return capDriverFindings(out); +} diff --git a/backend/src/services/securityExposureTargets.ts b/backend/src/services/securityExposureTargets.ts new file mode 100644 index 00000000..cdcaa2fb --- /dev/null +++ b/backend/src/services/securityExposureTargets.ts @@ -0,0 +1,316 @@ +/** + * Shared Security exposure-context builder: cached StackExposure descriptors + * plus Networking getExposureContext. Used by overview posture targets and + * standing /image-summaries enrichment. + * + * Unavailable context is distinct from unset intent. Absolute all-intentional + * claims require every context to be complete; unavailable never becomes certainty. + */ +import { getExposureContext } from './network/exposureContext'; +import type { ExposureIntent } from './network/types'; +import type { StackExposure } from './preflight/exposure'; +import type { PostureTarget } from './securityPosture'; + +/** Max contexts returned per image on /image-summaries (display list only). */ +export const IMAGE_EXPOSURE_CONTEXT_CAP = 20; + +/** Intents that mean the operator deliberately classified exposure. */ +const INTENTIONAL: ReadonlySet = new Set([ + 'public', 'lan', 'reverse-proxy', 'temporary', +]); + +const CONFLICT: ReadonlySet = new Set(['internal', 'same-node']); + +/** Per stack/service exposure + intent (image identity lives on the parent). */ +export interface ImageExposureContext { + stackName: string; + serviceName: string; + exposureReason: 'published-port' | 'host-network' | null; + exposureIntent?: ExposureIntent; + intentStatus: 'set' | 'unset' | 'unavailable'; + intentConflict?: boolean; +} + +export interface ImageExposureContextSummary { + hasConflict: boolean; + hasUnclassified: boolean; + hasUnavailable: boolean; + /** Every complete (set) context is intentional and there is no unset among available. */ + allKnownIntentional: boolean; +} + +export interface PackagedImageExposureContexts { + exposure_contexts: ImageExposureContext[]; + exposure_context_count: number; + exposure_contexts_truncated: boolean; + exposure_context_summary: ImageExposureContextSummary; +} + +export interface BuildImageExposureContextsInput { + nodeId: number; + exposures: StackExposure[]; + /** When set, only services whose image is in the set are emitted. */ + qualifyingImageRefs?: Set; + getContext?: typeof getExposureContext; +} + +/** Row with imageRef for grouping into per-image packages / posture targets. */ +export interface ImageExposureContextRow extends ImageExposureContext { + imageRef: string; +} + +function effectiveIntent( + service: string, + stackIntent: ExposureIntent | null, + serviceIntents: Record, +): ExposureIntent | null { + return serviceIntents[service] ?? stackIntent ?? null; +} + +function rowKey(imageRef: string, stackName: string, serviceName: string): string { + return `${imageRef}\0${stackName}\0${serviceName}`; +} + +function isIntentionalSet(c: Pick): boolean { + return ( + c.intentStatus === 'set' + && !c.intentConflict + && !!c.exposureIntent + && INTENTIONAL.has(c.exposureIntent) + ); +} + +/** Sort: conflict, unset, unavailable, then intentional/set. */ +export function sortExposureContexts(contexts: T[]): T[] { + return [...contexts].sort((a, b) => exposureContextRank(a) - exposureContextRank(b)); +} + +export function exposureContextRank(c: ImageExposureContext): number { + if (c.intentConflict) return 0; + if (c.intentStatus === 'unset') return 1; + if (c.intentStatus === 'unavailable') return 2; + return 3; +} + +export function summarizeExposureContexts( + contexts: ImageExposureContext[], +): ImageExposureContextSummary { + let hasConflict = false; + let hasUnclassified = false; + let hasUnavailable = false; + let sawComplete = false; + let allKnownIntentional = true; + + for (const c of contexts) { + if (c.intentConflict) hasConflict = true; + if (c.intentStatus === 'unset') hasUnclassified = true; + if (c.intentStatus === 'unavailable') { + hasUnavailable = true; + continue; + } + sawComplete = true; + if (!isIntentionalSet(c)) allKnownIntentional = false; + } + + if (!sawComplete) allKnownIntentional = false; + + return { hasConflict, hasUnclassified, hasUnavailable, allKnownIntentional }; +} + +/** + * Absolute intentional: every context is set, non-conflicting, intentional. + * Unavailable or unset anywhere yields false. Empty yields false. + */ +export function allContextsAbsolutelyIntentional( + contexts: ImageExposureContext[] | undefined, + truncated = false, +): boolean { + if (truncated || !contexts || contexts.length === 0) return false; + return contexts.every(isIntentionalSet); +} + +/** + * Partial intentional: at least one unavailable, no conflict/unset among + * available contexts, and every available set context is intentional. + */ +export function partialIntentionalWithUnavailable( + contexts: ImageExposureContext[] | undefined, + truncated = false, +): { partial: boolean; unavailableCount: number } { + if (truncated || !contexts || contexts.length === 0) { + return { partial: false, unavailableCount: 0 }; + } + let unavailableCount = 0; + let sawAvailable = false; + for (const c of contexts) { + if (c.intentStatus === 'unavailable') { + unavailableCount += 1; + continue; + } + sawAvailable = true; + if (!isIntentionalSet(c)) { + return { partial: false, unavailableCount }; + } + } + return { + partial: unavailableCount > 0 && sawAvailable, + unavailableCount, + }; +} + +export function packageImageExposureContexts( + contexts: ImageExposureContext[], + cap = IMAGE_EXPOSURE_CONTEXT_CAP, +): PackagedImageExposureContexts { + const summary = summarizeExposureContexts(contexts); + const sorted = sortExposureContexts(contexts); + const truncated = sorted.length > cap; + return { + exposure_contexts: sorted.slice(0, cap), + exposure_context_count: contexts.length, + exposure_contexts_truncated: truncated, + exposure_context_summary: summary, + }; +} + +/** + * Emit one context row per exposed service (optionally filtered by image set). + * Batches getExposureContext once per distinct stack. + */ +export function buildImageExposureContextRows( + input: BuildImageExposureContextsInput, +): ImageExposureContextRow[] { + const { nodeId, exposures, qualifyingImageRefs } = input; + const getContext = input.getContext ?? getExposureContext; + + if (qualifyingImageRefs && qualifyingImageRefs.size === 0) return []; + + const contextByStack = new Map>(); + const seen = new Set(); + const rows: ImageExposureContextRow[] = []; + + for (const exp of exposures) { + for (const svc of exp.services) { + if (!svc.image || !svc.publiclyExposed) continue; + if (qualifyingImageRefs && !qualifyingImageRefs.has(svc.image)) continue; + + let ctx = contextByStack.get(exp.stack); + if (ctx === undefined) { + ctx = getContext(nodeId, exp.stack); + contextByStack.set(exp.stack, ctx); + } + + const row: ImageExposureContextRow = { + imageRef: svc.image, + stackName: exp.stack, + serviceName: svc.service, + exposureReason: svc.reason, + intentStatus: 'unavailable', + }; + + if (ctx.available) { + const intent = effectiveIntent(svc.service, ctx.stackIntent, ctx.serviceIntents); + if (intent === null || intent === 'unknown') { + row.intentStatus = 'unset'; + } else { + row.intentStatus = 'set'; + row.exposureIntent = intent; + if (CONFLICT.has(intent)) row.intentConflict = true; + } + } + + const key = rowKey(row.imageRef, row.stackName, row.serviceName); + if (seen.has(key)) continue; + seen.add(key); + rows.push(row); + } + } + + return rows; +} + +/** Group rows by imageRef and package each for /image-summaries. */ +export function packageExposureContextsByImage( + rows: ImageExposureContextRow[], + cap = IMAGE_EXPOSURE_CONTEXT_CAP, +): Map { + const byImage = new Map(); + for (const row of rows) { + const { imageRef, ...ctx } = row; + const list = byImage.get(imageRef) ?? []; + list.push(ctx); + byImage.set(imageRef, list); + } + const out = new Map(); + for (const [imageRef, contexts] of byImage) { + out.set(imageRef, packageImageExposureContexts(contexts, cap)); + } + return out; +} + +export function toPostureTarget(row: ImageExposureContextRow): PostureTarget { + const target: PostureTarget = { + imageRef: row.imageRef, + stackName: row.stackName, + serviceName: row.serviceName, + exposureReason: row.exposureReason, + intentStatus: row.intentStatus, + }; + if (row.exposureIntent) target.exposureIntent = row.exposureIntent; + if (row.intentConflict) target.intentConflict = true; + return target; +} + +/** + * Emit PostureTarget rows for qualifying images (overview). + * Batches getExposureContext once per distinct stack. + */ +export function buildSecurityExposureTargets( + input: BuildImageExposureContextsInput & { qualifyingImageRefs: Set }, +): PostureTarget[] { + return buildImageExposureContextRows(input).map(toPostureTarget); +} + +function contextsFromTargets(targets: PostureTarget[] | undefined): ImageExposureContext[] { + if (!targets) return []; + const out: ImageExposureContext[] = []; + for (const t of targets) { + if (!t.stackName || !t.serviceName || !t.intentStatus) continue; + out.push({ + stackName: t.stackName, + serviceName: t.serviceName, + exposureReason: t.exposureReason ?? null, + exposureIntent: t.exposureIntent, + intentStatus: t.intentStatus, + intentConflict: t.intentConflict, + }); + } + return out; +} + +/** Absolute intentional for posture targets (unavailable yields false). */ +export function allTargetsIntentionallyClassified( + targets: PostureTarget[] | undefined, + truncated = false, +): boolean { + return allContextsAbsolutelyIntentional(contextsFromTargets(targets), truncated); +} + +export function anyTargetIntentConflict(targets: PostureTarget[] | undefined): boolean { + return targets?.some((t) => t.intentConflict) ?? false; +} + +export function anyTargetIntentUnset(targets: PostureTarget[] | undefined): boolean { + return targets?.some((t) => t.intentStatus === 'unset') ?? false; +} + +export function anyTargetIntentUnavailable(targets: PostureTarget[] | undefined): boolean { + return targets?.some((t) => t.intentStatus === 'unavailable') ?? false; +} + +export function partialTargetsIntentionalWithUnavailable( + targets: PostureTarget[] | undefined, + truncated = false, +): { partial: boolean; unavailableCount: number } { + return partialIntentionalWithUnavailable(contextsFromTargets(targets), truncated); +} diff --git a/backend/src/services/securityImageRemediation.ts b/backend/src/services/securityImageRemediation.ts new file mode 100644 index 00000000..bab49ebb --- /dev/null +++ b/backend/src/services/securityImageRemediation.ts @@ -0,0 +1,188 @@ +/** + * Join Security package-fix findings to persisted image-update evidence. + * + * Trivy `fixed_version` proves a patched package exists. It does not prove an + * applicable container image update exists. This module consumes the canonical + * ImageUpdateService rows only (no registry I/O) and classifies each finding + * image as: confirmed update available, authoritative waiting-for-upstream, or + * uncertain remediation availability. + * + * Classification may join via normalizeImageRef, but imageRefs* arrays always + * carry the raw finding image_ref so Images-tab targeting matches scan summaries. + */ +import { normalizeImageRef } from './DriftDetectionService'; +import type { StackServiceStatus, StackUpdateDetail } from './DatabaseService'; + +export type ImageRemediationClass = 'update_available' | 'waiting_upstream' | 'uncertain'; + +export interface RemediationFindingInput { + image_ref: string; + /** Count of unsuppressed Crit/High findings with fixed_version on this image. */ + count: number; +} + +export interface ImageRemediationFacts { + fixableWithImageUpdate: number; + fixableWaitingUpstream: number; + fixableUpdateUnknown: number; + /** True when checks are disabled and any fixable package findings exist. */ + updateChecksDisabled: boolean; + /** Distinct raw finding image_refs in each class (never normalizeImageRef'd). */ + imageRefsUpdateAvailable: string[]; + imageRefsWaitingUpstream: string[]; + imageRefsUpdateUnknown: string[]; +} + +export interface ClassifyImageRemediationInput { + findings: RemediationFindingInput[]; + details: Record; + checksEnabled: boolean; + freshnessWindowMs: number; + now: number; +} + +interface IndexedService { + stackName: string; + service: StackServiceStatus; + checkedAt: number; +} + +function collectServiceRefs(service: StackServiceStatus): string[] { + const refs: string[] = []; + if (service.image) refs.push(service.image); + for (const runtime of service.runtimeImages ?? []) { + if (runtime) refs.push(runtime); + } + return refs; +} + +/** Build normalizeImageRef → services index from persisted stack update detail. */ +export function buildUpdateServiceIndex( + details: Record, +): Map { + const index = new Map(); + for (const [stackName, detail] of Object.entries(details)) { + for (const service of detail.services ?? []) { + for (const ref of collectServiceRefs(service)) { + const key = normalizeImageRef(ref); + if (!key) continue; + const entry: IndexedService = { stackName, service, checkedAt: detail.checkedAt }; + const list = index.get(key); + if (list) list.push(entry); + else index.set(key, [entry]); + } + } + } + return index; +} + +function isStackFresh(checkedAt: number, now: number, freshnessWindowMs: number): boolean { + if (!Number.isFinite(checkedAt) || checkedAt <= 0) return false; + return now - checkedAt <= freshnessWindowMs; +} + +function classifyMatches( + matches: IndexedService[], + freshnessWindowMs: number, + now: number, +): ImageRemediationClass { + if (matches.length === 0) return 'uncertain'; + + let sawCheckable = false; + let anyConfirmedUpdate = false; + let anyUncertain = false; + let anyAuthoritativeNegative = false; + + for (const { service, checkedAt } of matches) { + if (service.checkStatus === 'not_checkable') continue; + sawCheckable = true; + if (service.checkStatus !== 'ok' || !isStackFresh(checkedAt, now, freshnessWindowMs)) { + anyUncertain = true; + continue; + } + if (service.hasUpdate) anyConfirmedUpdate = true; + else anyAuthoritativeNegative = true; + } + + // Confirmed update on any stack wins over sibling partial/stale uncertainty. + if (anyConfirmedUpdate) return 'update_available'; + if (anyUncertain || !sawCheckable) return 'uncertain'; + if (anyAuthoritativeNegative) return 'waiting_upstream'; + return 'uncertain'; +} + +function emptyRemediationFacts( + overrides: Partial = {}, +): ImageRemediationFacts { + return { + fixableWithImageUpdate: 0, + fixableWaitingUpstream: 0, + fixableUpdateUnknown: 0, + updateChecksDisabled: false, + imageRefsUpdateAvailable: [], + imageRefsWaitingUpstream: [], + imageRefsUpdateUnknown: [], + ...overrides, + }; +} + +function pushUnique(list: string[], ref: string): void { + if (!list.includes(ref)) list.push(ref); +} + +/** + * Classify package-fix Crit/High findings against persisted update evidence. + * Counts are finding counts (not distinct images). imageRefs* are distinct + * raw finding image_refs in each class. + */ +export function classifyImageRemediation(input: ClassifyImageRemediationInput): ImageRemediationFacts { + const { findings, details, checksEnabled, freshnessWindowMs, now } = input; + + const totalFixable = findings.reduce((sum, f) => sum + f.count, 0); + if (totalFixable === 0) return emptyRemediationFacts(); + if (!checksEnabled) { + const imageRefsUpdateUnknown: string[] = []; + for (const finding of findings) pushUnique(imageRefsUpdateUnknown, finding.image_ref); + return emptyRemediationFacts({ + fixableUpdateUnknown: totalFixable, + updateChecksDisabled: true, + imageRefsUpdateUnknown, + }); + } + + let fixableWithImageUpdate = 0; + let fixableWaitingUpstream = 0; + let fixableUpdateUnknown = 0; + const imageRefsUpdateAvailable: string[] = []; + const imageRefsWaitingUpstream: string[] = []; + const imageRefsUpdateUnknown: string[] = []; + + const index = buildUpdateServiceIndex(details); + for (const finding of findings) { + const key = normalizeImageRef(finding.image_ref); + const matches = key ? (index.get(key) ?? []) : []; + switch (classifyMatches(matches, freshnessWindowMs, now)) { + case 'update_available': + fixableWithImageUpdate += finding.count; + pushUnique(imageRefsUpdateAvailable, finding.image_ref); + break; + case 'waiting_upstream': + fixableWaitingUpstream += finding.count; + pushUnique(imageRefsWaitingUpstream, finding.image_ref); + break; + default: + fixableUpdateUnknown += finding.count; + pushUnique(imageRefsUpdateUnknown, finding.image_ref); + break; + } + } + + return emptyRemediationFacts({ + fixableWithImageUpdate, + fixableWaitingUpstream, + fixableUpdateUnknown, + imageRefsUpdateAvailable, + imageRefsWaitingUpstream, + imageRefsUpdateUnknown, + }); +} diff --git a/backend/src/services/securityPosture.ts b/backend/src/services/securityPosture.ts index fa37de30..a1d673d5 100644 --- a/backend/src/services/securityPosture.ts +++ b/backend/src/services/securityPosture.ts @@ -11,16 +11,37 @@ * changes never require a schema migration, and the same verdict can be reused * by other surfaces (action queue, per-stack blast radius). * - * Posture is deliberately NOT raw severity: a page is never "Secure" merely - * because counts are zero-weighted, and never "Action needed" merely because a - * Critical exists with nothing to do about it. "Secure" means nothing is - * actionable right now, not a claim that no vulnerabilities exist. + * Posture is deliberately NOT raw severity: a page is never "Action needed" + * merely because a Critical exists with nothing to do about it. + * + * Action needed: a concrete blocker Sencho can drive now. + * Monitoring: no blocker, but residual Crit/High (including accepted/ignored), + * a review reason, or relevant uncertainty remains. + * Secure: no blocker and no residual Crit/High or review/info reason under + * current evidence (stricter than "nothing actionable"). + * Unknown: scanner missing or no completed scan. + * + * Package fix availability (Trivy fixed_version) and container-image update + * availability (canonical ImageUpdateService) are distinct facts. A package + * fix alone never produces an "Update affected images" instruction. */ +import { + anyTargetIntentConflict, + anyTargetIntentUnset, + partialTargetsIntentionalWithUnavailable, +} from './securityExposureTargets'; +import type { ExposureIntent } from './network/types'; + /** EPSS score at or above this is treated as an elevated exploitation * likelihood, matching the frontend threshold in SecurityCharts.tsx. */ export const HIGH_EPSS_THRESHOLD = 0.1; +/** Max target rows attached to one posture reason (overview payload). + * For exposure reasons rows are per stack/service and may repeat imageRef; + * reason.count stays a distinct-image count and can diverge from targets.length. */ +export const POSTURE_TARGET_CAP = 200; + export type SecurityPostureState = 'Action needed' | 'Monitoring' | 'Secure' | 'Unknown'; /** Valid Security tab targets for a posture reason CTA. Mirrors the frontend @@ -35,7 +56,10 @@ export type SecurityPostureTargetTab = export type PostureReasonKind = | 'fixable_cve' + | 'waiting_upstream' + | 'update_check_uncertain' | 'known_exploited' + | 'elevated_exploit_risk' | 'secret' | 'dangerous_compose' | 'public_exposure' @@ -43,8 +67,37 @@ export type PostureReasonKind = | 'failed_scan' | 'needs_review'; +/** Bounded finding identities that drive a vulnerability-derived posture reason. */ +export interface PostureDriverFinding { + vulnerabilityId: string; + imageRef: string; +} + export type PostureReasonSeverity = 'blocker' | 'review' | 'info'; +/** + * Image identity behind a posture reason (raw scan image_ref). + * Exposure reasons may enrich with stack, service, and Networking intent; + * other reasons are typically imageRef-only. + */ +export interface PostureTarget { + imageRef: string; + /** Stack with configured beyond-loopback or host-network exposure. */ + stackName?: string; + /** Service within that stack. */ + serviceName?: string; + exposureReason?: 'published-port' | 'host-network' | null; + /** Effective intent when context is available and set; omit when unset. */ + exposureIntent?: ExposureIntent; + /** + * Intent resolution for this service. + * unavailable is distinct from unset (DB/context failure vs no classification). + */ + intentStatus?: 'set' | 'unset' | 'unavailable'; + /** True when intent is internal/same-node but configured exposure is beyond loopback. */ + intentConflict?: boolean; +} + export interface PostureReason { kind: PostureReasonKind; count: number; @@ -55,6 +108,22 @@ export interface PostureReason { description: string; /** Which Security tab the CTA navigates to. */ targetTab: SecurityPostureTargetTab; + /** Optional Open-button label; when omitted the UI derives from targetTab. */ + actionLabel?: string; + /** + * Target rows for this reason (image-only, or per stack/service for exposure). + * May repeat imageRef. Omitted when empty or unknown. + */ + targets?: PostureTarget[]; + /** + * Exact contributing findings for vulnerability-derived reasons (capped). + * Older remotes omit this field. + */ + drivers?: PostureDriverFinding[]; + /** Full contributing driver count before cap; omit when drivers omitted. */ + driverCount?: number; + /** True when driverCount exceeds the attached drivers array length. */ + driversTruncated?: boolean; } export interface PostureAction { @@ -63,6 +132,12 @@ export interface PostureAction { /** The reason kind that produced this action, so the UI can target the * affected items precisely (e.g. filter Images to fixable findings). */ kind: PostureReasonKind; + /** Same targets as the reason that produced this action, when available. */ + targets?: PostureTarget[]; + /** Same drivers as the reason that produced this action, when available. */ + drivers?: PostureDriverFinding[]; + driverCount?: number; + driversTruncated?: boolean; } export interface SecurityPostureFacts { @@ -70,31 +145,167 @@ export interface SecurityPostureFacts { scannerAvailable: boolean; /** At least one scan has completed (a freshly installed node has none). */ hasCompletedScan: boolean; - /** Critical/High findings with a fix available, net of suppressions. */ + /** Critical/High findings with a package fix available, net of suppressions. */ fixableCriticalHigh: number; + /** + * Subset of package-fix Crit/High findings whose managed image has a + * confirmed applicable image update (hasUpdate + checkStatus ok). + */ + fixableWithImageUpdate: number; + /** + * Package-fix Crit/High on managed images where every checkable match is an + * authoritative negative (ok, no update, fresh). + */ + fixableWaitingUpstream: number; + /** + * Package-fix Crit/High where update availability could not be established + * (partial, failed, stale, disabled, not_checkable, no stack match, etc.). + */ + fixableUpdateUnknown: number; + /** When true, uncertain rows should explain disabled checks (no Check again). */ + updateChecksDisabled: boolean; /** Detected secrets (not suppressible in the current model). */ secrets: number; /** High-severity Compose misconfigurations, net of acknowledgements. */ dangerousCompose: number; /** Known-exploited (CISA KEV) findings among non-suppressed Critical/High. */ knownExploited: number; - /** Total affected services published to a non-loopback address (legacy; - * exposedBlocker + exposedReview is the authoritative split). */ + /** Distinct Crit/High-index images with configured beyond-loopback exposure. */ publiclyExposed: number; - /** Exposed images with KEV, fixable, or elevated-EPSS findings (blocker). */ - exposedBlocker: number; - /** Exposed images without KEV, fix, or elevated EPSS (review only). */ - exposedReview: number; - /** Raw Critical scanner detections (for the Monitoring fallback). */ + /** + * Exposed images whose Networking intent conflicts with configured exposure + * (internal/same-node while beyond loopback), with unsuppressed Crit/High. + * This is the only exposure-correctness blocker. + */ + exposureIntentConflict: number; + /** + * Exposed images with unset/unavailable/non-intentional intent and + * unsuppressed Crit/High. Review-level: does not force Action needed alone. + */ + exposedUnclassified: number; + /** + * Network-exposed images with unsuppressed Crit/High at EPSS >= threshold. + * Independent Security driver; intentional exposure is context, not the action. + */ + elevatedExploitRisk: number; + /** Raw Critical scanner detections (UI tiles; triage-blind). */ rawCritical: number; - /** Raw High scanner detections (for the Monitoring fallback). */ + /** Raw High scanner detections (UI tiles; triage-blind). */ rawHigh: number; + /** + * Crit/High that still block Secure after Secure-clearing triage. + * Unsuppressed, accepted, and ignored count; not_affected, false_positive, + * and fixed do not. + */ + residualCriticalHigh: number; /** Images whose latest scan is older than the stale threshold. */ staleScans: number; /** Scans that terminated with an error. */ failedScans: number; /** Findings with triage_status = 'needs_review' (not dismissed, not accepted). */ needsReview: number; + /** Raw image_refs for Images-bound reasons (optional; omit when unknown). */ + fixableWithImageUpdateTargets?: string[]; + fixableWaitingUpstreamTargets?: string[]; + fixableUpdateUnknownTargets?: string[]; + knownExploitedTargets?: string[]; + knownExploitedDrivers?: PostureDriverFinding[]; + knownExploitedDriverCount?: number; + knownExploitedDriversTruncated?: boolean; + /** Per-service exposure targets (may repeat imageRef across stack/service). */ + exposureIntentConflictTargets?: PostureTarget[]; + exposedUnclassifiedTargets?: PostureTarget[]; + elevatedExploitRiskTargets?: PostureTarget[]; + elevatedExploitRiskDrivers?: PostureDriverFinding[]; + elevatedExploitRiskDriverCount?: number; + elevatedExploitRiskDriversTruncated?: boolean; + fixableWithImageUpdateDrivers?: PostureDriverFinding[]; + fixableWithImageUpdateDriverCount?: number; + fixableWithImageUpdateDriversTruncated?: boolean; + fixableWaitingUpstreamDrivers?: PostureDriverFinding[]; + fixableWaitingUpstreamDriverCount?: number; + fixableWaitingUpstreamDriversTruncated?: boolean; + fixableUpdateUnknownDrivers?: PostureDriverFinding[]; + fixableUpdateUnknownDriverCount?: number; + fixableUpdateUnknownDriversTruncated?: boolean; +} + +/** Cap and convert raw refs to PostureTarget[]. Returns truncated=true when capped. */ +export function capPostureTargets(refs: string[] | undefined): { + targets: PostureTarget[] | undefined; + truncated: boolean; +} { + if (!refs || refs.length === 0) return { targets: undefined, truncated: false }; + return { + targets: refs.slice(0, POSTURE_TARGET_CAP).map((imageRef) => ({ imageRef })), + truncated: refs.length > POSTURE_TARGET_CAP, + }; +} + +/** Cap enriched target rows (imageRef+stack+service), preferring conflict/unset/unavailable first. */ +export function capPostureTargetRows(rows: PostureTarget[] | undefined): { + targets: PostureTarget[] | undefined; + truncated: boolean; +} { + if (!rows || rows.length === 0) return { targets: undefined, truncated: false }; + const sorted = [...rows].sort((a, b) => postureTargetExposureRank(a) - postureTargetExposureRank(b)); + return { + targets: sorted.slice(0, POSTURE_TARGET_CAP), + truncated: rows.length > POSTURE_TARGET_CAP, + }; +} + +function postureTargetExposureRank(t: PostureTarget): number { + if (t.intentConflict) return 0; + if (t.intentStatus === 'unset') return 1; + if (t.intentStatus === 'unavailable') return 2; + return 3; +} + +function attachCappedTargets( + reason: PostureReason, + capped: { targets: PostureTarget[] | undefined; truncated: boolean }, +): { reason: PostureReason; truncated: boolean } { + if (!capped.targets) return { reason, truncated: false }; + return { reason: { ...reason, targets: capped.targets }, truncated: capped.truncated }; +} + +/** Default CTA label when a reason omits actionLabel. */ +const DEFAULT_ACTION_LABEL: Partial> = { + fixable_cve: 'Review update', + known_exploited: 'Review exploited findings', + elevated_exploit_risk: 'Review driving findings', + secret: 'Review detected secrets', + dangerous_compose: 'Review Compose risks', + public_exposure: 'Review networking', +}; + +function actionFrom(reason: PostureReason): PostureAction { + const action: PostureAction = { + label: reason.actionLabel ?? DEFAULT_ACTION_LABEL[reason.kind] ?? 'Open', + targetTab: reason.targetTab, + kind: reason.kind, + }; + if (reason.targets) action.targets = reason.targets; + if (reason.drivers) action.drivers = reason.drivers; + if (reason.driverCount !== undefined) action.driverCount = reason.driverCount; + if (reason.driversTruncated !== undefined) action.driversTruncated = reason.driversTruncated; + return action; +} + +function withDrivers( + base: PostureReason, + drivers: PostureDriverFinding[] | undefined, + driverCount?: number, + driversTruncated?: boolean, +): PostureReason { + if (!drivers || drivers.length === 0) return base; + return { + ...base, + drivers, + driverCount: driverCount ?? drivers.length, + driversTruncated: driversTruncated ?? false, + }; } /** @@ -107,95 +318,152 @@ export interface SecurityPostureFacts { * All reasons (blocker, review, info) are returned regardless of posture * state. The caller decides which subset to surface. */ +const VIEW_FINDINGS_LABEL = 'View findings'; +const REVIEW_NETWORKING_LABEL = 'Review networking'; + +function exposureConflictDescription(targets: PostureTarget[] | undefined): string { + const parts = [ + 'Configured exposure beyond loopback conflicts with declared Networking intent (internal or same-node).', + ]; + if (anyTargetIntentConflict(targets)) { + parts.push('Review networking to align Compose publish settings with intent.'); + } + return parts.join(' '); +} + +function exposureUnclassifiedDescription(targets: PostureTarget[] | undefined, truncated = false): string { + const parts = [ + 'Images are configured beyond loopback or with host networking and exposure intent is not yet classified.', + ]; + if (anyTargetIntentUnset(targets)) { + parts.push('Set exposure intent in Networking.'); + } + const partial = partialTargetsIntentionalWithUnavailable(targets, truncated); + if (partial.partial) { + const n = partial.unavailableCount; + parts.push( + `Intent could not be verified for ${n} service${n === 1 ? '' : 's'}.`, + ); + } + return parts.join(' '); +} + export function derivePostureReasons(f: SecurityPostureFacts): { reasons: PostureReason[]; primaryAction: PostureAction | null; + /** True when any attached target list was capped at POSTURE_TARGET_CAP. */ + targetsTruncated: boolean; } { const reasons: PostureReason[] = []; let primaryAction: PostureAction | null = null; + let targetsTruncated = false; + + const pushCapped = ( + base: PostureReason, + capped: { targets: PostureTarget[] | undefined; truncated: boolean }, + ): void => { + const { reason, truncated } = attachCappedTargets(base, capped); + if (truncated) targetsTruncated = true; + reasons.push(reason); + if (!primaryAction && reason.severity === 'blocker') { + primaryAction = actionFrom(reason); + } + }; + + const push = (base: PostureReason, refs?: string[]): void => { + pushCapped(base, capPostureTargets(refs)); + }; // Blockers. Each of these can keep the masthead red. - if (f.fixableCriticalHigh > 0) { - const r: PostureReason = { + if (f.fixableWithImageUpdate > 0) { + push(withDrivers({ kind: 'fixable_cve', - count: f.fixableCriticalHigh, + count: f.fixableWithImageUpdate, severity: 'blocker', - label: 'Fixable findings', - description: 'Critical or High findings with an available fix.', + label: 'Newer image available', + description: 'Critical or High findings have a newer image available to review. This does not prove the candidate removes the findings.', targetTab: 'images', - }; - reasons.push(r); - if (!primaryAction) primaryAction = { label: 'Update affected images', targetTab: r.targetTab, kind: r.kind }; + actionLabel: 'Review update', + }, f.fixableWithImageUpdateDrivers, f.fixableWithImageUpdateDriverCount, f.fixableWithImageUpdateDriversTruncated), f.fixableWithImageUpdateTargets); } if (f.knownExploited > 0) { - const r: PostureReason = { + push(withDrivers({ kind: 'known_exploited', count: f.knownExploited, severity: 'blocker', label: 'Known-exploited findings', description: 'Findings in the CISA Known Exploited Vulnerabilities catalog.', targetTab: 'images', - }; - reasons.push(r); - if (!primaryAction) primaryAction = { label: 'Review exploited findings', targetTab: r.targetTab, kind: r.kind }; + }, f.knownExploitedDrivers, f.knownExploitedDriverCount, f.knownExploitedDriversTruncated), f.knownExploitedTargets); + } + + if (f.elevatedExploitRisk > 0) { + const capped = capPostureTargetRows(f.elevatedExploitRiskTargets); + pushCapped(withDrivers({ + kind: 'elevated_exploit_risk', + count: f.elevatedExploitRisk, + severity: 'blocker', + label: 'Elevated exploit risk on network-exposed workload', + description: 'Critical or High findings on network-exposed images have elevated EPSS. Exposure intent is context; review the driving findings.', + targetTab: 'images', + actionLabel: 'Review driving findings', + }, f.elevatedExploitRiskDrivers, f.elevatedExploitRiskDriverCount, f.elevatedExploitRiskDriversTruncated), capped); } if (f.secrets > 0) { - const r: PostureReason = { + push({ kind: 'secret', count: f.secrets, severity: 'blocker', label: 'Detected secrets', description: 'Images with exposed credentials or keys. Review on the Secrets tab.', targetTab: 'secrets', - }; - reasons.push(r); - if (!primaryAction) primaryAction = { label: 'Review detected secrets', targetTab: r.targetTab, kind: r.kind }; + }); } if (f.dangerousCompose > 0) { - const r: PostureReason = { + push({ kind: 'dangerous_compose', count: f.dangerousCompose, severity: 'blocker', label: 'Unacknowledged Compose risks', description: 'High-severity misconfigurations that have not been acknowledged.', targetTab: 'compose', - }; - reasons.push(r); - if (!primaryAction) primaryAction = { label: 'Review Compose risks', targetTab: r.targetTab, kind: r.kind }; + }); } - if (f.exposedBlocker > 0) { - const r: PostureReason = { + if (f.exposureIntentConflict > 0) { + const capped = capPostureTargetRows(f.exposureIntentConflictTargets); + pushCapped({ kind: 'public_exposure', - count: f.exposedBlocker, + count: f.exposureIntentConflict, severity: 'blocker', - label: 'Publicly exposed affected images', - description: 'Images with fixable, known-exploited, or elevated-EPSS findings published on a public interface.', + label: 'Exposure conflicts with declared intent', + description: exposureConflictDescription(f.exposureIntentConflictTargets), targetTab: 'images', - }; - reasons.push(r); - if (!primaryAction) primaryAction = { label: 'Review public exposure', targetTab: r.targetTab, kind: r.kind }; + actionLabel: REVIEW_NETWORKING_LABEL, + }, capped); } // Review items. These appear in-page but do not force a red masthead. - if (f.exposedReview > 0) { - reasons.push({ + if (f.exposedUnclassified > 0) { + const capped = capPostureTargetRows(f.exposedUnclassifiedTargets); + pushCapped({ kind: 'public_exposure', - count: f.exposedReview, + count: f.exposedUnclassified, severity: 'review', - label: 'Exposed images (monitoring)', - description: 'Images published on a public interface with no fix, no KEV, and no elevated EPSS.', + label: 'Network-exposed images not yet classified', + description: exposureUnclassifiedDescription(f.exposedUnclassifiedTargets, capped.truncated), targetTab: 'images', - }); + actionLabel: VIEW_FINDINGS_LABEL, + }, capped); } if (f.needsReview > 0) { - reasons.push({ + push({ kind: 'needs_review', count: f.needsReview, severity: 'review', @@ -205,10 +473,36 @@ export function derivePostureReasons(f: SecurityPostureFacts): { }); } + if (f.fixableWaitingUpstream > 0) { + push(withDrivers({ + kind: 'waiting_upstream', + count: f.fixableWaitingUpstream, + severity: 'review', + label: 'Waiting for upstream image', + description: 'Package fixes exist for findings in this image, but Sencho cannot currently identify a newer image to apply under its latest authoritative check.', + targetTab: 'images', + actionLabel: VIEW_FINDINGS_LABEL, + }, f.fixableWaitingUpstreamDrivers, f.fixableWaitingUpstreamDriverCount, f.fixableWaitingUpstreamDriversTruncated), f.fixableWaitingUpstreamTargets); + } + + if (f.fixableUpdateUnknown > 0) { + push(withDrivers({ + kind: 'update_check_uncertain', + count: f.fixableUpdateUnknown, + severity: 'review', + label: 'Update availability unknown', + description: f.updateChecksDisabled + ? 'Package fixes exist, but image update checks are disabled on this node, so Sencho cannot tell whether a newer image is available.' + : 'Package fixes exist, but Sencho could not establish whether an applicable image update is available (partial, failed, stale, not checkable, or unmatched image).', + targetTab: 'images', + actionLabel: VIEW_FINDINGS_LABEL, + }, f.fixableUpdateUnknownDrivers, f.fixableUpdateUnknownDriverCount, f.fixableUpdateUnknownDriversTruncated), f.fixableUpdateUnknownTargets); + } + // Info items. Context only, never red. if (f.staleScans > 0) { - reasons.push({ + push({ kind: 'stale_scan', count: f.staleScans, severity: 'info', @@ -219,7 +513,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): { } if (f.failedScans > 0) { - reasons.push({ + push({ kind: 'failed_scan', count: f.failedScans, severity: 'info', @@ -229,7 +523,7 @@ export function derivePostureReasons(f: SecurityPostureFacts): { }); } - return { reasons, primaryAction }; + return { reasons, primaryAction, targetsTruncated }; } /** @@ -243,6 +537,6 @@ export function deriveSecurityPosture(f: SecurityPostureFacts): SecurityPostureS if (!f.scannerAvailable || !f.hasCompletedScan) return 'Unknown'; const { reasons } = derivePostureReasons(f); if (reasons.some((r) => r.severity === 'blocker')) return 'Action needed'; - if (f.rawCritical > 0 || f.rawHigh > 0 || reasons.length > 0) return 'Monitoring'; + if (f.residualCriticalHigh > 0 || reasons.length > 0) return 'Monitoring'; return 'Secure'; } diff --git a/backend/src/utils/senchoRollbackHold.ts b/backend/src/utils/senchoRollbackHold.ts new file mode 100644 index 00000000..2b9b3224 --- /dev/null +++ b/backend/src/utils/senchoRollbackHold.ts @@ -0,0 +1,26 @@ +/** + * Sencho rollback-hold image identity. + * + * Full-stack recovery tags images as `sencho-rb//:hold`. + * Those refs are Sencho-internal recovery state, not operator inventory or + * Security scan targets. Shared so Resources, Trivy, and Security agree. + */ + +export const SENCHO_ROLLBACK_HOLD_PREFIX = 'sencho-rb/'; + +/** SQLite LIKE pattern matching any Sencho rollback-hold image_ref. */ +export const SENCHO_ROLLBACK_HOLD_SQL_LIKE = `${SENCHO_ROLLBACK_HOLD_PREFIX}%`; + +/** True when an image reference is a Sencho synthetic rollback-hold tag. */ +export function isSenchoRollbackHoldRef(imageRef: string): boolean { + return imageRef.startsWith(SENCHO_ROLLBACK_HOLD_PREFIX); +} + +/** + * True when every visible RepoTag is a synthetic hold tag (hold-only image). + * Dual-tagged images (registry tag + hold) return false so they stay visible + * under the real tag. + */ +export function isFullySyntheticHoldImage(repoTags: string[]): boolean { + return repoTags.length > 0 && repoTags.every((tag) => isSenchoRollbackHoldRef(tag)); +} diff --git a/backend/src/utils/suppression-filter.ts b/backend/src/utils/suppression-filter.ts index d1820c85..3fe122b7 100644 --- a/backend/src/utils/suppression-filter.ts +++ b/backend/src/utils/suppression-filter.ts @@ -26,6 +26,36 @@ export const DISMISSING_STATUSES: ReadonlySet = new Set([ 'not_affected', 'accepted', 'fixed', 'false_positive', 'ignored', ]); +/** + * Dismissing statuses that clear Crit/High from residual Secure-gate risk. + * Accepted and ignored stay residual (operator is still carrying the risk). + * Fixed clears under the current Secure contract; re-verifying Fixed over time + * is a separate evidence-freshness concern. + */ +export const SECURE_CLEARING_STATUS_LIST = [ + 'not_affected', 'false_positive', 'fixed', +] as const; +export type SecureClearingStatus = typeof SECURE_CLEARING_STATUS_LIST[number]; +export const SECURE_CLEARING_STATUSES: ReadonlySet = new Set( + SECURE_CLEARING_STATUS_LIST, +); + +type _SecureClearingIsDismissing = SecureClearingStatus extends ( + 'not_affected' | 'accepted' | 'fixed' | 'false_positive' | 'ignored' +) ? true : never; +const _secureClearingIsDismissing: _SecureClearingIsDismissing = true; +void _secureClearingIsDismissing; + +/** True when a Crit/High finding still blocks Secure (residual material risk). */ +export function countsTowardResidualCriticalHigh( + decision: { suppressed: boolean; triage_status?: TriageStatus }, +): boolean { + if (!decision.suppressed) return true; + const status = decision.triage_status; + if (status == null) return true; + return !(SECURE_CLEARING_STATUS_LIST as readonly string[]).includes(status); +} + /** Optional OpenVEX-aligned justification taxonomy (never required). */ export const TRIAGE_JUSTIFICATIONS = [ 'vulnerable_code_not_in_execute_path', 'vulnerable_code_not_present', diff --git a/docs/features/cve-suppressions.mdx b/docs/features/cve-suppressions.mdx index ea864e02..ffebc4df 100644 --- a/docs/features/cve-suppressions.mdx +++ b/docs/features/cve-suppressions.mdx @@ -32,7 +32,7 @@ The dialog has the following fields: | Field | Description | |-------|-------------| | **CVE or advisory ID** | Required. Accepts both `CVE-YYYY-NNNN` and `GHSA-xxxx-xxxx-xxxx`. | -| **Triage decision** | How the finding was triaged: accepted risk (default), not affected, false positive, affected, fixed, ignored, or needs review. A decided state (accepted, not affected, false positive, fixed, ignored) stops the finding from driving the action posture; "needs review" and "affected" keep it counted but still actionable. | +| **Triage decision** | How the finding was triaged: accepted risk (default), not affected, false positive, affected, fixed, ignored, or needs review. Not affected, false positive, and fixed clear residual Crit/High so Secure can become reachable. Accepted and ignored residual risk stay on Monitoring and do not turn Secure green. Needs review and affected stay actionable. | | **OpenVEX justification** | Required when the triage decision is not affected or false positive. Explains why the vulnerable code is not exploitable, for example vulnerable code not present, vulnerable code not in the execute path, component not present, or inline mitigations already exist. Carried into the OpenVEX export for that decision. | | **Package (optional)** | Leave blank to suppress every occurrence of this CVE across every package, or pin a specific package name (e.g. `openssl`) to narrow the scope. | | **Image pattern (optional)** | Glob applied to image references (`*` matches any sequence, case-sensitive). For example, `lscr.io/linuxserver/*` matches every LinuxServer image, and `*alpine*` matches anything containing `alpine`. Leave blank to apply fleet-wide. | @@ -45,7 +45,14 @@ The dialog has the following fields: ### Suppressing directly from a scan result -The panel's empty state hints at the faster path: from any vulnerability scan, click the small shield icon at the right edge of a finding's row. The dialog opens pre-filled with the CVE ID and the package name from that row (both read-only in this flow), leaving you to set the Triage decision (accepted risk by default), add a Reason, an optional Image pattern, and an optional Expiry. Choosing not affected or false positive also requires an OpenVEX justification. This is the recommended workflow for everyday triage, because it keeps the scope as narrow as the originating finding. To broaden the scope (for example, to suppress across every package), create the rule from the **Security** page → **Suppressions** tab instead. +The panel's empty state hints at the faster path: from any vulnerability scan, click **Triage finding** +on a finding's row. The dialog opens pre-filled with the CVE ID, the package name, and the **Image +pattern** set to the current image reference so the decision stays scoped to that image by default. +You set the Triage decision (accepted risk by default), add a Reason, and an optional Expiry. You can +clear or broaden the image pattern if you intend a wider match. Choosing not affected or false +positive also requires an OpenVEX justification. This is the recommended workflow for everyday +triage. To start from a blank image pattern for fleet-wide matching, create the rule from the +**Security** page → **Suppressions** tab instead. ### How specificity is resolved diff --git a/docs/features/health-gated-updates.mdx b/docs/features/health-gated-updates.mdx index 60178268..008c319c 100644 --- a/docs/features/health-gated-updates.mdx +++ b/docs/features/health-gated-updates.mdx @@ -146,7 +146,7 @@ When a deploy or update fails, Sencho classifies the failure from the compose ou The sidebar's per-stack **Update** action runs the same path as the editor toolbar, so it shows the same readiness dialog and deploy progress. One click on **Update now** proceeds. On nodes that do not advertise the capability, updates run directly without the dialog. - Those are automatic rollback images: an opaque copy of a service's prior image, held so Sencho can restore it if a full-stack update fails. They are not leftovers. Sencho keeps them out of **Resources → Images** on purpose (they are recovery state, not image inventory) and lists them in **Resources → Rollback** instead, showing which stack and generation each one belongs to and how soon it clears on its own. If one still carries a normal registry tag too, it also stays visible in the Images tab with a **Rollback protected** badge. + Those are automatic rollback images: an opaque copy of a service's prior image, held so Sencho can restore it if a full-stack update fails. They are not leftovers. Sencho keeps them out of **Resources → Images** and **Security** on purpose (they are recovery state, not image inventory or scan targets) and lists them in **Resources → Rollback** instead, showing which stack and generation each one belongs to and how soon it clears on its own. If one still carries a normal registry tag too, it also stays visible in the Images tab with a **Rollback protected** badge, and Security continues to scan that registry tag. That failure is intentional: the image is protected by an active or recently superseded rollback generation. Open **Resources → Rollback**, find the matching generation, and use **Release** there if you are sure you do not need it. Releasing the current generation means Sencho cannot automatically roll that stack back until its next successful full-stack update. diff --git a/docs/features/security.mdx b/docs/features/security.mdx index f1a69e59..d185b31f 100644 --- a/docs/features/security.mdx +++ b/docs/features/security.mdx @@ -9,7 +9,7 @@ center, so you can answer "what should I look at first?" without hunting through scoped to the active node: the findings and scanner status you see reflect whichever node is selected. - Security page Overview tab with an Action needed masthead reading '3 actions: fixable findings, detected secrets' and CRITICAL/HIGH/LAST SCAN stat tiles, a Why Action needed review-queue card, a 30-day risk trend chart, an Action posture breakdown, and a Scan this node button. + Security page Overview tab with an Action needed masthead, CRITICAL/HIGH/LAST SCAN stat tiles, a Why Action needed review-queue card, a 30-day risk trend chart, an Action posture breakdown, and a Scan this node button. The page is organized into tabs: Overview, Images, Compose risks, Secrets, Policies, Suppressions, @@ -20,17 +20,35 @@ History, and Scanner setup. The overview opens with a status masthead that reads your **action posture** at a glance, the answer to "what can and should I do right now?": -- **Action needed**: something concrete to act on, such as a fixable Critical or High finding, a - detected secret, a dangerous Compose setting, or a known-exploited (CISA KEV) CVE. -- **Monitoring**: Critical or High findings exist, but none are currently actionable (no fix - available, or already triaged). -- **Secure**: nothing actionable right now. This is never a claim that no vulnerabilities exist. +- **Action needed**: something concrete to act on that Sencho can support right now, such as a + newer image available to review for Critical or High findings, a detected secret, a dangerous + Compose setting, a known-exploited (CISA KEV) CVE, elevated exploit risk on a network-exposed + workload, or exposure that conflicts with declared Networking intent. +- **Monitoring**: no Action needed blocker, but material residual risk, a pending review, or + relevant uncertainty remains. Residual Critical/High include undecided findings and accepted or + ignored residual risk. Package fixes may exist without an applicable container-image update, + workloads may be intentionally network-exposed without an independent Security driver, and + waiting-upstream or update-check-uncertain rows appear under **Why Monitoring** without a red + masthead. +- **Secure**: no Action needed blocker and no residual material Critical/High or pending + security-review condition under current evidence. Not affected, false positive, and fixed can + clear residual risk for those findings; accepting residual risk does not. This is never a claim + that no scanner detections exist. - **Unknown**: the scanner is not installed, or no scan has completed yet. +Monitoring keeps residual risk visible (raw detections, accepted risk, waiting-upstream rows, +intentional exposure context). Secure is stricter: residual Critical/High and review reasons must +be cleared, not merely the absence of an immediate action. + Raw Critical and High counts stay visible next to the posture as **scanner detections**, not as the posture itself: a vulnerable component being present is not the same as a reachable, exploitable risk. -The masthead carries a standing note to that effect, and posture weighs fix availability, exploit -intelligence, and triage decisions rather than raw severity alone. +The masthead carries a standing note to that effect, and posture weighs package-fix evidence, +image-update availability, exploit intelligence, exposure, and triage decisions rather than raw +severity alone. A Trivy package fix does not by itself mean Sencho can update the image; when a +newer image is confirmed available the review queue offers **Review update**, and when no applicable +update is identified it surfaces **Waiting for upstream image** instead of an impossible update +instruction. Deploy policies remain orthogonal: they may still block admission on package-fix +Critical/High even when Security posture is Monitoring. An admin on a node with a ready scanner sees a **Scan this node** button above the review queue. It opens a popover to pick any combination of image vulnerabilities, image secrets, and Compose @@ -40,11 +58,18 @@ clean. Below the masthead, a **Review queue** card leads the overview when actions or review items exist. When the posture is Action needed the card is titled **Why Action needed** and lists each concrete -action with a count and a tab-shortcut button: fixable findings, known-exploited CVEs, detected -secrets, unacknowledged Compose risks, and publicly exposed affected images. When only monitoring -items remain (exposed images with no fix or known exploit, findings awaiting triage, stale or failed -scans) the card is titled **Review queue** and lists them without the red masthead, so the operator -can see what to keep an eye on without a permanent alarm. +action with a count and a tab-shortcut button: newer images available to review, known-exploited +CVEs, elevated exploit risk on network-exposed workloads, detected secrets, unacknowledged Compose +risks, and exposure that conflicts with declared Networking intent. Intentional network exposure is +Security context, not an independent Action needed reason by itself. When only monitoring items +remain (waiting for an upstream image, update availability unknown, network-exposed images not yet +classified, findings awaiting triage, stale or failed scans) the card is titled **Why Monitoring** +and lists them with View findings shortcuts (and Check again when image update availability could +not be established and you can manage the node), so the operator can see what to keep an eye on +without a permanent alarm. Shortcuts that carry affected image identities open Images already +narrowed to those images, with a clearable banner so you can return to the full list. When the +reason includes exact driving findings, opening an image filters the scan report to that contributing +set. The charts then lead with prioritization rather than raw severity: a **risk trend** for context, an **action posture** breakdown (fixable, known-exploited, needs-review, accepted, not-affected), a @@ -64,11 +89,26 @@ clear "overview unavailable" state and the other tabs keep working. Security page Images tab listing scanned images with Image, Findings, Last scan, Severity, and Actions columns; findings show critical/high counts and fixable totals, and one row shows a Clean badge. -Image findings list every scanned image on the active node with its highest severity. Selecting an -image opens the full scan report, where you can review vulnerabilities, triage a CVE, compare against -another scan, and export a CSV, a SARIF file, or an SBOM. Each finding carries evidence tags so you can -tell scary from exploitable at a glance: known-exploited (KEV), EPSS exploitation probability, the -CVSS score, and vendor "will not fix" status, alongside whether a fix is available. +Image findings list every scanned image on the active node with its highest severity. Sencho +rollback-hold tags (`sencho-rb/...:hold`) are recovery state, not Security inventory: node-wide +scans skip them, and existing hold-only scan rows stay out of this list and the Overview posture. +When you arrive +from a Security posture action that named specific images, the list opens already filtered to those +images and shows a clearable banner with the reason and count. Images configured beyond loopback (or +with host networking) carry a **Network exposed** evidence label on the row during ordinary browsing, +not only after a posture drill-down. That label means Compose declares non-loopback reachability; it +is not a claim that the service is reachable from the Internet. When Networking exposure intent is +available, the row also shows compact intent evidence (for example Intent: public, Intent mismatch, +or Intent: not classified). Open Networking from that evidence to review or correct classification +for the matching stack. Intentional exposure raises the security relevance of vulnerable findings +but does not by itself keep the masthead on Action needed. Open the image, remediate, or triage +individual findings (Accepted risk and related decisions). There is no image-level "accept residual +risk" control. Compose Doctor acknowledgements are a separate triage surface and do not clear +Security exposure context. Selecting +an image opens the full scan report, where you can review vulnerabilities, triage a finding, compare +against another scan, and export a CSV, a SARIF file, or an SBOM. Each finding carries evidence tags +so you can tell scary from exploitable at a glance: known-exploited (KEV), EPSS exploitation +probability, the CVSS score, and vendor "will not fix" status, alongside whether a fix is available. Vulnerability scan report sheet for nginx:latest showing 340 vulnerabilities, a Critical/High/Medium/Low summary, Compare/CSV/SARIF/SBOM export buttons, and a findings table with CVE, package, severity, installed version, and per-row EPSS and CVSS evidence tags. @@ -99,7 +139,7 @@ the secret findings for a scan. ## Policies - New policy dialog with a Name field, an optional glob-style Stack pattern field, Block conditions toggles for Severity threshold, Known-exploited (KEV), and Fixable Critical/High, a Block on deploy toggle, and an Enabled toggle. + New policy dialog with a Name field, an optional glob-style Stack pattern field, Block conditions toggles for Severity threshold, Known-exploited (KEV), and Package fix available (Critical/High), a Block on deploy toggle, and an Enabled toggle. The Policies tab manages deploy-enforcement scan policies: a policy names one or more block @@ -121,8 +161,9 @@ in Settings. These are governed by the local instance, so this tab is shown when node; switch to the local node to manage them. Suppressing a CVE records a **triage decision**: accepted risk, not affected, false positive, fixed, -ignored, or needs review, with an optional OpenVEX justification. Decided findings stop driving the -action posture; a "needs review" decision stays counted but keeps the finding actionable. You can +ignored, or needs review, with an optional OpenVEX justification. Not affected, false positive, and +fixed clear residual Crit/High for Secure. Accepted and ignored residual risk stay on Monitoring and +do not turn the page green. Needs review and affected stay actionable. You can export the fleet's triage decisions as an OpenVEX document for use with other tooling. ## History diff --git a/docs/openapi.yaml b/docs/openapi.yaml index 6e40a2f5..cf83ddb9 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -1890,9 +1890,10 @@ paths: description: | Computes the same preview as GET. When every image reports `check_status: ok` and `has_update` is false (mixed `ok` + - `not_checkable` does not clear), clears sticky confirmed update rows - for the stack and sets `reconciled: true`. Requires `stack:read` - permission. + `not_checkable` does not clear), deletes sticky partial, failed, + and ok+true rows and sets `reconciled: true` only when a row was + deleted. Existing ok+false rows are kept (`reconciled` stays false). + Requires `stack:read` permission. parameters: - $ref: "#/components/parameters/stackName" - $ref: "#/components/parameters/nodeId" diff --git a/frontend/src/components/SecurityView.tsx b/frontend/src/components/SecurityView.tsx index 8de0f0e7..82e229dc 100644 --- a/frontend/src/components/SecurityView.tsx +++ b/frontend/src/components/SecurityView.tsx @@ -18,12 +18,17 @@ import { Masthead, type Tone } from './mobile/mobile-ui'; import { SecurityMobileTabs, type SecurityMobileTab } from './security/SecurityMobile'; import type { SecurityTab } from '@/lib/events'; import type { ImageFilterValue } from '@/lib/severityStyles'; -import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole } from '@/types/security'; +import type { SecurityOverview, ScanSummary, ScanDetailTab, SecurityRiskTrendPoint, ExploitIntelFinding, FleetRole, PostureReasonKind } from '@/types/security'; import { VulnerabilityScanSheet } from './VulnerabilityScanSheet'; import { SuppressionsPanel } from './settings/SuppressionsPanel'; import { MisconfigAckPanel } from './settings/MisconfigAckPanel'; import { OverviewTab } from './security/OverviewTab'; import { reasonImageFilter } from './security/postureNavigation'; +import { + targetingFromTargets, + type ImagesTargetingInput, + type ImagesTargetingState, +} from './security/imagesTargeting'; import { ImagesTab } from './security/ImagesTab'; import { FindingsTab } from './security/FindingsTab'; import { ScanPolicyManager } from './security/ScanPolicyManager'; @@ -86,19 +91,67 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security const [inspectScanId, setInspectScanId] = useState(null); const [inspectInitialTab, setInspectInitialTab] = useState(undefined); - // Filter to preselect on the Images tab when arriving from an overview link - // (e.g. "fixable findings"). Null leaves the Images tab on its own default. + const [inspectDriverVulnerabilityIds, setInspectDriverVulnerabilityIds] = useState(undefined); + // Filter / targeting for the Images tab when arriving from an overview link. + // SecurityView owns both; ImagesTab never clears targeting locally (R1). const [imagesFilter, setImagesFilter] = useState(null); + const [imagesFilterToken, setImagesFilterToken] = useState(0); + const [imagesTargeting, setImagesTargeting] = useState(null); - // Navigate between security tabs, optionally preselecting an Images filter so - // an overview action link lands on exactly the affected images. - const handleNavigate = useCallback((tab: SecurityTab, filter?: ImageFilterValue) => { - if (tab === 'images' && filter) setImagesFilter(filter); + // Navigate between security tabs, optionally preselecting an Images filter + // and/or posture targeting so an overview action lands on the affected images. + const handleNavigate = useCallback(( + tab: SecurityTab, + filter?: ImageFilterValue, + targeting?: ImagesTargetingInput, + ) => { + if (tab === 'images') { + if (targeting && targeting.imageRefs.length > 0) { + setImagesTargeting((prev) => ({ + kind: targeting.kind, + label: targeting.label, + imageRefs: targeting.imageRefs, + targets: targeting.targets, + ...(targeting.drivers ? { drivers: targeting.drivers } : {}), + ...(targeting.driverCount !== undefined ? { driverCount: targeting.driverCount } : {}), + ...(targeting.driversTruncated !== undefined + ? { driversTruncated: targeting.driversTruncated } + : {}), + token: (prev?.token ?? 0) + 1, + })); + // R2: targeting navigation resets severity unless an explicit filter is supplied. + setImagesFilter(filter ?? null); + setImagesFilterToken((t) => t + 1); + } else { + setImagesTargeting(null); + if (filter) { + setImagesFilter(filter); + setImagesFilterToken((t) => t + 1); + } + } + } onTabChange(tab); }, [onTabChange]); - const onInspect = useCallback((scanId: number, initialTab?: ScanDetailTab) => { + const clearImagesTargeting = useCallback(() => { + setImagesTargeting(null); + }, []); + + // Drop Images drill-down state when the active node changes so refs from + // node A never filter node B's summaries. + useEffect(() => { + setImagesTargeting(null); + setImagesFilter(null); + setImagesFilterToken(0); + }, [activeNode?.id]); + + const onInspect = useCallback(( + scanId: number, + initialTab?: ScanDetailTab, + driverVulnerabilityIds?: string[], + ) => { setInspectInitialTab(initialTab); + setInspectDriverVulnerabilityIds(driverVulnerabilityIds); setInspectScanId(scanId); }, []); @@ -229,12 +282,23 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security // The scanner-detections disclaimer rides as an info affordance next to the // scanned-images count rather than a standing caption below the masthead. - // When posture is Action needed, the subtitle leads with the action count and - // top blocker labels so the operator sees "why red" without opening the page. + // When posture is Action needed or Monitoring, the subtitle leads with the + // residual Crit/High count and top reason labels so the operator sees why + // without opening the review queue. const blockers = overview?.postureReasons?.filter((r) => r.severity === 'blocker') ?? []; - const actionSummary = overview?.posture === 'Action needed' && blockers.length > 0 - ? `${blockers.length} action${blockers.length === 1 ? '' : 's'}: ${blockers.slice(0, 2).map((r) => r.label.toLowerCase()).join(', ')} · ` - : null; + const reviewReasons = overview?.postureReasons?.filter((r) => r.severity === 'review') ?? []; + const residualCritHigh = (overview?.rawCritical ?? overview?.critical ?? 0) + + (overview?.rawHigh ?? overview?.high ?? 0); + let actionSummary: string | null = null; + if (overview?.posture === 'Action needed' && blockers.length > 0) { + actionSummary = `${blockers.length} action${blockers.length === 1 ? '' : 's'}: ${blockers.slice(0, 2).map((r) => r.label.toLowerCase()).join(', ')} · `; + } else if (overview?.posture === 'Monitoring' && residualCritHigh > 0) { + const labels = (reviewReasons.length > 0 ? reviewReasons : overview.postureReasons ?? []) + .slice(0, 2) + .map((r) => r.label.toLowerCase()); + const labelPart = labels.length > 0 ? `: ${labels.join(', ')}` : ''; + actionSummary = `${residualCritHigh} residual Crit/High${labelPart} · `; + } const subtitle = overview ? ( {actionSummary ? {actionSummary} : null} @@ -268,6 +332,7 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security onNavigate={handleNavigate} onInspect={onInspect} canScan={canScanNode} + canManageNode={!!activeNode?.id && can('node:manage', 'node', String(activeNode.id))} onScanComplete={() => setReloadToken((t) => t + 1)} /> @@ -283,6 +348,11 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security scanningRef={scanningRef} onScan={scanImage} initialFilter={imagesFilter ?? undefined} + filterToken={imagesFilterToken} + targeting={imagesTargeting} + onClearTargeting={clearImagesTargeting} + posturePartial={overview?.posturePartial === true} + nodeId={activeNode?.id} /> @@ -338,7 +408,18 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security setInspectScanId(null)} + driverVulnerabilityIds={inspectDriverVulnerabilityIds} + driverFilterMode={ + imagesTargeting?.kind === 'waiting_upstream' || imagesTargeting?.kind === 'update_check_uncertain' + ? 'monitoring' + : 'action' + } + driverCount={imagesTargeting?.driverCount} + driversTruncated={imagesTargeting?.driversTruncated} + onClose={() => { + setInspectScanId(null); + setInspectDriverVulnerabilityIds(undefined); + }} canGenerateSbom={canReadSecurityExports} canExportSarif={canReadSecurityExports} canCompare @@ -391,10 +472,21 @@ export function SecurityView({ activeTab, onTabChange, headerActions }: Security {overview?.posture === 'Action needed' && overview.primaryAction ? ( diff --git a/frontend/src/components/__tests__/VulnerabilityScanSheet.suppress.test.tsx b/frontend/src/components/__tests__/VulnerabilityScanSheet.suppress.test.tsx index e6561db2..d729cfe9 100644 --- a/frontend/src/components/__tests__/VulnerabilityScanSheet.suppress.test.tsx +++ b/frontend/src/components/__tests__/VulnerabilityScanSheet.suppress.test.tsx @@ -49,7 +49,7 @@ beforeEach(() => { async function openSuppressDialog() { render( {}} canManageSuppressions />); await waitFor(() => expect(screen.getByText('CVE-2026-1000')).toBeInTheDocument()); - await userEvent.click(screen.getByTitle('Suppress this CVE')); + await userEvent.click(screen.getByTitle('Triage finding')); } async function pickSelect(label: string, optionName: string) { @@ -58,6 +58,12 @@ async function pickSelect(label: string, optionName: string) { } describe('VulnerabilityScanSheet suppress dialog', () => { + it('titles the dialog Triage finding and prefills the exact image pattern', async () => { + await openSuppressDialog(); + expect(screen.getByRole('heading', { name: 'Triage finding' })).toBeInTheDocument(); + expect(screen.getByLabelText('Image pattern (optional)')).toHaveValue('img:1'); + }); + it('requires an OpenVEX justification for a not-affected decision and clears it when switching away', async () => { await openSuppressDialog(); @@ -65,7 +71,7 @@ describe('VulnerabilityScanSheet suppress dialog', () => { expect(screen.getByRole('combobox', { name: 'OpenVEX justification' })).toBeInTheDocument(); await userEvent.type(screen.getByLabelText('Reason'), 'Vendor confirmed unreachable code path.'); - await userEvent.click(screen.getByRole('button', { name: 'Suppress' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); expect(toast.error).toHaveBeenCalledWith('An OpenVEX justification is required for this triage decision.'); expect(postSuppressionCall()).toBeUndefined(); @@ -79,11 +85,12 @@ describe('VulnerabilityScanSheet suppress dialog', () => { await userEvent.type(screen.getByLabelText('Reason'), 'False positive confirmed by vendor.'); await pickSelect('Triage decision', 'False positive'); await pickSelect('OpenVEX justification', 'Inline mitigations already exist'); - await userEvent.click(screen.getByRole('button', { name: 'Suppress' })); + await userEvent.click(screen.getByRole('button', { name: 'Save' })); await waitFor(() => expect(postSuppressionCall()).toBeTruthy()); const body = JSON.parse((postSuppressionCall()![1] as { body: string }).body); expect(body.status).toBe('false_positive'); expect(body.justification).toBe('inline_mitigations_already_exist'); + expect(body.image_pattern).toBe('img:1'); }); }); diff --git a/frontend/src/components/security/ExposureNetworking.tsx b/frontend/src/components/security/ExposureNetworking.tsx new file mode 100644 index 00000000..d9c38c1b --- /dev/null +++ b/frontend/src/components/security/ExposureNetworking.tsx @@ -0,0 +1,144 @@ +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; +import { cn } from '@/lib/utils'; +import { SENCHO_OPEN_STACK_EVENT, type SenchoOpenStackDetail } from '@/lib/events'; +import type { ImageExposureContext } from '@/types/security'; + +function openNetworking(nodeId: number | undefined, stackName: string) { + if (nodeId === undefined) return; + window.dispatchEvent(new CustomEvent(SENCHO_OPEN_STACK_EVENT, { + detail: { nodeId, stackName, destination: 'anatomy-networking' }, + })); +} + +function networkingActionLabel(ctx: ImageExposureContext): string { + return ctx.intentConflict ? 'Review networking' : 'View networking'; +} + +function NetworkingContextList({ + contexts, + nodeId, + showConflictHint = false, +}: { + contexts: ImageExposureContext[]; + nodeId: number; + showConflictHint?: boolean; +}) { + return ( +
    + {contexts.map((ctx) => ( +
  • + +
  • + ))} +
+ ); +} + +/** Network exposed badge + optional multi-context Networking popover. */ +export function NetworkExposedControl({ + contexts, + nodeId, + className, +}: { + contexts: ImageExposureContext[]; + nodeId?: number; + className?: string; +}) { + const badge = ( + + Network exposed + + ); + + if (contexts.length === 0 || nodeId === undefined) { + return badge; + } + + if (contexts.length === 1) { + const only = contexts[0]!; + return ( + + ); + } + + return ( + + + + + e.stopPropagation()}> + + + + ); +} + +/** Banner-only View networking control (single dispatch or multi popover). */ +export function ViewNetworkingAction({ + contexts, + nodeId, +}: { + contexts: ImageExposureContext[]; + nodeId?: number; +}) { + if (contexts.length === 0 || nodeId === undefined) return null; + + if (contexts.length === 1) { + const only = contexts[0]!; + return ( + + ); + } + + return ( + + + + + + + + + ); +} diff --git a/frontend/src/components/security/ImagesTab.tsx b/frontend/src/components/security/ImagesTab.tsx index aa359176..3a990779 100644 --- a/frontend/src/components/security/ImagesTab.tsx +++ b/frontend/src/components/security/ImagesTab.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useRef, useState } from 'react'; +import { useEffect, useMemo, useRef, useState, type ReactNode } from 'react'; import { Boxes, AlertTriangle, Search, ArrowUp, ArrowDown, ChevronLeft, ChevronRight, ShieldCheck, Loader2 } from 'lucide-react'; import { Skeleton } from '@/components/ui/skeleton'; import { Button } from '@/components/ui/button'; @@ -14,8 +14,18 @@ import { formatTimeAgo } from '@/lib/relativeTime'; import { cn } from '@/lib/utils'; import { useIsMobile } from '@/hooks/use-is-mobile'; import { ImageScanRow, ImageFilterChips, type ImageFilterChip } from './SecurityMobile'; -import type { ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security'; - +import { NetworkExposedControl, ViewNetworkingAction } from './ExposureNetworking'; +import type { ImagesTargetingState } from './imagesTargeting'; +import { + intentionalBannerKind, + standingExposureContexts, + standingIntentEvidence, + targetingExposureContexts, + allTargetingExposureContexts, + primaryExposureIntentEvidence, + driverIdsForImage, +} from './imagesTargeting'; +import type { ImageExposureContext, ScanSummary, ScanDetailTab, ScannerKind } from '@/types/security'; // Mobile severity chips. 'FIXABLE' is a phone-only pseudo-filter (the desktop // Combobox never emits it), so the shared filter logic treats it specially. const MOBILE_FILTER_CHIPS: ImageFilterChip[] = [ @@ -66,24 +76,140 @@ const FILTER_OPTIONS: Array<{ value: ImageFilterValue; label: string }> = [ const findingsCount = (s: ScanSummary) => s.total + (s.secret_count ?? 0) + (s.misconfig_count ?? 0); +/** Intent evidence for standing summary, or targeting when active for this image. */ +function intentEvidenceFor( + summary: ScanSummary, + targeting: ImagesTargetingState | null | undefined, +): string | null { + if (targeting?.imageRefs.includes(summary.image_ref)) { + const fromTargets = primaryExposureIntentEvidence(targeting.targets, summary.image_ref); + if (fromTargets) return fromTargets; + } + return standingIntentEvidence(summary); +} + +function contextsForImage( + summary: ScanSummary, + targeting: ImagesTargetingState | null | undefined, +): ImageExposureContext[] { + if (targeting?.imageRefs.includes(summary.image_ref)) { + const fromTargets = targetingExposureContexts(targeting.targets, summary.image_ref); + if (fromTargets.length > 0) return fromTargets; + } + return standingExposureContexts(summary); +} + +function IntentEvidenceLine({ line }: { line: string | null }) { + if (!line) return null; + return ( +
{line}
+ ); +} + +const CLEAR_BTN_CLASS = + 'text-xs font-medium text-brand hover:underline whitespace-nowrap shrink-0'; + +function TargetingClearButton({ onClear }: { onClear?: () => void }) { + if (!onClear) return null; + return ( + + ); +} + +function TargetingBannerFrame({ children }: { children: ReactNode }) { + return ( +
+ {children} +
+ ); +} + +function formatTargetingTitle(label: string, matched: number, total: number): string { + if (matched < total) { + return `${label} · ${matched} of ${total} affected images`; + } + return `${label} · ${matched} affected image${matched === 1 ? '' : 's'}`; +} + +function IntentionalExposureBanner({ + kind, + unavailableCount, + contexts, + nodeId, + onClear, +}: { + kind: 'absolute' | 'partial'; + unavailableCount: number; + contexts: ImageExposureContext[]; + nodeId?: number; + onClear?: () => void; +}) { + const title = kind === 'absolute' + ? 'Exposure is intentional' + : 'Known exposure is intentional'; + const body = kind === 'absolute' + ? 'This workload is classified in Networking. Exposure still increases the security relevance of these findings. Open an affected image below to remediate or triage its findings.' + : `Known exposure contexts are intentionally classified. Intent could not be verified for ${unavailableCount} service${unavailableCount === 1 ? '' : 's'}. Open an affected image below to remediate or triage its findings.`; + + return ( + +
+
+

{title}

+

{body}

+
+
+ + +
+
+
+ ); +} + interface ImagesTabProps { summaries: Record; loading: boolean; /** True when the summaries fetch failed; render an error state, never a false "clean". */ error?: boolean; - onInspect: (scanId: number, initialTab?: ScanDetailTab) => void; + onInspect: (scanId: number, initialTab?: ScanDetailTab, driverVulnerabilityIds?: string[]) => void; /** Admin on a node with a ready scanner; gates the scan Actions column. */ canScan: boolean; /** image_ref of the scan currently in flight, for the per-row spinner. */ scanningRef: string | null; onScan: (imageRef: string, scanners: ScannerKind[]) => void; /** Preselects the severity/fixable filter, e.g. when arriving from an - * overview "fixable findings" link. Applied whenever the value changes. */ + * overview "fixable findings" link. */ initialFilter?: ImageFilterValue; + /** Bumped by SecurityView on each filter/targeting navigation so re-apply works. */ + filterToken?: number; + /** Parent-owned posture targeting (R1). */ + targeting?: ImagesTargetingState | null; + onClearTargeting?: () => void; + /** When true, targeting banner discloses the overview pass may be incomplete. */ + posturePartial?: boolean; + /** Active node id for SENCHO_OPEN_STACK Networking navigation. */ + nodeId?: number; } /** Latest-scan index for real images (stack/config scans live in Compose risks). */ -export function ImagesTab({ summaries, loading, error, onInspect, canScan, scanningRef, onScan, initialFilter }: ImagesTabProps) { +export function ImagesTab({ + summaries, + loading, + error, + onInspect, + canScan, + scanningRef, + onScan, + initialFilter, + filterToken = 0, + targeting = null, + onClearTargeting, + posturePartial = false, + nodeId, +}: ImagesTabProps) { const isMobile = useIsMobile(); const [search, setSearch] = useState(''); const [severity, setSeverity] = useState(initialFilter ?? 'all'); @@ -95,23 +221,57 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann useEffect(() => { if (searchExpanded) searchInputRef.current?.focus(); }, [searchExpanded]); - // Apply an externally-driven filter (e.g. an overview "fixable" deep link). - // Keyed on the incoming value so re-navigating to the same filter re-applies. + // Apply externally-driven filter / targeting. Keyed on tokens so repeating the + // same navigation re-applies after Clear (R1) and resets severity (R2). useEffect(() => { - if (initialFilter) { setSeverity(initialFilter); setPage(0); } - }, [initialFilter]); + if (targeting) { + setSeverity(initialFilter ?? 'all'); + setPage(0); + return; + } + if (initialFilter) { + setSeverity(initialFilter); + setPage(0); + } + }, [targeting?.token, filterToken, targeting, initialFilter]); + + const imageSummaries = useMemo( + () => Object.values(summaries).filter((s) => !s.image_ref.startsWith('stack:')), + [summaries], + ); + + const matchedTargetMeta = useMemo(() => { + if (!targeting || targeting.imageRefs.length === 0) { + return { active: false as const, matched: 0, total: 0, refs: null as Set | null }; + } + const wanted = new Set(targeting.imageRefs); + const refs = new Set( + imageSummaries.filter((s) => wanted.has(s.image_ref)).map((s) => s.image_ref), + ); + return { + active: true as const, + matched: refs.size, + total: targeting.imageRefs.length, + refs, + label: targeting.label, + }; + }, [targeting, imageSummaries]); + + // R3: never filter the list at zero matches; fall back to the full list. + const targetingActive = matchedTargetMeta.active && matchedTargetMeta.matched > 0; const filtered = useMemo(() => { const term = search.trim().toLowerCase(); - return Object.values(summaries) - .filter((s) => !s.image_ref.startsWith('stack:')) + const targetRefs = targetingActive ? matchedTargetMeta.refs : null; + return imageSummaries + .filter((s) => (targetRefs ? targetRefs.has(s.image_ref) : true)) .filter((s) => (term ? s.image_ref.toLowerCase().includes(term) : true)) .filter((s) => { if (severity === 'all') return true; if (severity === 'FIXABLE') return s.fixable > 0; return getSeverityKey(s) === severity; }); - }, [summaries, search, severity]); + }, [imageSummaries, search, severity, targetingActive, matchedTargetMeta.refs]); const sorted = useMemo(() => { const dir = sortDir === 'asc' ? 1 : -1; @@ -155,19 +315,128 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann ); } - const noImagesAtAll = Object.values(summaries).every((s) => s.image_ref.startsWith('stack:')); + const noImagesAtAll = imageSummaries.length === 0; if (noImagesAtAll) { return ( -
- -

No scanned images

-

Scan an image from Resources to see its findings here.

+
+ {targeting && onClearTargeting ? ( + +
+

+ None of the images for this Security action have a scan summary on this node. +

+ +
+
+ ) : null} +
+ +

No scanned images

+

Scan an image from Resources to see its findings here.

+
); } + let targetingBanner: ReactNode = null; + if (matchedTargetMeta.active && matchedTargetMeta.matched === 0) { + targetingBanner = ( + +
+

+ None of the images for this Security action have a scan summary on this node. Showing all images. +

+ +
+
+ ); + } else if (matchedTargetMeta.active && matchedTargetMeta.matched > 0 && targeting) { + const isExposure = targeting.kind === 'public_exposure'; + const hasConflict = isExposure && targeting.targets.some((t) => t.intentConflict); + const driverCount = targeting.drivers?.length ?? 0; + const intentional = isExposure && !hasConflict + ? intentionalBannerKind(targeting.targets, { truncated: posturePartial }) + : { kind: 'none' as const, unavailableCount: 0 }; + + if (hasConflict) { + targetingBanner = ( + +
+
+

Exposure conflicts with declared intent

+

+ Compose publishes beyond loopback while Networking intent is internal or same-node. Review networking to align configuration with intent. +

+
+
+ + +
+
+
+ ); + } else if (intentional.kind === 'absolute' || intentional.kind === 'partial') { + targetingBanner = ( + + ); + } else { + const partialMatch = matchedTargetMeta.matched < matchedTargetMeta.total; + const fullDriverCount = targeting.driverCount ?? driverCount; + const drivingTitle = driverCount > 0; + const monitoringKinds = new Set(['waiting_upstream', 'update_check_uncertain']); + const monitoringMode = monitoringKinds.has(targeting.kind); + const truncated = targeting.driversTruncated === true + && fullDriverCount > driverCount + && driverCount > 0; + const driverTitle = monitoringMode + ? (truncated + ? `Findings under Monitoring · showing ${driverCount} of ${fullDriverCount}` + : `Findings under Monitoring · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`) + : (truncated + ? `Driving current Security action · showing ${driverCount} of ${fullDriverCount}` + : `Driving current Security action · ${fullDriverCount} finding${fullDriverCount === 1 ? '' : 's'}`); + targetingBanner = ( + +
+
+

+ {drivingTitle + ? driverTitle + : formatTargetingTitle( + matchedTargetMeta.label, + matchedTargetMeta.matched, + matchedTargetMeta.total, + )} +

+

+ {drivingTitle + ? (monitoringMode + ? 'Open an image to review findings under Monitoring for this reason.' + : 'Open an image to review the exact findings driving this Security action.') + : 'Showing images responsible for the current Security action.'} + {partialMatch ? ' An affected image has no scan summary on this node.' : ''} + {posturePartial ? ' The overview pass may be incomplete.' : ''} +

+
+ +
+
+ ); + } + } + + const inspectDriversFor = (imageRef: string) => driverIdsForImage(targeting?.drivers, imageRef); + return (
+ {targetingBanner} + {isMobile ? ( <> {search !== '' || searchExpanded ? ( @@ -202,7 +471,15 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann
{pageItems.map((s) => ( - + ))}
{pageItems.length === 0 && ( @@ -263,14 +540,23 @@ export function ImagesTab({ summaries, loading, error, onInspect, canScan, scann {pageItems.map((s) => ( - +
+ + {s.publicly_exposed === true ? ( + + ) : null} +
+
+ ) : null} + +
+
+

{reason.description}

+
+ + ); +} + function ReviewQueueCard({ reasons, onNavigate, + canManageNode, + updateChecksDisabled, + posture, }: { reasons: PostureReason[]; onNavigate: NavigateFn; + canManageNode: boolean; + updateChecksDisabled: boolean; + posture?: SecurityOverview['posture']; }) { + const [checkAgainBusy, setCheckAgainBusy] = useState(false); const blockers = reasons.filter((r) => r.severity === 'blocker'); const nonBlockers = reasons.filter((r) => r.severity !== 'blocker'); const hasBlockers = blockers.length > 0; - const title = hasBlockers ? 'Why Action needed' : 'Review queue'; + const title = hasBlockers + ? 'Why Action needed' + : posture === 'Monitoring' + ? 'Why Monitoring' + : 'Review queue'; + + const handleCheckAgain = async () => { + if (checkAgainBusy) return; + setCheckAgainBusy(true); + try { + await triggerNodeImageUpdateCheck(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to start image update check'); + } finally { + setCheckAgainBusy(false); + } + }; + + const showCheckAgainFor = (r: PostureReason): boolean => + r.kind === 'update_check_uncertain' && canManageNode && !updateChecksDisabled; return (

{title}

{blockers.map((r, i) => ( -
- -
-
- {r.label} - {r.count} - -
-

{r.description}

-
-
+ ))} {nonBlockers.length > 0 && hasBlockers && (
)} {nonBlockers.map((r, i) => ( -
- -
-
- {r.label} - {r.count} -
-

{r.description}

-
-
+ ))}
); } -export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitTruncated, onNavigate, onInspect, canScan, onScanComplete }: OverviewTabProps) { +export function OverviewTab({ + overview, + loadError, + trend, + exploitIntel, + exploitTruncated, + onNavigate, + onInspect, + canScan, + onScanComplete, + canManageNode = false, +}: OverviewTabProps) { const isMobile = useIsMobile(); if (loadError === 'unsupported') { @@ -220,6 +313,9 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT )} @@ -288,7 +384,7 @@ export function OverviewTab({ overview, loadError, trend, exploitIntel, exploitT tone="subtitle" />

- Manage enforcement policies on the Policies tab. This is a read-only posture for the active node. + Security posture describes current operational actionability. Explicit deploy policies may enforce stricter admission rules (package fix available ≠ confirmed image update). Manage enforcement policies on the Policies tab. This is a read-only posture for the active node.

diff --git a/frontend/src/components/security/ScanPolicyManager.tsx b/frontend/src/components/security/ScanPolicyManager.tsx index 9e7bafc0..f71dd334 100644 --- a/frontend/src/components/security/ScanPolicyManager.tsx +++ b/frontend/src/components/security/ScanPolicyManager.tsx @@ -374,7 +374,7 @@ export function ScanPolicyManager() { )} {policy.block_on_fixable === 1 && ( - Fixable + Package fix )} {policy.block_on_deploy === 1 && ( @@ -501,11 +501,11 @@ export function ScanPolicyManager() {
- -

Flag an image with a Critical or High finding that has a fix available.

+ +

Uses the scanner fixed_version for Critical or High findings. This is not a confirmed container-image update.

setForm({ ...form, block_on_fixable: c })} /> diff --git a/frontend/src/components/security/SecurityMobile.tsx b/frontend/src/components/security/SecurityMobile.tsx index 87f69f47..1b839121 100644 --- a/frontend/src/components/security/SecurityMobile.tsx +++ b/frontend/src/components/security/SecurityMobile.tsx @@ -10,7 +10,8 @@ import { Checkbox } from '@/components/ui/checkbox'; import { getSeverityKey, SEVERITY_DOT_CLASSES, type ImageFilterValue } from '@/lib/severityStyles'; import { formatTimeAgo } from '@/lib/relativeTime'; import type { SecurityTab } from '@/lib/events'; -import type { ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security'; +import type { ImageExposureContext, ScanSummary, SecurityOverview, ScanDetailTab, VulnerabilityScan } from '@/types/security'; +import { NetworkExposedControl } from './ExposureNetworking'; export interface SecurityMobileTab { value: SecurityTab; @@ -122,9 +123,23 @@ function CountTag({ tone, children }: { tone: keyof typeof COUNT_TAG_TONE; child /** One image row in the mobile Images list: severity dot, truncated mono ref over * a freshness meta line, trailing C/H count tags (or CLEAN), and a chevron. */ -export function ImageScanRow({ summary, onInspect }: { +export function ImageScanRow({ + summary, + onInspect, + driverVulnerabilityIds, + intentEvidence = null, + exposureContexts = [], + nodeId, +}: { summary: ScanSummary; - onInspect: (scanId: number, initialTab?: ScanDetailTab) => void; + onInspect: (scanId: number, initialTab?: ScanDetailTab, driverVulnerabilityIds?: string[]) => void; + /** Per-image driving finding ids from posture targeting. */ + driverVulnerabilityIds?: string[]; + /** Compact exposure-intent line (standing or while posture-targeting). */ + intentEvidence?: string | null; + /** Stack/service contexts for Networking navigation from Network exposed. */ + exposureContexts?: ImageExposureContext[]; + nodeId?: number; }) { // Use the shared classifier so the count tags agree with the leading dot: a // medium/low-only image is not "clean", it is its highest severity. @@ -133,13 +148,19 @@ export function ImageScanRow({ summary, onInspect }: { return (