diff --git a/backend/src/__tests__/sarif-exporter.test.ts b/backend/src/__tests__/sarif-exporter.test.ts index 57088645..86960df1 100644 --- a/backend/src/__tests__/sarif-exporter.test.ts +++ b/backend/src/__tests__/sarif-exporter.test.ts @@ -127,7 +127,7 @@ describe('generateSarif', () => { ]); }); - it('attaches SARIF suppressions[] when a finding is marked suppressed', () => { + it('attaches SARIF suppressions[] without leaking free-form reasons', () => { const suppressed = { ...vuln(), suppressed: true, @@ -140,7 +140,7 @@ describe('generateSarif', () => { expect(result.suppressions?.[0]).toEqual({ kind: 'external', status: 'accepted', - justification: 'Not exploitable in our config', + justification: 'Suppressed in Sencho', }); }); @@ -155,6 +155,29 @@ describe('generateSarif', () => { expect(doc.runs[0].results[0].suppressions?.[0].justification).toBe('Suppressed in Sencho'); }); + it('exports acknowledged misconfigs without leaking free-form reasons', () => { + const misconfig: MisconfigFinding & { acknowledged: boolean; acknowledgement_reason: string } = { + id: 1, + scan_id: 1, + rule_id: 'DS002', + check_id: 'AVD-DS-0002', + severity: 'HIGH', + title: 'Running as root', + message: 'Specify a non-root user', + resolution: 'Set user:', + target: 'docker-compose.yml', + primary_url: null, + acknowledged: true, + acknowledgement_reason: 'Accepted for internal lab stack', + }; + const doc = generateSarif(mkScan(), [], [], [misconfig]); + expect(doc.runs[0].results[0].suppressions?.[0]).toEqual({ + kind: 'external', + status: 'accepted', + justification: 'Acknowledged in Sencho', + }); + }); + it('deduplicates rules across findings with the same CVE id', () => { const details = [ vuln({ pkg_name: 'openssl' }), diff --git a/backend/src/__tests__/scheduled-tasks-routes.test.ts b/backend/src/__tests__/scheduled-tasks-routes.test.ts index 563d5667..a9aaaf3c 100644 --- a/backend/src/__tests__/scheduled-tasks-routes.test.ts +++ b/backend/src/__tests__/scheduled-tasks-routes.test.ts @@ -13,6 +13,7 @@ let app: import('express').Express; let DatabaseService: typeof import('../services/DatabaseService').DatabaseService; let adminCookie: string; let viewerCookie: string; +let variantSpy: ReturnType; beforeAll(async () => { tmpDir = await setupTestDb(); @@ -20,7 +21,7 @@ beforeAll(async () => { const { LicenseService } = await import('../services/LicenseService'); vi.spyOn(LicenseService.getInstance(), 'getTier').mockReturnValue('paid'); - vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); + variantSpy = vi.spyOn(LicenseService.getInstance(), 'getVariant').mockReturnValue('admiral'); vi.spyOn(LicenseService.getInstance(), 'getSeatLimits').mockReturnValue({ maxAdmins: null, maxViewers: null }); ({ app } = await import('../index')); @@ -39,6 +40,7 @@ beforeEach(() => { // Start each test with an empty scheduled_tasks table. const db = DatabaseService.getInstance().getDb(); db.prepare('DELETE FROM scheduled_tasks').run(); + variantSpy.mockReturnValue('admiral'); }); describe('GET /api/scheduled-tasks', () => { @@ -87,6 +89,73 @@ describe('GET /api/scheduled-tasks', () => { expect(res.body[0].name).toBe('nightly-scan'); expect(Array.isArray(res.body[0].next_runs)).toBe(true); }); + + it('shows scan and snapshot tasks to Skipper users', async () => { + const db = DatabaseService.getInstance(); + const now = Date.now(); + db.createScheduledTask({ + name: 'nightly-scan', + target_type: 'system', + target_id: null, + node_id: 1, + action: 'scan', + cron_expression: '0 0 * * *', + enabled: 1, + created_by: 'admin', + created_at: now, + updated_at: now, + last_run_at: null, + next_run_at: null, + last_status: null, + last_error: null, + prune_targets: null, + target_services: null, + prune_label_filter: null, + }); + db.createScheduledTask({ + name: 'daily-snapshot', + target_type: 'fleet', + target_id: null, + node_id: 1, + action: 'snapshot', + cron_expression: '0 1 * * *', + enabled: 1, + created_by: 'admin', + created_at: now, + updated_at: now, + last_run_at: null, + next_run_at: null, + last_status: null, + last_error: null, + prune_targets: null, + target_services: null, + prune_label_filter: null, + }); + db.createScheduledTask({ + name: 'system-prune', + target_type: 'system', + target_id: null, + node_id: 1, + action: 'prune', + cron_expression: '0 2 * * *', + enabled: 1, + created_by: 'admin', + created_at: now, + updated_at: now, + last_run_at: null, + next_run_at: null, + last_status: null, + last_error: null, + prune_targets: JSON.stringify(['images']), + target_services: null, + prune_label_filter: null, + }); + variantSpy.mockReturnValue('individual'); + + const res = await request(app).get('/api/scheduled-tasks').set('Cookie', adminCookie); + expect(res.status).toBe(200); + expect(res.body.map((t: { action: string }) => t.action).sort()).toEqual(['scan', 'snapshot']); + }); }); describe('POST /api/scheduled-tasks', () => { @@ -149,6 +218,27 @@ describe('POST /api/scheduled-tasks', () => { expect(res.body.error).toMatch(/Scan action requires node_id/); }); + it('rejects scheduled scans on remote nodes', async () => { + const remoteNodeId = DatabaseService.getInstance().addNode({ + name: 'remote-scan-node', + type: 'remote', + api_url: 'http://remote.local:1852', + api_token: 'token', + compose_dir: '/srv/compose', + is_default: false, + }); + + const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ + name: 'remote-scan', + target_type: 'system', + node_id: remoteNodeId, + action: 'scan', + cron_expression: '0 0 * * *', + }); + expect(res.status).toBe(400); + expect(res.body.error).toMatch(/local node/i); + }); + it('rejects target_services with wrong action', async () => { const res = await request(app).post('/api/scheduled-tasks').set('Cookie', adminCookie).send({ ...basePayload, action: 'update', target_services: ['web'], diff --git a/backend/src/__tests__/scheduler-service.test.ts b/backend/src/__tests__/scheduler-service.test.ts index f4edad3c..9524f836 100644 --- a/backend/src/__tests__/scheduler-service.test.ts +++ b/backend/src/__tests__/scheduler-service.test.ts @@ -968,11 +968,19 @@ describe('SchedulerService - scheduled scan notifications', () => { medium?: number; low?: number; unknown?: number; + totalImages?: number; + processedImages?: number; + truncated?: boolean; + limitReason?: string; } = {}) { return { scanned: opts.scanned ?? 0, skipped: opts.skipped ?? 0, failed: opts.failed ?? 0, + totalImages: opts.totalImages, + processedImages: opts.processedImages, + truncated: opts.truncated, + limitReason: opts.limitReason, severity: { critical: opts.critical ?? 0, high: opts.high ?? 0, @@ -1090,6 +1098,25 @@ describe('SchedulerService - scheduled scan notifications', () => { ); }); + it('fails scheduled scan tasks that target remote nodes before scanning', async () => { + mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 210, node_id: 2 })); + mockGetNode + .mockReturnValueOnce({ id: 2, name: 'remote', type: 'remote', status: 'online' }) + .mockReturnValueOnce({ id: 2, name: 'remote', type: 'remote', status: 'online' }); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(210); + + expect(mockScanAllNodeImages).not.toHaveBeenCalled(); + expect(mockUpdateScheduledTaskRun).toHaveBeenCalledWith( + expect.any(Number), + expect.objectContaining({ + status: 'failure', + error: expect.stringMatching(/local node/i), + }), + ); + }); + it('includes severity counts in the notification message', async () => { mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 206 })); mockScanAllNodeImages.mockResolvedValue( @@ -1135,6 +1162,24 @@ describe('SchedulerService - scheduled scan notifications', () => { ); }); + it('reports when scan-all stops at a configured bound', async () => { + mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 211 })); + mockScanAllNodeImages.mockResolvedValue(scanResult({ + scanned: 100, + totalImages: 250, + processedImages: 100, + truncated: true, + limitReason: 'image limit 100 reached', + })); + + const svc = SchedulerService.getInstance(); + await svc.triggerTask(211); + + const message = mockDispatchAlert.mock.calls[0][2] as string; + expect(message).toContain('Scan limited after 100 of 250 image(s)'); + expect(message).toContain('image limit 100 reached'); + }); + it('persists the run as success even when notification dispatch throws', async () => { mockGetScheduledTask.mockReturnValue(makeScanTask({ id: 209 })); mockScanAllNodeImages.mockResolvedValue(scanResult({ scanned: 1 })); diff --git a/backend/src/__tests__/stacks-failure-notifications.test.ts b/backend/src/__tests__/stacks-failure-notifications.test.ts index 0ce7f027..0aa5f991 100644 --- a/backend/src/__tests__/stacks-failure-notifications.test.ts +++ b/backend/src/__tests__/stacks-failure-notifications.test.ts @@ -22,6 +22,10 @@ const { mockGetContainersByStack, mockRestartContainer, mockStopContainer, + mockListContainers, + mockIsTrivyAvailable, + mockGetImageDigest, + mockRunScanAndPersist, } = vi.hoisted(() => ({ mockDeployStack: vi.fn(), mockRunCommand: vi.fn(), @@ -29,6 +33,10 @@ const { mockGetContainersByStack: vi.fn(), mockRestartContainer: vi.fn(), mockStopContainer: vi.fn(), + mockListContainers: vi.fn(), + mockIsTrivyAvailable: vi.fn(), + mockGetImageDigest: vi.fn(), + mockRunScanAndPersist: vi.fn(), })); vi.mock('../services/ComposeService', async () => { @@ -60,6 +68,26 @@ vi.mock('../services/DockerController', async () => { getContainersByStack: mockGetContainersByStack, restartContainer: mockRestartContainer, stopContainer: mockStopContainer, + getDocker: () => ({ + listContainers: mockListContainers, + }), + }), + }, + }; +}); + +vi.mock('../services/TrivyService', async () => { + const actual = await vi.importActual( + '../services/TrivyService', + ); + return { + ...actual, + default: { + ...actual.default, + getInstance: () => ({ + isTrivyAvailable: mockIsTrivyAvailable, + getImageDigest: mockGetImageDigest, + runScanAndPersist: mockRunScanAndPersist, }), }, }; @@ -105,6 +133,17 @@ beforeEach(() => { mockGetContainersByStack.mockReset(); mockRestartContainer.mockReset(); mockStopContainer.mockReset(); + mockListContainers.mockReset(); + mockIsTrivyAvailable.mockReset(); + mockGetImageDigest.mockReset(); + mockRunScanAndPersist.mockReset(); + mockIsTrivyAvailable.mockReturnValue(true); + mockListContainers.mockResolvedValue([{ Image: 'nginx:latest' }]); + mockGetImageDigest.mockResolvedValue(null); + mockRunScanAndPersist.mockResolvedValue({ + critical_count: 0, + high_count: 0, + }); dispatchAlertSpy.mockClear(); }); @@ -187,6 +226,23 @@ describe('deploy_failure notification on /deploy error', () => { }); }); +describe('post-deploy scan opt-out', () => { + it('does not trigger a post-deploy scan when skip_scan is true', async () => { + mockDeployStack.mockResolvedValue(undefined); + + const res = await request(app) + .post('/api/stacks/myapp/deploy') + .set('Cookie', authCookie) + .send({ skip_scan: true }); + + expect(res.status).toBe(200); + await new Promise(resolve => setImmediate(resolve)); + + expect(mockListContainers).not.toHaveBeenCalled(); + expect(mockRunScanAndPersist).not.toHaveBeenCalled(); + }); +}); + describe('deploy_failure notification on /down error', () => { it('dispatches deploy_failure alert when runCommand (down) throws', async () => { mockRunCommand.mockRejectedValue(new Error('container removal error')); diff --git a/backend/src/__tests__/stacks-from-git-skip-scan.test.ts b/backend/src/__tests__/stacks-from-git-skip-scan.test.ts new file mode 100644 index 00000000..e1845cfa --- /dev/null +++ b/backend/src/__tests__/stacks-from-git-skip-scan.test.ts @@ -0,0 +1,154 @@ +import { describe, it, expect, beforeAll, afterAll, beforeEach, vi } from 'vitest'; +import request from 'supertest'; +import jwt from 'jsonwebtoken'; +import { setupTestDb, cleanupTestDb, TEST_JWT_SECRET, TEST_USERNAME } from './helpers/setupTestDb'; + +const { + mockCreateStackFromGit, + mockDeployStack, + mockGetStacks, + mockListContainers, + mockIsTrivyAvailable, + mockGetImageDigest, + mockRunScanAndPersist, +} = vi.hoisted(() => ({ + mockCreateStackFromGit: vi.fn(), + mockDeployStack: vi.fn(), + mockGetStacks: vi.fn(), + mockListContainers: vi.fn(), + mockIsTrivyAvailable: vi.fn(), + mockGetImageDigest: vi.fn(), + mockRunScanAndPersist: vi.fn(), +})); + +vi.mock('../services/FileSystemService', () => ({ + FileSystemService: { + getInstance: () => ({ + getStacks: mockGetStacks, + }), + }, +})); + +vi.mock('../services/GitSourceService', async () => { + const actual = await vi.importActual( + '../services/GitSourceService', + ); + return { + ...actual, + GitSourceService: { + getInstance: () => ({ + createStackFromGit: mockCreateStackFromGit, + }), + }, + }; +}); + +vi.mock('../services/ComposeService', async () => { + const actual = await vi.importActual( + '../services/ComposeService', + ); + return { + ...actual, + ComposeService: { + getInstance: () => ({ + deployStack: mockDeployStack, + }), + }, + }; +}); + +vi.mock('../services/DockerController', async () => { + const actual = await vi.importActual( + '../services/DockerController', + ); + return { + ...actual, + default: { + ...actual.default, + getInstance: () => ({ + getDocker: () => ({ + listContainers: mockListContainers, + }), + }), + }, + }; +}); + +vi.mock('../services/TrivyService', async () => { + const actual = await vi.importActual( + '../services/TrivyService', + ); + return { + ...actual, + default: { + ...actual.default, + getInstance: () => ({ + isTrivyAvailable: mockIsTrivyAvailable, + getImageDigest: mockGetImageDigest, + runScanAndPersist: mockRunScanAndPersist, + }), + }, + }; +}); + +let tmpDir: string; +let app: import('express').Express; + +function adminToken(): string { + return jwt.sign({ username: TEST_USERNAME, role: 'admin' }, TEST_JWT_SECRET, { expiresIn: '1m' }); +} + +beforeAll(async () => { + tmpDir = await setupTestDb(); + ({ app } = await import('../index')); +}); + +afterAll(() => { + cleanupTestDb(tmpDir); +}); + +beforeEach(() => { + mockCreateStackFromGit.mockReset(); + mockDeployStack.mockReset(); + mockGetStacks.mockReset(); + mockListContainers.mockReset(); + mockIsTrivyAvailable.mockReset(); + mockGetImageDigest.mockReset(); + mockRunScanAndPersist.mockReset(); + + mockGetStacks.mockResolvedValue([]); + mockCreateStackFromGit.mockResolvedValue({ + source: { stackName: 'route-from-git', repoUrl: 'https://github.com/example/repo.git' }, + commitSha: 'abcdef1234567890', + envWritten: false, + warnings: [], + }); + mockDeployStack.mockResolvedValue(undefined); + mockIsTrivyAvailable.mockReturnValue(true); + mockListContainers.mockResolvedValue([{ Image: 'nginx:latest' }]); + mockGetImageDigest.mockResolvedValue(null); + mockRunScanAndPersist.mockResolvedValue({ critical_count: 0, high_count: 0 }); +}); + +describe('POST /api/stacks/from-git scan opt-out', () => { + it('does not trigger a post-deploy scan when deploy_now and skip_scan are true', async () => { + const res = await request(app) + .post('/api/stacks/from-git') + .set('Authorization', `Bearer ${adminToken()}`) + .send({ + stack_name: 'route-from-git', + repo_url: 'https://github.com/example/repo.git', + branch: 'main', + compose_path: 'compose.yaml', + auth_type: 'none', + deploy_now: true, + skip_scan: true, + }); + + expect(res.status).toBe(200); + expect(res.body.deployed).toBe(true); + await new Promise(resolve => setImmediate(resolve)); + expect(mockListContainers).not.toHaveBeenCalled(); + expect(mockRunScanAndPersist).not.toHaveBeenCalled(); + }); +}); diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index c13072a5..c485a102 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -18,6 +18,7 @@ type TargetType = typeof VALID_TARGET_TYPES[number]; type ScheduledAction = typeof VALID_ACTIONS[number]; const STACK_ONLY_ACTIONS = new Set(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']); +const SKIPPER_VISIBLE_ACTIONS = new Set(['update', 'scan', 'snapshot']); /** * Validate that the target_type is compatible with the action. Each action @@ -36,6 +37,18 @@ function validateActionTarget(action: ScheduledAction, targetType: TargetType): return null; } +function validateScanNode(nodeId: unknown): string | null { + if (nodeId == null) return 'Scan action requires node_id.'; + const parsedNodeId = Number(nodeId); + if (!Number.isFinite(parsedNodeId)) return 'Scan action requires a valid node_id.'; + const node = DatabaseService.getInstance().getNode(parsedNodeId); + if (!node) return 'Scheduled vulnerability scans require an existing local node.'; + if (node?.type === 'remote') { + return 'Scheduled vulnerability scans currently require a local node.'; + } + return null; +} + /** Shared validation for prune_targets, target_services, prune_label_filter. Returns an error string or null. */ function validateOptionalFields( action: ScheduledAction, @@ -77,10 +90,10 @@ scheduledTasksRouter.get('/', (req: Request, res: Response): void => { if (!requirePaid(req, res)) return; try { let tasks = DatabaseService.getInstance().getScheduledTasks(); - // Skipper users only see 'update' tasks; Admiral sees all. + // Skipper users see v1 fleet-maintenance tasks; Admiral sees all. const ls = LicenseService.getInstance(); if (ls.getVariant() !== 'admiral') { - tasks = tasks.filter(t => t.action === 'update'); + tasks = tasks.filter(t => SKIPPER_VISIBLE_ACTIONS.has(t.action as ScheduledAction)); } // Split Auto-Update and Scheduled Operations into distinct views. const actionFilter = typeof req.query.action === 'string' ? req.query.action : undefined; @@ -130,6 +143,10 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => { if (action === 'scan' && !node_id) { res.status(400).json({ error: 'Scan action requires node_id.' }); return; } + if (action === 'scan') { + const nodeErr = validateScanNode(node_id); + if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } + } if (action === 'update' && target_type === 'fleet' && !node_id) { res.status(400).json({ error: ERR_FLEET_NODE_REQUIRED }); return; } @@ -223,9 +240,8 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => { if (finalAction === 'scan') { const finalNodeId = node_id !== undefined ? node_id : existing.node_id; - if (!finalNodeId) { - res.status(400).json({ error: 'Scan action requires node_id.' }); return; - } + const nodeErr = validateScanNode(finalNodeId); + if (nodeErr) { res.status(400).json({ error: nodeErr }); return; } } if (finalAction === 'update' && finalTargetType === 'fleet') { const finalNodeId = node_id !== undefined ? node_id : existing.node_id; diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 63d01d48..1860648b 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -388,6 +388,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { auto_apply_on_webhook, auto_deploy_on_apply, deploy_now, + skip_scan, } = req.body ?? {}; fromGitStackName = typeof stack_name === 'string' ? stack_name : ''; @@ -512,7 +513,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => { deployed, deployError, }); - if (deployed) { + if (deployed && skip_scan !== true) { triggerPostDeployScan(stack_name, req.nodeId).catch(err => console.error(`[Security] Post-deploy scan failed for ${sanitizeForLog(stack_name)}:`, err), ); @@ -602,6 +603,7 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; try { if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return; + const skipScan = req.body?.skip_scan === true; const debug = isDebugEnabled(); const atomic = effectiveTier(req) === 'paid'; if (debug) console.debug('[Stacks:debug] Deploy starting', { stackName, atomic, nodeId: req.nodeId }); @@ -612,9 +614,11 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => { if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`); res.json({ message: 'Deployed successfully' }); notifyActionSuccess('deploy_success', `${stackName} deployed`, stackName, req.user?.username ?? 'system'); - triggerPostDeployScan(stackName, req.nodeId).catch(err => - console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err), - ); + if (!skipScan) { + triggerPostDeployScan(stackName, req.nodeId).catch(err => + console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err), + ); + } } catch (error: unknown) { console.error('[Stacks] Deploy failed: %s', sanitizeForLog(stackName), error); const rollbackInfo = getComposeRollbackInfo(error); @@ -781,6 +785,7 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { if (!requirePermission(req, res, 'stack:deploy', 'stack', stackName)) return; try { if (!(await runPolicyGate(req, res, stackName, req.nodeId))) return; + const skipScan = req.body?.skip_scan === true; const debug = isDebugEnabled(); const atomic = effectiveTier(req) === 'paid'; if (debug) console.debug('[Stacks:debug] Update starting', { stackName, atomic, nodeId: req.nodeId }); @@ -792,9 +797,11 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => { if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`); res.json({ status: 'Update completed' }); notifyActionSuccess('image_update_applied', `${stackName} updated`, stackName, req.user?.username ?? 'system'); - triggerPostDeployScan(stackName, req.nodeId).catch(err => - console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err), - ); + if (!skipScan) { + triggerPostDeployScan(stackName, req.nodeId).catch(err => + console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err), + ); + } } catch (error: unknown) { console.error('[Stacks] Update failed: %s', sanitizeForLog(stackName), error); const rollbackInfo = getComposeRollbackInfo(error); diff --git a/backend/src/services/SarifExporter.ts b/backend/src/services/SarifExporter.ts index 09b34510..b2d3cc69 100644 --- a/backend/src/services/SarifExporter.ts +++ b/backend/src/services/SarifExporter.ts @@ -99,7 +99,7 @@ function toSuppressions(decision: Partial): SarifSuppressio { kind: 'external', status: 'accepted', - justification: decision.suppression_reason?.trim() || 'Suppressed in Sencho', + justification: 'Suppressed in Sencho', }, ]; } @@ -112,7 +112,7 @@ function toAckSuppressions( { kind: 'external', status: 'accepted', - justification: decision.acknowledgement_reason?.trim() || 'Acknowledged in Sencho', + justification: 'Acknowledged in Sencho', }, ]; } diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 02ecb4cb..fb95d873 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -755,6 +755,13 @@ export class SchedulerService { if (task.node_id == null && isDebugEnabled()) { console.log(`[SchedulerService:debug] Scan task ${task.id}: no node_id specified, using default node ${nodeId}`); } + const node = NodeRegistry.getInstance().getNode(nodeId); + if (!node) { + throw new Error('Scheduled vulnerability scans require an existing local node.'); + } + if (node?.type === 'remote') { + throw new Error('Scheduled vulnerability scans currently require a local node.'); + } const scanStart = Date.now(); if (isDebugEnabled()) console.log(`[SchedulerService:debug] executeScan start: task=${task.id} node=${nodeId}`); @@ -804,6 +811,13 @@ export function formatScanOutput(summary: ScanAllNodeImagesResult): string { header = parts.join('; '); } + if (summary.truncated) { + const total = summary.totalImages ?? scanned + skipped + failed; + const processed = summary.processedImages ?? scanned + skipped + failed; + header += `. Scan limited after ${processed} of ${total} image(s)`; + if (summary.limitReason) header += ` (${summary.limitReason})`; + } + const severityTiers: Array<[string, number]> = [ ['critical', severity.critical], ['high', severity.high], diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index 34c26de3..27f9b89c 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -25,6 +25,8 @@ const execFileAsync = promisify(execFile); const SCAN_TIMEOUT_MS = 5 * 60 * 1000; const SBOM_TIMEOUT_MS = 3 * 60 * 1000; export const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000; +const DEFAULT_SCAN_ALL_MAX_IMAGES = 100; +const DEFAULT_SCAN_ALL_MAX_DURATION_MS = 30 * 60 * 1000; const TRIVY_TEMP_DIR_PREFIX = 'sencho-trivy-'; const TRIVY_TEMP_DIR_MAX_AGE_MS = 60 * 60 * 1000; // 1 hour @@ -136,6 +138,10 @@ export interface ScanAllNodeImagesResult { scanned: number; skipped: number; failed: number; + totalImages?: number; + processedImages?: number; + truncated?: boolean; + limitReason?: string; severity: ScanAllNodeImagesSeverityTotals; /** * Policy violations observed across the freshly-scanned or cached rows. @@ -221,6 +227,12 @@ export interface TrivyComposeScanResult { export type SbomFormat = 'spdx-json' | 'cyclonedx'; +function positiveIntFromEnv(name: string, fallback: number): number { + const value = Number(process.env[name]); + if (!Number.isFinite(value) || value <= 0) return fallback; + return Math.floor(value); +} + // Keep scanners in canonical order so the DB value is comparable as-is. export function normalizeScanners(input?: readonly TrivyScanner[]): TrivyScanner[] { const set = new Set(input && input.length > 0 ? input : ['vuln']); @@ -1029,10 +1041,16 @@ class TrivyService { if (tag && tag !== ':') imageRefs.add(tag); } } + const refs = Array.from(imageRefs); + const maxImages = positiveIntFromEnv('TRIVY_SCAN_ALL_MAX_IMAGES', DEFAULT_SCAN_ALL_MAX_IMAGES); + const maxDurationMs = positiveIntFromEnv('TRIVY_SCAN_ALL_MAX_DURATION_MS', DEFAULT_SCAN_ALL_MAX_DURATION_MS); let scanned = 0; let skipped = 0; let failed = 0; + let processedImages = 0; + let truncated = false; + let limitReason: string | undefined; const severity = { critical: 0, high: 0, medium: 0, low: 0, unknown: 0 }; const countedDigests = new Set(); const violations: ScanAllNodeImagesViolation[] = []; @@ -1068,7 +1086,19 @@ class TrivyService { } }; - for (const ref of imageRefs) { + for (const ref of refs) { + if (processedImages >= maxImages) { + truncated = true; + limitReason = `image limit ${maxImages} reached`; + break; + } + const elapsedMs = Date.now() - batchStartedAt; + if (elapsedMs >= maxDurationMs) { + truncated = true; + limitReason = `duration limit ${maxDurationMs}ms reached`; + break; + } + processedImages++; try { const digest = await this.getImageDigest(ref, nodeId); if (digest) { @@ -1097,9 +1127,19 @@ class TrivyService { diag( `scanAllNodeImages: nodeId=${nodeId} unique=${imageRefs.size} ` + `scanned=${scanned} skipped=${skipped} failed=${failed} ` - + `violations=${violations.length} elapsedMs=${Date.now() - batchStartedAt}`, + + `violations=${violations.length} truncated=${truncated} elapsedMs=${Date.now() - batchStartedAt}`, ); - return { scanned, skipped, failed, severity, violations }; + return { + scanned, + skipped, + failed, + totalImages: refs.length, + processedImages, + truncated, + limitReason, + severity, + violations, + }; } async generateSBOM(imageRef: string, format: SbomFormat): Promise {