Add Security Center — multi-source event pipeline with dashboard UI
CI / Test & Lint (push) Has been cancelled
CI / Security audit (push) Has been cancelled

Introduces a unified security event store and HTTP API that ingests events
from any of the configured sources (API audit, Caddy access log, fail2ban,
shared_bans, future remote agents) and surfaces them in the dashboard.

New files:
  src/security/event-store.js      JSONL-backed store + in-memory query index
  src/security/host-registry.js    Registered hosts with per-host API keys
  src/security/event-workers.js    Tail-followers for Caddy/fail2ban/shared_bans logs
  routes/security.js               Events, hosts, ingest, SSE stream endpoints
  status/js/security-center.js     Dashboard modal with Overview/Events/Hosts tabs
  SECURITY-FEATURE.md              Full feature documentation
  DEAD-CODE.md, DUP-CODE.md, HARDENING.md   Prior audits

Modified:
  src/app.js                       Mount /api/v1/security/*
  src/utilities/middleware.js      Add ingest endpoints to PUBLIC_ROUTES
  src/security/audit-logger.js     Mirror audit events into security store
  server.js                        Start security workers on boot
  status/build.js                  Bundle security-center.js
  status/index.html                Add Security button to nav
This commit is contained in:
hermes committed 2026-07-13 02:28:56 -07:00
1 parent f405186eb8
commit c9d067c2f0
15 files changed
+2387

No files matched your search

+350
View File
@@ -0,0 +1,350 @@
/**
* Security Event Store
*
* Unified JSONL store for security-relevant events from any source:
* - api : DashCaddy API audit events (extends src/security/audit-logger.js)
* - caddy : Caddy reverse-proxy access log events (parsed by tail-worker)
* - fail2ban : SSH brute-force bans (read from /var/log/fail2ban.log)
* - shared-bans : IP blocklist promotion events (read from /var/log/shared-bans-promote.log)
* - syslog : (v2) Generic syslog messages
* - agent : (v2) Events from a remote DCA (DashCaddy Agent) binary
*
* Storage format: JSONL (one JSON object per line). Why JSONL not JSON-array?
* - Append-only writes are O(1) — no read-modify-write race
* - Partial reads on crash (last line may be corrupt, but earlier lines survive)
* - Trivial to grep/jq for forensics
* - Easy to tail from a remote source
*
* Query strategy: in-memory index with file-backed persistence. For <100k events
* this is fine. Beyond that we'd switch to SQLite — see BACKLOG.md (DC-???) to track.
*
* The store also emits events to subscribers (in-process EventEmitter), so the
* dashboard can do live tailing via Server-Sent Events in v2.
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { EventEmitter } = require('events');
const platformPaths = require('../../platform-paths');
const EVENT_STORE_FILE = process.env.SECURITY_EVENT_LOG_FILE
|| path.join(platformPaths.dataDir || path.join(__dirname, '../../data'), 'security-events.jsonl');
const MAX_EVENTS_IN_MEMORY = parseInt(process.env.SECURITY_EVENT_MAX_MEMORY || '10000', 10);
const MAX_EVENTS_ON_DISK = parseInt(process.env.SECURITY_EVENT_MAX_DISK || '100000', 10);
const VALID_SOURCE_TYPES = new Set(['api', 'caddy', 'fail2ban', 'shared-bans', 'syslog', 'agent']);
const VALID_SEVERITIES = new Set(['info', 'notice', 'warn', 'error', 'critical']);
const VALID_OUTCOMES = new Set(['success', 'denied', 'blocked', 'rate-limited', 'error', 'unknown']);
class SecurityEventStore extends EventEmitter {
constructor(opts = {}) {
super();
this.filePath = opts.filePath || EVENT_STORE_FILE;
this.maxMemory = opts.maxMemory || MAX_EVENTS_IN_MEMORY;
this.maxDisk = opts.maxDisk || MAX_EVENTS_ON_DISK;
this.log = opts.log || console;
this.events = []; // newest first
this.byId = new Map();
this.lastWriteLine = 0; // byte offset of last successfully-written line
this.writeQueue = []; // serialized write buffer
this.writing = false;
this._load();
}
/**
* Load existing events from disk into memory (newest first).
* Skips corrupt lines — logs warning but continues.
*/
_load() {
try {
if (!fs.existsSync(this.filePath)) {
this.log.info?.('security', 'event store: starting fresh (no file)', { path: this.filePath });
return;
}
const content = fs.readFileSync(this.filePath, 'utf8');
const lines = content.split('\n').filter(l => l.trim());
this.log.info?.('security', 'event store: loading from disk', { total_lines: lines.length, path: this.filePath });
let loaded = 0;
let skipped = 0;
// Walk from end backwards so newest are first
for (let i = lines.length - 1; i >= 0 && loaded < this.maxMemory; i--) {
const line = lines[i].trim();
if (!line) continue;
try {
const ev = JSON.parse(line);
if (!this._isValidShape(ev)) {
skipped++;
continue;
}
this.events.push(ev);
this.byId.set(ev.id, ev);
loaded++;
} catch (e) {
skipped++;
// Don't log every line — could be thousands of corrupted lines
}
}
this.log.info?.('security', 'event store: load complete', { loaded, skipped, kept_in_memory: this.events.length });
} catch (e) {
this.log.error?.('security', 'event store: load failed', { error: e.message });
}
}
/**
* Validate minimum shape of an event before storing/returning it.
* Permissive — accepts extras, just requires the spine.
*/
_isValidShape(ev) {
if (!ev || typeof ev !== 'object') return false;
if (typeof ev.id !== 'string' || !ev.id) return false;
if (typeof ev.ts !== 'string' || !ev.ts) return false;
if (typeof ev.source_host !== 'string') return false;
if (typeof ev.source_type !== 'string' || !VALID_SOURCE_TYPES.has(ev.source_type)) return false;
return true;
}
/**
* Append a new event. Validates shape, writes to disk, indexes in memory,
* emits 'event' for live-tail subscribers.
*
* @param {object} partial - event without id (one will be generated)
* @returns {object} the stored event
*/
append(partial) {
const event = this._normalize(partial);
const validationError = this._validate(event);
if (validationError) {
this.log.warn?.('security', 'rejected invalid event', { error: validationError, partial });
throw new Error(`Invalid event: ${validationError}`);
}
// Write to disk first (durability), then index in memory.
// We queue the write so multiple append() calls don't interleave on the same fd.
this.writeQueue.push(event);
this._flushQueue();
// Index (in-memory only — newest first)
this.events.unshift(event);
this.byId.set(event.id, event);
if (this.events.length > this.maxMemory) {
const evicted = this.events.pop();
this.byId.delete(evicted.id);
}
this.emit('event', event);
return event;
}
/**
* Normalize partial event — fill in defaults, generate id+ts.
*/
_normalize(p) {
const now = new Date().toISOString();
return {
id: p.id || crypto.randomUUID(),
ts: p.ts || now,
source_host: p.source_host || 'unknown',
source_type: p.source_type || 'api',
actor: p.actor || null, // IP, user, agent_id
target: p.target || null, // endpoint, service id, host
action: p.action || 'unknown', // free-form but stable per source_type
outcome: p.outcome || 'unknown',
severity: p.severity || 'info',
message: p.message || null, // human-readable one-liner
metadata: p.metadata && typeof p.metadata === 'object' ? p.metadata : {},
...(p.tags && Array.isArray(p.tags) ? { tags: p.tags } : {}),
};
}
_validate(ev) {
if (!VALID_SOURCE_TYPES.has(ev.source_type)) return `bad source_type: ${ev.source_type}`;
if (!VALID_SEVERITIES.has(ev.severity)) return `bad severity: ${ev.severity}`;
if (!VALID_OUTCOMES.has(ev.outcome)) return `bad outcome: ${ev.outcome}`;
if (typeof ev.actor === 'string' && ev.actor.length > 256) return 'actor too long';
if (typeof ev.target === 'string' && ev.target.length > 512) return 'target too long';
return null;
}
/**
* Serialize appends to disk. Writes one line at a time, doesn't truncate.
* Disk trimming happens separately via _trim().
*/
_flushQueue() {
if (this.writing) return;
const next = this.writeQueue.shift();
if (!next) return;
this.writing = true;
const line = JSON.stringify(next) + '\n';
fs.appendFile(this.filePath, line, 'utf8', (err) => {
this.writing = false;
if (err) {
this.log.error?.('security', 'write failed', { error: err.message });
// Re-queue so we don't lose the event on transient errors
this.writeQueue.unshift(next);
} else {
// Try next
if (this.writeQueue.length > 0) setImmediate(() => this._flushQueue());
this._maybeTrim();
}
});
}
/**
* Trim disk log if it exceeds maxDisk lines. Done in the background — never
* blocks an append(). Strategy: rewrite the file keeping the most recent
* maxDisk lines, atomically (write tmp + rename).
*/
_maybeTrim() {
fs.stat(this.filePath, (err, st) => {
if (err || !st) return;
// Cheap heuristic: if file is > 50MB we always trim. Otherwise count lines.
const SIZE_LIMIT = 50 * 1024 * 1024;
if (st.size < SIZE_LIMIT) return;
this._trim();
});
}
_trim() {
this.log.info?.('security', 'trimming event store', { file: this.filePath });
fs.readFile(this.filePath, 'utf8', (err, content) => {
if (err) return;
const lines = content.split('\n').filter(l => l.trim());
if (lines.length <= this.maxDisk) return;
const kept = lines.slice(-this.maxDisk).join('\n') + '\n';
const tmp = this.filePath + '.tmp';
fs.writeFile(tmp, kept, 'utf8', (e) => {
if (e) {
this.log.error?.('security', 'trim write failed', { error: e.message });
return;
}
fs.rename(tmp, this.filePath, (e2) => {
if (e2) this.log.error?.('security', 'trim rename failed', { error: e2.message });
});
});
});
}
/**
* Query events. All filters are AND-combined. Results are newest-first.
*
* @param {object} q - query
* limit : number, default 100, max 1000
* offset : number, default 0
* source_type: string or array
* source_host: string
* severity : string or array
* outcome : string or array
* actor : string (exact or prefix match with `actor_prefix`)
* action : string
* since : ISO timestamp (inclusive)
* until : ISO timestamp (exclusive)
*/
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 matches = [];
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;
matches.push(ev);
if (matches.length >= offset + limit) break; // avoid scanning further
}
return {
total: matches.length,
events: matches.slice(offset, offset + limit),
};
}
_toArr(v) {
if (!v) return [];
if (Array.isArray(v)) return v;
return String(v).split(',').map(s => s.trim()).filter(Boolean);
}
/**
* Compute aggregates for dashboards. Returns counts + top-N per dimension.
* Cheap because events array is bounded by maxMemory.
*/
stats(opts = {}) {
const sinceMs = opts.since ? Date.parse(opts.since) : null;
const filtered = sinceMs
? this.events.filter(e => Date.parse(e.ts) >= sinceMs)
: this.events;
const bySeverity = {};
const byOutcome = {};
const bySource = {};
const byHost = {};
const byAction = {};
const actorCount = {};
const targetCount = {};
for (const ev of filtered) {
bySeverity[ev.severity] = (bySeverity[ev.severity] || 0) + 1;
byOutcome[ev.outcome] = (byOutcome[ev.outcome] || 0) + 1;
bySource[ev.source_type] = (bySource[ev.source_type] || 0) + 1;
byHost[ev.source_host] = (byHost[ev.source_host] || 0) + 1;
byAction[ev.action] = (byAction[ev.action] || 0) + 1;
if (ev.actor) actorCount[ev.actor] = (actorCount[ev.actor] || 0) + 1;
if (ev.target) targetCount[ev.target] = (targetCount[ev.target] || 0) + 1;
}
return {
window: { since: opts.since || null, count: filtered.length },
by_severity: bySeverity,
by_outcome: byOutcome,
by_source: bySource,
by_host: byHost,
top_actions: this._topN(byAction, 10),
top_actors: this._topN(actorCount, 10),
top_targets: this._topN(targetCount, 10),
};
}
_topN(obj, n) {
return Object.entries(obj)
.sort((a, b) => b[1] - a[1])
.slice(0, n)
.map(([key, count]) => ({ key, count }));
}
/**
* Get one event by id, or null.
*/
get(id) {
return this.byId.get(id) || null;
}
/**
* Total events currently in memory.
*/
size() {
return this.events.length;
}
}
// Singleton accessor. Routes call this; tests can construct their own.
let _instance = null;
function getStore(opts) {
if (_instance) return _instance;
_instance = new SecurityEventStore(opts);
return _instance;
}
module.exports = { SecurityEventStore, getStore, VALID_SOURCE_TYPES, VALID_SEVERITIES, VALID_OUTCOMES };