/** * Security Center API routes * * Endpoints (all under /api/v1/security): * * GET /events — Query events (filters: source_type, source_host, * severity, outcome, actor, action, since, until, * target; pagination via limit/offset) * GET /events/stats — Aggregations (top actors, top targets, * counts by source/severity/host) * GET /events/:id — Single event by id * GET /events/stream — Server-Sent Events live tail (auth required) * * POST /events/ingest — Single ingest (auth: Bearer host-api-key) * POST /events/batch — Batch ingest (auth: Bearer host-api-key) * * GET /hosts — List registered hosts * POST /hosts — Register new host * GET /hosts/:id — Host details * PATCH /hosts/:id — Update host (label, type, enabled, meta) * DELETE /hosts/:id — Deregister host * GET /hosts/:id/health — Host health summary * * POST /hosts/:id/rotate-key — Rotate host api_key * * Most endpoints require TOTP/JWT/API-key auth like the rest of the dashboard. * The /events/ingest and /events/batch endpoints accept per-host Bearer tokens * AND must be added to the PUBLIC_ROUTES allowlist in middleware.js so they * don't require TOTP. Per-host auth replaces TOTP for those endpoints. */ const express = require('express'); // DC-063: use the canonical `errorResponse(res, statusCode, message, extras)` // shape — alias `error: errorResponse` used here previously was message-first // which silently mis-called every callsite (15 endpoints surfaced as 500 HTML // panics instead of the intended 4xx JSON). const { ok, errorResponse } = require('../src/utils/responses'); const { getStore } = require('../src/security/event-store'); const { getRegistry } = require('../src/security/host-registry'); const platformPaths = require('../platform-paths'); module.exports = function({ log }) { const router = express.Router(); const store = getStore({ log }); const registry = getRegistry({ log }); // ===================== EVENTS ===================== // GET /events — list/query router.get('/events', (req, res) => { const result = store.query({ limit: req.query.limit, offset: req.query.offset, source_type: req.query.source_type, source_host: req.query.source_host, severity: req.query.severity, outcome: req.query.outcome, actor: req.query.actor, actor_prefix: req.query.actor_prefix, action: req.query.action, target: req.query.target, since: req.query.since, until: req.query.until, }); ok(res, result); }); // GET /events/stats — aggregations router.get('/events/stats', (req, res) => { const stats = store.stats({ since: req.query.since, }); ok(res, stats); }); // GET /events/stream — SSE live tail (must come BEFORE /events/:id!) router.get('/events/stream', (req, res) => { res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'X-Accel-Buffering': 'no', }); // Initial sync — send last 20 events so the UI isn't empty const initial = store.query({ limit: 20 }); res.write(`event: init\ndata: ${JSON.stringify(initial)}\n\n`); const onEvent = (ev) => { try { res.write(`event: security\ndata: ${JSON.stringify(ev)}\n\n`); } catch (_) { cleanup(); } }; const heartbeat = setInterval(() => { try { res.write(`: heartbeat ${Date.now()}\n\n`); } catch (_) { cleanup(); } }, 30000); function cleanup() { store.off('event', onEvent); clearInterval(heartbeat); } store.on('event', onEvent); req.on('close', cleanup); req.on('aborted', cleanup); }); // GET /events/:id — single event router.get('/events/:id', (req, res) => { const ev = store.get(req.params.id); if (!ev) return errorResponse(res, 404, 'event not found'); ok(res, ev); }); // ===================== INGEST ===================== // POST /events/ingest — single event from an authenticated host router.post('/events/ingest', (req, res) => { const host = _authHost(req, res); if (!host) return; if (!req.body || typeof req.body !== 'object') { return errorResponse(res, 400, 'event body required'); } try { const event = store.append({ ...req.body, source_host: host.id, // override — server is source of truth on host id source_type: req.body.source_type || 'agent', }); ok(res, { id: event.id, accepted: true }); } catch (e) { errorResponse(res, 400, e.message); } }); // POST /events/batch — multiple events (more efficient for agents) router.post('/events/batch', (req, res) => { const host = _authHost(req, res); if (!host) return; const events = Array.isArray(req.body?.events) ? req.body.events : null; if (!events) return errorResponse(res, 400, 'events[] required'); if (events.length > 500) return errorResponse(res, 413, 'batch too large (max 500)'); const accepted = []; const errors = []; for (const ev of events) { try { const stored = store.append({ ...ev, source_host: host.id, source_type: ev.source_type || 'agent', }); accepted.push(stored.id); } catch (e) { errors.push({ error: e.message, event: ev }); } } ok(res, { accepted: accepted.length, errors: errors.length, ids: accepted, error_details: errors }); }); // ===================== HOSTS ===================== // GET /hosts — list router.get('/hosts', (req, res) => { ok(res, { hosts: registry.list() }); }); // POST /hosts — register new router.post('/hosts', (req, res) => { const { id, label, type, meta, enabled } = req.body || {}; if (!id) return errorResponse(res, 400, 'id required'); try { const { host, api_key } = registry.register({ id, label, type, meta, enabled }); // api_key returned EXACTLY ONCE — caller must store it now ok(res, { host, api_key, notice: 'store this api_key now — it will not be shown again' }); } catch (e) { errorResponse(res, 409, e.message); } }); // GET /hosts/:id router.get('/hosts/:id', (req, res) => { const h = registry.get(req.params.id); if (!h) return errorResponse(res, 404, 'host not found'); ok(res, h); }); // PATCH /hosts/:id router.patch('/hosts/:id', (req, res) => { const updated = registry.update(req.params.id, req.body || {}); if (!updated) return errorResponse(res, 404, 'host not found'); ok(res, updated); }); // DELETE /hosts/:id router.delete('/hosts/:id', (req, res) => { try { const ok_ = registry.remove(req.params.id); if (!ok_) return errorResponse(res, 404, 'host not found'); ok(res, { removed: true }); } catch (e) { errorResponse(res, 400, e.message); } }); // GET /hosts/:id/health — last_seen, event rate, status router.get('/hosts/:id/health', (req, res) => { const host = registry.get(req.params.id); if (!host) return errorResponse(res, 404, 'host not found'); const last24h = new Date(Date.now() - 24*60*60*1000).toISOString(); const events24h = store.query({ source_host: req.params.id, since: last24h, limit: 1000 }); const sev = events24h.events.reduce((acc, e) => { acc[e.severity] = (acc[e.severity] || 0) + 1; return acc; }, {}); const lastEvent = events24h.events[0] || null; ok(res, { host, events_24h: events24h.total, severity_breakdown_24h: sev, last_event_at: lastEvent?.ts || null, last_event_id: lastEvent?.id || null, status: !host.enabled ? 'disabled' : !host.last_seen_at ? 'registered' : (Date.now() - Date.parse(host.last_seen_at) > 30*60*1000) ? 'stale' : 'online', }); }); // POST /hosts/:id/rotate-key — issue a new key, return it once // (Implementation note: rotate would need to keep _raw_key retrieval. For v1 // we'll document this as "deferred — re-register instead". The endpoint // returns 501 with a clear message so callers don't get silently no-op'd.) router.post('/hosts/:id/rotate-key', (req, res) => { errorResponse(res, 501, 'rotate-key deferred in v1 — re-register the host to get a new key'); }); // ===================== HELPERS ===================== function _authHost(req, res) { const auth = req.headers.authorization || ''; const m = auth.match(/^Bearer\s+(.+)$/); if (!m) { errorResponse(res, 401, 'Bearer token required'); return null; } const host = registry.authenticate(m[1]); if (!host) { errorResponse(res, 401, 'invalid or disabled host key'); return null; } return host; } return router; };