fix: harden vulnerability scan scheduling (#1035)

This commit is contained in:
Anso
2026-05-13 09:40:45 -04:00
committed by GitHub
parent c31d48b933
commit 328a98439d
10 changed files with 465 additions and 20 deletions
+25 -2
View File
@@ -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' }),
@@ -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<typeof vi.spyOn>;
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'],
@@ -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 }));
@@ -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<typeof import('../services/TrivyService')>(
'../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'));
@@ -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<typeof import('../services/GitSourceService')>(
'../services/GitSourceService',
);
return {
...actual,
GitSourceService: {
getInstance: () => ({
createStackFromGit: mockCreateStackFromGit,
}),
},
};
});
vi.mock('../services/ComposeService', async () => {
const actual = await vi.importActual<typeof import('../services/ComposeService')>(
'../services/ComposeService',
);
return {
...actual,
ComposeService: {
getInstance: () => ({
deployStack: mockDeployStack,
}),
},
};
});
vi.mock('../services/DockerController', async () => {
const actual = await vi.importActual<typeof import('../services/DockerController')>(
'../services/DockerController',
);
return {
...actual,
default: {
...actual.default,
getInstance: () => ({
getDocker: () => ({
listContainers: mockListContainers,
}),
}),
},
};
});
vi.mock('../services/TrivyService', async () => {
const actual = await vi.importActual<typeof import('../services/TrivyService')>(
'../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();
});
});
+21 -5
View File
@@ -18,6 +18,7 @@ type TargetType = typeof VALID_TARGET_TYPES[number];
type ScheduledAction = typeof VALID_ACTIONS[number];
const STACK_ONLY_ACTIONS = new Set<ScheduledAction>(['auto_backup', 'auto_stop', 'auto_down', 'auto_start']);
const SKIPPER_VISIBLE_ACTIONS = new Set<ScheduledAction>(['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;
+14 -7
View File
@@ -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);
+2 -2
View File
@@ -99,7 +99,7 @@ function toSuppressions(decision: Partial<SuppressionDecision>): 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',
},
];
}
+14
View File
@@ -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],
+43 -3
View File
@@ -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<TrivyScanner>(input && input.length > 0 ? input : ['vuln']);
@@ -1029,10 +1041,16 @@ class TrivyService {
if (tag && tag !== '<none>:<none>') 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<string>();
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<string> {