From 7ce045accb3872e4aad57e5f0dbf2296fb7a3d5c Mon Sep 17 00:00:00 2001 From: Anso Date: Tue, 16 Jun 2026 00:42:33 -0400 Subject: [PATCH] feat: pre-deploy scan visibility and pinned scanner version (#1378) * feat: pre-deploy scan visibility and pinned scanner version Pin managed Trivy installs and add an opt-in pre-deploy scan advisory so a manual deploy can surface each image's latest scan before it runs. - Managed Trivy now installs a pinned, known-good version by default for reproducible installs. Auto-update still tracks the latest release, and an explicit update always pulls the latest. - Add an opt-in pre-deploy scan advisory: when enabled, deploying a stack from the editor first shows each image's latest cached scan severity for review. It is visibility only and never blocks; deploy enforcement is unchanged. - Backend: pre_deploy_scan_advisory setting, PUT /security/pre-deploy-scan-advisory, a cache-only GET /security/stacks/:name/pre-deploy-summary, and a node-scoped getLatestVulnScanByDigestForNode lookup. - Frontend: advisory toggle on the Security page scanner setup, and a PreDeployScanDialog wired into the editor deploy flow that fails open when the summary is unavailable. - Docs: scanner configuration, version pinning, and the advisory. * fix: harden pre-deploy advisory guard, toggle visibility, and installer busy state Addresses review findings on the pre-deploy advisory. - Block a second editor deploy during the async advisory window with a synchronous pending ref, cleared on cancel and in the deploy's finally, so a double-click can no longer start two deploys. - Keep the pre-deploy advisory toggle visible to admins whenever the setting is on, so it can still be turned off after the scanner becomes unavailable. - Resolve the managed Trivy version inside the install lock so the busy state and serialization cover the latest-version fetch and the managed-install check. --- ...atabase-latest-vuln-scan-by-digest.test.ts | 89 ++++++++++ ...ity-pre-deploy-scan-advisory-route.test.ts | 96 +++++++++++ .../security-pre-deploy-summary-route.test.ts | 137 +++++++++++++++ backend/src/__tests__/settings-routes.test.ts | 5 + .../__tests__/trivy-installer-version.test.ts | 89 ++++++++++ backend/src/routes/security.ts | 88 +++++++++- backend/src/services/DatabaseService.ts | 28 ++++ backend/src/services/TrivyInstaller.ts | 35 +++- docs/features/vulnerability-scanning.mdx | 17 +- docs/operations/trivy-setup.mdx | 1 + .../components/EditorLayout/ShellOverlays.tsx | 11 ++ .../__tests__/fetchPreDeployAdvisory.test.ts | 67 ++++++++ .../EditorLayout/hooks/useOverlayState.ts | 13 ++ .../hooks/useStackActions.test.ts | 157 ++++++++++++++++++ .../EditorLayout/hooks/useStackActions.ts | 99 +++++++++-- .../src/components/security/TrivyManager.tsx | 37 ++++- .../__tests__/ScanPolicyManager.test.tsx | 2 +- .../components/stack/PreDeployScanDialog.tsx | 78 +++++++++ .../__tests__/PreDeployScanDialog.test.tsx | 49 ++++++ frontend/src/hooks/useTrivyStatus.ts | 2 + frontend/src/types/security.ts | 16 ++ 21 files changed, 1093 insertions(+), 23 deletions(-) create mode 100644 backend/src/__tests__/database-latest-vuln-scan-by-digest.test.ts create mode 100644 backend/src/__tests__/security-pre-deploy-scan-advisory-route.test.ts create mode 100644 backend/src/__tests__/security-pre-deploy-summary-route.test.ts create mode 100644 backend/src/__tests__/trivy-installer-version.test.ts create mode 100644 frontend/src/components/EditorLayout/hooks/__tests__/fetchPreDeployAdvisory.test.ts create mode 100644 frontend/src/components/stack/PreDeployScanDialog.tsx create mode 100644 frontend/src/components/stack/__tests__/PreDeployScanDialog.test.tsx diff --git a/backend/src/__tests__/database-latest-vuln-scan-by-digest.test.ts b/backend/src/__tests__/database-latest-vuln-scan-by-digest.test.ts new file mode 100644 index 00000000..66f541bd --- /dev/null +++ b/backend/src/__tests__/database-latest-vuln-scan-by-digest.test.ts @@ -0,0 +1,89 @@ +/** + * getLatestVulnScanByDigestForNode: the node-scoped, vulnerability-bearing + * latest-scan lookup that powers the pre-deploy advisory. It must (a) only ever + * return a scan for the requested node, and (b) only consider scanner sets that + * actually ran the vulnerability scanner, so a secret-only or config scan is + * never mistaken for a clean vuln scan. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; +import type { DatabaseService as DatabaseServiceType, VulnerabilityScan } from '../services/DatabaseService'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +function insertScan( + db: DatabaseServiceType, + over: Partial>, +): number { + return db.createVulnerabilityScan({ + node_id: 1, + image_ref: 'img', + image_digest: 'sha256:default', + scanned_at: 1000, + 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, + ...over, + }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('getLatestVulnScanByDigestForNode', () => { + it('includes vuln and vuln,secret rows but excludes secret-only and config scans', () => { + const db = DatabaseService.getInstance(); + const digest = 'sha256:setfilter'; + // Newer rows that did NOT run the vuln scanner must be ignored even though + // they are more recent than the vuln-bearing rows. + insertScan(db, { image_digest: digest, scanners_used: 'secret', scanned_at: 5000, critical_count: 99 }); + insertScan(db, { image_digest: digest, scanners_used: 'config', scanned_at: 4000, critical_count: 88 }); + insertScan(db, { image_digest: digest, scanners_used: 'vuln', scanned_at: 1000, critical_count: 1 }); + insertScan(db, { image_digest: digest, scanners_used: 'vuln,secret', scanned_at: 2000, critical_count: 2 }); + + const found = db.getLatestVulnScanByDigestForNode(digest, 1); + expect(found).not.toBeNull(); + expect(found?.scanners_used).toBe('vuln,secret'); + expect(found?.critical_count).toBe(2); + }); + + it('is node-scoped: a scan from another node is not returned', () => { + const db = DatabaseService.getInstance(); + const digest = 'sha256:nodescope'; + insertScan(db, { node_id: 2, image_digest: digest, scanners_used: 'vuln', scanned_at: 9000, critical_count: 7 }); + + expect(db.getLatestVulnScanByDigestForNode(digest, 1)).toBeNull(); + expect(db.getLatestVulnScanByDigestForNode(digest, 2)?.critical_count).toBe(7); + }); + + it('ignores non-completed scans', () => { + const db = DatabaseService.getInstance(); + const digest = 'sha256:status'; + insertScan(db, { image_digest: digest, scanners_used: 'vuln', status: 'in_progress', scanned_at: 8000 }); + expect(db.getLatestVulnScanByDigestForNode(digest, 1)).toBeNull(); + }); + + it('returns null for an empty digest', () => { + expect(DatabaseService.getInstance().getLatestVulnScanByDigestForNode('', 1)).toBeNull(); + }); +}); diff --git a/backend/src/__tests__/security-pre-deploy-scan-advisory-route.test.ts b/backend/src/__tests__/security-pre-deploy-scan-advisory-route.test.ts new file mode 100644 index 00000000..e6868342 --- /dev/null +++ b/backend/src/__tests__/security-pre-deploy-scan-advisory-route.test.ts @@ -0,0 +1,96 @@ +/** + * PUT /api/security/pre-deploy-scan-advisory. + * + * Flips the per-instance `pre_deploy_scan_advisory` setting that drives the + * manual-deploy scan advisory. Visibility only, so it is admin-gated but carries + * no tier gate: reachable by any admin on Community as well as Admiral. The + * value is reflected back on GET /trivy-status. + */ +import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let adminCookie: string; +let viewerCookie: string; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + + const { LicenseService } = await import('../services/LicenseService'); + vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); + + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass4', 1); + DatabaseService.getInstance().addUser({ username: 'advisory-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'advisory-viewer', password: 'viewerpass4' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +describe('PUT /api/security/pre-deploy-scan-advisory', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).put('/api/security/pre-deploy-scan-advisory').send({ enabled: true }); + expect(res.status).toBe(401); + }); + + it('rejects an authenticated viewer with 403', async () => { + const res = await request(app) + .put('/api/security/pre-deploy-scan-advisory') + .set('Cookie', viewerCookie) + .send({ enabled: true }); + expect(res.status).toBe(403); + }); + + it('rejects a non-boolean enabled with 400', async () => { + const res = await request(app) + .put('/api/security/pre-deploy-scan-advisory') + .set('Cookie', adminCookie) + .send({ enabled: 'yes' }); + expect(res.status).toBe(400); + }); + + it('accepts a Community admin (no tier gate) and persists the setting', async () => { + const { LicenseService } = await import('../services/LicenseService'); + const spy = vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('community'); + try { + const res = await request(app) + .put('/api/security/pre-deploy-scan-advisory') + .set('Cookie', adminCookie) + .send({ enabled: true }); + expect(res.status).toBe(200); + expect(res.body.preDeployScanAdvisory).toBe(true); + expect(DatabaseService.getInstance().getGlobalSettings().pre_deploy_scan_advisory).toBe('1'); + } finally { + spy.mockReturnValue('paid'); + } + }); + + it('reflects the persisted value on GET /trivy-status', async () => { + await request(app) + .put('/api/security/pre-deploy-scan-advisory') + .set('Cookie', adminCookie) + .send({ enabled: true }); + const res = await request(app).get('/api/security/trivy-status').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.preDeployScanAdvisory).toBe(true); + }); + + it('disables the setting', async () => { + const res = await request(app) + .put('/api/security/pre-deploy-scan-advisory') + .set('Cookie', adminCookie) + .send({ enabled: false }); + expect(res.status).toBe(200); + expect(res.body.preDeployScanAdvisory).toBe(false); + expect(DatabaseService.getInstance().getGlobalSettings().pre_deploy_scan_advisory).toBe('0'); + }); +}); diff --git a/backend/src/__tests__/security-pre-deploy-summary-route.test.ts b/backend/src/__tests__/security-pre-deploy-summary-route.test.ts new file mode 100644 index 00000000..a6da271c --- /dev/null +++ b/backend/src/__tests__/security-pre-deploy-summary-route.test.ts @@ -0,0 +1,137 @@ +/** + * GET /api/security/stacks/:stackName/pre-deploy-summary. + * + * Read-only advisory data for the pre-deploy dialog. It must: + * - short-circuit (no compose/digest/scan work) when the advisory is off, + * - resolve each image ref to a digest before the node-scoped scan lookup, + * - never trigger a scan (cache-only), + * - reject an invalid stack name, and require auth. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import request from 'supertest'; +import bcrypt from 'bcrypt'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; +import type { VulnerabilityScan } from '../services/DatabaseService'; + +let tmpDir: string; +let app: import('express').Express; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let ComposeService: typeof import('../services/ComposeService').ComposeService; +let TrivyService: typeof import('../services/TrivyService').default; +let adminCookie: string; +let viewerCookie: string; + +function makeScan(over: Partial): VulnerabilityScan { + return { + id: 1, node_id: 1, image_ref: 'img', image_digest: 'sha256:a', scanned_at: 1000, + total_vulnerabilities: 5, critical_count: 3, high_count: 2, medium_count: 1, low_count: 4, + 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, policy_evaluation: null, ...over, + }; +} + +function setAdvisory(enabled: boolean): void { + DatabaseService.getInstance().updateGlobalSetting('pre_deploy_scan_advisory', enabled ? '1' : '0'); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ ComposeService } = await import('../services/ComposeService')); + TrivyService = (await import('../services/TrivyService')).default; + ({ app } = await import('../index')); + adminCookie = await loginAsTestAdmin(app); + + const viewerHash = await bcrypt.hash('viewerpass5', 1); + DatabaseService.getInstance().addUser({ username: 'summary-viewer', password_hash: viewerHash, role: 'viewer' }); + const viewerRes = await request(app).post('/api/auth/login').send({ username: 'summary-viewer', password: 'viewerpass5' }); + const cookies = viewerRes.headers['set-cookie'] as string | string[]; + viewerCookie = Array.isArray(cookies) ? cookies[0] : cookies; +}); + +afterAll(() => { + setAdvisory(false); + cleanupTestDb(tmpDir); +}); + +afterEach(() => { + vi.restoreAllMocks(); + setAdvisory(false); +}); + +describe('GET /api/security/stacks/:stackName/pre-deploy-summary', () => { + it('rejects unauthenticated requests with 401', async () => { + const res = await request(app).get('/api/security/stacks/web/pre-deploy-summary'); + expect(res.status).toBe(401); + }); + + it('short-circuits to { enabled: false } without any compose/digest work when off', async () => { + setAdvisory(false); + const listImages = vi.spyOn(ComposeService.prototype, 'listStackImages'); + const getDigest = vi.spyOn(TrivyService.getInstance(), 'getImageDigest'); + + const res = await request(app).get('/api/security/stacks/web/pre-deploy-summary').set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body).toEqual({ enabled: false }); + expect(listImages).not.toHaveBeenCalled(); + expect(getDigest).not.toHaveBeenCalled(); + }); + + it('returns per-image cached scan counts, resolving refs to digests (cache-only)', async () => { + setAdvisory(true); + vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:1.14', 'redis:7']); + vi.spyOn(TrivyService.getInstance(), 'getImageDigest').mockImplementation( + async (ref: string) => (ref === 'nginx:1.14' ? 'sha256:nginx' : null), + ); + const lookup = vi + .spyOn(DatabaseService.getInstance(), 'getLatestVulnScanByDigestForNode') + .mockImplementation((digest: string) => (digest === 'sha256:nginx' ? makeScan({ critical_count: 31, high_count: 82, highest_severity: 'CRITICAL', scanned_at: 4242 }) : null)); + // Cache-only: the advisory must never kick off a scan. + const runScan = vi.spyOn(TrivyService.getInstance(), 'scanImagePreflight'); + + const res = await request(app).get('/api/security/stacks/web/pre-deploy-summary').set('Cookie', adminCookie); + + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); + expect(res.body.images).toEqual([ + { + imageRef: 'nginx:1.14', + scan: { criticalCount: 31, highCount: 82, mediumCount: 1, lowCount: 4, highestSeverity: 'CRITICAL', scannedAt: 4242 }, + }, + { imageRef: 'redis:7', scan: null }, + ]); + expect(lookup).toHaveBeenCalled(); + expect(runScan).not.toHaveBeenCalled(); + }); + + it('rejects an invalid stack name with 400 when enabled', async () => { + setAdvisory(true); + const res = await request(app).get('/api/security/stacks/bad%20name/pre-deploy-summary').set('Cookie', adminCookie); + expect(res.status).toBe(400); + }); + + it('is readable by a non-admin (advisory visibility is not admin-gated)', async () => { + setAdvisory(true); + vi.spyOn(ComposeService.prototype, 'listStackImages').mockResolvedValue(['nginx:1.14']); + vi.spyOn(TrivyService.getInstance(), 'getImageDigest').mockResolvedValue(null); + + const res = await request(app).get('/api/security/stacks/web/pre-deploy-summary').set('Cookie', viewerCookie); + + expect(res.status).toBe(200); + expect(res.body.enabled).toBe(true); + }); + + it('returns a generic 500 (no internal detail) when image enumeration fails', async () => { + setAdvisory(true); + vi.spyOn(ComposeService.prototype, 'listStackImages').mockRejectedValue(new Error('/secret/path: compose parse boom')); + + const res = await request(app).get('/api/security/stacks/web/pre-deploy-summary').set('Cookie', adminCookie); + + expect(res.status).toBe(500); + expect(res.body.error).toBe('Failed to build pre-deploy summary'); + expect(JSON.stringify(res.body)).not.toContain('/secret/path'); + }); +}); diff --git a/backend/src/__tests__/settings-routes.test.ts b/backend/src/__tests__/settings-routes.test.ts index 2bbef2e4..010186fe 100644 --- a/backend/src/__tests__/settings-routes.test.ts +++ b/backend/src/__tests__/settings-routes.test.ts @@ -69,6 +69,7 @@ describe('GET /api/settings', () => { db.updateGlobalSetting(k, ''); } db.updateGlobalSetting('trivy_auto_update', '0'); + db.updateGlobalSetting('pre_deploy_scan_advisory', '0'); db.updateGlobalSetting('mesh_auto_recreate', '0'); }); @@ -83,6 +84,9 @@ describe('GET /api/settings', () => { db.updateGlobalSetting('cloud_backup_endpoint', 'https://s3.example.com'); db.updateGlobalSetting('cloud_backup_bucket', 'private-bucket'); db.updateGlobalSetting('trivy_auto_update', '1'); + // Scanner toggles live under /api/security/*, never the generic settings + // allowlist, so the advisory key must not surface here either. + db.updateGlobalSetting('pre_deploy_scan_advisory', '1'); // A key that is neither allowlisted nor obviously sensitive: the allowlist // must still exclude it. This locks the fail-closed contract that is the // reason the GET uses an allowlist rather than a denylist. @@ -97,6 +101,7 @@ describe('GET /api/settings', () => { expect(res.body.cloud_backup_endpoint).toBeUndefined(); expect(res.body.cloud_backup_bucket).toBeUndefined(); expect(res.body.trivy_auto_update).toBeUndefined(); + expect(res.body.pre_deploy_scan_advisory).toBeUndefined(); expect(res.body.some_future_setting).toBeUndefined(); // Allowlisted keys still come through. Two structurally different keys (a // numeric and an enum-shaped one) prove the projection passes the whole diff --git a/backend/src/__tests__/trivy-installer-version.test.ts b/backend/src/__tests__/trivy-installer-version.test.ts new file mode 100644 index 00000000..e4dd0a6a --- /dev/null +++ b/backend/src/__tests__/trivy-installer-version.test.ts @@ -0,0 +1,89 @@ +/** + * Managed Trivy install/update version selection. + * + * A normal managed install pins to a known-good bundled version for + * reproducibility; opting into auto-update tracks the latest release; and an + * explicit Update always pulls the latest regardless of the auto-update setting. + * The download itself (doInstall) is mocked so these stay offline and fast. + */ +import { describe, it, expect, beforeAll, afterAll, afterEach, vi } from 'vitest'; +import { setupTestDb, cleanupTestDb } from './helpers/setupTestDb'; + +let tmpDir: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; +let TrivyInstaller: typeof import('../services/TrivyInstaller').default; + +// The pinned default the installer must use when auto-update is off. Kept in +// sync with PINNED_TRIVY_VERSION in TrivyInstaller.ts. +const PINNED = '0.70.0'; + +type InstallerInternals = { doInstall: (version: string) => Promise<{ version: string }> }; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + TrivyInstaller = (await import('../services/TrivyInstaller')).default; +}); + +afterAll(() => cleanupTestDb(tmpDir)); + +afterEach(() => { + vi.restoreAllMocks(); + DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', '0'); +}); + +describe('TrivyInstaller managed version selection', () => { + it('install() pins to the bundled version by default (auto-update off)', async () => { + const installer = TrivyInstaller.getInstance(); + DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', '0'); + const fetchLatest = vi.spyOn(installer, 'fetchLatestVersion').mockResolvedValue('0.99.0'); + const doInstall = vi + .spyOn(installer as unknown as InstallerInternals, 'doInstall') + .mockResolvedValue({ version: PINNED }); + + await installer.install(); + + expect(fetchLatest).not.toHaveBeenCalled(); + expect(doInstall).toHaveBeenCalledWith(PINNED); + }); + + it('install() tracks the latest release when auto-update is on', async () => { + const installer = TrivyInstaller.getInstance(); + DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', '1'); + const fetchLatest = vi.spyOn(installer, 'fetchLatestVersion').mockResolvedValue('0.99.0'); + const doInstall = vi + .spyOn(installer as unknown as InstallerInternals, 'doInstall') + .mockResolvedValue({ version: '0.99.0' }); + + await installer.install(); + + expect(fetchLatest).toHaveBeenCalled(); + expect(doInstall).toHaveBeenCalledWith('0.99.0'); + }); + + it('update() always installs the latest release, even with auto-update off', async () => { + const installer = TrivyInstaller.getInstance(); + DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', '0'); + vi.spyOn(installer, 'isManagedInstalled').mockReturnValue(true); + const fetchLatest = vi.spyOn(installer, 'fetchLatestVersion').mockResolvedValue('0.99.0'); + const doInstall = vi + .spyOn(installer as unknown as InstallerInternals, 'doInstall') + .mockResolvedValue({ version: '0.99.0' }); + + await installer.update(); + + expect(fetchLatest).toHaveBeenCalled(); + expect(doInstall).toHaveBeenCalledWith('0.99.0'); + }); + + it('rejects a resolved version below the supported minimum (real doInstall guard)', async () => { + const installer = TrivyInstaller.getInstance(); + // Auto-update on so install() takes the fetched-latest path; the fetch returns + // a sub-minimum version, which the doInstall guard must reject before any + // download happens. doInstall is NOT mocked here so the real guard runs. + DatabaseService.getInstance().updateGlobalSetting('trivy_auto_update', '1'); + vi.spyOn(installer, 'fetchLatestVersion').mockResolvedValue('0.49.0'); + + await expect(installer.install()).rejects.toThrow(/below minimum/); + }); +}); diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index bcc7de29..02da3af5 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -4,7 +4,9 @@ import { requireAdmin, requirePaid } from '../middleware/tierGates'; import { trivyInstallLimiter } from '../middleware/rateLimiters'; import TrivyService, { SbomFormat } from '../services/TrivyService'; import TrivyInstaller from '../services/TrivyInstaller'; -import { DatabaseService, parsePolicyEvaluation, type VulnerabilityScan } from '../services/DatabaseService'; +import { DatabaseService, parsePolicyEvaluation, type VulnerabilityScan, type VulnSeverity } from '../services/DatabaseService'; +import { ComposeService } from '../services/ComposeService'; +import { isValidStackName } from '../utils/validation'; import { FleetSyncService } from '../services/FleetSyncService'; import { LicenseService } from '../services/LicenseService'; import { validateImageRef } from '../utils/image-ref'; @@ -149,6 +151,7 @@ securityRouter.get('/trivy-status', authMiddleware, (_req: Request, res: Respons source: svc.getSource(), autoUpdate: settings.trivy_auto_update === '1', honorSuppressionsOnDeploy: settings.deploy_block_honor_suppressions === '1', + preDeployScanAdvisory: settings.pre_deploy_scan_advisory === '1', busy: installer.isBusy(), }); }); @@ -264,6 +267,89 @@ securityRouter.put('/deploy-block-honor-suppressions', authMiddleware, (req: Req } }); +// Pre-deploy scan advisory toggle. When on, a manual stack deploy first shows +// the latest cached scan severity for each image so the operator can review it +// before deploying. Visibility only: it never blocks (that is the paid +// deploy-block policy). Per-instance, admin-only, all tiers. Default off. +securityRouter.put('/pre-deploy-scan-advisory', authMiddleware, (req: Request, res: Response): void => { + if (!requireAdmin(req, res)) return; + if (typeof req.body?.enabled !== 'boolean') { + res.status(400).json({ error: 'enabled must be a boolean' }); + return; + } + const enabled = req.body.enabled; + try { + DatabaseService.getInstance().updateGlobalSetting('pre_deploy_scan_advisory', enabled ? '1' : '0'); + res.json({ preDeployScanAdvisory: enabled }); + } catch (err) { + const msg = getErrorMessage(err, 'Failed to update setting'); + console.error('[Security] Pre-deploy scan advisory toggle failed:', msg); + res.status(500).json({ error: msg }); + } +}); + +interface PreDeployImageSummary { + imageRef: string; + scan: { + criticalCount: number; + highCount: number; + mediumCount: number; + lowCount: number; + highestSeverity: VulnSeverity | null; + scannedAt: number; + } | null; +} + +// Read-only advisory data for the pre-deploy dialog: the latest cached, +// node-scoped, vulnerability-bearing scan for each image declared in the stack's +// compose file. Cache-only by design (never triggers a scan) so it stays fast +// and side-effect-free on the deploy path, and short-circuits when the advisory +// is off so the common path does no compose/digest work. No tier gate +// (visibility is free); matches the auth model of the other /scans read routes. +securityRouter.get('/stacks/:stackName/pre-deploy-summary', authMiddleware, async (req: Request, res: Response): Promise => { + const settings = DatabaseService.getInstance().getGlobalSettings(); + if (settings.pre_deploy_scan_advisory !== '1') { + res.json({ enabled: false }); + return; + } + const stackName = String(req.params.stackName); + if (!isValidStackName(stackName)) { + res.status(400).json({ error: 'Invalid stack name' }); + return; + } + const nodeId = req.nodeId; + try { + const db = DatabaseService.getInstance(); + const trivy = TrivyService.getInstance(); + const imageRefs = await ComposeService.getInstance(nodeId).listStackImages(stackName); + const images: PreDeployImageSummary[] = []; + for (const imageRef of imageRefs) { + const digest = await trivy.getImageDigest(imageRef, nodeId); + const scan = digest ? db.getLatestVulnScanByDigestForNode(digest, nodeId) : null; + images.push({ + imageRef, + scan: scan + ? { + criticalCount: scan.critical_count, + highCount: scan.high_count, + mediumCount: scan.medium_count, + lowCount: scan.low_count, + highestSeverity: scan.highest_severity, + scannedAt: scan.scanned_at, + } + : null, + }); + } + res.json({ enabled: true, images }); + } catch (err) { + const msg = getErrorMessage(err, 'Failed to build pre-deploy summary'); + console.error('[Security] Pre-deploy summary failed for %s:', sanitizeForLog(stackName), sanitizeForLog(msg)); + // Keep the detail in the server log only: this path runs docker compose and + // image inspect, whose error text can carry filesystem or daemon detail. + res.status(500).json({ error: 'Failed to build pre-deploy summary' }); + } +}); + securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void => { if (!requireAdmin(req, res)) return; const svc = TrivyService.getInstance(); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index f84a03d7..fbaf8b94 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -588,6 +588,13 @@ export interface VulnerabilityScan { policy_evaluation: string | null; } +// Fail-closed allowlist of the scanners_used values that ran the vulnerability +// scanner. Image scans store vuln/secret joins from normalizeScanners (TrivyService), +// while compose scans store 'config' directly; listing only the vuln-bearing sets +// means any other value (secret-only, config, or a future literal) is excluded, so +// a clean vuln read can never be a secret-only or config scan in disguise. +export const VULN_BEARING_SCANNER_SETS = ['vuln', 'vuln,secret'] as const; + export function parsePolicyEvaluation(raw: string | null | undefined): PolicyEvaluation | null { if (!raw) return null; try { @@ -1458,6 +1465,7 @@ export class DatabaseService { stmt.run('trivy_auto_update', '0'); stmt.run('trivy_last_notified_version', ''); stmt.run('deploy_block_honor_suppressions', '0'); + stmt.run('pre_deploy_scan_advisory', '0'); stmt.run('mesh_auto_recreate', '0'); stmt.run('prune_on_update', '1'); stmt.run('reclaim_hero', '1'); @@ -4226,6 +4234,26 @@ export class DatabaseService { ); } + /** + * Latest completed vulnerability-bearing scan for a digest ON A SPECIFIC NODE. + * + * Unlike getLatestScanByDigest, this filters by node_id so a read-only + * response never surfaces a scan (or scan id) produced on another node, and + * it restricts to scanner sets that actually ran the vulnerability scanner so + * a secret-only or config scan is not mistaken for a clean vuln scan. + */ + public getLatestVulnScanByDigestForNode(digest: string, nodeId: number): VulnerabilityScan | null { + if (!digest) return null; + const placeholders = VULN_BEARING_SCANNER_SETS.map(() => '?').join(', '); + return ( + (this.db + .prepare( + `SELECT * FROM vulnerability_scans WHERE image_digest = ? AND node_id = ? AND status = 'completed' AND scanners_used IN (${placeholders}) ORDER BY scanned_at DESC LIMIT 1`, + ) + .get(digest, nodeId, ...VULN_BEARING_SCANNER_SETS) as VulnerabilityScan | undefined) ?? null + ); + } + public deleteOldScans(olderThanMs: number): number { const cutoff = Date.now() - olderThanMs; const result = this.db diff --git a/backend/src/services/TrivyInstaller.ts b/backend/src/services/TrivyInstaller.ts index 6bede733..f4857e0e 100644 --- a/backend/src/services/TrivyInstaller.ts +++ b/backend/src/services/TrivyInstaller.ts @@ -4,6 +4,7 @@ import fs from 'fs'; import path from 'path'; import crypto from 'crypto'; import semver from 'semver'; +import { DatabaseService } from './DatabaseService'; const execFileAsync = promisify(execFile); @@ -15,6 +16,11 @@ const GITHUB_API_TIMEOUT_MS = 15 * 1000; const VERIFY_TIMEOUT_MS = 10 * 1000; const LATEST_VERSION_TTL_MS = 60 * 60 * 1000; const MIN_TRIVY_VERSION = '0.50.0'; +// Managed installs pin to a known-good release by default so the scanner binary +// is reproducible and supply-chain stable rather than whatever "latest" resolves +// to at install time. Opting into auto-update (trivy_auto_update) tracks the +// newest release instead. Keep this at or above MIN_TRIVY_VERSION. +const PINNED_TRIVY_VERSION = '0.70.0'; export type TrivySource = 'managed' | 'host' | 'none'; @@ -142,14 +148,28 @@ class TrivyInstaller { } public async install(): Promise<{ version: string }> { - return this.acquire(async () => this.doInstall()); + // Resolve the version inside acquire() so busy stays true through the + // latest-version network fetch and a concurrent op is serialized. + return this.acquire(async () => this.doInstall(await this.resolveInstallVersion())); } public async update(): Promise<{ version: string }> { - if (!this.isManagedInstalled()) { - throw new Error('No managed Trivy install to update'); - } - return this.acquire(async () => this.doInstall()); + return this.acquire(async () => { + if (!this.isManagedInstalled()) { + throw new Error('No managed Trivy install to update'); + } + // An explicit update always pulls the newest release, regardless of + // the auto-update setting; that is the whole point of the action. + return this.doInstall(await this.fetchLatestVersion(true)); + }); + } + + // Fresh installs pin by default for reproducibility; when the operator has + // opted into auto-update, a fresh install tracks the latest release so it + // matches the cadence the scheduler will keep it on. + private async resolveInstallVersion(): Promise { + const autoUpdate = DatabaseService.getInstance().getGlobalSettings().trivy_auto_update === '1'; + return autoUpdate ? this.fetchLatestVersion(true) : PINNED_TRIVY_VERSION; } public async uninstall(): Promise { @@ -176,10 +196,9 @@ class TrivyInstaller { } } - private async doInstall(): Promise<{ version: string }> { - const version = await this.fetchLatestVersion(true); + private async doInstall(version: string): Promise<{ version: string }> { if (semver.lt(version, MIN_TRIVY_VERSION)) { - throw new Error(`Fetched Trivy version ${version} is below minimum ${MIN_TRIVY_VERSION}`); + throw new Error(`Trivy version ${version} is below minimum ${MIN_TRIVY_VERSION}`); } const archTag = archAssetTag(); const assetName = `trivy_${version}_Linux-${archTag}.tar.gz`; diff --git a/docs/features/vulnerability-scanning.mdx b/docs/features/vulnerability-scanning.mdx index 94dd8a4f..b0b2a5ba 100644 --- a/docs/features/vulnerability-scanning.mdx +++ b/docs/features/vulnerability-scanning.mdx @@ -21,13 +21,14 @@ The Trivy CLI must be available on the machine running Sencho. Trivy is not bund | Feature | Community | Admiral | |---------|:---------:|:-------:| -| Install / update / uninstall Trivy from Settings | ✓ | ✓ | +| Install / update / uninstall Trivy | ✓ | ✓ | | On-demand image vulnerability scanning | ✓ | ✓ | | Full scan (vulnerabilities + secrets) | ✓ | ✓ | | Compose file misconfiguration scanning | ✓ | ✓ | | Severity badges in the Resources Hub | ✓ | ✓ | | Scan results drawer with grouped tabs | ✓ | ✓ | | Post-deploy automated scanning | ✓ | ✓ | +| Pre-deploy scan advisory on manual deploys | ✓ | ✓ | | Scan history sheet | ✓ | ✓ | | Scan comparison | ✓ | ✓ | | CVE suppressions | ✓ | ✓ | @@ -127,6 +128,20 @@ The App Store deploy sheet has a **Security** section with a **Scan images for v App Store deploy sheet Security section with the auto-scan checkbox enabled +## Pre-deploy scan advisory + +The pre-deploy scan advisory is an opt-in review step for manual deploys. When it is on, deploying a stack from the editor (**Deploy** or **Save & deploy**) first opens a dialog listing each image in the stack with its latest scan severity, so you can review the security posture before the deploy runs. + +The advisory is visibility only: it never blocks a deploy. Anyone who can deploy reviews the breakdown and chooses **Deploy** to continue or **Cancel** to stop. Blocking a deploy on a severity threshold is a separate capability covered in [Deploy Enforcement](/features/deploy-enforcement). + +Enable it on the **Security** page → **Scanner setup** tab with the **Pre-deploy scan advisory** toggle (admin only). It is off by default. + +How it behaves: + +- The counts come from each image's most recent cached scan. An image that has not been scanned yet shows **not scanned**, and the dialog notes how long ago each scan ran. +- It reads cached results only and never starts a new scan, so it does not slow the deploy. If the scan data cannot be read, the deploy proceeds normally. +- It covers manual editor deploys (**Deploy** and **Save & deploy**). Automated paths (stack update, template deploy, Git source apply, scheduled runs) are already covered by [post-deploy automated scanning](#post-deploy-automated-scanning) and, where configured, deploy enforcement. + ## Scheduled fleet scans You can run recurring scans of every image on a node through the standard [Scheduled Operations](/features/scheduled-operations) system. Create a scheduled task with action **Scan** and a cron expression. The scheduler iterates every image on the target node with a short delay between scans and records the result in the task's run history. diff --git a/docs/operations/trivy-setup.mdx b/docs/operations/trivy-setup.mdx index 9fa5440e..7cb5b53b 100644 --- a/docs/operations/trivy-setup.mdx +++ b/docs/operations/trivy-setup.mdx @@ -36,6 +36,7 @@ Behind the scenes: - The Trivy binary is downloaded into Sencho's existing data volume at `/app/data/bin/trivy`. No host filesystem changes. - The vulnerability database cache defaults to `/app/data/trivy-cache` so it persists across container restarts. - Downloads are verified against the official Trivy checksum file before the binary is put in place. +- By default the managed install pins to a known-good Trivy version, so a fresh install is reproducible rather than tracking whatever "latest" happens to be. Turn on **Auto-update Trivy** to track the newest release instead; an explicit **Update** always pulls the latest regardless of the toggle. - The installed version survives Sencho image upgrades because it lives on the mounted data volume. ### Updating the managed install diff --git a/frontend/src/components/EditorLayout/ShellOverlays.tsx b/frontend/src/components/EditorLayout/ShellOverlays.tsx index 9cbb3ea2..9d21815f 100644 --- a/frontend/src/components/EditorLayout/ShellOverlays.tsx +++ b/frontend/src/components/EditorLayout/ShellOverlays.tsx @@ -1,5 +1,6 @@ import BashExecModal from '../BashExecModal'; import { PolicyBlockDialog } from '../stack/PolicyBlockDialog'; +import { PreDeployScanDialog } from '../stack/PreDeployScanDialog'; import { UpdateReadinessDialog } from '../stack/UpdateReadinessDialog'; import { DeleteStackDialog } from './DeleteStackDialog'; import { UnsavedChangesDialog } from './UnsavedChangesDialog'; @@ -43,6 +44,7 @@ export function ShellOverlays({ stackMonitor, closeStackMonitor, policyBlock, setPolicyBlock, policyBypassing, updateReadiness, setUpdateReadiness, + preDeployAdvisory, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, } = overlayState; @@ -98,6 +100,15 @@ export function ShellOverlays({ onProceed={() => updateReadiness?.proceed()} /> + {/* Pre-deploy scan advisory (visibility only; never blocks) */} + preDeployAdvisory?.cancel()} + onDeploy={() => preDeployAdvisory?.proceed()} + /> + {/* Pre-deploy policy block */} ({ + apiFetch: vi.fn(), + withDeploySession: (_id: string, o: object) => o, +})); +vi.mock('@/components/ui/toast-store', () => ({ + toast: { error: vi.fn(), success: vi.fn(), info: vi.fn(), warning: vi.fn(), loading: vi.fn(() => 'id'), dismiss: vi.fn() }, +})); + +import { apiFetch } from '@/lib/api'; +import { fetchPreDeployAdvisory } from '../useStackActions'; + +const mockedFetch = vi.mocked(apiFetch); + +function response(ok: boolean, body: unknown): Response { + return { ok, json: async () => body } as unknown as Response; +} + +beforeEach(() => mockedFetch.mockReset()); + +describe('fetchPreDeployAdvisory', () => { + it('returns the image list when the advisory is enabled, bound to the captured node', async () => { + mockedFetch.mockResolvedValue(response(true, { enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] })); + const result = await fetchPreDeployAdvisory('web', 7); + expect(result).toEqual([{ imageRef: 'nginx:1.14', scan: null }]); + expect(mockedFetch).toHaveBeenCalledWith( + '/security/stacks/web/pre-deploy-summary', + expect.objectContaining({ nodeId: 7 }), + ); + }); + + it('returns null when the advisory is disabled', async () => { + mockedFetch.mockResolvedValue(response(true, { enabled: false })); + expect(await fetchPreDeployAdvisory('web', 1)).toBeNull(); + }); + + it('fails open on a non-ok response (older node without the route)', async () => { + mockedFetch.mockResolvedValue(response(false, {})); + expect(await fetchPreDeployAdvisory('web', null)).toBeNull(); + }); + + it('fails open when the response cannot be read (timeout / abort / network)', async () => { + // A read failure inside the request exercises the same fail-open catch a + // network rejection would, without leaving a stray rejected promise that the + // test runner would flag as unhandled. + mockedFetch.mockResolvedValue({ + ok: true, + json: async () => { + throw new Error('aborted'); + }, + } as unknown as Response); + expect(await fetchPreDeployAdvisory('web', 1)).toBeNull(); + }); + + it('fails open on a malformed body', async () => { + mockedFetch.mockResolvedValue(response(true, { enabled: true, images: 'not-an-array' })); + expect(await fetchPreDeployAdvisory('web', 1)).toBeNull(); + }); +}); diff --git a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts index 19bebe59..4214cca4 100644 --- a/frontend/src/components/EditorLayout/hooks/useOverlayState.ts +++ b/frontend/src/components/EditorLayout/hooks/useOverlayState.ts @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect } from 'react'; import { SENCHO_OPEN_LOGS_EVENT } from '@/lib/events'; import type { SenchoOpenLogsDetail } from '@/lib/events'; import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog'; +import type { PreDeployScanImage } from '@/types/security'; import type { Node } from '@/context/NodeContext'; type DiffPreview = { @@ -96,6 +97,17 @@ export function useOverlayState() { proceed: () => void; } | null>(null); + // Pre-deploy scan advisory dialog. `proceed` runs the actual deploy when the + // user confirms; opened by useStackActions.deployStack when the advisory + // setting is on. Callback continuation (like updateReadiness), so cancel / + // close / unmount simply discards it with no pending action to leak. + const [preDeployAdvisory, setPreDeployAdvisory] = useState<{ + stackName: string; + images: PreDeployScanImage[]; + proceed: () => void; + cancel: () => void; + } | null>(null); + const [stackMisconfigScanId, setStackMisconfigScanId] = useState(null); const [diffPreview, setDiffPreview] = useState(null); @@ -112,6 +124,7 @@ export function useOverlayState() { stackMonitor, openAlertSheet, openAutoHeal, closeStackMonitor, policyBlock, setPolicyBlock, policyBypassing, setPolicyBypassing, updateReadiness, setUpdateReadiness, + preDeployAdvisory, setPreDeployAdvisory, stackMisconfigScanId, setStackMisconfigScanId, diffPreview, setDiffPreview, diffPreviewConfirming, setDiffPreviewConfirming, } as const; diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts index 43966271..e9010960 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.test.ts @@ -86,6 +86,8 @@ function makeOverlay(over: Partial = {}): OverlayState { setPolicyBypassing: vi.fn(), updateReadiness: null, setUpdateReadiness: vi.fn(), + preDeployAdvisory: null, + setPreDeployAdvisory: vi.fn(), setDiffPreview: vi.fn(), ...over, } as unknown as OverlayState; @@ -253,6 +255,9 @@ describe('useStackActions policy-block dialog wiring', () => { }); it('opens the dialog with action "deploy" when an editor deploy is blocked', async () => { + // deployStack first fetches the pre-deploy advisory summary; keep it off so + // the flow reaches the deploy POST that returns the policy block. + vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify({ enabled: false }), { status: 200 })); vi.mocked(apiFetch).mockResolvedValueOnce(new Response(JSON.stringify(policyPayload), { status: 409 })); const { result, overlayState } = setup(); await result.current.deployStack(mouseEvent); @@ -309,6 +314,158 @@ describe('useStackActions policy-block dialog wiring', () => { }); }); +describe('useStackActions pre-deploy advisory', () => { + const mouseEvent = { preventDefault: vi.fn(), stopPropagation: vi.fn() } as unknown as React.MouseEvent; + + beforeEach(() => { + vi.mocked(apiFetch).mockReset(); + lastRunWithLogParams = null; + }); + + it('opens the advisory before deploying and defers the deploy until the user proceeds', async () => { + const setPreDeployAdvisory = vi.fn(); + vi.mocked(apiFetch).mockImplementation(async (url: string) => { + if (String(url).includes('/pre-deploy-summary')) { + return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); + } + return new Response(null, { status: 200 }); + }); + const { result } = setup({ overlay: { setPreDeployAdvisory } }); + + await result.current.deployStack(mouseEvent); + + // The advisory opened and the deploy has NOT started (no progress log, no POST). + expect(setPreDeployAdvisory).toHaveBeenCalledWith( + expect.objectContaining({ stackName: 'web', images: [{ imageRef: 'nginx:1.14', scan: null }] }), + ); + expect(lastRunWithLogParams).toBeNull(); + expect(vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).includes('/deploy'))).toHaveLength(0); + + // Proceeding runs the actual deploy. + const arg = setPreDeployAdvisory.mock.calls[0][0] as { proceed: () => void }; + arg.proceed(); + await vi.waitFor(() => expect(lastRunWithLogParams).not.toBeNull()); + }); + + it('deploys directly, with no advisory, when the summary is disabled', async () => { + const setPreDeployAdvisory = vi.fn(); + vi.mocked(apiFetch).mockResolvedValue(new Response(JSON.stringify({ enabled: false }), { status: 200 })); + const { result } = setup({ overlay: { setPreDeployAdvisory } }); + + await result.current.deployStack(mouseEvent); + + expect(setPreDeployAdvisory).not.toHaveBeenCalled(); + expect(lastRunWithLogParams).not.toBeNull(); + }); + + it('deploys directly when the advisory is enabled but no images are returned', async () => { + const setPreDeployAdvisory = vi.fn(); + vi.mocked(apiFetch).mockImplementation(async (url: string) => { + if (String(url).includes('/pre-deploy-summary')) { + return new Response(JSON.stringify({ enabled: true, images: [] }), { status: 200 }); + } + return new Response(null, { status: 200 }); + }); + const { result } = setup({ overlay: { setPreDeployAdvisory } }); + + await result.current.deployStack(mouseEvent); + + expect(setPreDeployAdvisory).not.toHaveBeenCalled(); + expect(lastRunWithLogParams).not.toBeNull(); + }); + + it('binds the advisory fetch and the deferred deploy to the captured node', async () => { + const setPreDeployAdvisory = vi.fn(); + vi.mocked(apiFetch).mockImplementation(async (url: string) => { + if (String(url).includes('/pre-deploy-summary')) { + return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); + } + return new Response(null, { status: 200 }); + }); + const { result } = setup({ + overlay: { setPreDeployAdvisory }, + activeNode: { id: 42, type: 'local' } as Parameters[0]['activeNode'], + }); + + await result.current.deployStack(mouseEvent); + + expect(apiFetch).toHaveBeenCalledWith( + expect.stringContaining('/pre-deploy-summary'), + expect.objectContaining({ nodeId: 42 }), + ); + const arg = (setPreDeployAdvisory.mock.calls[0][0]) as { proceed: () => void }; + arg.proceed(); + await vi.waitFor(() => expect(lastRunWithLogParams?.nodeId).toBe(42)); + }); + + it('runs the deploy at most once even if proceed fires twice', async () => { + const setPreDeployAdvisory = vi.fn(); + vi.mocked(apiFetch).mockImplementation(async (url: string) => { + if (String(url).includes('/pre-deploy-summary')) { + return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); + } + return new Response(null, { status: 200 }); + }); + const { result } = setup({ overlay: { setPreDeployAdvisory } }); + + await result.current.deployStack(mouseEvent); + const arg = (setPreDeployAdvisory.mock.calls[0][0]) as { proceed: () => void }; + arg.proceed(); + await vi.waitFor(() => expect(lastRunWithLogParams).not.toBeNull()); + + lastRunWithLogParams = null; + arg.proceed(); + await Promise.resolve(); + expect(lastRunWithLogParams).toBeNull(); + }); + + it('blocks a second deploy click while the advisory fetch is still pending', async () => { + const setPreDeployAdvisory = vi.fn(); + const summaryGate: { release: () => void } = { release: () => {} }; + vi.mocked(apiFetch).mockImplementation((url: string) => { + if (String(url).includes('/pre-deploy-summary')) { + return new Promise((resolve) => { + summaryGate.release = () => resolve(new Response(JSON.stringify({ enabled: false }), { status: 200 })); + }); + } + return Promise.resolve(new Response(null, { status: 200 })); + }); + const { result } = setup({ overlay: { setPreDeployAdvisory } }); + + const first = result.current.deployStack(mouseEvent); + const second = result.current.deployStack(mouseEvent); // double-click while the summary is in flight + summaryGate.release(); + await Promise.all([first, second]); + await vi.waitFor(() => expect(lastRunWithLogParams).not.toBeNull()); + + // The second click is blocked before it ever issues a request, so exactly + // one summary fetch and one deploy occur. + const summaryCalls = vi.mocked(apiFetch).mock.calls.filter(c => String(c[0]).includes('/pre-deploy-summary')); + expect(summaryCalls).toHaveLength(1); + }); + + it('clears the pending guard on cancel so a later deploy is allowed', async () => { + const setPreDeployAdvisory = vi.fn(); + vi.mocked(apiFetch).mockImplementation(async (url: string) => { + if (String(url).includes('/pre-deploy-summary')) { + return new Response(JSON.stringify({ enabled: true, images: [{ imageRef: 'nginx:1.14', scan: null }] }), { status: 200 }); + } + return new Response(null, { status: 200 }); + }); + const { result } = setup({ overlay: { setPreDeployAdvisory } }); + + await result.current.deployStack(mouseEvent); + const arg = (setPreDeployAdvisory.mock.calls[0][0]) as { cancel: () => void }; + arg.cancel(); // dismiss the advisory; the guard must release + + await result.current.deployStack(mouseEvent); // a fresh deploy must not be blocked + // Calls: open, null (cancel), open again. The third proves the guard cleared + // and the later deploy opened a fresh advisory rather than being blocked. + expect(setPreDeployAdvisory).toHaveBeenCalledTimes(3); + expect(setPreDeployAdvisory.mock.calls[2][0]).toMatchObject({ stackName: 'web' }); + }); +}); + describe('useStackActions.bypassPolicyAndRetry', () => { const payload = { error: 'blocked', diff --git a/frontend/src/components/EditorLayout/hooks/useStackActions.ts b/frontend/src/components/EditorLayout/hooks/useStackActions.ts index b7c667f0..65b8b92c 100644 --- a/frontend/src/components/EditorLayout/hooks/useStackActions.ts +++ b/frontend/src/components/EditorLayout/hooks/useStackActions.ts @@ -11,6 +11,7 @@ import type { RunWithLogParams } from '@/context/DeployFeedbackContext'; import type { StackAction, RecoverableAction, FailureClassification } from '../EditorView'; import type { NotificationItem } from '../../dashboard/types'; import type { PolicyBlockPayload, PolicyBlockableAction } from '../../stack/PolicyBlockDialog'; +import type { PreDeployScanImage } from '@/types/security'; interface RunResult { ok: boolean; @@ -118,6 +119,38 @@ interface UseStackActionsOptions { const isRecord = (value: unknown): value is Record => typeof value === 'object' && value !== null; +const PRE_DEPLOY_SUMMARY_TIMEOUT_MS = 5000; + +/** + * Fetch the pre-deploy scan advisory for a manual deploy. Returns the image + * list when the advisory is enabled and the backend answers in time, or null to + * mean "no advisory, deploy normally" for every other case (setting off, + * timeout, an older node without the route, or any error). Failing open is + * deliberate: the advisory is visibility, it must never block a deploy. Bound to + * the captured node so it targets the same node the deploy will hit. + */ +export async function fetchPreDeployAdvisory( + stackName: string, + opNodeId: number | null, +): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), PRE_DEPLOY_SUMMARY_TIMEOUT_MS); + try { + const res = await apiFetch( + `/security/stacks/${encodeURIComponent(stackName)}/pre-deploy-summary`, + { nodeId: opNodeId, signal: controller.signal }, + ); + if (!res.ok) return null; + const data: unknown = await res.json(); + if (!isRecord(data) || data.enabled !== true || !Array.isArray(data.images)) return null; + return data.images as PreDeployScanImage[]; + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + const parseStackOpInProgress = (rawBody: string): StackOpInProgressInfo | null => { try { const parsed: unknown = JSON.parse(rawBody); @@ -202,6 +235,9 @@ export function useStackActions(options: UseStackActionsOptions) { const pendingStackLoadRef = useRef(null); const pendingLogsRef = useRef<{ stackName: string; containerName: string } | null>(null); const checkUpdatesIntervalRef = useRef | null>(null); + // True from a deploy click through the async pre-deploy advisory phase until + // the deploy starts or is cancelled, so a double-click cannot start two deploys. + const deployPendingRef = useRef(false); // Aborts the most recent loadFile sequence (compose GET, envs GET, env content // GET, containers GET, backup GET). A node switch, an unmount, or a second // loadFile call before the first finishes all cancel the in-flight fetches so @@ -723,22 +759,63 @@ export function useStackActions(options: UseStackActionsOptions) { const deployStack = async (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); - if (!stackListState.selectedFile || stackListState.isStackBusy(stackListState.selectedFile)) + // deployPendingRef blocks a second deploy from the moment of the click + // through the async advisory phase, before setStackAction marks the stack + // busy. Without it, a double-click during the advisory fetch window could + // launch two deploys. Cleared on cancel and in the deploy's finally. + if ( + !stackListState.selectedFile || + stackListState.isStackBusy(stackListState.selectedFile) || + deployPendingRef.current + ) return; const stackFile = stackListState.selectedFile; const stackName = stackFile.replace(/\.(yml|yaml)$/, ''); - stackListState.setStackAction(stackFile, 'deploy'); - // Snapshot the node once so the request stays bound to it even if the active - // node changes while the operation is in flight. + // Snapshot the node once so the advisory fetch and the deploy stay bound to + // it even if the active node changes while the advisory dialog is open. const opNodeId = activeNode?.id ?? null; - try { - await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) => - runDeploy(stackName, stackFile, false, started, ds, opNodeId), - ); - } finally { - stackListState.clearStackAction(stackFile); - stackListState.refreshStacks(true); + deployPendingRef.current = true; + + // The actual deploy, pulled out so the optional pre-deploy advisory can gate + // it without duplicating the action lifecycle. The stack action is set here + // (not before the advisory) so cancelling the advisory leaves no stuck state. + const runDeployFlow = async () => { + stackListState.setStackAction(stackFile, 'deploy'); + try { + await runWithLog({ stackName, action: 'deploy', nodeId: opNodeId }, (started, ds) => + runDeploy(stackName, stackFile, false, started, ds, opNodeId), + ); + } finally { + stackListState.clearStackAction(stackFile); + stackListState.refreshStacks(true); + deployPendingRef.current = false; + } + }; + + // Advisory runs before the deploy log opens (fails open: a null result means + // setting off / timeout / older node / error, so the deploy proceeds). + const advisoryImages = await fetchPreDeployAdvisory(stackName, opNodeId); + if (advisoryImages && advisoryImages.length > 0) { + let settled = false; + overlayState.setPreDeployAdvisory({ + stackName, + images: advisoryImages, + proceed: () => { + if (settled) return; + settled = true; + overlayState.setPreDeployAdvisory(null); + void runDeployFlow(); + }, + cancel: () => { + if (settled) return; + settled = true; + overlayState.setPreDeployAdvisory(null); + deployPendingRef.current = false; + }, + }); + return; } + await runDeployFlow(); }; const handleSaveAndDeploy = async (e: React.MouseEvent) => { diff --git a/frontend/src/components/security/TrivyManager.tsx b/frontend/src/components/security/TrivyManager.tsx index 00fb532e..f05b216a 100644 --- a/frontend/src/components/security/TrivyManager.tsx +++ b/frontend/src/components/security/TrivyManager.tsx @@ -46,7 +46,7 @@ interface TrivyManagerProps { */ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck }: TrivyManagerProps) { const { isAdmin } = useAuth(); - const [trivyBusy, setTrivyBusy] = useState(null); + const [trivyBusy, setTrivyBusy] = useState(null); const [uninstallConfirm, setUninstallConfirm] = useState(false); const runTrivyOp = async ( @@ -99,6 +99,25 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck } }; + const handleAdvisoryToggle = async (enabled: boolean) => { + setTrivyBusy('advisory'); + try { + const res = await apiFetch('/security/pre-deploy-scan-advisory', { + method: 'PUT', + body: JSON.stringify({ enabled }), + }); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.error || 'Failed to update setting'); + } + await refresh(); + } catch (err) { + toast.error((err as Error)?.message || 'Failed to update setting'); + } finally { + setTrivyBusy(null); + } + }; + return ( <>
@@ -172,6 +191,22 @@ export function TrivyManager({ status, updateCheck, refresh, refreshUpdateCheck />
)} + + {(status.available || status.preDeployScanAdvisory) && isAdmin && ( +
+
+ +

+ Before a manual deploy, show each image's latest scan results so you can review them first. Advisory only; it never blocks the deploy. +

+
+ +
+ )} ); vi.mocked(NodeContext.useNodes).mockReturnValue({ activeNode: { type: 'local', id: 1, name: 'local' } } as unknown as ReturnType); vi.mocked(TrivyStatus.useTrivyStatus).mockReturnValue({ - status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, busy: false }, + status: { available: true, version: '1', source: 'managed', autoUpdate: false, honorSuppressionsOnDeploy: false, preDeployScanAdvisory: false, busy: false }, updateCheck: null, refresh: vi.fn().mockResolvedValue(undefined), refreshUpdateCheck: vi.fn().mockResolvedValue(undefined), diff --git a/frontend/src/components/stack/PreDeployScanDialog.tsx b/frontend/src/components/stack/PreDeployScanDialog.tsx new file mode 100644 index 00000000..22af3f40 --- /dev/null +++ b/frontend/src/components/stack/PreDeployScanDialog.tsx @@ -0,0 +1,78 @@ +import { + Modal, + ModalHeader, + ModalBody, + ModalFooter, +} from '@/components/ui/modal'; +import { Button } from '@/components/ui/button'; +import { SeverityChip } from '@/components/VulnerabilityScanSheet'; +import { formatTimeAgo } from '@/lib/relativeTime'; +import type { PreDeployScanImage } from '@/types/security'; + +interface PreDeployScanDialogProps { + open: boolean; + stackName: string; + images: PreDeployScanImage[]; + onCancel: () => void; + onDeploy: () => void; +} + +/** + * Advisory pre-deploy review. Shows the latest cached scan for each image in a + * manual deploy so the operator can review the security posture before + * proceeding. Unlike PolicyBlockDialog this never blocks: anyone can deploy or + * cancel, and there is no override gate (blocking is the paid deploy-block + * policy). Opened opt-in via the pre-deploy scan advisory setting. + */ +export function PreDeployScanDialog({ open, stackName, images, onCancel, onDeploy }: PreDeployScanDialogProps) { + return ( + { if (!next) onCancel(); }} size="xl"> + + +
+ {images.length === 0 ? ( +
No images found for this stack.
+ ) : ( + images.map((img) => ( +
+
+
{img.imageRef}
+
+ {img.scan + ? `${img.scan.criticalCount} critical · ${img.scan.highCount} high · ${img.scan.mediumCount} medium · ${img.scan.lowCount} low · scanned ${formatTimeAgo(img.scan.scannedAt)}` + : 'not scanned'} +
+
+ {img.scan?.highestSeverity ? ( + + ) : null} +
+ )) + )} +
+
+ + Cancel + + } + primary={ + + } + /> +
+ ); +} diff --git a/frontend/src/components/stack/__tests__/PreDeployScanDialog.test.tsx b/frontend/src/components/stack/__tests__/PreDeployScanDialog.test.tsx new file mode 100644 index 00000000..e960fdf5 --- /dev/null +++ b/frontend/src/components/stack/__tests__/PreDeployScanDialog.test.tsx @@ -0,0 +1,49 @@ +import { describe, it, expect, vi } from 'vitest'; +import { render, screen, fireEvent } from '@testing-library/react'; +import { PreDeployScanDialog } from '../PreDeployScanDialog'; +import type { PreDeployScanImage } from '@/types/security'; + +const images: PreDeployScanImage[] = [ + { + imageRef: 'nginx:1.14', + scan: { + criticalCount: 31, + highCount: 82, + mediumCount: 1, + lowCount: 4, + highestSeverity: 'CRITICAL', + scannedAt: Date.now() - 3_600_000, + }, + }, + { imageRef: 'redis:7', scan: null }, +]; + +describe('PreDeployScanDialog', () => { + it('renders each image with its scan counts or a not-scanned note', () => { + render( + , + ); + expect(screen.getByText('nginx:1.14')).toBeInTheDocument(); + expect(screen.getByText(/31 critical/)).toBeInTheDocument(); + expect(screen.getByText('redis:7')).toBeInTheDocument(); + expect(screen.getByText('not scanned')).toBeInTheDocument(); + }); + + it('calls onDeploy when Deploy is clicked', () => { + const onDeploy = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Deploy' })); + expect(onDeploy).toHaveBeenCalledTimes(1); + }); + + it('calls onCancel when Cancel is clicked', () => { + const onCancel = vi.fn(); + render( + , + ); + fireEvent.click(screen.getByRole('button', { name: 'Cancel' })); + expect(onCancel).toHaveBeenCalledTimes(1); + }); +}); diff --git a/frontend/src/hooks/useTrivyStatus.ts b/frontend/src/hooks/useTrivyStatus.ts index d82cf770..c3a7bdc6 100644 --- a/frontend/src/hooks/useTrivyStatus.ts +++ b/frontend/src/hooks/useTrivyStatus.ts @@ -8,6 +8,7 @@ const INITIAL_STATUS: TrivyStatus = { source: 'none', autoUpdate: false, honorSuppressionsOnDeploy: false, + preDeployScanAdvisory: false, busy: false, }; @@ -33,6 +34,7 @@ export function useTrivyStatus(): UseTrivyStatusResult { source: d.source === 'managed' || d.source === 'host' ? d.source : 'none', autoUpdate: !!d.autoUpdate, honorSuppressionsOnDeploy: !!d.honorSuppressionsOnDeploy, + preDeployScanAdvisory: !!d.preDeployScanAdvisory, busy: !!d.busy, }); } catch (err) { diff --git a/frontend/src/types/security.ts b/frontend/src/types/security.ts index 75539e4f..98634bc2 100644 --- a/frontend/src/types/security.ts +++ b/frontend/src/types/security.ts @@ -17,9 +17,25 @@ export interface TrivyStatus { source: TrivySource; autoUpdate: boolean; honorSuppressionsOnDeploy: boolean; + preDeployScanAdvisory: boolean; busy: boolean; } +/** One image's latest cached scan, shown in the pre-deploy advisory dialog. */ +export interface PreDeployScanImageScan { + criticalCount: number; + highCount: number; + mediumCount: number; + lowCount: number; + highestSeverity: VulnSeverity | null; + scannedAt: number; +} + +export interface PreDeployScanImage { + imageRef: string; + scan: PreDeployScanImageScan | null; +} + export interface TrivyUpdateCheck { current: string | null; latest: string;