Multiple modules derived file paths from __dirname, which is unstable in two
ways: (1) it moves whenever the file is reorganized under src/, and (2) it
points to the in-container source dir /app/src/<x> in production, which is
not bind-mounted, so writes would silently land in the image layer.
Affected modules (10 files): backup-manager, resource-monitor, update-manager,
docker-security, audit-logger, bundled-workflows, port-lock-manager, logging,
error-handler, license-keygen, plus crypto-utils and credential-manager which
already had multi-candidate resolvers but no centralised fallback.
Introduced platformPaths.dataDir (derived from SERVICES_FILE/CONFIG_FILE/
DNS_CREDENTIALS_FILE env vars when set, else path.dirname(servicesFile)) so
every module resolves the same canonical data directory. Each module now
fans the runtime files into the data dir while preserving per-file env-var
overrides for custom deployments.
Why a single resolver:
- one place to swap the default path scheme in v2.x without chasing
hardcoded __dirname joins
- a single source-of-truth for tests, backup tools, and the soon-to-be
added single-volume migration script
- prevents the class of DC-033 (self-updater 0.0.0) bugs where __dirname
drift in a subdirectory silently loses runtime state
Also fixed:
- audit-logger: AUDIT_LOG_FILE default was /app/src/security/audit-log.json
(writable in dev, image-layer in production). Now /app/data/audit-log.json
via platformPaths.dataDir, matching logging.js's same file. Same physical
path, no behavior change for callers that already set AUDIT_LOG_FILE.
- logging.js: LOG_DIR was __dirname (src/utils/) — error.log and
audit-log.json were being written into the source tree. Now
platformPaths.dataDir, matching every other persistent file.
- error-handler.js: ERROR_LOG_FILE hard-coded to __dirname/error.log
(src/utilities/error.log), redundant with logging.js's own default.
Now platformPaths.dataDir/error.log.
- host-registry / event-store / event-workers: simplified the
'platformPaths.dataDir || path.join(__dirname, ../../data)' pattern
to just platformPaths.dataDir (the legacy fallback is no longer
reachable — services.json lives at dataDir/services.json now).
- public-routes-drift.test.js: added 'routes/security.js' to the
direct-mount list so the /api/v1/security/events/ingest and
/api/v1/security/events/batch entries in PUBLIC_ROUTES are
recognized as mounted (was missing — fixed DC-044's drift-detection
test gap).
Tests: 1214/1214 pass (0 new failures, 1 new test for the corrected route
mount detection path). ESLint: 146 warnings + 4 errors — same baseline as
HEAD (no new warnings or errors introduced; one pre-existing require-await
on readline was removed as a drive-by in event-workers.js since the module
uses line-level fs reads, not readline). Container config files like
audit-log.json, container-stats.json, and workflow-history.json still
exist on the running container's image layer — Docker will pick up the
new defaults on the next recreate (the update path already moves
services.json+config.json+credentials.json via the data bind mount).
244 lines
9.5 KiB
JavaScript
244 lines
9.5 KiB
JavaScript
const path = require('path');
|
|
const StateManager = require('../managers/state-manager');
|
|
const crypto = require('crypto');
|
|
const platformPaths = require('../../platform-paths');
|
|
|
|
const AUDIT_LOG_FILE = process.env.AUDIT_LOG_FILE || path.join(platformPaths.dataDir, 'audit-log.json');
|
|
const MAX_ENTRIES = parseInt(process.env.AUDIT_MAX_ENTRIES || '1000', 10);
|
|
|
|
// Route path → readable action mapping
|
|
const ACTION_MAP = {
|
|
'POST /api/v1/services/update': 'service.reorder',
|
|
'POST /api/v1/services': 'service.create',
|
|
'PUT /api/v1/services': 'service.update',
|
|
'DELETE /api/v1/services/': 'service.delete',
|
|
'POST /api/v1/site': 'caddy.add-site',
|
|
'POST /api/v1/site/external': 'caddy.add-external',
|
|
'DELETE /api/v1/site/': 'caddy.remove-site',
|
|
'POST /api/v1/caddy/reload': 'caddy.reload',
|
|
'POST /api/v1/dns/record': 'dns.add-record',
|
|
'DELETE /api/v1/dns/record': 'dns.delete-record',
|
|
'POST /api/v1/dns/credentials': 'dns.save-credentials',
|
|
'DELETE /api/v1/dns/credentials': 'dns.delete-credentials',
|
|
'POST /api/v1/dns/refresh-token': 'dns.refresh-token',
|
|
'POST /api/v1/dns/update': 'dns.update-server',
|
|
'POST /api/v1/containers/': 'container.action',
|
|
'DELETE /api/v1/containers/': 'container.delete',
|
|
'POST /api/v1/apps/deploy': 'container.deploy',
|
|
'DELETE /api/v1/apps/': 'container.undeploy',
|
|
'POST /api/v1/backups/execute': 'backup.execute',
|
|
'POST /api/v1/backups/restore/': 'backup.restore',
|
|
'POST /api/v1/backups/config': 'backup.config',
|
|
'POST /api/v1/config': 'config.update',
|
|
'DELETE /api/v1/config': 'config.reset',
|
|
'POST /api/v1/notifications/config': 'config.notifications',
|
|
'POST /api/v1/totp/setup': 'auth.totp-setup',
|
|
'POST /api/v1/totp/verify-setup': 'auth.totp-activate',
|
|
'POST /api/v1/totp/disable': 'auth.totp-disable',
|
|
'POST /api/v1/totp/config': 'auth.totp-config',
|
|
'POST /api/v1/credentials/rotate-key': 'config.rotate-key',
|
|
'POST /api/v1/updates/update/': 'container.update',
|
|
'POST /api/v1/updates/rollback/': 'container.rollback',
|
|
'POST /api/v1/updates/auto-update/': 'container.auto-update',
|
|
'POST /api/v1/updates/check': 'container.check-updates',
|
|
'POST /api/v1/health-checks/': 'config.health-check',
|
|
'DELETE /api/v1/health-checks/': 'config.health-check-delete',
|
|
'POST /api/v1/monitoring/alerts/': 'config.monitoring-alert',
|
|
'DELETE /api/v1/monitoring/alerts/': 'config.monitoring-alert-delete',
|
|
'POST /api/v1/arr/smart-connect': 'service.arr-connect',
|
|
'POST /api/v1/arr/credentials': 'config.arr-credentials',
|
|
'DELETE /api/v1/arr/credentials/': 'config.arr-credentials-delete',
|
|
'POST /api/v1/logo': 'config.logo-upload',
|
|
'DELETE /api/v1/logo': 'config.logo-delete',
|
|
'POST /api/v1/favicon': 'config.favicon-upload',
|
|
'DELETE /api/v1/favicon': 'config.favicon-delete',
|
|
'POST /api/v1/tailscale/config': 'config.tailscale',
|
|
'POST /api/v1/tailscale/protect-service': 'config.tailscale-protect',
|
|
// SECURITY [DC-028]: Credential-exposure events get named actions so the
|
|
// audit log can answer "who hit /auth/gate/plex at 03:00 with what outcome?".
|
|
'GET /api/v1/auth/gate': 'auth.credential-injection',
|
|
'GET /api/v1/auth/app-token': 'auth.app-token-issue',
|
|
'POST /api/v1/auth/keys': 'auth.api-key-generate',
|
|
'DELETE /api/v1/auth/keys': 'auth.api-key-revoke',
|
|
'POST /api/v1/auth/jwt': 'auth.jwt-mint',
|
|
};
|
|
|
|
// Paths to skip logging (noisy or internal)
|
|
const SKIP_PATHS = [
|
|
'/api/v1/totp/verify',
|
|
'/api/v1/totp/check-session',
|
|
// SECURITY [DC-028]: /auth/gate and /auth/app-token are NOT skipped —
|
|
// they expose credentials (Basic Auth, X-Api-Key, upstream service tokens)
|
|
// so we MUST log every hit. Previously these were in SKIP_PATHS which
|
|
// silently dropped credential-exposure events from the audit log.
|
|
'/api/v1/audit-logs',
|
|
'/api/v1/health',
|
|
'/health',
|
|
'/api/v1/notifications/test',
|
|
'/api/v1/notifications/health-check',
|
|
];
|
|
|
|
class AuditLogger {
|
|
constructor() {
|
|
this.stateManager = new StateManager(AUDIT_LOG_FILE);
|
|
}
|
|
|
|
resolveAction(method, urlPath) {
|
|
const key = `${method} ${urlPath}`;
|
|
// Exact match first
|
|
if (ACTION_MAP[key]) return ACTION_MAP[key];
|
|
// Prefix match (for parameterized routes like /api/services/:id)
|
|
for (const [pattern, action] of Object.entries(ACTION_MAP)) {
|
|
if (key.startsWith(pattern)) return action;
|
|
}
|
|
// Fallback: derive from path
|
|
const parts = urlPath.replace('/api/v1/', '').split('/');
|
|
const category = parts[0] || 'unknown';
|
|
return `${category}.${method.toLowerCase()}`;
|
|
}
|
|
|
|
extractResource(urlPath) {
|
|
// Pull a meaningful resource identifier from the URL path
|
|
const parts = urlPath.replace('/api/v1/', '').split('/');
|
|
if (parts.length >= 2) return parts.slice(1).join('/');
|
|
return parts[0] || '';
|
|
}
|
|
|
|
shouldSkip(method, urlPath) {
|
|
// SECURITY [DC-028]: Auth endpoints that expose credentials are
|
|
// logged even though they're GETs. /auth/gate and /auth/app-token
|
|
// return Basic Auth headers and upstream service tokens — these
|
|
// events MUST be auditable. Other GETs remain skipped (probes,
|
|
// dashboards, status checks flood the log).
|
|
if (urlPath.startsWith('/api/v1/auth/gate') || urlPath.startsWith('/api/v1/auth/app-token')) {
|
|
return false; // log it
|
|
}
|
|
if (method === 'GET') return true;
|
|
for (const skip of SKIP_PATHS) {
|
|
if (urlPath.startsWith(skip)) return true;
|
|
}
|
|
return false;
|
|
}
|
|
|
|
/**
|
|
* Map a (action, outcome) pair to a severity level for the security event store.
|
|
* Most actions are 'info', but security-sensitive ones get escalated.
|
|
*/
|
|
resolveSeverity(action, outcome) {
|
|
// Failed auth + sensitive actions are warnings at minimum
|
|
if (outcome === 'failure' || outcome === 'denied' || outcome === 'error') {
|
|
if (action?.startsWith('auth.')) return 'warn';
|
|
if (action?.includes('credential')) return 'warn';
|
|
if (action?.includes('delete') || action?.includes('disable')) return 'warn';
|
|
return 'notice';
|
|
}
|
|
// Successful sensitive actions (key generation, TOTP setup, config changes)
|
|
if (action?.startsWith('auth.totp-') || action?.includes('rotate-key')) return 'notice';
|
|
if (action?.includes('delete') || action?.includes('disable')) return 'notice';
|
|
if (action?.startsWith('config.')) return 'notice';
|
|
return 'info';
|
|
}
|
|
|
|
async log({ action, resource, details, outcome, ip }) {
|
|
try {
|
|
const entry = {
|
|
id: crypto.randomUUID(),
|
|
timestamp: new Date().toISOString(),
|
|
ip: ip || '',
|
|
action: action || '',
|
|
resource: resource || '',
|
|
details: details || {},
|
|
outcome: outcome || 'unknown'
|
|
};
|
|
|
|
await this.stateManager.update(entries => {
|
|
entries.unshift(entry);
|
|
if (entries.length > MAX_ENTRIES) {
|
|
entries.length = MAX_ENTRIES;
|
|
}
|
|
return entries;
|
|
});
|
|
|
|
// ALSO emit to the unified security event store so security events from
|
|
// the API show up alongside Caddy access logs, fail2ban events, and any
|
|
// future remote-agent events in one timeline. This is best-effort —
|
|
// failure here MUST NOT block the audit log write.
|
|
try {
|
|
const { getStore } = require('./event-store');
|
|
const store = getStore();
|
|
const severity = this.resolveSeverity(action, outcome);
|
|
const hostname = require('os').hostname();
|
|
store.append({
|
|
source_host: hostname,
|
|
source_type: 'api',
|
|
actor: ip || null,
|
|
target: resource || null,
|
|
action: action || 'unknown',
|
|
outcome: outcome || 'unknown',
|
|
severity,
|
|
message: `${action} ${outcome} on ${resource}`.trim(),
|
|
metadata: {
|
|
method: details?.body && Object.keys(details.body)[0] ? '(see audit-log)' : undefined,
|
|
audit_id: entry.id,
|
|
},
|
|
});
|
|
} catch (e) {
|
|
// Non-fatal — security store is a best-effort mirror
|
|
console.error('[AuditLogger] Security event emit failed:', e.message);
|
|
}
|
|
} catch (e) {
|
|
console.error('[AuditLogger] Failed to write entry:', e.message);
|
|
}
|
|
}
|
|
|
|
async query({ limit = 50, offset = 0, action } = {}) {
|
|
try {
|
|
let entries = await this.stateManager.read();
|
|
if (action) {
|
|
entries = entries.filter(e => e.action && e.action.startsWith(action));
|
|
}
|
|
return entries.slice(offset, offset + limit);
|
|
} catch (e) {
|
|
console.error('[AuditLogger] Failed to read:', e.message);
|
|
return [];
|
|
}
|
|
}
|
|
|
|
async clear() {
|
|
await this.stateManager.write([]);
|
|
}
|
|
|
|
middleware() {
|
|
return (req, res, next) => {
|
|
if (this.shouldSkip(req.method, req.path)) return next();
|
|
|
|
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 outcome = data && data.success === false ? 'failure' : 'success';
|
|
|
|
// Sanitize details — don't log passwords or tokens
|
|
const details = {};
|
|
if (req.params && Object.keys(req.params).length) details.params = req.params;
|
|
if (req.body) {
|
|
const safe = { ...req.body };
|
|
for (const key of ['password', 'token', 'secret', 'apikey', 'encryptionKey', 'code']) {
|
|
if (safe[key]) safe[key] = '***';
|
|
}
|
|
details.body = safe;
|
|
}
|
|
|
|
this.log({ action, resource, details, outcome, ip }).catch(() => {});
|
|
|
|
return originalJson(data);
|
|
};
|
|
next();
|
|
};
|
|
}
|
|
}
|
|
|
|
module.exports = new AuditLogger();
|