fix(audit): restore named audit actions for SSO gate traffic — 3 live defects (DC-111) [glm-grade=A]
CI / Test & Lint (push) Canceled after 0s
CI / Security audit (push) Canceled after 0s

1. audit-logger middleware computed action/resource from req.path INSIDE
   the res.json override — after the /api/v1 router rebased req.url to the
   router-relative path. resolveAction fell through ACTION_MAP for every
   HTTP request, producing 45,899 'unknown.get' entries since 2026-07-14.
   Fix: snapshot req.path/req.method at app-level (post-shim, pre-router).
2. DC-044 shim double-prefixed ALREADY-canonical /api/v1/auth/gate|x
   into /api/v1/v1/... → 401 for canonical-URI clients. Fix: rewrite only
   legacy /api/auth/* shapes; canonical pass through untouched.
3. event-store VALID_OUTCOMES lacked 'failure' → every failed API action's
   security event was rejected+dropped from security-events.jsonl. Fix: add
   'failure' to the vocabulary set.

8 regression pins over a faithful shim→audit→router mount mirror.
Suite: 124 suites / 2809 tests green.
Judge: GLM-5.3 cold read round-1 A/ship, URN urn:ump:ywv6rpmx55tlxbgc7ciyxqfcmx2v6q756cjbmif2d66k4bwwn6ya
This commit is contained in:
Hermes
2026-08-23 09:05:27 -07:00
parent 0721b1cb04
commit 56b807543c
4 changed files with 257 additions and 7 deletions
+11 -4
View File
@@ -235,9 +235,9 @@ async function createApp() {
//
// Path mapping (any -> canonical):
// /api/auth/gate/<id> -> /api/v1/auth/gate/<id> (mounted at /auth/gate/:serviceId)
// /api/v1/auth/gate/<id> -> /api/v1/auth/gate/<id> (drift, gate pre-1.5.0 sometimes used this)
// /api/v1/auth/gate/<id> -> (unchanged — already canonical, DC-111)
// /api/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (mounted at /auth/app-token/:serviceId)
// /api/v1/auth/app-token/<id> -> /api/v1/auth/app-token/<id> (drift)
// /api/v1/auth/app-token/<id> -> (unchanged — already canonical, DC-111)
// /api/auth/totp/check-session -> /api/v1/totp/check-session (mounted at /totp/check-session — no /auth prefix)
// /api/v1/auth/totp/check-session->/api/v1/totp/check-session (drift)
// /api/auth/sso-exchange -> /api/v1/auth/sso-exchange (mounted at /auth/sso-exchange, same shape as gate/app-token)
@@ -254,8 +254,14 @@ async function createApp() {
// — needs the same rewrite as gate/app-token, not the check-session one
// (this route's canonical mount already includes /auth/).
app.use((req, res, next) => {
if (req.url.startsWith('/api/auth/gate/') || req.url.startsWith('/api/v1/auth/gate/')
|| req.url.startsWith('/api/auth/app-token/') || req.url.startsWith('/api/v1/auth/app-token/')
// DC-111: the '/api/v1/...' drift variants are ALREADY canonical — the
// gate and app-token routes mount at /auth/* INSIDE the /api/v1 router.
// DC-044 added them to the '/api' + slice(4) rewrite, which turned
// /api/v1/auth/gate/plex into /api/v1/v1/auth/gate/plex → 401/404 for
// every canonical-URI client (the exact drift case DC-044 meant to
// tolerate). Only the legacy '/api/auth/...' shapes need rewriting.
if (req.url.startsWith('/api/auth/gate/')
|| req.url.startsWith('/api/auth/app-token/')
|| req.url.startsWith('/api/auth/sso-exchange')) {
req.url = '/api/v1' + req.url.slice(4); // '/api'.length === 4
} else if (req.url.startsWith('/api/auth/totp/check-session')) {
@@ -264,6 +270,7 @@ async function createApp() {
req.url = '/api/v1' + req.url.slice(9); // '/api/auth'.length === 9
} else if (req.url.startsWith('/api/v1/auth/totp/check-session')) {
// Drift: /api/v1/auth/totp/check-session -> /api/v1/totp/check-session
// (canonical route is /totp/check-session — genuinely different mount)
// Drop the '/api/v1/auth' prefix (12 chars), keep the leading '/'.
req.url = '/api/v1' + req.url.slice(12); // '/api/v1/auth'.length === 12
}
+19 -2
View File
@@ -228,12 +228,29 @@ class AuditLogger {
return (req, res, next) => {
if (this.shouldSkip(req.method, req.path)) return next();
// DC-111: snapshot the request path NOW, at app-level (pre-router).
// The res.json override below fires AFTER the /api/v1 router has
// dispatched the request, and Express rebases req.url to the
// router-relative path at that point (/api/v1/auth/gate/plex becomes
// /auth/gate/plex). resolveAction/extractResource on the rebased path
// fall through ACTION_MAP and derive 'unknown.*' — which is exactly
// how 45,899 'unknown.get' entries landed in the audit trail between
// 2026-07-14 and 2026-08-23. Snapshot req.path in the middleware body
// (string copy — req.path is a live getter over req.url): this runs
// after the DC-044 legacy-prefix shim has canonicalized /api/auth/*
// to /api/v1/* but before the router rebase, so ACTION_MAP sees the
// canonical path for both legacy and canonical clients. Do NOT use
// req.originalUrl — it freezes the PRE-shim legacy path, which
// ACTION_MAP does not cover.
const requestPath = req.path;
const requestMethod = req.method;
const originalJson = res.json.bind(res);
res.json = (data) => {
// Log asynchronously — don't block the response
const ip = req.ip || req.socket?.remoteAddress || '';
const action = this.resolveAction(req.method, req.path);
const resource = this.extractResource(req.path);
const action = this.resolveAction(requestMethod, requestPath);
const resource = this.extractResource(requestPath);
const outcome = data && data.success === false ? 'failure' : 'success';
// Sanitize details — don't log passwords or tokens
+1 -1
View File
@@ -36,7 +36,7 @@ const MAX_EVENTS_ON_DISK = parseInt(process.env.SECURITY_EVENT_MAX_DISK || '
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']);
const VALID_OUTCOMES = new Set(['success', 'failure', 'denied', 'blocked', 'rate-limited', 'error', 'unknown']);
class SecurityEventStore extends EventEmitter {
constructor(opts = {}) {