/** * DC-081: Plain-English log insights + dispose endpoint * * GET /api/v1/log-insights — Plain English summary of who's doing what * POST /api/v1/log-insights/dispose — Preview then confirm cleanup * * DC-081 hardening (paired with the deploy path fix): * - AUDIT_LOG_FILE / SECURITY_EVENT_LOG_FILE were HARDCODED to * `/opt/dashcaddy/dashcaddy-api/data/...` which DOES NOT EXIST in the * production container — files live at `/app/data/...`. The dispose * endpoint silently no-op'd (read empty arrays, wrote empty arrays * back) and the GET endpoint dropped the storage-size block. Both * paths now use the same canonical resolution as the audit-logger * itself: `process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json')`. * - keepDays was unbounded — `parseInt(req.body.keepDays) || 30` accepted * negative numbers (e.g. -1000 → cutoff = +3 years in the future, * deleting 100% of forensic context) and non-integers (Infinity, * floats). Now validated to an integer in [1, 3650] (1 day .. 10 years) * before any file read. * - confirm gate added: must send { confirm: true, keepDays: N } — the * preview pass is read-only, the confirm pass writes. Matches the * audit-logs/DELETE confirm=CLEAR pattern. * - The dispose handler now uses a single shared `_resolvePaths()` helper * to keep GET and POST in lockstep (and so a future path-config change * touches one site, not four). * * Pre-DC-081 verification: from inside the running container, both * `/app/data/audit-log.json` (318 KB) and `/app/data/security-events.jsonl` * (15 MB) exist, but the old hardcoded `/opt/dashcaddy/dashcaddy-api/data/...` * paths resolve to ENOENT. The dispose endpoint therefore did nothing; * this fix wires it back to the actual files. */ const express = require('express'); const path = require('path'); const fs = require('fs').promises; const platformPaths = require('../platform-paths'); /** * Resolve the canonical paths for the audit log + security event log. * * Both store the file path in their own module-level constants, so any * environment override (e.g. AUDIT_LOG_FILE=...) is honoured here too — * exactly the same behaviour as src/security/audit-logger.js and * src/security/event-store.js. Without this, a container with * AUDIT_LOG_FILE set would see the dispose handler read from one file * and the audit-logger write to a different one. * * @returns {{auditPath: string, secPath: string, auditPathFrom: string, secPathFrom: string}} * paths + the source ("env" or "default") so tests can verify. */ function _resolvePaths() { const auditPath = process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json'); const secPath = process.env.SECURITY_EVENT_LOG_FILE || path.join(platformPaths.dataDir, 'security-events.jsonl'); return { auditPath, secPath, auditPathFrom: process.env.AUDIT_LOG_FILE ? 'env' : 'default', secPathFrom: process.env.SECURITY_EVENT_LOG_FILE ? 'env' : 'default', }; } /** * Validate the keepDays input. Coerces + bounds-checks BEFORE any file * read so a malicious or mistyped client can't: * - pass a negative number (cutoff = far future → wipe 100%) * - pass Infinity (parseInt(Infinity, 10) === NaN, currently falls * through `|| 30` — fixed to fail-fast instead) * - pass a non-integer (e.g. 1.5 → cutoff mid-day, off-by-half-day) * - pass 0 (no-op-but-lies) or 10000 (way past retention policy) * * @param {unknown} raw - value from req.body.keepDays * @returns {number} validated integer in [1, 3650] * @throws {Error} when out of range / wrong type */ function _validateKeepDays(raw) { if (raw === undefined || raw === null) { throw new Error('keepDays is required (integer in [1, 3650])'); } const n = Number(raw); if (!Number.isFinite(n)) { throw new Error(`keepDays must be a finite number (received ${JSON.stringify(raw)})`); } if (!Number.isInteger(n)) { throw new Error(`keepDays must be an integer (received ${raw})`); } if (n < 1 || n > 3650) { throw new Error(`keepDays must be between 1 and 3650 (received ${n})`); } return n; } module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) { const router = express.Router(); // Resolve once at module init so GET + POST both use the same files. // If the env vars change at runtime (rare — start.sh wires them at // container start), operators re-deploy rather than mutate env mid-flight. const { auditPath, secPath } = _resolvePaths(); // GET /api/v1/log-insights — Plain English summary of who's doing what router.get('/log-insights', asyncHandler(async (req, res) => { const hours = parseInt(req.query.hours) || 24; const since = new Date(Date.now() - hours * 60 * 60 * 1000).toISOString(); // --- Collect data --- const auditEntries = await auditLogger.query({ limit: 10000 }); const recentAudit = auditEntries.filter(e => e.timestamp >= since); let securityEvents = []; try { securityEvents = securityEventStore.query({ since, limit: 10000 }); } catch {} // --- Analyze IPs --- const ipMap = {}; recentAudit.forEach(e => { const ip = e.ip || 'unknown'; if (!ipMap[ip]) ipMap[ip] = { count: 0, actions: {}, resources: new Set(), first: e.timestamp, last: e.timestamp, failures: 0 }; const s = ipMap[ip]; s.count++; const cat = (e.action || 'unknown').split('.')[0]; s.actions[cat] = (s.actions[cat] || 0) + 1; if (e.resource) s.resources.add(e.resource); if (e.timestamp < s.first) s.first = e.timestamp; if (e.timestamp > s.last) s.last = e.timestamp; if (e.outcome === 'failure' || e.outcome === 'denied') s.failures++; }); // --- Build plain-English insights --- const insights = []; const ipArray = Object.entries(ipMap).sort((a, b) => b[1].count - a[1].count); // Heavy users ipArray.slice(0, 3).forEach(([ip, s]) => { const topAction = Object.entries(s.actions).sort((a, b) => b[1] - a[1])[0]; insights.push({ severity: s.count > 500 ? 'warning' : 'info', title: ip + ' — ' + s.count + ' requests in ' + hours + 'h', plain: ip + ' made ' + s.count + ' requests (mostly ' + (topAction ? topAction[0] : 'unknown') + ')' + (s.failures > 0 ? ', ' + s.failures + ' failed' : '') + '.' }); }); // Auth failures const totalFailures = recentAudit.filter(e => e.outcome === 'failure' || e.outcome === 'denied').length; if (totalFailures > 5) { insights.push({ severity: totalFailures > 50 ? 'warning' : 'info', title: totalFailures + ' failed actions', plain: totalFailures + ' requests were denied or failed in the last ' + hours + ' hours.' + (totalFailures > 50 ? ' This could indicate someone trying to brute-force access.' : '') }); } // Security events const secBySev = {}; securityEvents.forEach(e => { secBySev[e.severity] = (secBySev[e.severity] || 0) + 1; }); if (secBySev.critical || secBySev.error) { insights.push({ severity: 'warning', title: ((secBySev.critical || 0) + (secBySev.error || 0)) + ' security alerts', plain: (secBySev.critical || 0) + ' critical and ' + (secBySev.error || 0) + ' error-level security events were logged.' }); } // Quiet / nothing if (insights.length === 0) { insights.push({ severity: 'ok', title: 'All quiet', plain: 'No notable activity in the last ' + hours + ' hours.' }); } // --- Storage info --- // DC-081: read from the canonical resolved paths (NOT the hardcoded // /opt/... paths that don't exist in the container). Empty-object // fallback on ENOENT — the file may legitimately be absent on a // fresh install where the audit-logger hasn't written yet. let storage = {}; try { const a = await fs.stat(auditPath); storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length, path: auditPath }; } catch {} try { const s = await fs.stat(secPath); storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length, path: secPath }; } catch {} ok(res, { period: { hours, since, until: new Date().toISOString() }, summary: { totalRequests: recentAudit.length, uniqueIPs: ipArray.length, securityEvents: securityEvents.length, failedActions: totalFailures }, topIPs: ipArray.slice(0, 10).map(([ip, s]) => ({ ip: ip, count: s.count, failures: s.failures, topActions: Object.entries(s.actions).sort((a, b) => b[1] - a[1]).slice(0, 3), activeFrom: s.first, lastSeen: s.last })), insights: insights, storage: storage }); })); // POST /api/v1/log-insights/dispose — Preview then confirm cleanup // // Two-call pattern: // 1. { keepDays: 30 } → preview, no writes // 2. { keepDays: 30, confirm: true } → actually delete // // DC-081 hardening: // - keepDays is validated to integer [1, 3650] BEFORE any file read. // A negative keepDays (e.g. -1000) would previously compute a // cutoff +3 years in the future, then delete every entry older // than that — i.e. 100% of the audit log. Now rejected at the gate. // - auditPath / secPath come from the canonical _resolvePaths() helper // so the container's actual /app/data files are read (the pre-fix // hardcoded /opt/dashcaddy/dashcaddy-api/data/... paths resolved to // ENOENT inside the container, so the endpoint silently did nothing). router.post('/log-insights/dispose', asyncHandler(async (req, res) => { // Validate keepDays first — fail-fast before any file IO so a bad // client never touches disk. let keepDays; try { keepDays = _validateKeepDays(req.body?.keepDays); } catch (e) { return res.status(400).json({ success: false, error: e.message, code: 'DC-081_INVALID_KEEP_DAYS' }); } const confirm = req.body.confirm === true; const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString(); // Read both files via the canonical resolved paths (NOT the hardcoded // /opt/... paths from before — those don't exist in the container). const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; }); let auditData; try { auditData = JSON.parse(auditRaw); } catch (e) { return res.status(500).json({ success: false, error: `audit-log file is corrupt (${auditPath}): ${e.message}`, code: 'DC-081_AUDIT_PARSE_FAILED' }); } if (!Array.isArray(auditData)) { return res.status(500).json({ success: false, error: `audit-log file is not an array (${auditPath})`, code: 'DC-081_AUDIT_SHAPE_INVALID' }); } const oldAudit = auditData.filter(function (e) { return e.timestamp < cutoff; }); const secRaw = await fs.readFile(secPath, 'utf8').catch(function () { return ''; }); const secLines = secRaw.split('\n').filter(Boolean); const oldSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp < cutoff; } catch (e) { return false; } }); if (!confirm) { ok(res, { preview: true, message: 'This will delete ' + oldAudit.length + ' audit entries and ' + oldSec.length + ' security events older than ' + keepDays + ' days. Send {confirm: true, keepDays: ' + keepDays + '} to proceed.', wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length }, cutoffDate: cutoff, paths: { auditPath, secPath }, }); return; } // Execute cleanup. Audit the wipe FIRST via the audit-logger so the // fact that a delete happened is itself preserved (matches the // audit-logs/DELETE + error-logs/DELETE pattern). try { if (auditLogger && typeof auditLogger.log === 'function') { await auditLogger.log({ action: 'log-insights.dispose', resource: 'audit-log,security-events', outcome: 'success', details: { keepDays, cutoff, deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length }, }, }); } } catch { /* don't fail the dispose on audit-side errors */ } // Rewrite audit-log.json atomically — write to tmp + rename so a // crash mid-write can't leave the file half-empty (the file is read // by state-manager on every container start; a corrupt file would // block the whole API). const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; }); const tmpAudit = auditPath + '.tmp'; await fs.writeFile(tmpAudit, JSON.stringify(keptAudit, null, 2)); await fs.rename(tmpAudit, auditPath); const keptSec = secLines.filter(function (l) { try { return JSON.parse(l).timestamp >= cutoff; } catch (e) { return false; } }); await fs.writeFile(secPath, keptSec.join('\n') + '\n'); ok(res, { disposed: true, deleted: { auditEntries: oldAudit.length, securityEvents: oldSec.length }, remaining: { auditEntries: keptAudit.length, securityEvents: keptSec.length }, cutoffDate: cutoff, }); })); return router; }; // DC-081: export helpers for direct unit testing (the route handlers are // otherwise unreachable from outside the factory closure). module.exports.__test = { _resolvePaths, _validateKeepDays, };