[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
+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;