refactor(backend): sanitize user input before logging to close CRLF injection (#807)

* refactor(backend): sanitize user input before logging to close CRLF injection

Adds a small sanitizeForLog helper that strips CR, LF, tab, and ASCII
control characters (0x00-0x1F, 0x7F) from a value before it is embedded
in a console.log/warn/error/debug call. Wraps every call site where a
user-controlled value (req.params, req.body, req.query, or a value
derived from them) flows into a log message.

Closes the bulk of the open CodeQL alerts in this family:
- 96 js/log-injection
- 28 js/tainted-format-string

The helper is in backend/src/utils/safeLog.ts. Routes still pre-validate
input at the request boundary; this is the second line of defense and
gives static analyzers a sanitizer they can trace through. JSON
responses, Docker filter labels, and other non-log call sites are
intentionally left unwrapped.

* refactor(backend): printf-style format strings for tainted-log call sites

CodeQL's js/tainted-format-string rule flags template literals in the first
arg of console.X when any interpolated value is user-controlled, regardless
of whether each value is sanitized inline. The canonical mitigation is to
use a static format string and pass values as positional args.

Converts the 28 flagged template literals to printf-style ("%s") format
strings, with sanitizeForLog applied to each positional arg. Also fills in
the log-injection wraps on 9 sites where a user-controlled value was
missed in the first sweep (agents, fleet, gitSources, imageUpdates,
GitSourceService).

No behavior change at runtime. Node's util.format substitutes %s tokens
identically to template-literal interpolation.

* fix(backend): wrap nodeId/snapshotId in fleet restore debug log

CodeQL flagged the unwrapped numeric args even though they cannot
contain control chars in practice. Apply the sanitizer for taint-flow
recognition.
This commit is contained in:
Anso
2026-04-27 10:47:23 -04:00
committed by GitHub
parent 77f27b4bf9
commit 4e5ba17710
32 changed files with 148 additions and 102 deletions
+3 -2
View File
@@ -3,6 +3,7 @@ import { DatabaseService } from '../services/DatabaseService';
import { authMiddleware } from '../middleware/auth';
import { requireAdmin } from '../middleware/tierGates';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { NOTIFICATION_CHANNEL_TYPES, validateHttpsUrl } from '../helpers/notificationChannels';
export const agentsRouter = Router();
@@ -34,8 +35,8 @@ agentsRouter.post('/', authMiddleware, async (req: Request, res: Response): Prom
}
const nodeId = req.nodeId ?? 0;
DatabaseService.getInstance().upsertAgent(nodeId, { type, url, enabled });
console.log(`[Agents] Agent ${type} updated`);
if (isDebugEnabled()) console.log(`[Agents:diag] Agent ${type} upsert: enabled=${enabled}`);
console.log('[Agents] Agent %s updated', sanitizeForLog(type));
if (isDebugEnabled()) console.log('[Agents:diag] Agent %s upsert: enabled=%s', sanitizeForLog(type), sanitizeForLog(enabled));
res.json({ success: true });
} catch (error) {
console.error('Failed to update agent:', error);
+2 -1
View File
@@ -5,6 +5,7 @@ import { requireAdmiral } from '../middleware/tierGates';
import { requirePermission } from '../middleware/permissions';
import { isDebugEnabled } from '../utils/debug';
import { escapeCsvField } from '../utils/csv';
import { sanitizeForLog } from '../utils/safeLog';
export const auditLogRouter = Router();
@@ -23,7 +24,7 @@ auditLogRouter.get('/', async (req: Request, res: Response): Promise<void> => {
const withAnomalies = req.query.with_anomalies === '1';
if (isDebugEnabled()) {
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${username || '-'} method=${method || '-'} search=${search || '-'}`);
console.log(`[Audit:diag] Query: page=${page} limit=${limit} username=${sanitizeForLog(username || '-')} method=${sanitizeForLog(method || '-')} search=${sanitizeForLog(search || '-')}`);
}
const db = DatabaseService.getInstance();
const result = db.getAuditLogs({ page, limit, username, method, from, to, search });
+2 -1
View File
@@ -13,6 +13,7 @@ import {
import { authRateLimiter } from '../middleware/rateLimiters';
import { requireAdmin } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { sanitizeForLog } from '../utils/safeLog';
import {
BCRYPT_SALT_ROUNDS,
COOKIE_NAME,
@@ -143,7 +144,7 @@ authRouter.post('/login', authRateLimiter, async (req: Request, res: Response):
}
}
console.warn('[Auth] Login failed for username:', username);
console.warn('[Auth] Login failed for username:', sanitizeForLog(username));
res.status(401).json({ error: 'Invalid credentials' });
} catch (error) {
console.error('Login error:', error);
+2 -1
View File
@@ -2,6 +2,7 @@ import { Router, type Request, type Response } from 'express';
// @ts-ignore - composerize lacks proper type definitions
import composerize from 'composerize';
import { authMiddleware } from '../middleware/auth';
import { sanitizeForLog } from '../utils/safeLog';
const MAX_DOCKER_RUN_LENGTH = 8192;
@@ -37,7 +38,7 @@ convertRouter.post('/', authMiddleware, async (req: Request, res: Response): Pro
}
if (typeof yaml !== 'string' || !yaml.includes('services:')) {
console.warn('Converter produced unexpected output for input:', trimmed.slice(0, 200));
console.warn('Converter produced unexpected output for input:', sanitizeForLog(trimmed.slice(0, 200)));
res.status(422).json({ error: 'Could not parse command. Check syntax and supported flags.' });
return;
}
+3 -2
View File
@@ -22,6 +22,7 @@ import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { POLICY_SEVERITIES } from '../utils/severity';
import { sanitizeForLog } from '../utils/safeLog';
import { CloudBackupService } from '../services/CloudBackupService';
import { NotificationService } from '../services/NotificationService';
import { buildLocalConfigurationStatus, type ConfigurationStatus } from './dashboard';
@@ -993,7 +994,7 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
if (isDebugEnabled()) {
const fileNames = files.map(f => f.filename).join(', ');
console.debug(`[Fleet:debug] Restore: snapshot=${snapshotId}, node=${nodeId}, stack="${stackName}", files=[${fileNames}], redeploy=${redeploy}`);
console.debug('[Fleet:debug] Restore: snapshot=%s, node=%s, stack="%s", files=[%s], redeploy=%s', sanitizeForLog(snapshotId), sanitizeForLog(nodeId), sanitizeForLog(stackName), sanitizeForLog(fileNames), sanitizeForLog(redeploy));
}
const node = db.getNode(nodeId);
@@ -1065,7 +1066,7 @@ fleetRouter.post('/snapshots/:id/restore', authMiddleware, async (req: Request,
}
}
console.log('[Fleet] Snapshot restore:', snapshotId, 'node=', nodeId, 'stack=', stackName);
console.log('[Fleet] Snapshot restore: snapshot=%s node=%s stack=%s', snapshotId, sanitizeForLog(nodeId), sanitizeForLog(stackName));
res.json({ message: 'Stack restored successfully', redeployed: redeploy });
} catch (error) {
console.error('[Fleet Snapshot] Restore error:', error);
+5 -4
View File
@@ -7,6 +7,7 @@ import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { triggerPostDeployScan } from '../helpers/policyGate';
import { isValidStackName } from '../utils/validation';
import { sendGitSourceError } from '../utils/gitSourceHttp';
import { sanitizeForLog } from '../utils/safeLog';
// Reasonable upper bounds so a caller cannot flood the service with huge
// payloads. Generous compared to anything a real Git provider emits.
@@ -206,16 +207,16 @@ stackGitSourceRouter.post('/:stackName/git-source/apply', async (req: Request, r
invalidateNodeCaches(req.nodeId);
const shortSha = commitSha.trim().slice(0, 7);
if (result.deployed) {
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName} (deployed)`);
console.log('[GitSource] Applied commit %s to %s (deployed)', sanitizeForLog(shortSha), sanitizeForLog(stackName));
} else if (result.deployError) {
console.warn(`[GitSource] Applied commit ${shortSha} to ${stackName}, deploy failed: ${result.deployError}`);
console.warn('[GitSource] Applied commit %s to %s, deploy failed: %s', sanitizeForLog(shortSha), sanitizeForLog(stackName), sanitizeForLog(result.deployError));
} else {
console.log(`[GitSource] Applied commit ${shortSha} to ${stackName}`);
console.log('[GitSource] Applied commit %s to %s', sanitizeForLog(shortSha), sanitizeForLog(stackName));
}
res.json(result);
if (result.deployed) {
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
console.error(`[Security] Post-deploy scan failed for ${sanitizeForLog(stackName)}:`, err),
);
}
} catch (error) {
+3 -2
View File
@@ -13,6 +13,7 @@ import { authMiddleware } from '../middleware/auth';
import { requireAdmin, requirePaid } from '../middleware/tierGates';
import { buildPolicyGateOptions } from '../helpers/policyGate';
import { isValidStackName } from '../utils/validation';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
// Fleet aggregation cache: 2-minute TTL, shared across dashboard tabs.
@@ -189,7 +190,7 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
if (!requireAdmin(req, res)) return;
try {
const { target } = req.body as { target?: string };
console.log(`[AutoUpdate] Execute requested: target="${target || ''}"`);
console.log(`[AutoUpdate] Execute requested: target="${sanitizeForLog(target || '')}"`);
if (!target || typeof target !== 'string') {
res.status(400).json({ error: 'Missing "target" (stack name or "*" for all)' });
return;
@@ -256,7 +257,7 @@ autoUpdateRouter.post('/execute', authMiddleware, async (req: Request, res: Resp
} catch (e) {
const errMsg = getErrorMessage(e, String(e));
checkErrors.push(errMsg);
console.warn(`[AutoUpdate] Failed to check image ${imageRef}:`, e);
console.warn('[AutoUpdate] Failed to check image %s:', sanitizeForLog(imageRef), sanitizeForLog((e as Error)?.message ?? String(e)));
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ import { isValidStackName } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
const activeBulkActions = new Set<string>();
@@ -231,7 +232,7 @@ labelsRouter.post('/:id/action', authMiddleware, async (req: Request, res: Respo
const succeeded = results.filter(r => r.success).length;
const failed = results.length - succeeded;
console.log(`[Labels] Bulk ${action} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
console.log(`[Labels] Bulk ${sanitizeForLog(action)} on label ${id}: ${validStacks.length} stacks (${succeeded} succeeded, ${failed} failed)`);
if (isDebugEnabled()) console.debug('[Labels:debug] Bulk action complete:', { id, action, total: results.length, succeeded, failed });
if (succeeded > 0) {
+4 -3
View File
@@ -7,6 +7,7 @@ import { requirePaid, requireAdmin, requireScheduledTaskTier } from '../middlewa
import { escapeCsvField } from '../utils/csv';
import { getErrorMessage } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
const VALID_TARGET_TYPES = ['stack', 'fleet', 'system'] as const;
const VALID_ACTIONS = ['restart', 'snapshot', 'prune', 'update', 'scan', 'auto_backup', 'auto_stop', 'auto_down', 'auto_start'] as const;
@@ -140,7 +141,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
if (optionalErr) { res.status(400).json({ error: optionalErr }); return; }
try { CronExpressionParser.parse(cron_expression); } catch (e) {
console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, getErrorMessage(e, 'unknown'));
console.warn('[Scheduler] Invalid cron expression rejected:', sanitizeForLog(cron_expression), sanitizeForLog(getErrorMessage(e, 'unknown')));
res.status(400).json({ error: 'Invalid cron expression.' }); return;
}
@@ -169,7 +170,7 @@ scheduledTasksRouter.post('/', (req: Request, res: Response): void => {
delete_after_run: delete_after_run ? 1 : 0,
});
console.log(`[ScheduledTasks] Created task id=${id} action=${action} target=${target_id || 'none'}`);
console.log(`[ScheduledTasks] Created task id=${id} action=${sanitizeForLog(action)} target=${sanitizeForLog(target_id || 'none')}`);
const task = DatabaseService.getInstance().getScheduledTask(id);
res.status(201).json(task);
} catch (error) {
@@ -238,7 +239,7 @@ scheduledTasksRouter.put('/:id', (req: Request, res: Response): void => {
if (cron_expression) {
try { CronExpressionParser.parse(cron_expression); } catch (e) {
console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, getErrorMessage(e, 'unknown'));
console.warn('[Scheduler] Invalid cron expression rejected:', sanitizeForLog(cron_expression), sanitizeForLog(getErrorMessage(e, 'unknown')));
res.status(400).json({ error: 'Invalid cron expression.' }); return;
}
}
+2 -1
View File
@@ -10,6 +10,7 @@ import { LicenseService } from '../services/LicenseService';
import { validateImageRef } from '../utils/image-ref';
import { applySuppressions } from '../utils/suppression-filter';
import { generateSarif } from '../services/SarifExporter';
import { sanitizeForLog } from '../utils/safeLog';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { blockIfReplica } from '../middleware/fleetSyncGuards';
@@ -187,7 +188,7 @@ securityRouter.post('/scan', authMiddleware, (req: Request, res: Response): void
res.status(202).json({ scanId });
svc.finishScan(scanId, imageRef, nodeId, { useCache: !force, scanners }).catch((err) => {
console.error(`[Security] Scan failed for ${imageRef}:`, (err as Error).message);
console.error('[Security] Scan failed for %s:', sanitizeForLog(imageRef), sanitizeForLog((err as Error).message));
});
});
+2 -1
View File
@@ -7,6 +7,7 @@ import { authRateLimiter, ssoRateLimiter } from '../middleware/rateLimiters';
import { isSecureRequest } from '../helpers/cookies';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
// Seed SSO config from environment variables on module load. One-shot side
// effect at startup; safe to repeat (upsert).
@@ -22,7 +23,7 @@ function getSSOBaseUrl(req: Request, res: Response): string | null {
return null;
}
if (!process.env.SSO_CALLBACK_URL && isDebugEnabled()) {
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', host);
console.debug('[SSO:debug] SSO_CALLBACK_URL not set; using Host header for callback URL:', sanitizeForLog(host));
}
return process.env.SSO_CALLBACK_URL || `${req.protocol}://${host}`;
}
+3 -2
View File
@@ -3,6 +3,7 @@ import { DatabaseService } from '../services/DatabaseService';
import { SSOService, type SSOProviderConfig } from '../services/SSOService';
import { requireAdmin, requireTierForSsoProvider } from '../middleware/tierGates';
import { rejectApiTokenScope } from '../middleware/apiTokenScope';
import { sanitizeForLog } from '../utils/safeLog';
const VALID_SSO_PROVIDERS = ['ldap', 'oidc_google', 'oidc_github', 'oidc_okta', 'oidc_custom'] as const;
const SSO_SCOPE_MESSAGE = 'API tokens cannot access SSO configuration.';
@@ -85,7 +86,7 @@ ssoConfigRouter.put('/:provider', (req: Request, res: Response): void => {
}
SSOService.getInstance().saveProviderConfig(config);
console.log(`[SSO] Config updated: ${provider} ${config.enabled ? 'enabled' : 'disabled'}`);
console.log(`[SSO] Config updated: ${sanitizeForLog(provider)} ${config.enabled ? 'enabled' : 'disabled'}`);
res.json({ success: true, message: 'SSO configuration saved' });
} catch (error) {
console.error('[SSO] Failed to save SSO config:', error);
@@ -101,7 +102,7 @@ ssoConfigRouter.delete('/:provider', (req: Request, res: Response): void => {
if (!requireTierForSsoProvider(provider, req, res)) return;
try {
SSOService.getInstance().deleteProviderConfig(provider);
console.log(`[SSO] Config deleted: ${provider}`);
console.log(`[SSO] Config deleted: ${sanitizeForLog(provider)}`);
res.json({ success: true, message: 'SSO configuration deleted' });
} catch (error) {
console.error('[SSO] Failed to delete SSO config:', error);
+39 -38
View File
@@ -17,6 +17,7 @@ import { NotificationService } from '../services/NotificationService';
import { isValidStackName, isValidServiceName, isPathWithinBase, isValidRelativeStackPath } from '../utils/validation';
import { getErrorMessage } from '../utils/errors';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { sendGitSourceError } from '../utils/gitSourceHttp';
import { buildPolicyGateOptions, runPolicyGate, triggerPostDeployScan } from '../helpers/policyGate';
import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
@@ -27,7 +28,7 @@ function notifyActionFailure(action: string, stackName: string, error: unknown):
const message = getErrorMessage(error, `Failed to ${action} stack`);
NotificationService.getInstance()
.dispatchAlert('error', 'deploy_failure', message, { stackName })
.catch(err => console.error(`[Stacks] Failed to dispatch failure notification for ${stackName}:`, err));
.catch(err => console.error('[Stacks] Failed to dispatch failure notification for %s:', sanitizeForLog(stackName), err));
}
async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promise<string[]> {
@@ -90,7 +91,7 @@ async function resolveAllEnvFilePaths(nodeId: number, stackName: string): Promis
}
return existing;
} catch (error) {
console.warn(`Could not parse compose.yaml for env_file resolution in stack "${stackName}":`, error);
console.warn('Could not parse compose.yaml for env_file resolution in stack "%s":', sanitizeForLog(stackName), error);
}
try {
@@ -217,15 +218,15 @@ stacksRouter.put('/:stackName', async (req: Request, res: Response) => {
try {
const { content } = req.body;
if (typeof content !== 'string') {
console.error('Content is not a string:', content);
console.error('Content is not a string, got:', typeof content);
return res.status(400).json({ error: 'Content must be a string' });
}
await FileSystemService.getInstance(req.nodeId).saveStackContent(stackName, content);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Compose file saved: ${stackName}`);
console.log(`[Stacks] Compose file saved: ${sanitizeForLog(stackName)}`);
res.json({ message: 'Stack saved successfully' });
} catch (error) {
console.error('Failed to save stack:', error);
console.error('Failed to save stack:', sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to save stack' });
}
});
@@ -331,7 +332,7 @@ stacksRouter.put('/:stackName/env', async (req: Request, res: Response) => {
await fsService.writeFile(envPath, content, 'utf-8');
invalidateNodeCaches(req.nodeId);
const envFileName = path.basename(envPath);
console.log(`[Stacks] Env file saved: ${stackName}/${envFileName}`);
console.log(`[Stacks] Env file saved: ${sanitizeForLog(stackName)}/${sanitizeForLog(envFileName)}`);
res.json({ message: 'Env file saved successfully' });
} catch (error) {
console.error('[Stacks] Failed to save env file:', error);
@@ -351,7 +352,7 @@ stacksRouter.post('/', async (req: Request, res: Response) => {
}
await FileSystemService.getInstance(req.nodeId).createStack(stackName);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack created: ${stackName}`);
console.log(`[Stacks] Stack created: ${sanitizeForLog(stackName)}`);
res.json({ message: 'Stack created successfully', name: stackName });
} catch (error: unknown) {
const message = getErrorMessage(error, '');
@@ -433,7 +434,7 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
if (fromGitDiag) {
console.log(
`[Stacks:diag] from-git start stack=${stack_name} nodeId=${req.nodeId ?? 'local'} host=${gitRepoHost(repo_url)} branch=${branch} composePath=${compose_path} envPath=${resolvedEnvPath ?? 'none'} authType=${resolvedAuthType} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}`
`[Stacks:diag] from-git start stack=${sanitizeForLog(stack_name)} nodeId=${req.nodeId ?? 'local'} host=${sanitizeForLog(gitRepoHost(repo_url))} branch=${sanitizeForLog(branch)} composePath=${sanitizeForLog(compose_path)} envPath=${sanitizeForLog(resolvedEnvPath ?? 'none')} authType=${sanitizeForLog(resolvedAuthType)} autoApplyOnWebhook=${Boolean(auto_apply_on_webhook)} autoDeployOnApply=${Boolean(auto_deploy_on_apply)} deployNow=${deploy_now === true}`
);
}
@@ -469,15 +470,15 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
invalidateNodeCaches(req.nodeId);
} catch (e) {
deployError = getErrorMessage(e, 'Deploy failed');
console.error(`[Stacks] Deploy after create-from-git failed for ${stack_name}:`, deployError);
console.error(`[Stacks] Deploy after create-from-git failed for ${sanitizeForLog(stack_name)}:`, deployError);
}
}
}
console.log(`[Stacks] Stack created from Git: ${stack_name} at ${result.commitSha.slice(0, 7)}`);
console.log(`[Stacks] Stack created from Git: ${sanitizeForLog(stack_name)} at ${result.commitSha.slice(0, 7)}`);
if (fromGitDiag) {
console.log(
`[Stacks:diag] from-git ok stack=${stack_name} sha=${result.commitSha.slice(0, 7)} deployed=${deployed} envWritten=${result.envWritten} warnings=${result.warnings.length} elapsedMs=${Date.now() - fromGitStartedAt}`
`[Stacks:diag] from-git ok stack=${sanitizeForLog(stack_name)} sha=${result.commitSha.slice(0, 7)} deployed=${deployed} envWritten=${result.envWritten} warnings=${result.warnings.length} elapsedMs=${Date.now() - fromGitStartedAt}`
);
}
res.json({
@@ -491,14 +492,14 @@ stacksRouter.post('/from-git', async (req: Request, res: Response) => {
});
if (deployed) {
triggerPostDeployScan(stack_name, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stack_name}:`, err),
console.error(`[Security] Post-deploy scan failed for ${sanitizeForLog(stack_name)}:`, err),
);
}
} catch (error) {
if (fromGitDiag) {
const code = error instanceof GitSourceError ? error.code : 'UNKNOWN';
console.log(
`[Stacks:diag] from-git fail stack=${fromGitStackName} code=${code} elapsedMs=${Date.now() - fromGitStartedAt}`
`[Stacks:diag] from-git fail stack=${sanitizeForLog(fromGitStackName)} code=${code} elapsedMs=${Date.now() - fromGitStartedAt}`
);
}
sendGitSourceError(res, error);
@@ -513,15 +514,15 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
try {
await ComposeService.getInstance(req.nodeId).downStack(stackName);
} catch (downErr) {
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${stackName}`);
console.warn(`[Teardown] Docker down failed or nothing to clean up for ${sanitizeForLog(stackName)}`);
}
if (pruneVolumes) {
try {
const result = await DockerController.getInstance().pruneManagedOnly('volumes', [stackName]);
console.log(`[Stacks] Pruned volumes for ${stackName}: ${result.reclaimedBytes} bytes reclaimed`);
console.log(`[Stacks] Pruned volumes for ${sanitizeForLog(stackName)}: ${result.reclaimedBytes} bytes reclaimed`);
} catch (pruneErr) {
console.warn(`[Stacks] Volume prune failed for ${stackName}, continuing delete:`, pruneErr);
console.warn('[Stacks] Volume prune failed for %s, continuing delete:', sanitizeForLog(stackName), pruneErr);
}
}
@@ -530,7 +531,7 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
await FileSystemService.getInstance(req.nodeId).deleteStack(stackName);
} catch (err) {
fsErr = err;
console.error(`[Stacks] File deletion failed for ${stackName}, continuing with DB cleanup:`, err);
console.error('[Stacks] File deletion failed for %s, continuing with DB cleanup:', sanitizeForLog(stackName), err);
}
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
@@ -541,10 +542,10 @@ stacksRouter.delete('/:stackName', async (req: Request, res: Response) => {
if (fsErr) throw fsErr;
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Stack deleted: ${stackName}`);
console.log(`[Stacks] Stack deleted: ${sanitizeForLog(stackName)}`);
res.json({ success: true });
} catch (error: unknown) {
console.error(`[Stacks] Failed to delete stack ${stackName}:`, error);
console.error('[Stacks] Failed to delete stack %s:', sanitizeForLog(stackName), error);
const message = getErrorMessage(error, 'Failed to delete stack');
res.status(500).json({ error: message });
}
@@ -569,7 +570,7 @@ stacksRouter.get('/:stackName/services', async (req: Request, res: Response) =>
const services = parsed?.services ? Object.keys(parsed.services) : [];
res.json(services);
} catch (error) {
console.error('[Stacks] Failed to fetch services:', error);
console.error('[Stacks] Failed to fetch services:', sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to fetch services' });
}
});
@@ -585,16 +586,16 @@ stacksRouter.post('/:stackName/deploy', async (req: Request, res: Response) => {
const t0 = Date.now();
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), atomic);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Deploy completed: ${stackName}`);
console.log(`[Stacks] Deploy completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Deploy finished in ${Date.now() - t0}ms`);
res.json({ message: 'Deployed successfully' });
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
);
} catch (error: unknown) {
console.error(`[Stacks] Deploy failed: ${stackName}`, error);
console.error('[Stacks] Deploy failed: %s', sanitizeForLog(stackName), error);
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn(`[Stacks] Deploy failed, rolled back: ${stackName}`);
if (rolledBack) console.warn('[Stacks] Deploy failed, rolled back: %s', sanitizeForLog(stackName));
const message = getErrorMessage(error, 'Failed to deploy stack');
notifyActionFailure('deploy', stackName, error);
res.status(500).json({ error: message, rolledBack });
@@ -607,10 +608,10 @@ stacksRouter.post('/:stackName/down', async (req: Request, res: Response) => {
try {
await ComposeService.getInstance(req.nodeId).runCommand(stackName, 'down', getTerminalWs());
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Down completed: ${stackName}`);
console.log(`[Stacks] Down completed: ${sanitizeForLog(stackName)}`);
res.json({ status: 'Command started' });
} catch (error: unknown) {
console.error(`[Stacks] Down failed: ${stackName}`, error);
console.error('[Stacks] Down failed: %s', sanitizeForLog(stackName), error);
notifyActionFailure('down', stackName, error);
res.status(500).json({ error: 'Failed to start command' });
}
@@ -642,10 +643,10 @@ async function bulkContainerOp(
await Promise.all(containers.map(c => op(c.Id)));
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] ${titleCase} completed: ${stackName} (${containers.length} containers)`);
console.log(`[Stacks] ${titleCase} completed: ${sanitizeForLog(stackName)} (${containers.length} containers)`);
res.json({ success: true, message: `${titleCase} completed via Engine API.` });
} catch (error: unknown) {
console.error(`[Stacks] ${titleCase} failed: ${stackName}`, error);
console.error('[Stacks] %s failed: %s', sanitizeForLog(titleCase), sanitizeForLog(stackName), error);
const message = getErrorMessage(error, `Failed to ${action} containers`);
if (action !== 'start') {
notifyActionFailure(action, stackName, error);
@@ -693,7 +694,7 @@ async function handleServiceAction(
await Promise.all(matching.map(c => op(c.Id)));
invalidateNodeCaches(req.nodeId);
console.log(
`[Stacks] Service ${action} completed: ${stackName}/${serviceName} (${matching.length} containers)`,
`[Stacks] Service ${sanitizeForLog(action)} completed: ${sanitizeForLog(stackName)}/${sanitizeForLog(serviceName)} (${matching.length} containers)`,
);
res.json({
success: true,
@@ -701,7 +702,7 @@ async function handleServiceAction(
count: matching.length,
});
} catch (error: unknown) {
console.error(`[Stacks] Service ${action} failed: ${stackName}/${serviceName}`, error);
console.error('[Stacks] Service %s failed: %s/%s', sanitizeForLog(action), sanitizeForLog(stackName), sanitizeForLog(serviceName), error);
res.status(500).json({ error: getErrorMessage(error, `Failed to ${action} service`) });
}
}
@@ -719,7 +720,7 @@ stacksRouter.get('/:stackName/update-preview', async (req: Request, res: Respons
const preview = await UpdatePreviewService.getInstance().getPreview(req.nodeId, stackName);
res.json(preview);
} catch (error) {
console.error(`[Stacks] Update preview failed: ${stackName}`, error);
console.error('[Stacks] Update preview failed: %s', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(error, 'unknown')));
res.status(500).json({ error: 'Failed to compute update preview' });
}
});
@@ -736,16 +737,16 @@ stacksRouter.post('/:stackName/update', async (req: Request, res: Response) => {
await ComposeService.getInstance(req.nodeId).updateStack(stackName, getTerminalWs(), atomic);
DatabaseService.getInstance().clearStackUpdateStatus(req.nodeId, stackName);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Update completed: ${stackName}`);
console.log(`[Stacks] Update completed: ${sanitizeForLog(stackName)}`);
if (debug) console.debug(`[Stacks:debug] Update finished in ${Date.now() - t0}ms`);
res.json({ status: 'Update completed' });
triggerPostDeployScan(stackName, req.nodeId).catch(err =>
console.error(`[Security] Post-deploy scan failed for ${stackName}:`, err),
console.error('[Security] Post-deploy scan failed for %s:', sanitizeForLog(stackName), err),
);
} catch (error: unknown) {
console.error(`[Stacks] Update failed: ${stackName}`, error);
console.error('[Stacks] Update failed: %s', sanitizeForLog(stackName), error);
const rolledBack = LicenseService.getInstance().getTier() === 'paid';
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${stackName}`);
if (rolledBack) console.warn(`[Stacks] Update failed, rolled back: ${sanitizeForLog(stackName)}`);
notifyActionFailure('update', stackName, error);
res.status(500).json({ error: getErrorMessage(error, 'Failed to update'), rolledBack });
}
@@ -761,14 +762,14 @@ stacksRouter.post('/:stackName/rollback', async (req: Request, res: Response) =>
if (!backupInfo.exists) {
return res.status(404).json({ error: 'No backup available for this stack.' });
}
console.log(`[Stacks] Rollback initiated: ${stackName}`);
console.log(`[Stacks] Rollback initiated: ${sanitizeForLog(stackName)}`);
await fsSvc.restoreStackFiles(stackName);
await ComposeService.getInstance(req.nodeId).deployStack(stackName, getTerminalWs(), false);
invalidateNodeCaches(req.nodeId);
console.log(`[Stacks] Rollback completed: ${stackName}`);
console.log(`[Stacks] Rollback completed: ${sanitizeForLog(stackName)}`);
res.json({ message: 'Stack rolled back successfully.' });
} catch (error: unknown) {
console.error(`[Stacks] Rollback failed: ${stackName}`, error);
console.error('[Stacks] Rollback failed: %s', sanitizeForLog(stackName), error);
const message = getErrorMessage(error, 'Rollback failed.');
res.status(500).json({ error: message });
}
+5 -4
View File
@@ -6,6 +6,7 @@ import { invalidateNodeCaches } from '../helpers/cacheInvalidation';
import { isValidDockerResourceId, isValidCidr, isValidIPv4 } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
export const systemMaintenanceRouter = Router();
@@ -32,11 +33,11 @@ systemMaintenanceRouter.post('/prune/orphans', async (req: Request, res: Respons
if (invalidIds.length > 0) {
return res.status(400).json({ error: 'One or more container IDs have an invalid format' });
}
console.log(`[Resources] Prune orphans: ${containerIds.length} container(s) requested`);
console.log(`[Resources] Prune orphans: ${sanitizeForLog(containerIds.length)} container(s) requested`);
const dockerController = DockerController.getInstance(req.nodeId);
const results = await dockerController.removeContainers(containerIds);
const succeeded = results.filter((r: { success: boolean }) => r.success).length;
console.log(`[Resources] Prune orphans completed: ${succeeded}/${containerIds.length} removed`);
console.log(`[Resources] Prune orphans completed: ${succeeded}/${sanitizeForLog(containerIds.length)} removed`);
invalidateNodeCaches(req.nodeId);
res.json({ results });
} catch (error) {
@@ -158,7 +159,7 @@ systemMaintenanceRouter.post('/volumes/delete', async (req: Request, res: Respon
try {
const { id } = req.body;
if (!id || typeof id !== 'string') return res.status(400).json({ error: 'Volume name is required' });
console.log(`[Resources] Delete volume: ${id}`);
console.log(`[Resources] Delete volume: ${sanitizeForLog(id)}`);
const dockerController = DockerController.getInstance(req.nodeId);
await dockerController.removeVolume(id);
invalidateNodeCaches(req.nodeId);
@@ -246,7 +247,7 @@ systemMaintenanceRouter.post('/networks', async (req: Request, res: Response) =>
const dockerController = DockerController.getInstance(req.nodeId);
const network = await dockerController.createNetwork(options);
console.log(`[Resources] Network created: ${name}`);
console.log(`[Resources] Network created: ${sanitizeForLog(name)}`);
invalidateNodeCaches(req.nodeId);
res.status(201).json({ success: true, message: 'Network created', id: network.id });
} catch (error: unknown) {
+3 -2
View File
@@ -9,6 +9,7 @@ import { BCRYPT_SALT_ROUNDS, MIN_PASSWORD_LENGTH } from '../helpers/constants';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage, isSqliteUniqueViolation } from '../utils/errors';
import { parseIntParam } from '../utils/parseIntParam';
import { sanitizeForLog } from '../utils/safeLog';
const USERS_SCOPE_MESSAGE = 'API tokens cannot access user management.';
const VALID_USER_ROLES: UserRole[] = ['admin', 'viewer', 'deployer', 'node-admin', 'auditor'];
@@ -95,7 +96,7 @@ usersRouter.post('/', authMiddleware, async (req: Request, res: Response): Promi
const passwordHash = await bcrypt.hash(password, BCRYPT_SALT_ROUNDS);
const id = db.addUser({ username, password_hash: passwordHash, role });
console.log('[Users] Created:', username, 'role:', role, 'by:', req.user!.username);
console.log('[Users] Created:', sanitizeForLog(username), 'role:', sanitizeForLog(role), 'by:', sanitizeForLog(req.user!.username));
res.status(201).json({ id, username, role });
} catch (error) {
console.error('[Users] Create error:', error);
@@ -304,7 +305,7 @@ usersRouter.post('/:id/roles', authMiddleware, (req: Request, res: Response): vo
try {
const id = db.addRoleAssignment({ user_id: userId, role, resource_type, resource_id });
console.log('[Roles] Assigned', role, 'on', resource_type, resource_id, 'to user', userId, 'by:', req.user!.username);
console.log('[Roles] Assigned', sanitizeForLog(role), 'on', sanitizeForLog(resource_type), sanitizeForLog(resource_id), 'to user', userId, 'by:', sanitizeForLog(req.user!.username));
res.status(201).json({ id, user_id: userId, role, resource_type, resource_id });
} catch (err: unknown) {
if (isSqliteUniqueViolation(err)) {