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
+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);