const path = require('path'); const StateManager = require('../managers/state-manager'); const crypto = require('crypto'); const platformPaths = require('../../platform-paths'); const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json'); const MAX_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10); // Route path → readable action mapping const ACTION_MAP = { 'POST /api/v1/services/update': 'service.reorder', 'POST /api/v1/services': 'service.create', 'PUT /api/v1/services': 'service.update', 'DELETE /api/v1/services/': 'service.delete', 'POST /api/v1/site': 'caddy.add-site', 'POST /api/v1/site/external': 'caddy.add-external', 'DELETE /api/v1/site/': 'caddy.remove-site', 'POST /api/v1/caddy/reload': 'caddy.reload', 'POST /api/v1/dns/record': 'dns.add-record', 'DELETE /api/v1/dns/record': 'dns.delete-record', 'POST /api/v1/dns/credentials': 'dns.save-credentials', 'DELETE /api/v1/dns/credentials': 'dns.delete-credentials', 'POST /api/v1/dns/refresh-token': 'dns.refresh-token', 'POST /api/v1/dns/update': 'dns.update-server', 'POST /api/v1/containers/': 'container.action', 'DELETE /api/v1/containers/': 'container.delete', 'POST /api/v1/apps/deploy': 'container.deploy', 'DELETE /api/v1/apps/': 'container.undeploy', 'POST /api/v1/backups/execute': 'backup.execute', 'POST /api/v1/backups/restore/': 'backup.restore', 'POST /api/v1/backups/config': 'backup.config', 'POST /api/v1/config': 'config.update', 'DELETE /api/v1/config': 'config.reset', 'POST /api/v1/notifications/config': 'config.notifications', 'POST /api/v1/totp/setup': 'auth.totp-setup', 'POST /api/v1/totp/verify-setup': 'auth.totp-activate', 'POST /api/v1/totp/disable': 'auth.totp-disable', 'POST /api/v1/totp/config': 'auth.totp-config', 'POST /api/v1/credentials/rotate-key': 'config.rotate-key', 'POST /api/v1/updates/update/': 'container.update', 'POST /api/v1/updates/rollback/': 'container.rollback', 'POST /api/v1/updates/auto-update/': 'container.auto-update', 'POST /api/v1/updates/check': 'container.check-updates', 'POST /api/v1/health-checks/': 'config.health-check', 'DELETE /api/v1/health-checks/': 'config.health-check-delete', 'POST /api/v1/monitoring/alerts/': 'config.monitoring-alert', 'DELETE /api/v1/monitoring/alerts/': 'config.monitoring-alert-delete', 'POST /api/v1/arr/smart-connect': 'service.arr-connect', 'POST /api/v1/arr/credentials': 'config.arr-credentials', 'DELETE /api/v1/arr/credentials/': 'config.arr-credentials-delete', 'POST /api/v1/logo': 'config.logo-upload', 'DELETE /api/v1/logo': 'config.logo-delete', 'POST /api/v1/favicon': 'config.favicon-upload', 'DELETE /api/v1/favicon': 'config.favicon-delete', 'POST /api/v1/tailscale/config': 'config.tailscale', 'POST /api/v1/tailscale/protect-service': 'config.tailscale-protect', // SECURITY [DC-028]: Credential-exposure events get named actions so the // audit log can answer "who hit /auth/gate/plex at 03:00 with what outcome?". 'GET /api/v1/auth/gate': 'auth.credential-injection', 'GET /api/v1/auth/app-token': 'auth.app-token-issue', 'POST /api/v1/auth/keys': 'auth.api-key-generate', 'DELETE /api/v1/auth/keys': 'auth.api-key-revoke', 'POST /api/v1/auth/jwt': 'auth.jwt-mint', }; // Paths to skip logging (noisy or internal) const SKIP_PATHS = [ '/api/v1/totp/verify', '/api/v1/totp/check-session', // SECURITY [DC-028]: /auth/gate and /auth/app-token are NOT skipped — // they expose credentials (Basic Auth, X-Api-Key, upstream service tokens) // so we MUST log every hit. Previously these were in SKIP_PATHS which // silently dropped credential-exposure events from the audit log. '/api/v1/audit-logs', '/api/v1/health', '/health', '/api/v1/notifications/test', '/api/v1/notifications/health-check', ]; class AuditLogger { constructor() { this.stateManager = new StateManager(AUDIT_LOG_FILE); } resolveAction(method, urlPath) { const key = `${method} ${urlPath}`; // Exact match first if (ACTION_MAP[key]) return ACTION_MAP[key]; // Prefix match (for parameterized routes like /api/services/:id) for (const [pattern, action] of Object.entries(ACTION_MAP)) { if (key.startsWith(pattern)) return action; } // Fallback: derive from path const parts = urlPath.replace('/api/v1/', '').split('/'); const category = parts[0] || 'unknown'; return `${category}.${method.toLowerCase()}`; } extractResource(urlPath) { // Pull a meaningful resource identifier from the URL path const parts = urlPath.replace('/api/v1/', '').split('/'); if (parts.length >= 2) return parts.slice(1).join('/'); return parts[0] || ''; } shouldSkip(method, urlPath) { // SECURITY [DC-028]: Auth endpoints that expose credentials are // logged even though they're GETs. /auth/gate and /auth/app-token // return Basic Auth headers and upstream service tokens — these // events MUST be auditable. Other GETs remain skipped (probes, // dashboards, status checks flood the log). if (urlPath.startsWith('/api/v1/auth/gate') || urlPath.startsWith('/api/v1/auth/app-token')) { return false; // log it } if (method === 'GET') return true; for (const skip of SKIP_PATHS) { if (urlPath.startsWith(skip)) return true; } return false; } /** * Map a (action, outcome) pair to a severity level for the security event store. * Most actions are 'info', but security-sensitive ones get escalated. */ resolveSeverity(action, outcome) { // Failed auth + sensitive actions are warnings at minimum if (outcome === 'failure' || outcome === 'denied' || outcome === 'error') { if (action?.startsWith('auth.')) return 'warn'; if (action?.includes('credential')) return 'warn'; if (action?.includes('delete') || action?.includes('disable')) return 'warn'; return 'notice'; } // Successful sensitive actions (key generation, TOTP setup, config changes) if (action?.startsWith('auth.totp-') || action?.includes('rotate-key')) return 'notice'; if (action?.includes('delete') || action?.includes('disable')) return 'notice'; if (action?.startsWith('config.')) return 'notice'; return 'info'; } async log({ action, resource, details, outcome, ip }) { try { const entry = { id: crypto.randomUUID(), timestamp: new Date().toISOString(), ip: ip || '', action: action || '', resource: resource || '', details: details || {}, outcome: outcome || 'unknown' }; await this.stateManager.update(entries => { entries.unshift(entry); if (entries.length > MAX_ENTRIES) { entries.length = MAX_ENTRIES; } return entries; }); // ALSO emit to the unified security event store so security events from // the API show up alongside Caddy access logs, fail2ban events, and any // future remote-agent events in one timeline. This is best-effort — // failure here MUST NOT block the audit log write. try { const { getStore } = require('./event-store'); const store = getStore(); const severity = this.resolveSeverity(action, outcome); const hostname = require('os').hostname(); store.append({ source_host: hostname, source_type: 'api', actor: ip || null, target: resource || null, action: action || 'unknown', outcome: outcome || 'unknown', severity, message: `${action} ${outcome} on ${resource}`.trim(), metadata: { method: details?.body && Object.keys(details.body)[0] ? '(see audit-log)' : undefined, audit_id: entry.id, }, }); } catch (e) { // Non-fatal — security store is a best-effort mirror console.error('[AuditLogger] Security event emit failed:', e.message); } } catch (e) { console.error('[AuditLogger] Failed to write entry:', e.message); } } async query({ limit = 50, offset = 0, action } = {}) { try { let entries = await this.stateManager.read(); if (action) { entries = entries.filter(e => e.action && e.action.startsWith(action)); } return entries.slice(offset, offset + limit); } catch (e) { console.error('[AuditLogger] Failed to read:', e.message); return []; } } async clear() { await this.stateManager.write([]); } middleware() { return (req, res, next) => { if (this.shouldSkip(req.method, req.path)) return next(); const originalJson = res.json.bind(res); res.json = (data) => { // Log asynchronously — don't block the response const ip = req.ip || req.socket?.remoteAddress || ''; const action = this.resolveAction(req.method, req.path); const resource = this.extractResource(req.path); const outcome = data && data.success === false ? 'failure' : 'success'; // Sanitize details — don't log passwords or tokens const details = {}; if (req.params && Object.keys(req.params).length) details.params = req.params; if (req.body) { const safe = { ...req.body }; for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) { if (safe[key]) safe[key] = '***'; } details.body = safe; } this.log({ action, resource, details, outcome, ip }).catch(() => {}); return originalJson(data); }; next(); }; } } module.exports = new AuditLogger();