mirror of
https://github.com/Studio-Saelix/sencho.git
synced 2026-08-30 12:09:15 +00:00
fix: harden vulnerability scan scheduling (#1035)
This commit is contained in:
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user