- New route /api/v1/log-insights: analyzes audit logs + security events
- Shows top IPs with request counts, failures, and top actions
- Plain English insights (heavy users, auth failures, security alerts)
- Summary stats: total requests, unique IPs, failed actions
- Storage info showing log file sizes and entry counts
- New route POST /api/v1/log-insights/dispose: preview-then-confirm cleanup
- First call shows what would be deleted (preview mode)
- Second call with confirm:true actually deletes
- Configurable retention period (default 30 days)
- Frontend panel with modal UI showing insights as cards
- Period selector (1h, 6h, 24h, 7d)
- Top visitors table with IP, requests, failures, actions, last seen
- Storage info footer
- Clean Old Logs button with preview confirmation dialog
- Wired into app.js and dashboard navbar (🔍 Insights button)
- Addresses QA issue: users need to see who is accessing before cleanup
154 lines
6.4 KiB
JavaScript
154 lines
6.4 KiB
JavaScript
const express = require('express');
|
|
const fs = require('fs').promises;
|
|
|
|
module.exports = function({ asyncHandler, ok, auditLogger, securityEventStore }) {
|
|
const router = express.Router();
|
|
|
|
// 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 ---
|
|
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
|
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
|
let storage = {};
|
|
try {
|
|
const a = await fs.stat(auditPath);
|
|
storage.auditLog = { sizeMB: +(a.size / 1048576).toFixed(2), entries: auditEntries.length };
|
|
} catch {}
|
|
try {
|
|
const s = await fs.stat(secPath);
|
|
storage.securityEvents = { sizeMB: +(s.size / 1048576).toFixed(2), entries: securityEvents.length };
|
|
} 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
|
|
router.post('/log-insights/dispose', asyncHandler(async (req, res) => {
|
|
const keepDays = parseInt(req.body.keepDays) || 30;
|
|
const confirm = req.body.confirm === true;
|
|
const cutoff = new Date(Date.now() - keepDays * 86400000).toISOString();
|
|
|
|
const auditPath = process.env.AUDIT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/audit-log.json';
|
|
const secPath = process.env.SECURITY_EVENT_LOG_FILE || '/opt/dashcaddy/dashcaddy-api/data/security-events.jsonl';
|
|
|
|
const auditRaw = await fs.readFile(auditPath, 'utf8').catch(function () { return '[]'; });
|
|
const auditData = JSON.parse(auditRaw);
|
|
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} to proceed.',
|
|
wouldDelete: { auditEntries: oldAudit.length, securityEvents: oldSec.length },
|
|
cutoffDate: cutoff
|
|
});
|
|
return;
|
|
}
|
|
|
|
// Execute cleanup
|
|
const keptAudit = auditData.filter(function (e) { return e.timestamp >= cutoff; });
|
|
await fs.writeFile(auditPath, JSON.stringify(keptAudit, null, 2));
|
|
|
|
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;
|
|
};
|