// ───────────────────────────────────────────────────────────────────────────── // A09:2025 SECURITY LOGGING AND ALERTING FAILURES (2021: A09 Security Logging and Monitoring Failures) // ───────────────────────────────────────────────────────────────────────────── // WHAT : Breaches go undetected or uninvestigable because the right events // aren't logged, logs are unsafe/untrustworthy, or nobody is alerted. // HAPPENS : auth failures / access-control denials not logged; logs contain // passwords, tokens, PII, card numbers; log injection (CRLF forges // fake entries, breaks parsers); logs only local (attacker wipes // them); no alert thresholds (10k failed logins at 3am, silence); // no correlation/request ids; retention too short for an // investigation; unclear time zones / unsynced clocks; log pipeline // itself is a DoS vector (unbounded field sizes). // SPOT : console.log(req.body); logger.info(`user ${name} logged in`) with // raw user strings; no audit log for admin actions; no SIEM / // alerting rules; logs written to the same box as the app only; // errors swallowed with empty catch. // FIX : structured JSON logs; log: auth success/failure, authZ denials, // input validation failures, high-value txns, admin actions, config // changes; never log secrets — redact by key AND pattern; strip // CR/LF and control chars; correlation id per request; ship to // centralized append-only store (SIEM); alert on thresholds and // anomalies; retention policy (e.g., 90d hot / 1y cold); UTC // timestamps; tamper-evidence (hash chain / WORM storage). // SAY : CWE-778 Insufficient Logging, CWE-117 Improper Output // Neutralization for Logs (log injection), CWE-532 Insertion of // Sensitive Information into Log File, CWE-223 Omission of Security- // relevant Information. SIEM, SOC, MTTD/MTTR, audit trail, // correlation id, WORM. Pitch: "Log the security events, not the // secrets; make logs structured, central, tamper-evident, and // wired to alerts someone actually reads." // ───────────────────────────────────────────────────────────────────────────── 'use strict'; const assert = require('node:assert/strict'); const crypto = require('node:crypto'); // ── 1. Structured logger: redaction by key + pattern, newline/control-char stripping // VULNERABLE: // console.log(`login attempt for ${req.body.username}`); // username = "bob\n[INFO] admin login OK" // logger.info('request', req.body); // { password: 'hunter2', card: '4111...' } // FIXED: const SECRET_KEYS = /^(pass(word)?|pwd|secret|token|authorization|cookie|api[-_]?key|ssn|card(number)?|cvv|refresh_token|access_token)$/i; const PATTERNS = [ [/\b(?:\d[ -]?){13,19}\b/g, '[CARD]'], // PAN-like digit runs [/\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\b/g, '[JWT]'], [/\bBearer\s+[A-Za-z0-9._~+/=-]+/gi, 'Bearer [REDACTED]'], [/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN]'], ]; const MAX_FIELD = 512; function cleanString(s) { let out = String(s).replace(/[\r\n\t\u0000-\u001f\u007f\u2028\u2029]/g, ' '); // no forged lines for (const [re, rep] of PATTERNS) out = out.replace(re, rep); if (out.length > MAX_FIELD) out = out.slice(0, MAX_FIELD) + '…[truncated]'; return out; } function redact(value, depth = 0) { if (depth > 8) return '[depth]'; if (typeof value === 'string') return cleanString(value); if (Array.isArray(value)) return value.slice(0, 100).map((v) => redact(v, depth + 1)); if (value && typeof value === 'object') { const out = {}; for (const [k, v] of Object.entries(value)) out[cleanString(k)] = SECRET_KEYS.test(k) ? '[REDACTED]' : redact(v, depth + 1); return out; } return value; } class SecurityLogger { constructor(sink) { this.sink = sink; } event(type, fields, ctx = {}) { const entry = { ts: new Date(0).toISOString(), // real code: new Date().toISOString() — UTC always level: fields.level || 'info', event: cleanString(type), requestId: ctx.requestId || crypto.randomUUID(), ...redact(fields), }; this.sink(JSON.stringify(entry)); // one JSON object per line; parsers can't be confused return entry; } } const lines = []; const log = new SecurityLogger((line) => lines.push(line)); // log injection attempt const e1 = log.event('auth.login.failure', { username: 'bob\r\n{"event":"auth.login.success","username":"admin"}', ip: '203.0.113.9' }, { requestId: 'r-1' }); assert.equal(lines.length, 1); // still exactly one line assert.equal(lines[0].includes('\n'), false); assert.equal(e1.username.includes('\n'), false); assert.equal(JSON.parse(lines[0]).event, 'auth.login.failure'); // forged "success" is inside a string, not a record // secret redaction by key const e2 = log.event('auth.login.attempt', { username: 'alice', password: 'hunter2', headers: { Authorization: 'Bearer abc.def.ghi', Cookie: 'sid=1' }, nested: { apiKey: 'k-123' } }); assert.equal(e2.password, '[REDACTED]'); assert.equal(e2.headers.Authorization, '[REDACTED]'); assert.equal(e2.headers.Cookie, '[REDACTED]'); assert.equal(e2.nested.apiKey, '[REDACTED]'); assert.equal(JSON.stringify(e2).includes('hunter2'), false); // pattern redaction inside free text const jwt = 'eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiIxMjM0NTY3ODkwIn0.sflKxwRJSMeKKF2QT4fwpMeJf36POk6yJVadQssw5c'; const e3 = log.event('payment.declined', { note: `card 4111 1111 1111 1111 declined; token ${jwt}; auth Bearer xyz123; ssn 123-45-6789` }); assert.equal(e3.note.includes('4111'), false); assert.equal(e3.note.includes(jwt), false); assert.match(e3.note, /\[CARD\].*\[JWT\].*Bearer \[REDACTED\].*\[SSN\]/); // correlation id + bounded field size assert.equal(e1.requestId, 'r-1'); assert.match(e2.requestId, /^[0-9a-f-]{36}$/); assert.ok(log.event('x', { big: 'A'.repeat(10_000) }).big.length < 600); // ── 2. What to log (event catalog) vs never log const SECURITY_EVENTS = Object.freeze([ 'auth.login.success', 'auth.login.failure', 'auth.logout', 'auth.mfa.failure', 'auth.password.reset', 'authz.denied', // access-control failures: the IDOR probe signal 'input.validation.failure', // injection probes 'txn.high_value', 'admin.action', 'config.change', 'session.rotated', 'ratelimit.triggered', ]); const NEVER_LOG = Object.freeze(['passwords', 'session ids', 'tokens / API keys', 'full card numbers / CVV', 'PII beyond a stable user id', 'request bodies wholesale']); assert.ok(SECURITY_EVENTS.includes('authz.denied') && NEVER_LOG.length >= 5); // ── 3. Alerting thresholds (sliding window counter) — the "alerting" half of the category class ThresholdAlert { constructor(limit, windowMs) { this.limit = limit; this.windowMs = windowMs; this.hits = new Map(); } record(key, now) { const arr = (this.hits.get(key) || []).filter((ts) => now - ts < this.windowMs); arr.push(now); this.hits.set(key, arr); return arr.length >= this.limit; // true => page someone } } const failedLogins = new ThresholdAlert(10, 60_000); let t = 0; let fired = false; for (let i = 0; i < 10; i++) fired = failedLogins.record('ip:203.0.113.9', t += 100); assert.equal(fired, true); assert.equal(failedLogins.record('ip:198.51.100.1', t), false); assert.equal(failedLogins.record('ip:203.0.113.9', t + 61_000), false); // window slid // ── 4. Tamper-evident log chain (what centralized WORM / hash-chained storage gives you) function chainHash(prevHash, line) { return crypto.createHash('sha256').update(prevHash + '\n' + line).digest('hex'); } let head = '0'.repeat(64); const chain = lines.map((line) => (head = chainHash(head, line))); const replay = lines.reduce((h, line) => chainHash(h, line), '0'.repeat(64)); assert.equal(replay, chain.at(-1)); const tamperedLines = [...lines]; tamperedLines[0] = tamperedLines[0].replace('failure', 'success'); assert.notEqual(tamperedLines.reduce((h, line) => chainHash(h, line), '0'.repeat(64)), chain.at(-1)); // Retention: keep security logs long enough to investigate (commonly 90d searchable, 1y+ archived); clocks via NTP; UTC. console.log('ok: owasp/a09-security-logging-alerting-failures.js');