mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-27 18:57:09 +00:00
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.
This commit is contained in:
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -82,7 +82,13 @@ export async function triggerPostDeployScan(
|
|||||||
nodeId: number,
|
nodeId: number,
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const svc = TrivyService.getInstance();
|
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 {
|
try {
|
||||||
const docker = DockerController.getInstance(nodeId).getDocker();
|
const docker = DockerController.getInstance(nodeId).getDocker();
|
||||||
const containers = await docker.listContainers({
|
const containers = await docker.listContainers({
|
||||||
@@ -93,18 +99,23 @@ export async function triggerPostDeployScan(
|
|||||||
for (const c of containers as Array<{ Image?: string }>) {
|
for (const c of containers as Array<{ Image?: string }>) {
|
||||||
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
|
if (c.Image && !c.Image.startsWith('sha256:')) imageRefs.add(c.Image);
|
||||||
}
|
}
|
||||||
if (imageRefs.size === 0) return;
|
if (imageRefs.size === 0) {
|
||||||
|
db.recordStackScanAttempt(nodeId, stackName, 'skipped', 'No images to scan');
|
||||||
const db = DatabaseService.getInstance();
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
for (const imageRef of imageRefs) {
|
for (const imageRef of imageRefs) {
|
||||||
try {
|
try {
|
||||||
const digest = await svc.getImageDigest(imageRef, nodeId);
|
const digest = await svc.getImageDigest(imageRef, nodeId);
|
||||||
if (digest) {
|
if (digest) {
|
||||||
const cached = db.getLatestScanByDigest(digest, 'vuln');
|
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);
|
const scan = await svc.runScanAndPersist(imageRef, nodeId, 'deploy', stackName);
|
||||||
|
imageSuccesses += 1;
|
||||||
|
|
||||||
if (scan.critical_count > 0 || scan.high_count > 0) {
|
if (scan.critical_count > 0 || scan.high_count > 0) {
|
||||||
NotificationService.getInstance().dispatchAlert(
|
NotificationService.getInstance().dispatchAlert(
|
||||||
@@ -115,6 +126,7 @@ export async function triggerPostDeployScan(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
|
imageFailures += 1;
|
||||||
const message = getErrorMessage(err, 'unknown error');
|
const message = getErrorMessage(err, 'unknown error');
|
||||||
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
|
console.error(`[Security] Post-deploy scan failed for ${imageRef}:`, message);
|
||||||
NotificationService.getInstance().dispatchAlert(
|
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) {
|
} 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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -647,6 +647,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
|
|||||||
try {
|
try {
|
||||||
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
|
||||||
DatabaseService.getInstance().clearStackAutoUpdateSetting(req.nodeId, stackName);
|
DatabaseService.getInstance().clearStackAutoUpdateSetting(req.nodeId, stackName);
|
||||||
|
DatabaseService.getInstance().clearStackScanAttempts(req.nodeId, stackName);
|
||||||
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
DatabaseService.getInstance().deleteRoleAssignmentsByResource('stack', stackName);
|
||||||
DatabaseService.getInstance().deleteGitSource(stackName);
|
DatabaseService.getInstance().deleteGitSource(stackName);
|
||||||
if (debug) console.debug(`[Stacks:debug] Delete: db OK`, { stackName: sanitizedName });
|
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 ──
|
// ── File explorer endpoints ──
|
||||||
|
|
||||||
type FsErrorCode =
|
type FsErrorCode =
|
||||||
|
|||||||
@@ -746,6 +746,15 @@ export class DatabaseService {
|
|||||||
PRIMARY KEY (node_id, stack_name)
|
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 (
|
CREATE TABLE IF NOT EXISTS nodes (
|
||||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
name TEXT NOT NULL UNIQUE,
|
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);
|
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 }> {
|
public getNodeUpdateSummary(): Array<{ node_id: number; stacks_with_updates: number }> {
|
||||||
return this.db.prepare(
|
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'
|
'SELECT node_id, SUM(has_update) as stacks_with_updates FROM stack_update_status WHERE has_update = 1 GROUP BY node_id'
|
||||||
|
|||||||
@@ -249,6 +249,11 @@ export default function StackAnatomyPanel({
|
|||||||
|
|
||||||
const [gitSource, setGitSource] = useState<GitSourceInfo | null>(null);
|
const [gitSource, setGitSource] = useState<GitSourceInfo | null>(null);
|
||||||
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
|
const [updatePreview, setUpdatePreview] = useState<UpdatePreview | null>(null);
|
||||||
|
const [scanStatus, setScanStatus] = useState<{
|
||||||
|
status: 'ok' | 'partial' | 'failed' | 'skipped' | null;
|
||||||
|
attemptedAt?: number;
|
||||||
|
errorMessage?: string | null;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
@@ -290,6 +295,25 @@ export default function StackAnatomyPanel({
|
|||||||
return () => { cancelled = true; };
|
return () => { cancelled = true; };
|
||||||
}, [stackName]);
|
}, [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
|
const networkName = anatomy && anatomy.networks.length > 0
|
||||||
? anatomy.networks[0]
|
? anatomy.networks[0]
|
||||||
: `${stackName}_default`;
|
: `${stackName}_default`;
|
||||||
@@ -510,6 +534,21 @@ export default function StackAnatomyPanel({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
{scanStatus && scanStatus.status && scanStatus.status !== 'ok' && (
|
||||||
|
<div
|
||||||
|
className="mx-3 my-2 flex items-start gap-2 rounded-md border border-warning/40 bg-warning/[0.06] px-2 py-1.5 text-[11px] text-warning"
|
||||||
|
role="status"
|
||||||
|
title={scanStatus.errorMessage ?? undefined}
|
||||||
|
>
|
||||||
|
<span className="font-mono text-[9px] uppercase tracking-wide shrink-0 mt-0.5">scan</span>
|
||||||
|
<span className="flex-1">
|
||||||
|
{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}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
{anatomy && anatomy.services.length > 0 && (
|
{anatomy && anatomy.services.length > 0 && (
|
||||||
<div className="border-t border-muted px-3 py-2 flex items-center justify-between">
|
<div className="border-t border-muted px-3 py-2 flex items-center justify-between">
|
||||||
|
|||||||
Reference in New Issue
Block a user