Files
dashcaddy/dashcaddy-api/routes/security.js
Hermes a2e2a12eb8
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s
fix(routes): convert alias-import + canonical-shape callsites to canonical errorResponse (DC-063) [glm-grade=A]
Background (DC-062, 2026-08-18, c01a011): errorResponse has TWO bindings in
src/utils/responses.js:
  - canonical: errorResponse(res, statusCode, message, extras) + DC-062 validator
  - alias: error(res, message, statusCode = 500) -- NO validator

DC-062 already fixed routes/caddy-upstreams.js and added a defensive
TypeError-throwing validator on the canonical path.

DC-063 (this commit): the same bug class lurks in 2 more route files that
import the alias 'error: errorResponse' but call it with the canonical
shape '(res, NUM, STRING)'. The alias function does NOT run the validator,
so at runtime the alias path silently fires
  res.status('event not found') -> TypeError -> 500 HTML panic
silently masking the intended 4xx JSON response for the client.

Affected files:
  - routes/security.js: 15 callsites (lines 110-251)
    Pre-fix every GET /events/:id (404), POST /events (400/409), PUT
    /events/batch (400/413), POST/PATCH/DELETE /hosts (400/404/409) all
    returned 500 HTML with a RangeError stack instead of the intended JSON.
    Fix: switched import to canonical so the existing canonical-shape
    callsites bind to the validator-armed function. 0 callsite changes.

  - routes/services.js: 7 callsites total
    3 already in canonical shape (POST /services credentials,
    lines 222/246/261) -- switched import fixes them.
    4 alias-shape callsites (lines 406/432/455/486) -- rewritten to
    canonical shape per responses.js:76.

Test sweep:
  - NEW __tests__/routes/errorresponse-arg-order.regression.test.js (284
    lines, 75 tests): pins
    (1) the validator (defense-in-depth) — 14 tests
    (2) the routes/ + src/utilities/ convention — 49 one-per-file
        static-tree walk that classifies each file's import style
        (alias vs canonical) and asserts each callsite matches the
        file's own convention.
    (3) live-HTTP smoke — security.js /events/:id + /hosts/:id return
        404 JSON, never 500 HTML.
    Also serves as the spec defining the alias-vs-canonical convention
    for any future contributor.

  - UPDATED __tests__/routes/services.routes.test.js: fixture mock for
    src/utils/responses now exposes both errorResponse (canonical) and
    error (alias) so the route's canonical-shape import resolves.
    29/29 tests still pass.

Verification: full suite 93/93 / 2114/2114 green; security.js + services.js
both fully canonical; 13 canonical-import files (DC-062 + DC-063) + 10
alias-import files (using message-first shape correctly) — proven
consistent by the static sweep.

GLM-5.3 stand-in judge round 1: GRADE=A (verified cold diff + convention
check + 4-tool-call budget); 2 LOW polish suggestions logged for a
follow-up DC: (a) require.cache injection in the live HTTP smoke
should migrate to jest.mock(virtual:true) so a module rename fails
loudly; (b) static sweep should assert a min-callsite floor per
convention class.
2026-08-18 11:06:34 -07:00

258 lines
9.0 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/: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;
};