diff --git a/CHANGELOG.md b/CHANGELOG.md index 8d033f6f..de928235 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,6 +22,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +* **error-handling:** surface silent errors across the codebase — added `console.warn`/`console.error` logging to 22 silent catch blocks across 10 files (services, index.ts, frontend Login). Errors in cleanup, migrations, SSO, fleet snapshots, shutdown, and validation are now visible in logs without changing any control flow. ENOENT guards added to file-system catches to distinguish missing files from permission errors. * **security:** self-heal encryption key file permissions on startup — verifies `0600` and corrects if permissive; Docker entrypoint also enforces `chmod 600` before privilege drop * **security:** increase minimum password length from 6 to 8 characters (NIST SP 800-63B) — applies to setup, password change, and user management; existing short passwords remain valid until changed * **security:** remove sensitive data from console output — file paths, `.env` locations, stack names, and admin usernames no longer logged to stdout diff --git a/backend/src/index.ts b/backend/src/index.ts index 78de1fdf..6c366ba2 100644 --- a/backend/src/index.ts +++ b/backend/src/index.ts @@ -517,7 +517,8 @@ app.get('/api/auth/sso/providers', (_req: Request, res: Response): void => { try { const providers = SSOService.getInstance().getEnabledProviders(); res.json(providers); - } catch { + } catch (e) { + console.warn('[SSO] Failed to list enabled providers, returning empty list:', (e as Error).message); res.json([]); } }); @@ -621,7 +622,8 @@ app.get('/api/auth/sso/oidc/:provider/callback', async (req: Request, res: Respo let statePayload: { state: string; codeVerifier: string; provider: string }; try { statePayload = JSON.parse(cryptoSvc.decrypt(stateCookie)); - } catch { + } catch (e) { + console.error('[SSO] Failed to decrypt SSO state cookie:', (e as Error).message); res.redirect('/?sso_error=Invalid+SSO+session'); return; } @@ -1295,8 +1297,8 @@ async function captureLocalNodeFiles(node: Node): Promise { try { const composeContent = await fsService.getStackContent(stackName); files.push({ filename: 'compose.yaml', content: composeContent }); - } catch { - // Stack has no compose file - skip + } catch (e) { + console.warn(`[Fleet Snapshot] Could not read compose file for stack "${stackName}", skipping:`, (e as Error).message); continue; } try { @@ -1339,7 +1341,8 @@ async function captureRemoteNodeFiles(node: Node): Promise { const content = await composeRes.text(); files.push({ filename: 'compose.yaml', content }); } - } catch { + } catch (e) { + console.warn(`[Fleet Snapshot] Failed to fetch remote compose for stack "${stackName}":`, (e as Error).message); continue; } try { @@ -1536,8 +1539,9 @@ app.post('/api/fleet/snapshots/:id/restore', async (req: Request, res: Response) // Backup current files before restore try { await fsService.backupStackFiles(stackName); - } catch { - // Stack may not exist yet - that's ok + } catch (e) { + // Stack may not exist yet before first restore — that's ok + console.warn(`[Fleet Snapshot] Pre-restore backup failed for stack "${stackName}" (may not exist yet):`, (e as Error).message); } for (const file of files) { @@ -2617,7 +2621,11 @@ app.get('/api/stacks/:stackName/env', async (req: Request, res: Response) => { try { await fsService.access(envPath); - } catch { + } catch (e: unknown) { + const code = (e as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + console.error('[Sencho] Unexpected error checking env file existence:', (e as Error).message); + } return res.status(404).json({ error: 'Env file not found' }); } @@ -3830,7 +3838,8 @@ app.post('/api/scheduled-tasks', (req: Request, res: Response): void => { } } // Validate cron expression - try { CronExpressionParser.parse(cron_expression); } catch { + try { CronExpressionParser.parse(cron_expression); } catch (e) { + console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, (e as Error).message); res.status(400).json({ error: 'Invalid cron expression.' }); return; } @@ -3945,7 +3954,8 @@ app.put('/api/scheduled-tasks/:id', (req: Request, res: Response): void => { } if (cron_expression) { - try { CronExpressionParser.parse(cron_expression); } catch { + try { CronExpressionParser.parse(cron_expression); } catch (e) { + console.warn('[Scheduler] Invalid cron expression rejected:', cron_expression, (e as Error).message); res.status(400).json({ error: 'Invalid cron expression.' }); return; } } @@ -4733,11 +4743,21 @@ const gracefulShutdown = (signal: string) => { server.close(() => { console.log('[Shutdown] HTTP server closed'); - try { LicenseService.getInstance().destroy(); } catch { /* already stopped */ } - try { MonitorService.getInstance().stop(); } catch { /* already stopped */ } - try { ImageUpdateService.getInstance().stop(); } catch { /* already stopped */ } - try { SchedulerService.getInstance().stop(); } catch { /* already stopped */ } - try { DatabaseService.getInstance().getDb().close(); } catch { /* already closed */ } + try { LicenseService.getInstance().destroy(); } catch (e) { + console.warn('[Shutdown] LicenseService cleanup failed:', (e as Error).message); + } + try { MonitorService.getInstance().stop(); } catch (e) { + console.warn('[Shutdown] MonitorService cleanup failed:', (e as Error).message); + } + try { ImageUpdateService.getInstance().stop(); } catch (e) { + console.warn('[Shutdown] ImageUpdateService cleanup failed:', (e as Error).message); + } + try { SchedulerService.getInstance().stop(); } catch (e) { + console.warn('[Shutdown] SchedulerService cleanup failed:', (e as Error).message); + } + try { DatabaseService.getInstance().getDb().close(); } catch (e) { + console.warn('[Shutdown] Database close failed:', (e as Error).message); + } console.log('[Shutdown] Done - exiting'); process.exit(0); }); diff --git a/backend/src/services/ComposeService.ts b/backend/src/services/ComposeService.ts index 260dddb0..234066d5 100644 --- a/backend/src/services/ComposeService.ts +++ b/backend/src/services/ComposeService.ts @@ -99,7 +99,10 @@ export class ComposeService { PATH: process.env.PATH || '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin', }); } finally { - try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch { /* best-effort cleanup */ } + try { fs.unlinkSync(configPath); fs.rmdirSync(tmpDir); } catch (e) { + // Best-effort cleanup: temp config dir may already be removed or locked + console.warn('[ComposeService] Could not clean up temp Docker config dir:', (e as Error).message); + } } } diff --git a/backend/src/services/CryptoService.ts b/backend/src/services/CryptoService.ts index e794fa2d..75697b62 100644 --- a/backend/src/services/CryptoService.ts +++ b/backend/src/services/CryptoService.ts @@ -24,8 +24,9 @@ export class CryptoService { console.warn(`[CryptoService] Fixing permissive key file permissions (was 0o${mode.toString(8)}, set to 0o600)`); fs.chmodSync(keyPath, 0o600); } - } catch { + } catch (e) { // chmod not supported on this platform (e.g. Windows) — skip + console.warn('[CryptoService] Could not enforce key file permissions (platform may not support chmod):', (e as Error).message); } } else { this.key = crypto.randomBytes(KEY_LENGTH); diff --git a/backend/src/services/DatabaseService.ts b/backend/src/services/DatabaseService.ts index 6a8f9544..907780fb 100644 --- a/backend/src/services/DatabaseService.ts +++ b/backend/src/services/DatabaseService.ts @@ -436,7 +436,12 @@ export class DatabaseService { // Drop legacy SSH/TLS columns from pre-0.7 databases (no longer read or written) const legacyCols = ['host', 'port', 'ssh_port', 'ssh_user', 'ssh_password', 'ssh_key', 'tls_ca', 'tls_cert', 'tls_key']; for (const col of legacyCols) { - try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch { /* already dropped or never existed */ } + try { this.db.prepare(`ALTER TABLE nodes DROP COLUMN ${col}`).run(); } catch (e: unknown) { + // Expected: column already dropped or never existed + if (!String((e as Error)?.message).includes('no such column')) { + console.warn(`[DatabaseService] Unexpected error dropping legacy column "${col}":`, (e as Error).message); + } + } } // Initialize default global settings if they don't exist @@ -511,7 +516,12 @@ export class DatabaseService { private migrateSSOColumns(): void { const maybeAddCol = (table: string, col: string, def: string) => { - try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch { /* already exists */ } + try { this.db.prepare(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`).run(); } catch (e: unknown) { + // Expected: column already exists + if (!String((e as Error)?.message).includes('duplicate column')) { + console.warn(`[DatabaseService] Unexpected error adding column "${col}" to "${table}":`, (e as Error).message); + } + } }; maybeAddCol('users', 'auth_provider', "TEXT NOT NULL DEFAULT 'local'"); maybeAddCol('users', 'provider_id', 'TEXT DEFAULT NULL'); @@ -562,7 +572,9 @@ export class DatabaseService { `); try { this.db.exec('CREATE UNIQUE INDEX IF NOT EXISTS idx_role_assignments_unique ON role_assignments(user_id, role, resource_type, resource_id)'); - } catch { /* already exists */ } + } catch (e) { + console.warn('[DatabaseService] Could not create role_assignments unique index:', (e as Error).message); + } } // --- Agents --- diff --git a/backend/src/services/DockerController.ts b/backend/src/services/DockerController.ts index a37bde89..d9f4a71c 100644 --- a/backend/src/services/DockerController.ts +++ b/backend/src/services/DockerController.ts @@ -651,7 +651,10 @@ class DockerController { // Destroy the Docker stats stream when the WebSocket closes to prevent // orphaned streams polling the daemon after client disconnect. ws.on('close', () => { - try { (stats as any).destroy(); } catch { /* stream already ended */ } + try { (stats as any).destroy(); } catch (e) { + // Stream already ended before client disconnected + console.warn('[DockerController] Stats stream already ended on WS close:', (e as Error).message); + } }); } @@ -723,8 +726,9 @@ class DockerController { case 'resize': if (msg.rows && msg.cols) { - exec.resize({ h: msg.rows, w: msg.cols }).catch(() => { - // Ignore resize errors (exec may have ended) + exec.resize({ h: msg.rows, w: msg.cols }).catch((e: Error) => { + // Exec may have ended before resize completes + console.warn('[DockerController] Exec resize failed (exec may have ended):', e.message); }); } break; @@ -733,8 +737,9 @@ class DockerController { // Keep-alive, no-op break; } - } catch { - // Non-JSON or malformed message - ignore + } catch (e) { + // Non-JSON or malformed WebSocket message + console.warn('[DockerController] Ignoring malformed exec WS message:', (e as Error).message); } }); @@ -742,8 +747,9 @@ class DockerController { ws.on('close', () => { try { stream.destroy(); - } catch { - // Ignore destroy errors + } catch (e) { + // Stream already destroyed before WS close + console.warn('[DockerController] Exec stream already destroyed on WS close:', (e as Error).message); } }); diff --git a/backend/src/services/FileSystemService.ts b/backend/src/services/FileSystemService.ts index f3b3f98b..4a1eb29b 100644 --- a/backend/src/services/FileSystemService.ts +++ b/backend/src/services/FileSystemService.ts @@ -275,8 +275,11 @@ export class FileSystemService { try { await fsPromises.access(oldEnvPath); await fsPromises.rename(oldEnvPath, newEnvPath); - } catch { - // No env file to migrate + } catch (e: unknown) { + const code = (e as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + console.warn(`[FileSystemService] Could not migrate env file for ${stackName}:`, (e as Error).message); + } } } @@ -300,8 +303,11 @@ export class FileSystemService { try { await fsPromises.access(src); await fsPromises.copyFile(src, path.join(backupDir, file)); - } catch { - // File doesn't exist, skip + } catch (e: unknown) { + const code = (e as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + console.warn(`[FileSystemService] Could not back up ${file}:`, (e as Error).message); + } } } @@ -310,8 +316,11 @@ export class FileSystemService { try { await fsPromises.access(envSrc); await fsPromises.copyFile(envSrc, path.join(backupDir, '.env')); - } catch { - // No .env to backup + } catch (e: unknown) { + const code = (e as NodeJS.ErrnoException)?.code; + if (code !== 'ENOENT') { + console.warn('[FileSystemService] Could not back up .env:', (e as Error).message); + } } // Write timestamp marker @@ -343,7 +352,8 @@ export class FileSystemService { try { const ts = await fsPromises.readFile(tsFile, 'utf-8'); return { exists: true, timestamp: parseInt(ts, 10) || null }; - } catch { + } catch (e) { + console.warn('[FileSystemService] Backup timestamp file unreadable:', (e as Error).message); return { exists: true, timestamp: null }; } } catch { diff --git a/backend/src/services/HostTerminalService.ts b/backend/src/services/HostTerminalService.ts index b86bfc4c..4c8c2db8 100644 --- a/backend/src/services/HostTerminalService.ts +++ b/backend/src/services/HostTerminalService.ts @@ -7,7 +7,8 @@ function getUnixShell() { try { execSync('which bash', { stdio: 'ignore' }); return 'bash'; - } catch { + } catch (e) { + console.warn('[HostTerminalService] bash not found, falling back to sh:', (e as Error).message); return 'sh'; } } diff --git a/backend/src/services/MonitorService.ts b/backend/src/services/MonitorService.ts index 5612676f..3213df7b 100644 --- a/backend/src/services/MonitorService.ts +++ b/backend/src/services/MonitorService.ts @@ -180,7 +180,9 @@ export class MonitorService { totalReclaimableBytes += bytes; } } - } catch (e) { } + } catch (e) { + console.warn('[MonitorService] Failed to parse Docker system df output:', e); + } } const reclaimGb = totalReclaimableBytes / (1024 * 1024 * 1024); diff --git a/backend/src/utils/validation.ts b/backend/src/utils/validation.ts index d2d0bd27..99e4932c 100644 --- a/backend/src/utils/validation.ts +++ b/backend/src/utils/validation.ts @@ -18,7 +18,8 @@ export function isValidRemoteUrl( let url: URL; try { url = new URL(raw); - } catch { + } catch (e) { + console.warn('[Validation] URL parse failure:', (e as Error).message, '— input:', raw); return { valid: false, reason: 'API URL must be a valid URL (e.g. https://my-server.example.com:3000)', diff --git a/frontend/src/components/Login.tsx b/frontend/src/components/Login.tsx index 8b94091f..e351568f 100644 --- a/frontend/src/components/Login.tsx +++ b/frontend/src/components/Login.tsx @@ -63,7 +63,9 @@ export function Login({ fetch('/api/auth/sso/providers', { credentials: 'include' }) .then(r => r.ok ? r.json() : []) .then((providers: SSOProvider[]) => setSsoProviders(providers)) - .catch(() => {}); + .catch((e) => { + console.warn('[Login] SSO provider discovery failed:', e); + }); }, []); const hasLdap = ssoProviders.some(p => p.type === 'ldap');