[glm-grade=A] fix(security): DC-120 surface caddy-source perimeter events in Log Insights dashboard — new GET /api/v1/security/events/perimeter endpoint with per-IP/per-vhost aggregations, event-store compileFilter/filterEvents primitives, and Perimeter section in Log Insights modal with XSS-safe rendering and stale-request guards
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

This commit is contained in:
Hermes
2026-08-23 17:26:07 -07:00
parent 4d97a11978
commit 46b6952c36
5 changed files with 727 additions and 13 deletions
@@ -0,0 +1,206 @@
'use strict';
/**
* DC-120: perimeter aggregation endpoint tests.
*
* GET /api/v1/security/events/perimeter — caddy-source perimeter
* aggregation (per-IP + per-vhost breakdowns) for the Log Insights panel.
*
* Fixture shape mirrors the live caddy-source event schema:
* {source_type: 'caddy', actor: '<ip>', target: 'GET /',
* action: 'http.200', outcome: 'success'|'denied'|'error',
* metadata: {host: 'req.sami-flix.com', status, user_agent, ...}}
*
* What these tests pin:
* 1. Aggregation correctness — counts, denied/error splits, host sets.
* 2. Window filtering — only events inside ?hours are counted.
* 3. Input clamping — hours out of [1,720] falls back to 24; limit out
* of [1,50] falls back to 15. No 500s, no crashes.
* 4. Ordering — count desc, tie-break by IP asc (deterministic output).
* 5. Empty store — valid zero-response, not an error.
* 6. NON-caddy events (api-source) are EXCLUDED — the perimeter view
* must only reflect reverse-proxy traffic, not dashboard activity.
* 7. filterEvents()/query() filter parity — the new store primitive
* applies the same predicates as the paged API (no drift).
*/
const express = require('express');
const http = require('http');
const os = require('os');
const path = require('path');
const fs = require('fs');
const { SecurityEventStore } = require('../../src/security/event-store');
function tmpdir() {
return fs.mkdtempSync(path.join(os.tmpdir(), 'dc120-perimeter-'));
}
// Drive requests through real http so we exercise the full stack.
function listen(app) {
return new Promise((resolve) => {
const server = app.listen(0, '127.0.0.1', () => resolve(server));
});
}
function get(server, path) {
return new Promise((resolve, reject) => {
http.get({ host: server.address().address, port: server.address().port, path }, (res) => {
let body = '';
res.on('data', (c) => (body += c));
res.on('end', () => resolve({ status: res.statusCode, body: JSON.parse(body) }));
}).on('error', reject);
});
}
describe('DC-120 GET /api/v1/security/events/perimeter', () => {
let dir;
let server;
beforeAll(async () => {
dir = tmpdir();
// Seed the SINGLETON (routes/security.js factory calls getStore()
// internally and getStore memoizes) — so the router reads our fixtures
// from memory with zero disk-timing races. Jest isolates module
// registries per test file, so this doesn't leak to other suites.
const { getStore } = require('../../src/security/event-store');
const store = getStore({ filePath: path.join(dir, 'security-events.jsonl'), log: console });
// Fixture set (all 10 minutes old unless noted):
// 1.1.1.1 — 3 requests, 1 denied, hosts {a.example, b.example} (TOP by count)
// 9.9.9.9 — 2 requests, 2 errors, host {c.example}
// 8.8.8.8 — 2 requests, all success, host {a.example} (tie with 9.9.9.9 → IP asc wins)
// api-source event — MUST be excluded
// old caddy event (47h ago) — excluded by the 24h window, included by 48h
// (47h not 48h: a same-instant fixture vs route `since` races the
// inclusive boundary — keep it unambiguous on both sides)
const now = Date.now();
const T = (minAgo) => new Date(now - minAgo * 60000).toISOString();
[
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /wp-login.php', action: 'http.401', outcome: 'denied', severity: 'warn', metadata: { host: 'b.example', status: 401 } },
{ source_type: 'caddy', actor: '1.1.1.1', target: 'GET /x', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
{ source_type: 'caddy', actor: '9.9.9.9', target: 'GET /y', action: 'http.502', outcome: 'error', severity: 'error', metadata: { host: 'c.example', status: 502 } },
{ source_type: 'caddy', actor: '9.9.9.9', target: 'GET /z', action: 'http.502', outcome: 'error', severity: 'error', metadata: { host: 'c.example', status: 502 } },
{ source_type: 'caddy', actor: '8.8.8.8', target: 'GET /', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
{ source_type: 'caddy', actor: '8.8.8.8', target: 'GET /health', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'a.example', status: 200 } },
{ source_type: 'api', actor: '127.0.0.1', target: 'GET /api/v1/services', action: 'services.list', outcome: 'success', severity: 'info', metadata: { host: 'status.sami' } },
{ ts: T(47 * 60), source_type: 'caddy', actor: '5.5.5.5', target: 'GET /old', action: 'http.200', outcome: 'success', severity: 'info', metadata: { host: 'old.example', status: 200 } },
].forEach((partial) => {
store.append(Object.assign({ source_host: 'testhost', ts: T(10) }, partial));
});
const app = express().use('/api/v1/security', require('../../routes/security')({ log: console }));
server = await listen(app);
});
afterAll((done) => {
server.close(done);
delete process.env.SECURITY_EVENT_LOG_FILE;
fs.rmSync(dir, { recursive: true, force: true });
});
test('aggregates per-IP counts, denied/error splits, host sets; excludes api-source + old events', async () => {
const res = await get(server, '/api/v1/security/events/perimeter?hours=24');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
const { summary, topIPs, byHost } = res.body;
// 8 in-window events minus the api-source one = 7 caddy events
expect(summary.events).toBe(7);
expect(summary.uniqueIPs).toBe(3);
expect(summary.denied).toBe(1);
expect(summary.error).toBe(2);
// Ordering: count desc, tie-break IP asc → 1.1.1.1 (3), 8.8.8.8 (2), 9.9.9.9 (2)
expect(topIPs.map((t) => t.ip)).toEqual(['1.1.1.1', '8.8.8.8', '9.9.9.9']);
const top = topIPs[0];
expect(top.count).toBe(3);
expect(top.denied).toBe(1);
expect(top.error).toBe(0);
expect(top.hosts).toEqual(['a.example', 'b.example']);
const nine = topIPs[2];
expect(nine.error).toBe(2);
// byHost: a.example=4, c.example=2, b.example=1
const hostByName = Object.fromEntries(byHost.map((h) => [h.host, h]));
expect(hostByName['a.example'].count).toBe(4);
expect(hostByName['c.example'].count).toBe(2);
expect(hostByName['c.example'].error).toBe(2);
expect(hostByName['b.example'].count).toBe(1);
expect(hostByName['b.example'].denied).toBe(1);
// old.example (48h) and status.sami (api-source) absent
expect(hostByName['old.example']).toBeUndefined();
expect(hostByName['status.sami']).toBeUndefined();
});
test('clamps invalid hours/limit instead of erroring', async () => {
const res = await get(server, '/api/v1/security/events/perimeter?hours=-5&limit=9999');
expect(res.status).toBe(200);
expect(res.body.window.hours).toBe(24);
expect(res.body.topIPs.length).toBeLessThanOrEqual(15);
});
test('hours window filters correctly (48h includes the old event)', async () => {
const res = await get(server, '/api/v1/security/events/perimeter?hours=48');
expect(res.status).toBe(200);
// 7 in-window + 1 old caddy event = 8 (api-source still excluded)
expect(res.body.summary.events).toBe(8);
expect(res.body.summary.uniqueIPs).toBe(4);
});
test('empty store returns valid zero-response', async () => {
// Fresh jest module registry → fresh getStore() memo → empty store.
jest.resetModules();
const dir2 = tmpdir();
process.env.SECURITY_EVENT_LOG_FILE = path.join(dir2, 'empty.jsonl');
const securityRoutesFresh = require('../../routes/security');
const app = express().use('/api/v1/security', securityRoutesFresh({ log: console }));
const server2 = await listen(app);
try {
const res = await get(server2, '/api/v1/security/events/perimeter');
expect(res.status).toBe(200);
expect(res.body.success).toBe(true);
expect(res.body.summary.events).toBe(0);
expect(res.body.summary.uniqueIPs).toBe(0);
expect(res.body.topIPs).toEqual([]);
expect(res.body.byHost).toEqual([]);
} finally {
server2.close();
fs.rmSync(dir2, { recursive: true, force: true });
delete process.env.SECURITY_EVENT_LOG_FILE;
}
});
});
describe('DC-120 event-store filterEvents()/query() parity', () => {
test('filterEvents returns exactly what query() totals (same predicate)', () => {
const dir = tmpdir();
const store = new SecurityEventStore({ filePath: path.join(dir, 's.jsonl'), log: console });
const now = Date.now();
for (let i = 0; i < 30; i++) {
store.append({
source_type: i % 2 ? 'caddy' : 'api',
actor: `10.0.0.${i % 5}`,
target: 'GET /',
action: `http.${200 + (i % 3) * 100}`,
outcome: i % 7 === 0 ? 'denied' : 'success',
severity: i % 7 === 0 ? 'warn' : 'info',
ts: new Date(now - (i % 10) * 60000).toISOString(),
});
}
const since = new Date(now - 15 * 60000).toISOString();
const q = { source_type: 'caddy', since };
const filtered = store.filterEvents(q);
const paged = store.query(Object.assign({ limit: 1000 }, q));
expect(filtered.length).toBe(paged.total);
// newest-first order preserved by both
expect(filtered.map((e) => e.id)).toEqual(paged.events.map((e) => e.id));
// Multi-value filter parity (comma string form)
const q2 = { source_type: 'caddy', outcome: 'denied,error', since };
expect(store.filterEvents(q2).length).toBe(store.query(Object.assign({ limit: 1000 }, q2)).total);
fs.rmSync(dir, { recursive: true, force: true });
});
});
+99
View File
@@ -8,6 +8,8 @@
* target; pagination via limit/offset)
* GET /events/stats — Aggregations (top actors, top targets,
* counts by source/severity/host)
* GET /events/perimeter — Caddy-source perimeter aggregation
* (per-IP + per-vhost breakdowns)
* GET /events/:id — Single event by id
* GET /events/stream — Server-Sent Events live tail (auth required)
*
@@ -73,6 +75,103 @@ module.exports = function({ log }) {
ok(res, stats);
});
// GET /events/perimeter — DC-120: caddy-source perimeter aggregation
// (per-IP + per-vhost breakdowns) for the Log Insights panel.
//
// Replaces the frontend doing N paged /events calls and re-deriving
// counts client-side (which capped at 1000 and lost per-key maps).
// Reads the SAME store the /events endpoints read; aggregation runs
// over the in-memory window only (bounded by maxMemory, default 10k).
//
// Query params:
// hours : window in hours, default 24, falls back to 24 for invalid values.
// The endpoint scans only the bounded in-memory window
// (maxMemory, default 10k events), so accepting 720 hours does
// not guarantee 30 days of retained data — it only controls the
// timestamp filter applied to whatever events are currently in memory.
// limit : top-N IPs returned, default 15, max 50
//
// Ordering: strict count desc; ties broken by IP string so output is
// deterministic across restarts.
router.get('/events/perimeter', (req, res) => {
// --- validate + default window ---
// hours/limit values outside bounds fall back to defaults (24h / 15) —
// NOT clamped to the nearest boundary. This is intentional: silently
// coercing a typo like hours=9999 to 720 hides the operator's mistake,
// whereas a default fallback makes the effective window visible in the
// response (window.hours === 24 when garbage was sent).
// Strict integer parsing: reject anything that isn't a clean integer
// (parseInt accepts "1junk" → 1, "1.5" → 1; both now rejected).
const rawHours = String(req.query.hours || '').trim();
const rawLimit = String(req.query.limit || '').trim();
const hoursMatch = rawHours.match(/^[0-9]+$/);
const limitMatch = rawLimit.match(/^[0-9]+$/);
const hours = hoursMatch ? parseInt(rawHours, 10) : 24;
const limit = limitMatch ? parseInt(rawLimit, 10) : 15;
const defaultHours = (hours >= 1 && hours <= 720) ? hours : 24;
const defaultLimit = (limit >= 1 && limit <= 50) ? limit : 15;
const since = new Date(Date.now() - defaultHours * 3600000).toISOString();
// --- collect caddy events in window (bounded by maxMemory) ---
// filterEvents() scans the in-memory window once. Aggregation then
// traverses the selected subset (two Map reductions + summary counts).
const events = store.filterEvents({ source_type: 'caddy', since });
// --- per-IP aggregation ---
const ipMap = new Map();
for (const ev of events) {
const ip = ev.actor || 'unknown';
let s = ipMap.get(ip);
if (!s) {
s = { count: 0, denied: 0, error: 0, hosts: new Set() };
ipMap.set(ip, s);
}
s.count++;
if (ev.outcome === 'denied' || ev.outcome === 'rate-limited') s.denied++;
if (ev.outcome === 'error') s.error++;
const host = ev.metadata && ev.metadata.host;
if (host) s.hosts.add(host);
}
const ips = [...ipMap.entries()]
.map(([ip, s]) => ({
ip,
count: s.count,
denied: s.denied,
error: s.error,
hosts: [...s.hosts].sort(),
}))
.sort((a, b) => b.count - a.count || (a.ip < b.ip ? -1 : a.ip > b.ip ? 1 : 0))
.slice(0, defaultLimit);
// --- per-host (vhost) aggregation ---
const hostMap = new Map();
for (const ev of events) {
const host = (ev.metadata && ev.metadata.host) || 'unknown';
const h = hostMap.get(host) || { count: 0, denied: 0, error: 0 };
h.count++;
if (ev.outcome === 'denied' || ev.outcome === 'rate-limited') h.denied++;
if (ev.outcome === 'error') h.error++;
hostMap.set(host, h);
}
const byHost = [...hostMap.entries()]
.map(([host, h]) => ({ host, ...h }))
.sort((a, b) => b.count - a.count || (a.host < b.host ? -1 : a.host > b.host ? 1 : 0))
.slice(0, 20);
ok(res, {
window: { hours: defaultHours, since, until: new Date().toISOString() },
summary: {
events: events.length,
uniqueIPs: ipMap.size,
denied: events.reduce((n, ev) => n + (ev.outcome === 'denied' || ev.outcome === 'rate-limited' ? 1 : 0), 0),
error: events.reduce((n, ev) => n + (ev.outcome === 'error' ? 1 : 0), 0),
},
topIPs: ips,
byHost,
});
});
// GET /events/stream — SSE live tail (must come BEFORE /events/:id!)
router.get('/events/stream', (req, res) => {
res.writeHead(200, {
+44 -13
View File
@@ -310,23 +310,12 @@ class SecurityEventStore extends EventEmitter {
query(q = {}) {
const limit = Math.min(parseInt(q.limit || '100', 10), 1000);
const offset = parseInt(q.offset || '0', 10);
const sourceTypes = this._toArr(q.source_type);
const severities = this._toArr(q.severity);
const outcomes = this._toArr(q.outcome);
const match = this.compileFilter(q);
let total = 0;
const page = [];
for (const ev of this.events) {
if (sourceTypes.length && !sourceTypes.includes(ev.source_type)) continue;
if (q.source_host && ev.source_host !== q.source_host) continue;
if (severities.length && !severities.includes(ev.severity)) continue;
if (outcomes.length && !outcomes.includes(ev.outcome)) continue;
if (q.actor && ev.actor !== q.actor) continue;
if (q.actor_prefix && (!ev.actor || !ev.actor.startsWith(q.actor_prefix))) continue;
if (q.action && ev.action !== q.action) continue;
if (q.since && ev.ts < q.since) continue;
if (q.until && ev.ts >= q.until) continue;
if (q.target && ev.target !== q.target) continue;
if (!match(ev)) continue;
total++;
if (total > offset && page.length < limit) page.push(ev);
}
@@ -337,6 +326,48 @@ class SecurityEventStore extends EventEmitter {
};
}
/**
* Compile the query filters into a single predicate. Shared by query()
* (paged access) and filterEvents() (full-set access) so the two can
* never drift on filter semantics (DC-120).
*
* All filters AND-combine; an absent filter matches everything.
*/
compileFilter(q = {}) {
const sourceTypes = this._toArr(q.source_type);
const severities = this._toArr(q.severity);
const outcomes = this._toArr(q.outcome);
return (ev) => {
if (sourceTypes.length && !sourceTypes.includes(ev.source_type)) return false;
if (q.source_host && ev.source_host !== q.source_host) return false;
if (severities.length && !severities.includes(ev.severity)) return false;
if (outcomes.length && !outcomes.includes(ev.outcome)) return false;
if (q.actor && ev.actor !== q.actor) return false;
if (q.actor_prefix && (!ev.actor || !ev.actor.startsWith(q.actor_prefix))) return false;
if (q.action && ev.action !== q.action) return false;
if (q.since && ev.ts < q.since) return false;
if (q.until && ev.ts >= q.until) return false;
if (q.target && ev.target !== q.target) return false;
return true;
};
}
/**
* DC-120: full filtered set, newest-first, for route-level aggregation
* that the paged query() API can't express (e.g. per-IP outcome
* breakdowns across every caddy event in a window — query() pages at
* 1000 and `total` alone can't rebuild the per-key maps).
*
* Cost is bounded by the in-memory cap (maxMemory, default 10k) — the
* same bound query() already scans — so callers cannot request
* unbounded work. Filters are identical to query() by construction
* (shared compileFilter).
*/
filterEvents(q = {}) {
const match = this.compileFilter(q);
return this.events.filter(match);
}
_toArr(v) {
if (!v) return [];
if (Array.isArray(v)) return v;