From d727a55a5fbbdb26c3c3103cdc0d43a0e0628e95 Mon Sep 17 00:00:00 2001 From: Anso Date: Sun, 24 May 2026 15:44:12 -0400 Subject: [PATCH] feat(stacks): surface post-deploy scan attempt status (#1198) triggerPostDeployScan was fire-and-forget. When Trivy was missing on a node, when the registry refused the digest lookup, or when a single image scan threw, the failure went to console.error and the user never learned. Open the security tab later, see stale data, no indicator that the scan even tried. Backend: - New stack_scan_attempts table (node_id, stack_name, status, attempted_at, error_message). One row per stack; latest attempt overwrites the previous one. - DatabaseService gains recordStackScanAttempt / getStackScanAttempt / clearStackScanAttempts. Status is one of 'ok' | 'partial' | 'failed' | 'skipped'. - triggerPostDeployScan in helpers/policyGate.ts now records every exit path: 'skipped' when Trivy is unavailable or no images to scan; 'failed' when container enumeration or all images fail; 'partial' when some images scan and others fail; 'ok' on full success. - New GET /api/stacks/:name/scan-status returns { status, attemptedAt, errorMessage } or { status: null } when never tried. - DELETE /:stackName cleanup chain now clears the row alongside the existing update-status / auto-update cleanups. Frontend: - StackAnatomyPanel fetches /scan-status on stackName change. - Renders a small warning strip below the update banner when status !== 'ok' (failed / partial / skipped). Hidden when status is 'ok' or unknown (never attempted). Title attribute carries the full error message for hover inspection. Cross-feature note: the audit doc flagged this as M-6 with a coordination note for the pending Security feature audit. The schema kept intentionally narrow (one row per stack, simple status enum) so the Security audit can extend it (richer history, per-image-row breakdown, etc.) without a destructive migration. Resolves M-6 from the stack-management audit. --- .../src/__tests__/stack-scan-status.test.ts | 107 ++++++++++++++++++ backend/src/helpers/policyGate.ts | 34 +++++- backend/src/routes/stacks.ts | 21 ++++ backend/src/services/DatabaseService.ts | 46 ++++++++ frontend/src/components/StackAnatomyPanel.tsx | 39 +++++++ 5 files changed, 241 insertions(+), 6 deletions(-) create mode 100644 backend/src/__tests__/stack-scan-status.test.ts diff --git a/backend/src/__tests__/stack-scan-status.test.ts b/backend/src/__tests__/stack-scan-status.test.ts new file mode 100644 index 00000000..7a08043f --- /dev/null +++ b/backend/src/__tests__/stack-scan-status.test.ts @@ -0,0 +1,107 @@ +/** + * Tests for the post-deploy scan-attempt persistence + read endpoint. + * Covers the database layer (recordStackScanAttempt / getStackScanAttempt) + * and the GET /api/stacks/:name/scan-status route shape. + */ +import { describe, it, expect, beforeAll, afterAll, beforeEach } from 'vitest'; +import request from 'supertest'; +import { setupTestDb, cleanupTestDb, loginAsTestAdmin } from './helpers/setupTestDb'; + +let tmpDir: string; +let app: import('express').Express; +let authCookie: string; +let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; + +const STACK = 'web'; +const NODE_ID = 1; + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ DatabaseService } = await import('../services/DatabaseService')); + ({ app } = await import('../index')); + authCookie = await loginAsTestAdmin(app); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + DatabaseService.getInstance().clearStackScanAttempts(NODE_ID, STACK); +}); + +describe('DatabaseService stack scan attempts', () => { + it('returns null when no attempt has been recorded', () => { + expect(DatabaseService.getInstance().getStackScanAttempt(NODE_ID, STACK)).toBeNull(); + }); + + it('records and reads back a successful attempt', () => { + const db = DatabaseService.getInstance(); + db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null); + const row = db.getStackScanAttempt(NODE_ID, STACK); + expect(row).not.toBeNull(); + expect(row?.status).toBe('ok'); + expect(row?.error_message).toBeNull(); + expect(typeof row?.attempted_at).toBe('number'); + }); + + it('overwrites a previous attempt (one row per stack)', () => { + const db = DatabaseService.getInstance(); + db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null); + db.recordStackScanAttempt(NODE_ID, STACK, 'failed', 'trivy crashed'); + const row = db.getStackScanAttempt(NODE_ID, STACK); + expect(row?.status).toBe('failed'); + expect(row?.error_message).toBe('trivy crashed'); + }); + + it('accepts skipped and partial statuses', () => { + const db = DatabaseService.getInstance(); + db.recordStackScanAttempt(NODE_ID, STACK, 'skipped', 'Trivy not available'); + expect(db.getStackScanAttempt(NODE_ID, STACK)?.status).toBe('skipped'); + db.recordStackScanAttempt(NODE_ID, STACK, 'partial', '1 of 3 failed'); + expect(db.getStackScanAttempt(NODE_ID, STACK)?.status).toBe('partial'); + }); + + it('clearStackScanAttempts removes the row', () => { + const db = DatabaseService.getInstance(); + db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null); + db.clearStackScanAttempts(NODE_ID, STACK); + expect(db.getStackScanAttempt(NODE_ID, STACK)).toBeNull(); + }); + + it('keys per (nodeId, stackName) - other rows are unaffected', () => { + const db = DatabaseService.getInstance(); + db.recordStackScanAttempt(NODE_ID, STACK, 'ok', null); + db.recordStackScanAttempt(NODE_ID, 'api', 'failed', 'oops'); + db.recordStackScanAttempt(2, STACK, 'partial', '1 failed'); + expect(db.getStackScanAttempt(NODE_ID, STACK)?.status).toBe('ok'); + expect(db.getStackScanAttempt(NODE_ID, 'api')?.status).toBe('failed'); + expect(db.getStackScanAttempt(2, STACK)?.status).toBe('partial'); + }); +}); + +describe('GET /api/stacks/:stackName/scan-status', () => { + it('returns {status: null} when no scan has been attempted', async () => { + const res = await request(app) + .get(`/api/stacks/${STACK}/scan-status`) + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body).toEqual({ status: null }); + }); + + it('returns the recorded attempt shape', async () => { + DatabaseService.getInstance().recordStackScanAttempt(NODE_ID, STACK, 'failed', 'trivy missing'); + const res = await request(app) + .get(`/api/stacks/${STACK}/scan-status`) + .set('Cookie', authCookie); + expect(res.status).toBe(200); + expect(res.body.status).toBe('failed'); + expect(res.body.errorMessage).toBe('trivy missing'); + expect(typeof res.body.attemptedAt).toBe('number'); + }); + + it('returns 401 without auth', async () => { + const res = await request(app).get(`/api/stacks/${STACK}/scan-status`); + expect(res.status).toBe(401); + }); +}); diff --git a/backend/src/helpers/policyGate.ts b/backend/src/helpers/policyGate.ts index cda3061e..15e347f3 100644 --- a/backend/src/helpers/policyGate.ts +++ b/backend/src/helpers/policyGate.ts @@ -82,7 +82,13 @@ export async function triggerPostDeployScan( nodeId: number, ): Promise { const svc = TrivyService.getInstance(); - if (!svc.isTrivyAvailable()) return; + const db = DatabaseService.getInstance(); + if (!svc.isTrivyAvailable()) { + db.recordStackScanAttempt(nodeId, stackName, 'skipped', 'Trivy is not available on this node'); + return; + } + let imageFailures = 0; + let imageSuccesses = 0; try { const docker = DockerController.getInstance(nodeId).getDocker(); const containers = await docker.listContainers({ @@ -93,18 +99,23 @@ export async function triggerPostDeployScan( for (const c of containers as Array<{ Image?: string }>) { if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image); } - if (imageRefs.size === 0) return; - - const db = DatabaseService.getInstance(); + if (imageRefs.size === 0) { + db.recordStackScanAttempt(nodeId, stackName, 'skipped', 'No images to scan'); + return; + } for (const imageRef of imageRefs) { try { const digest = await svc.getImageDigest(imageRef, nodeId); if (digest) { const cached = db.getLatestScanByDigest(digest, 'vuln'); - if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) continue; + if (cached && Date.now() - cached.scanned_at < DIGEST_CACHE_TTL_MS) { + imageSuccesses += 1; + continue; + } } const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName); + imageSuccesses += 1; if (scan.critical_count > 0 || scan.high_count > 0) { NotificationService.getInstance().dispatchAlert( @@ -115,6 +126,7 @@ export async function triggerPostDeployScan( ); } } catch (err) { + imageFailures += 1; const message = getErrorMessage(err, 'unknown error'); console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message); NotificationService.getInstance().dispatchAlert( @@ -125,7 +137,17 @@ export async function triggerPostDeployScan( ); } } + + if (imageFailures === 0) { + db.recordStackScanAttempt(nodeId, stackName, 'ok', null); + } else if (imageSuccesses === 0) { + db.recordStackScanAttempt(nodeId, stackName, 'failed', `${imageFailures} image(s) failed to scan`); + } else { + db.recordStackScanAttempt(nodeId, stackName, 'partial', `${imageFailures} of ${imageFailures + imageSuccesses} image(s) failed`); + } } catch (err) { - console.error('[Security] triggerPostDeployScan error for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown error'))); + const message = getErrorMessage(err, 'unknown error'); + console.error('[Security] triggerPostDeployScan error for %s:', sanitizeForLog(stackName), sanitizeForLog(message)); + db.recordStackScanAttempt(nodeId, stackName, 'failed', message); } } diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 64e50398..884fee69 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -647,6 +647,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => { try { DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName); DatabaseService.getInstance().clearStackAutoUpdateSetting(req.nodeId, stackName); + DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName); DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName); DatabaseService.getInstance().deleteGitSource(stackName); if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName }); @@ -1060,6 +1061,26 @@ stacksRouter.get('/:stackName/backup', async (req: Request, res: Response) => { } }); +/** + * Returns the latest post-deploy scan attempt for this stack, or null if + * no scan has been attempted yet. Used by the editor UI to flag stacks + * whose latest deploy did not trigger a successful scan (Trivy missing, + * registry rejection, etc). + */ +stacksRouter.get('/:stackName/scan-status', (req: Request, res: Response): void => { + const stackName = req.params.stackName as string; + const row = DatabaseService.getInstance().getStackScanAttempt(req.nodeId, stackName); + if (!row) { + res.json({ status: null }); + return; + } + res.json({ + status: row.status, + attemptedAt: row.attempted_at, + errorMessage: row.error_message, + }); +}); + // ── File explorer endpoints ── type FsErrorCode = diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 2e11350c..d7e6b634 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -746,6 +746,15 @@ export class DatabaseService { PRIMARY KEY (node_id, stack_name) ); + CREATE TABLE IF NOT EXISTS stack_scan_attempts ( + node_id INTEGER NOT NULL DEFAULT 0, + stack_name TEXT NOT NULL, + status TEXT NOT NULL, + attempted_at INTEGER NOT NULL, + error_message TEXT, + PRIMARY KEY (node_id, stack_name) + ); + CREATE TABLE IF NOT EXISTS nodes ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL UNIQUE, @@ -2388,6 +2397,43 @@ export class DatabaseService { this.db.prepare('DELETE FROM stack_auto_update_settings WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); } + // --- Stack Scan Attempts --- + // + // Tracks the latest post-deploy scan attempt per (nodeId, stackName) so + // operators can see when a scan was skipped or failed without scrolling + // logs. One row per stack; the table is overwritten on every attempt. + + public recordStackScanAttempt( + nodeId: number, + stackName: string, + status: 'ok' | 'partial' | 'failed' | 'skipped', + errorMessage: string | null, + ): void { + this.db.prepare( + `INSERT INTO stack_scan_attempts (node_id, stack_name, status, attempted_at, error_message) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT(node_id, stack_name) DO UPDATE SET + status = excluded.status, + attempted_at = excluded.attempted_at, + error_message = excluded.error_message` + ).run(nodeId, stackName, status, Date.now(), errorMessage); + } + + public getStackScanAttempt(nodeId: number, stackName: string): { + status: string; + attempted_at: number; + error_message: string | null; + } | null { + const row = this.db.prepare( + 'SELECT status, attempted_at, error_message FROM stack_scan_attempts WHERE node_id = ? AND stack_name = ?' + ).get(nodeId, stackName) as { status: string; attempted_at: number; error_message: string | null } | undefined; + return row ?? null; + } + + public clearStackScanAttempts(nodeId: number, stackName: string): void { + this.db.prepare('DELETE FROM stack_scan_attempts WHERE node_id = ? AND stack_name = ?').run(nodeId, stackName); + } + public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> { return this.db.prepare( 'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id' diff --git a/frontend/src/components/StackAnatomyPanel.tsx b/frontend/src/components/StackAnatomyPanel.tsx index e747d67d..eb27ac17 100644 --- a/frontend/src/components/StackAnatomyPanel.tsx +++ b/frontend/src/components/StackAnatomyPanel.tsx @@ -249,6 +249,11 @@ export default function StackAnatomyPanel({ const [gitSource, setGitSource] = useState(null); const [updatePreview, setUpdatePreview] = useState(null); + const [scanStatus, setScanStatus] = useState<{ + status: 'ok' | 'partial' | 'failed' | 'skipped' | null; + attemptedAt?: number; + errorMessage?: string | null; + } | null>(null); useEffect(() => { let cancelled = false; @@ -290,6 +295,25 @@ export default function StackAnatomyPanel({ return () => { cancelled = true; }; }, [stackName]); + useEffect(() => { + let cancelled = false; + const run = async () => { + try { + const res = await apiFetch(`/stacks/${stackName}/scan-status`); + if (cancelled) return; + if (res.ok) { + setScanStatus(await res.json()); + } else { + setScanStatus(null); + } + } catch { + if (!cancelled) setScanStatus(null); + } + }; + void run(); + return () => { cancelled = true; }; + }, [stackName]); + const networkName = anatomy && anatomy.networks.length > 0 ? anatomy.networks[0] : `${stackName}_default`; @@ -510,6 +534,21 @@ export default function StackAnatomyPanel({ )} + {scanStatus && scanStatus.status && scanStatus.status !== 'ok' && ( +
+ scan + + {scanStatus.status === 'failed' && 'Last post-deploy scan failed.'} + {scanStatus.status === 'partial' && 'Last post-deploy scan partially failed.'} + {scanStatus.status === 'skipped' && 'Post-deploy scan did not run.'} + {scanStatus.errorMessage ? ` ${scanStatus.errorMessage}` : ''} + +
+ )} {anatomy && anatomy.services.length > 0 && (