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
+2 -1
View File
@@ -5,6 +5,7 @@ import { DatabaseService } from '../services/DatabaseService';
import { NotificationService } from '../services/NotificationService';
import TrivyService, { DIGEST_CACHE_TTL_MS } from '../services/TrivyService';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
// Bypass requires `?ignorePolicy=true` AND `req.user.role === 'admin'`. The
// `stack:deploy` permission alone is not sufficient because the `deployer`
@@ -98,6 +99,6 @@ export async function triggerPostDeployScan(
}
}
} catch (err) {
console.error(`[Security] triggerPostDeployScan error for ${stackName}:`, getErrorMessage(err, 'unknown error'));
console.error('[Security] triggerPostDeployScan error for %s:', sanitizeForLog(stackName), sanitizeForLog(getErrorMessage(err, 'unknown error')));
}
}
+3 -2
View File
@@ -1,5 +1,6 @@
import type { Request, Response, NextFunction, RequestHandler } from 'express';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import type { ApiTokenScope } from '../services/DatabaseService';
/**
@@ -28,14 +29,14 @@ const DEPLOY_ALLOWED_PATTERNS: RegExp[] = [
];
const deny = (res: Response, req: Request, error: string, scope: ApiTokenScope | 'unknown'): void => {
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', req.method, req.path, 'scope:', scope);
if (isDebugEnabled()) console.log('[ApiTokenScope:diag] Denied:', sanitizeForLog(req.method), sanitizeForLog(req.path), 'scope:', scope);
res.status(403).json({ error, code: 'SCOPE_DENIED' });
};
export const enforceApiTokenScope: RequestHandler = (req: Request, res: Response, next: NextFunction): void => {
const scope = req.apiTokenScope;
if (!scope) { next(); return; } // Not an API token request
if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', req.method, req.path, 'scope:', scope);
if (isDebugEnabled()) console.log('[ApiTokenScope:diag]', sanitizeForLog(req.method), sanitizeForLog(req.path), 'scope:', scope);
if (scope === 'full-admin') { next(); return; }
if (scope === 'read-only') {
+2 -1
View File
@@ -2,6 +2,7 @@ import type { Request, Response, NextFunction, RequestHandler } from 'express';
import { DatabaseService } from '../services/DatabaseService';
import { isDebugEnabled } from '../utils/debug';
import { getAuditSummary } from '../utils/audit-summaries';
import { sanitizeForLog } from '../utils/safeLog';
import { WEBHOOK_TRIGGER_RE } from '../helpers/routePatterns';
import { authMiddleware } from './auth';
@@ -40,7 +41,7 @@ export const auditLog: RequestHandler = (req: Request, res: Response, next: Next
res.on('finish', () => {
try {
if (isDebugEnabled()) {
console.log(`[Audit:diag] ${req.method} /api${apiPath} by=${username} status=${res.statusCode} node=${nodeId ?? 'local'} ip=${ip}`);
console.log(`[Audit:diag] ${sanitizeForLog(req.method)} /api${sanitizeForLog(apiPath)} by=${sanitizeForLog(username)} status=${res.statusCode} node=${nodeId ?? 'local'} ip=${sanitizeForLog(ip)}`);
}
DatabaseService.getInstance().insertAuditLog({
timestamp: Date.now(),
+2 -1
View File
@@ -1,6 +1,7 @@
import type { Request, Response } from 'express';
import { DatabaseService, type UserRole, type ResourceType } from '../services/DatabaseService';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
import { effectiveVariant } from './tierGates';
// --- Scoped RBAC Permission Engine (Admiral) ---
@@ -44,7 +45,7 @@ export function checkPermission(
const globalRole = req.user.role;
if (isDebugEnabled()) console.log('[RBAC:diag] checkPermission:', action, 'user:', req.user.username, 'globalRole:', globalRole, 'resource:', resourceType, resourceId);
if (isDebugEnabled()) console.log('[RBAC:diag] checkPermission:', sanitizeForLog(action), 'user:', sanitizeForLog(req.user.username), 'globalRole:', globalRole, 'resource:', sanitizeForLog(resourceType), sanitizeForLog(resourceId));
if (globalRole === 'admin') return true;
if (ROLE_PERMISSIONS[globalRole]?.includes(action)) return true;
+3 -2
View File
@@ -13,6 +13,7 @@ import {
wsDataToBuffer,
wsDataToString,
} from './protocol';
import { sanitizeForLog } from '../utils/safeLog';
const RECONNECT_MIN_MS = 1_000;
const RECONNECT_MAX_MS = 60_000;
@@ -168,7 +169,7 @@ class PilotAgent {
this.handleJsonFrame(frame);
}
} catch (err) {
console.warn('[Pilot] Malformed frame from primary:', (err as Error).message);
console.warn('[Pilot] Malformed frame from primary:', sanitizeForLog((err as Error).message));
}
}
@@ -178,7 +179,7 @@ class PilotAgent {
switch (frame.t) {
case 'hello': {
if (frame.version !== PROTOCOL_VERSION) {
console.error(`[Pilot] Protocol version ${frame.version} from primary is incompatible with agent (${PROTOCOL_VERSION}); exiting.`);
console.error(`[Pilot] Protocol version ${sanitizeForLog(frame.version)} from primary is incompatible with agent (${PROTOCOL_VERSION}); exiting.`);
this.shuttingDown = true;
try { ws.close(1002, 'incompatible version'); } catch { /* ignore */ }
process.exit(1);
+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)) {
+8 -7
View File
@@ -12,6 +12,7 @@ import { RegistryService } from './RegistryService';
import { isDebugEnabled } from '../utils/debug';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { sanitizeForLog } from '../utils/safeLog';
/**
* ComposeService - local docker compose CLI execution.
@@ -141,7 +142,7 @@ export class ComposeService {
await fsSvc.backupStackFiles(stackName);
sendOutput('=== Backup created for atomic deployment ===\n');
} catch (e) {
console.warn(`Failed to backup stack files for ${stackName}:`, e);
console.warn('Failed to backup stack files for %s:', sanitizeForLog(stackName), e);
}
}
@@ -154,7 +155,7 @@ export class ComposeService {
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
console.warn('Failed to clean up legacy containers for %s:', sanitizeForLog(stackName), e);
}
await this.withRegistryAuth(async (env) => {
@@ -196,7 +197,7 @@ export class ComposeService {
}, sendOutput);
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
console.error('Rollback failed for %s:', sanitizeForLog(stackName), rollbackError);
sendOutput('=== Rollback failed - manual intervention may be required ===\n');
}
}
@@ -331,7 +332,7 @@ export class ComposeService {
await fsSvc.backupStackFiles(stackName);
sendOutput('=== Backup created for atomic update ===\n');
} catch (e) {
console.warn(`Failed to backup stack files for ${stackName}:`, e);
console.warn('Failed to backup stack files for %s:', sanitizeForLog(stackName), e);
}
}
@@ -344,7 +345,7 @@ export class ComposeService {
await dockerController.removeContainers(legacyContainers.map((c: any) => c.Id));
}
} catch (e) {
console.warn(`Failed to clean up legacy containers for ${stackName}:`, e);
console.warn('Failed to clean up legacy containers for %s:', sanitizeForLog(stackName), e);
}
await this.withRegistryAuth(async (env) => {
@@ -392,7 +393,7 @@ export class ComposeService {
}, sendOutput);
sendOutput('=== Rolled back successfully ===\n');
} catch (rollbackError) {
console.error(`Rollback failed for ${stackName}:`, rollbackError);
console.error('Rollback failed for %s:', sanitizeForLog(stackName), rollbackError);
sendOutput('=== Rollback failed - manual intervention may be required ===\n');
}
}
@@ -405,7 +406,7 @@ export class ComposeService {
try {
await this.execute('docker', ['compose', 'down', '--volumes', '--remove-orphans'], stackPath, undefined, false);
} catch (error) {
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)}`);
}
}
+6 -5
View File
@@ -10,6 +10,7 @@ import { NodeRegistry } from './NodeRegistry';
import { CacheService } from './CacheService';
import { isPathWithinBase } from '../utils/validation';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
const execAsync = promisify(exec);
const COMPOSE_DIR = process.env.COMPOSE_DIR || '/app/compose';
@@ -644,7 +645,7 @@ class DockerController {
} catch (err: unknown) {
const code = (err as NodeJS.ErrnoException)?.code;
if (code !== 'ENOENT' && code !== 'ENOTDIR') {
console.error(`[DockerController] Failed to read ${filePath}:`, err);
console.error('[DockerController] Failed to read %s:', sanitizeForLog(filePath), sanitizeForLog((err as Error)?.message ?? String(err)));
break;
}
}
@@ -790,7 +791,7 @@ class DockerController {
containers = lines.map(line => JSON.parse(line) as ComposeContainer);
} catch (innerError) {
// Log parsing failure with stderr for debugging
console.error(`Docker Compose JSON Parse Error for ${stackName}:`, stderr || (parseError as Error).message);
console.error('Docker Compose JSON Parse Error for %s:', sanitizeForLog(stackName), sanitizeForLog(stderr || (parseError as Error).message));
// Don't return empty - trigger smart fallback below
}
}
@@ -827,7 +828,7 @@ class DockerController {
} catch (error) {
// If command fails (e.g., stack not deployed, invalid YAML, missing env_file)
const execError = error as { stderr?: string; message?: string };
console.error(`Docker Compose Error for ${stackName}:`, execError.stderr || execError.message);
console.error('Docker Compose Error for %s:', sanitizeForLog(stackName), sanitizeForLog(execError.stderr || execError.message || 'unknown'));
// Try smart fallback even on error
return await this.enrichContainers(await this.smartFallback(stackName, stackDir));
@@ -930,7 +931,7 @@ class DockerController {
};
});
} catch (fallbackError) {
console.error(`Smart Fallback failed for ${stackName}:`, fallbackError);
console.error('Smart Fallback failed for %s:', sanitizeForLog(stackName), sanitizeForLog((fallbackError as Error)?.message ?? String(fallbackError)));
return [];
}
}
@@ -1053,7 +1054,7 @@ class DockerController {
await container.remove({ force: true });
results.push({ id, success: true });
} catch (error: any) {
console.error(`Failed to remove container ${id}:`, error.message);
console.error('Failed to remove container %s:', sanitizeForLog(id), sanitizeForLog(error.message));
results.push({ id, success: false, error: error.message });
}
}
+2 -1
View File
@@ -4,6 +4,7 @@ import type { Readable } from 'stream';
import { NodeRegistry } from './NodeRegistry';
import { isPathWithinBase, isValidStackName } from '../utils/validation';
import { isBinaryBuffer } from '../utils/binaryDetect';
import { sanitizeForLog } from '../utils/safeLog';
export interface FileEntry {
name: string;
@@ -133,7 +134,7 @@ export class FileSystemService {
const filePath = await this.getComposeFilePath(stackName);
return await fsPromises.readFile(filePath, 'utf-8');
} catch (error) {
console.error('Error reading stack content:', error);
console.error('Error reading stack content:', sanitizeForLog((error as Error)?.message ?? String(error)));
throw new Error(`Failed to read stack: ${stackName}`);
}
}
+8 -7
View File
@@ -11,6 +11,7 @@ import { DatabaseService, type StackGitSource, type GitSourceAuthType } from './
import { FileSystemService } from './FileSystemService';
import { ComposeService } from './ComposeService';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
/**
* GitSourceService - fetch compose files from a Git repository and apply
@@ -380,7 +381,7 @@ export class GitSourceService {
const diag = isDebugEnabled();
if (diag) {
console.log(
`[GitSource:diag] fetch start host=${repoHost(repoUrl)} branch=${branch} compose=${composePath} envSync=${envPath ? 'true' : 'false'} timeoutMs=${timeoutMs}`
`[GitSource:diag] fetch start host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} compose=${sanitizeForLog(composePath)} envSync=${envPath ? 'true' : 'false'} timeoutMs=${timeoutMs}`
);
}
@@ -444,7 +445,7 @@ export class GitSourceService {
throw new GitSourceError('GIT_ERROR', scrubCredentials((e as Error).message));
}
if (isLfsPointer(composeContent)) {
console.error(`[GitSource] LFS pointer detected in ${composePath}`);
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(composePath)}`);
throw new GitSourceError(
'GIT_ERROR',
`Compose file at ${composePath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
@@ -470,7 +471,7 @@ export class GitSourceService {
}
}
if (envContent !== null && isLfsPointer(envContent)) {
console.error(`[GitSource] LFS pointer detected in ${envPath}`);
console.error(`[GitSource] LFS pointer detected in ${sanitizeForLog(envPath)}`);
throw new GitSourceError(
'GIT_ERROR',
`Env file at ${envPath} is stored in Git LFS, which is not supported. Commit the plain file or replace the LFS pointer before linking this repository.`,
@@ -489,7 +490,7 @@ export class GitSourceService {
if (diag) {
console.log(
`[GitSource:diag] fetch ok host=${repoHost(repoUrl)} branch=${branch} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
`[GitSource:diag] fetch ok host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} sha=${commitSha.slice(0, 7)} env=${envContent !== null ? 'present' : 'absent'} warnings=${warnings.length} elapsedMs=${Date.now() - startedAt}`
);
}
return { composeContent, envContent, commitSha, warnings };
@@ -497,7 +498,7 @@ export class GitSourceService {
if (diag) {
const msg = err instanceof GitSourceError ? `${err.code}: ${err.message}` : (err as Error).message;
console.log(
`[GitSource:diag] fetch fail host=${repoHost(repoUrl)} branch=${branch} elapsedMs=${Date.now() - startedAt} err=${scrubCredentials(msg)}`
`[GitSource:diag] fetch fail host=${sanitizeForLog(repoHost(repoUrl))} branch=${sanitizeForLog(branch)} elapsedMs=${Date.now() - startedAt} err=${sanitizeForLog(scrubCredentials(msg))}`
);
}
throw err;
@@ -742,7 +743,7 @@ export class GitSourceService {
throw new GitSourceError('GIT_ERROR', 'No pending pull to apply. Fetch the source again.');
}
if (src.pending_commit_sha !== commitSha) {
if (diag) console.log(`[GitSource:diag] apply sha mismatch stack=${stackName} expected=${commitSha.slice(0, 7)} pending=${src.pending_commit_sha.slice(0, 7)}`);
if (diag) console.log('[GitSource:diag] apply sha mismatch stack=%s expected=%s pending=%s', sanitizeForLog(stackName), sanitizeForLog(commitSha.slice(0, 7)), sanitizeForLog(src.pending_commit_sha.slice(0, 7)));
throw new GitSourceError('GIT_ERROR', 'Pending commit has changed since this pull was fetched. Please review the latest diff.');
}
@@ -770,7 +771,7 @@ export class GitSourceService {
db.markGitSourceApplied(stackName, commitSha, hash);
const shouldDeploy = opts.deploy ?? src.auto_deploy_on_apply;
if (diag) console.log(`[GitSource:diag] apply wrote stack=${stackName} sha=${commitSha.slice(0, 7)} deploy=${shouldDeploy}`);
if (diag) console.log('[GitSource:diag] apply wrote stack=%s sha=%s deploy=%s', sanitizeForLog(stackName), sanitizeForLog(commitSha.slice(0, 7)), sanitizeForLog(shouldDeploy));
if (shouldDeploy) {
try {
+2 -1
View File
@@ -9,6 +9,7 @@ import { NotificationService } from './NotificationService';
import { parseImageRef, getRemoteDigest } from './registry-api';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
const BACKFILL_KEY = 'image_update_notifications_backfilled';
@@ -239,7 +240,7 @@ export class ImageUpdateService {
try {
imageUpdateMap.set(imageRef, await this.checkImage(docker, imageRef));
} catch (e) {
console.error(`[ImageUpdateService] Error checking ${imageRef}:`, e);
console.error(`[ImageUpdateService] Error checking ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
imageUpdateMap.set(imageRef, { hasUpdate: false, error: String(e) });
}
await sleep(ImageUpdateService.INTER_IMAGE_DELAY_MS);
+2 -1
View File
@@ -3,6 +3,7 @@ import { DatabaseService, NotificationHistory } from './DatabaseService';
import { NodeRegistry } from './NodeRegistry';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
export type NotificationCategory =
| 'deploy_success'
@@ -139,7 +140,7 @@ export class NotificationService {
return true;
});
if (matched.length > 0) {
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${stackName ?? '(none)'}", category="${category}"`);
if (isDebugEnabled()) console.log(`[Notify:diag] Matched ${matched.length} route(s) for stack "${sanitizeForLog(stackName ?? '(none)')}", category="${sanitizeForLog(category)}"`);
await Promise.allSettled(
matched.map(route =>
this.sendToChannel(route.channel_type, route.channel_url, level, message)
+2 -1
View File
@@ -13,6 +13,7 @@ import { DatabaseService } from './DatabaseService';
import type { ScanPolicy, VulnSeverity } from './DatabaseService';
import { FleetSyncService } from './FleetSyncService';
import { NotificationService } from './NotificationService';
import { sanitizeForLog } from '../utils/safeLog';
import TrivyService from './TrivyService';
import { isSeverityAtLeast } from '../utils/severity';
import { validateImageRef } from '../utils/image-ref';
@@ -72,7 +73,7 @@ export async function enforcePolicyPreDeploy(
imageRefs = await ComposeService.getInstance(nodeId).listStackImages(stackName);
} catch (err) {
const message = getErrorMessage(err, 'compose parse failed');
console.error(`[Policy] listStackImages failed for ${stackName}:`, message);
console.error('[Policy] listStackImages failed for %s:', sanitizeForLog(stackName), sanitizeForLog(message));
return {
ok: false,
bypassed: false,
+5 -4
View File
@@ -3,6 +3,7 @@ import http from 'http';
import { CryptoService } from './CryptoService';
import { DatabaseService, type Registry, type RegistryType } from './DatabaseService';
import { isDebugEnabled } from '../utils/debug';
import { sanitizeForLog } from '../utils/safeLog';
// ─── Types ───────────────────────────────────────────────────────────────────
@@ -141,7 +142,7 @@ function httpGet(
nextHeaders = rest;
}
if (isDebugEnabled()) {
console.debug(`[RegistryService][debug] redirect ${status} ${url} -> ${nextUrl.toString()} (auth ${nextHeaders === headers ? 'kept' : 'stripped'})`);
console.debug(`[RegistryService][debug] redirect ${status} ${sanitizeForLog(url)} -> ${sanitizeForLog(nextUrl.toString())} (auth ${nextHeaders === headers ? 'kept' : 'stripped'})`);
}
httpGet(nextUrl.toString(), nextHeaders, timeoutMs, false).then(resolve, reject);
return;
@@ -208,7 +209,7 @@ export class RegistryService {
created_at: now,
updated_at: now,
});
console.info(`[RegistryService] Registry created: id=${id} type=${input.type} name="${input.name}"`);
console.info(`[RegistryService] Registry created: id=${id} type=${sanitizeForLog(input.type)} name="${sanitizeForLog(input.name)}"`);
return id;
}
@@ -282,7 +283,7 @@ export class RegistryService {
return { success: false, error: 'AWS region is required for ECR registries.' };
}
if (isDebugEnabled()) {
console.debug(`[RegistryService][debug] testWithCredentials ECR region=${input.aws_region}`);
console.debug(`[RegistryService][debug] testWithCredentials ECR region=${sanitizeForLog(input.aws_region)}`);
}
await this.fetchEcrToken(input.username, input.secret, input.aws_region);
if (isDebugEnabled()) {
@@ -294,7 +295,7 @@ export class RegistryService {
const probeUrl = toProbeUrl(input.url, input.type);
const basicAuth = Buffer.from(`${input.username}:${input.secret}`).toString('base64');
if (isDebugEnabled()) {
console.debug(`[RegistryService][debug] testWithCredentials probing ${probeUrl}/v2/`);
console.debug(`[RegistryService][debug] testWithCredentials probing ${sanitizeForLog(probeUrl)}/v2/`);
}
const res = await httpGet(`${probeUrl}/v2/`, { Authorization: `Basic ${basicAuth}` });
+2 -1
View File
@@ -9,6 +9,7 @@ import { ImageUpdateService } from './ImageUpdateService';
import type { ImageCheckResult } from './ImageUpdateService';
import { isDebugEnabled } from '../utils/debug';
import { getErrorMessage } from '../utils/errors';
import { sanitizeForLog } from '../utils/safeLog';
import { captureLocalNodeFiles, captureRemoteNodeFiles } from '../utils/snapshot-capture';
import { NodeRegistry } from './NodeRegistry';
import { NotificationService } from './NotificationService';
@@ -698,7 +699,7 @@ export class SchedulerService {
} catch (e) {
const msg = getErrorMessage(e, String(e));
checkErrors.push(msg);
console.warn(`[SchedulerService] Failed to check image ${imageRef}:`, e);
console.warn(`[SchedulerService] Failed to check image ${sanitizeForLog(imageRef)}:`, sanitizeForLog((e as Error)?.message ?? String(e)));
}
}
+2 -1
View File
@@ -13,6 +13,7 @@ import {
import { FileSystemService } from './FileSystemService';
import { RegistryService } from './RegistryService';
import { disableCapability, enableCapability } from './CapabilityRegistry';
import { sanitizeForLog } from '../utils/safeLog';
import TrivyInstaller, { type TrivySource } from './TrivyInstaller';
import { FleetSyncService } from './FleetSyncService';
import { getErrorMessage } from '../utils/errors';
@@ -26,7 +27,7 @@ const SBOM_TIMEOUT_MS = 3 * 60 * 1000;
export const DIGEST_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
function diag(msg: string, ...args: unknown[]): void {
if (isDebugEnabled()) console.log(`[Trivy:diag] ${msg}`, ...args);
if (isDebugEnabled()) console.log(`[Trivy:diag] ${sanitizeForLog(msg)}`, ...args);
}
interface TrivyRawVulnerability {
+15
View File
@@ -0,0 +1,15 @@
// eslint-disable-next-line no-control-regex
const CONTROL_CHARS_REGEX = /[\r\n\t\x00-\x1F\x7F]/g;
/**
* Strip CR, LF, tab, and other ASCII control characters from a value before
* embedding it in a log line. Prevents log-injection attacks where untrusted
* input could forge multi-line log entries or terminal escape sequences.
*
* Use at every site where a user-controlled string flows into console.log /
* console.warn / console.error, including via template literals.
*/
export function sanitizeForLog(value: unknown): string {
const s = typeof value === 'string' ? value : String(value);
return s.replace(CONTROL_CHARS_REGEX, '');
}
+2 -1
View File
@@ -1,4 +1,5 @@
import path from 'path';
import { sanitizeForLog } from './safeLog';
/**
* Stack name must only contain URL-safe characters with no path separators.
@@ -19,7 +20,7 @@ export function isValidRemoteUrl(
try {
url = new URL(raw);
} catch (e) {
console.warn('[Validation] URL parse failure:', (e as Error).message, 'input:', raw);
console.warn('[Validation] URL parse failure:', sanitizeForLog((e as Error).message), 'input:', sanitizeForLog(raw));
return {
valid: false,
reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:1852)',