357 lines
13 KiB
JavaScript
357 lines
13 KiB
JavaScript
/**
|
|
* 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/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)
|
|
*
|
|
* 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/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, {
|
|
'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;
|
|
}; |