mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-07-26 11:49:16 +00:00
fix: harden vulnerability scan scheduling (#1035)
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user