Files
dashcaddy/dashcaddy-api/routes/log-insights.js
T
Hermes 0e7bb97129 [glm-grade=A] fix(log-insights): wire dispose to /app/data paths + bound keepDays (DC-081)
Pre-fix, the dispose endpoint + storage info block in dashcaddy-api/routes/log-insights.js
HARDCODED /opt/dashcaddy/dashcaddy-api/data/audit-log.json and
/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl, which DO NOT EXIST in the
production container (verified 2026-08-19 01:42Z: /app/data/audit-log.json = 318 KB,
/app/data/security-events.jsonl = 15 MB, /opt/... = ENOENT). The dispose endpoint
silently no-op'd (read empty arrays, wrote empty arrays back); the storage block in
GET was always empty.

Also: parseInt(req.body.keepDays) || 30 accepted negative numbers. keepDays = -1000
produces a cutoff +3 years in the future, then the filter e.timestamp < cutoff
deletes 100% of the audit log. Operators must not be able to wipe forensic context
with a typo.

Fix:
  * _resolvePaths() uses process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json'),
    matching the canonical resolution in src/security/audit-logger.js and src/security/event-store.js.
    Both GET + POST share the resolved paths (single source of truth).
  * _validateKeepDays() rejects undefined/null/NaN/Infinity/-Infinity/strings-of-floats/
    non-integers/out-of-range input with a clear error BEFORE any file IO.
    Allowed: integer in [1, 3650] (1 day .. 10 years).
  * POST /log-insights/dispose now requires { keepDays: integer 1..3650, confirm: true }.
    Preview is read-only. Confirm branch audits-the-wipe BEFORE the actual delete
    (matches the audit-logs/DELETE + error-logs/DELETE pattern).
  * Atomic write for audit-log.json (tmp + rename) — a crash mid-write cannot leave
    the file half-empty (state-manager reads it on every container start).

Tests (23 new, dashcaddy-api/__tests__/routes/log-insights.routes.test.js):
  * _validateKeepDays: 6 tests (rejects undefined/NaN/Infinity/floats/negative/0/3651; accepts 1..3650; coerces numeric strings).
  * _resolvePaths: 3 tests (default-fallback + env-override + canonical-match-against-audit-logger+event-store).
  * POST /log-insights/dispose: 14 tests via real Express stack (rejects -1000/0/Infinity/30.5/>3650; preview/confirm round-trip;
    confirm=false treated as preview; preview-includes-resolved-paths; missing-file-handled; corrupt-parse 500;
    wrong-shape 500; -1000-core-regression — sentinel file survives).

GLM-5.3 round 1: A.
2026-08-18 18:56:13 -07:00

309 lines
13 KiB
JavaScript

/**
* 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,
};