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.
309 lines
13 KiB
JavaScript
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,
|
|
}; |