diff --git a/backend/src/helpers/policyGate.ts b/backend/src/helpers/policyGate.ts index cc3978fd..8dae391a 100644 --- a/backend/src/helpers/policyGate.ts +++ b/backend/src/helpers/policyGate.ts @@ -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'))); } } diff --git a/backend/src/middleware/apiTokenScope.ts b/backend/src/middleware/apiTokenScope.ts index 2a7c8d61..e84e6154 100644 --- a/backend/src/middleware/apiTokenScope.ts +++ b/backend/src/middleware/apiTokenScope.ts @@ -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') { diff --git a/backend/src/middleware/authGate.ts b/backend/src/middleware/authGate.ts index 190e843e..b581ed94 100644 --- a/backend/src/middleware/authGate.ts +++ b/backend/src/middleware/authGate.ts @@ -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(), diff --git a/backend/src/middleware/permissions.ts b/backend/src/middleware/permissions.ts index 077631a9..b485982d 100644 --- a/backend/src/middleware/permissions.ts +++ b/backend/src/middleware/permissions.ts @@ -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; diff --git a/backend/src/pilot/agent.ts b/backend/src/pilot/agent.ts index 10da8653..7f26d74d 100644 --- a/backend/src/pilot/agent.ts +++ b/backend/src/pilot/agent.ts @@ -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); diff --git a/backend/src/routes/agents.ts b/backend/src/routes/agents.ts index 9768fd0d..873024e1 100644 --- a/backend/src/routes/agents.ts +++ b/backend/src/routes/agents.ts @@ -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); diff --git a/backend/src/routes/auditLog.ts b/backend/src/routes/auditLog.ts index 0af04fd8..8a588549 100644 --- a/backend/src/routes/auditLog.ts +++ b/backend/src/routes/auditLog.ts @@ -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 => { 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 }); diff --git a/backend/src/routes/auth.ts b/backend/src/routes/auth.ts index f3bf5c97..817aa2a1 100644 --- a/backend/src/routes/auth.ts +++ b/backend/src/routes/auth.ts @@ -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); diff --git a/backend/src/routes/convert.ts b/backend/src/routes/convert.ts index fd32a7ec..875c456f 100644 --- a/backend/src/routes/convert.ts +++ b/backend/src/routes/convert.ts @@ -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; } diff --git a/backend/src/routes/fleet.ts b/backend/src/routes/fleet.ts index b11640d9..ed0a2d40 100644 --- a/backend/src/routes/fleet.ts +++ b/backend/src/routes/fleet.ts @@ -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); diff --git a/backend/src/routes/gitSources.ts b/backend/src/routes/gitSources.ts index ec71048d..40a1dfe8 100644 --- a/backend/src/routes/gitSources.ts +++ b/backend/src/routes/gitSources.ts @@ -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) { diff --git a/backend/src/routes/imageUpdates.ts b/backend/src/routes/imageUpdates.ts index 3e0876e0..de6ebf5f 100644 --- a/backend/src/routes/imageUpdates.ts +++ b/backend/src/routes/imageUpdates.ts @@ -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))); } } diff --git a/backend/src/routes/labels.ts b/backend/src/routes/labels.ts index 7bac8a29..92a1ba24 100644 --- a/backend/src/routes/labels.ts +++ b/backend/src/routes/labels.ts @@ -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(); @@ -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) { diff --git a/backend/src/routes/scheduledTasks.ts b/backend/src/routes/scheduledTasks.ts index 7c600d89..c13072a5 100644 --- a/backend/src/routes/scheduledTasks.ts +++ b/backend/src/routes/scheduledTasks.ts @@ -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; } } diff --git a/backend/src/routes/security.ts b/backend/src/routes/security.ts index 5c9a095c..ab9e6ce2 100644 --- a/backend/src/routes/security.ts +++ b/backend/src/routes/security.ts @@ -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)); }); }); diff --git a/backend/src/routes/sso.ts b/backend/src/routes/sso.ts index 0f1e7691..1839cf27 100644 --- a/backend/src/routes/sso.ts +++ b/backend/src/routes/sso.ts @@ -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}`; } diff --git a/backend/src/routes/ssoConfig.ts b/backend/src/routes/ssoConfig.ts index 5b692820..ec11845d 100644 --- a/backend/src/routes/ssoConfig.ts +++ b/backend/src/routes/ssoConfig.ts @@ -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); diff --git a/backend/src/routes/stacks.ts b/backend/src/routes/stacks.ts index 776d1aba..39a6cfcd 100644 --- a/backend/src/routes/stacks.ts +++ b/backend/src/routes/stacks.ts @@ -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 { @@ -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 }); } diff --git a/backend/src/routes/systemMaintenance.ts b/backend/src/routes/systemMaintenance.ts index 2abda37a..7bbf6ebb 100644 --- a/backend/src/routes/systemMaintenance.ts +++ b/backend/src/routes/systemMaintenance.ts @@ -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) { diff --git a/backend/src/routes/users.ts b/backend/src/routes/users.ts index 5e9278d6..fc34e24a 100644 --- a/backend/src/routes/users.ts +++ b/backend/src/routes/users.ts @@ -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)) { diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 7c3d3738..f1ad807e 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -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)}`); } } diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index c6418dc7..02bab200 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -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 }); } } diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index cae34551..a76c7320 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -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}`); } } diff --git a/backend/src/services/GitSourceService.ts b/backend/src/services/GitSourceService.ts index fb1bfe22..848370ac 100644 --- a/backend/src/services/GitSourceService.ts +++ b/backend/src/services/GitSourceService.ts @@ -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 { diff --git a/backend/src/services/ImageUpdateService.ts b/backend/src/services/ImageUpdateService.ts index 6aae48e3..8b6cc03a 100644 --- a/backend/src/services/ImageUpdateService.ts +++ b/backend/src/services/ImageUpdateService.ts @@ -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); diff --git a/backend/src/services/NotificationService.ts b/backend/src/services/NotificationService.ts index eb2c73fb..68e2039e 100644 --- a/backend/src/services/NotificationService.ts +++ b/backend/src/services/NotificationService.ts @@ -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) diff --git a/backend/src/services/PolicyEnforcement.ts b/backend/src/services/PolicyEnforcement.ts index fb11f071..cbdfacd5 100644 --- a/backend/src/services/PolicyEnforcement.ts +++ b/backend/src/services/PolicyEnforcement.ts @@ -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, diff --git a/backend/src/services/RegistryService.ts b/backend/src/services/RegistryService.ts index e523acaa..6576aee9 100644 --- a/backend/src/services/RegistryService.ts +++ b/backend/src/services/RegistryService.ts @@ -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}` }); diff --git a/backend/src/services/SchedulerService.ts b/backend/src/services/SchedulerService.ts index 33b33195..b38027c3 100644 --- a/backend/src/services/SchedulerService.ts +++ b/backend/src/services/SchedulerService.ts @@ -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))); } } diff --git a/backend/src/services/TrivyService.ts b/backend/src/services/TrivyService.ts index fe1b527f..d79b5c99 100644 --- a/backend/src/services/TrivyService.ts +++ b/backend/src/services/TrivyService.ts @@ -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 { diff --git a/backend/src/utils/safeLog.ts b/backend/src/utils/safeLog.ts new file mode 100644 index 00000000..0cf6ac57 --- /dev/null +++ b/backend/src/utils/safeLog.ts @@ -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, ''); +} diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index 1b08a26f..51529730 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -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)',