Back

A9:2025 — Logging & Alerting Failures: Examples

How to Read These Examples

Each example shows a vulnerable implementation and the secure version that fixes it. The point of this category is that the vulnerable code often works perfectly—it just leaves no usable trace and raises no alarm. The secure version produces structured, contextual, sanitised security events, and the final example turns those events into a firing alert.

Example 1 — Authentication (Python / Flask)

Vulnerable

from flask import Flask, request

app = Flask(__name__)

@app.route('/login', methods=['POST'])
def login():
    username = request.form['username']
    password = request.form['password']
    user = authenticate(username, password)
    if user:
        return {'status': 'ok', 'token': issue_token(user)}
    # FAILURE: the failed login is never recorded.
    # No username, no IP, no count -> credential stuffing is invisible.
    return {'status': 'invalid'}, 401

Why it fails: only the happy path is (implicitly) handled; failures vanish. An attacker can try millions of credentials and generate zero security signal. There is nothing to correlate, so no alert can ever fire.

Secure

import logging, sys, re
from flask import Flask, request
from pythonjsonlogger import jsonlogger

app = Flask(__name__)

# --- structured security logger to stdout (collector ships it off-host) ---
_handler = logging.StreamHandler(sys.stdout)
_handler.setFormatter(jsonlogger.JsonFormatter(
    '%(asctime)s %(levelname)s %(message)s',
    rename_fields={'asctime': 'timestamp', 'levelname': 'severity'},
    datefmt='%Y-%m-%dT%H:%M:%S%z'))
slog = logging.getLogger('security')
slog.setLevel(logging.INFO)
slog.addHandler(_handler)

def _clean(v):                                  # neutralise log injection (CWE-117)
    return re.sub(r'[\r\n\t\x00-\x1f\x7f]', ' ', str(v))[:256]

def sec_event(event, outcome, **ctx):
    slog.info(event, extra={
        'event': event, 'outcome': outcome,
        'source_ip': request.remote_addr,
        'user_agent': _clean(request.headers.get('User-Agent', '')),
        'correlation_id': request.headers.get('X-Correlation-ID'),
        'service': 'auth-api', 'env': 'production', **ctx})

@app.route('/login', methods=['POST'])
def login():
    username = _clean(request.form.get('username', ''))
    user = authenticate(username, request.form.get('password', ''))
    if user:
        sec_event('authn_login_success', 'success', actor=username)  # no secrets logged
        return {'status': 'ok', 'token': issue_token(user)}
    # SUCCESS PATH FOR DETECTION: the failure is a first-class security event.
    sec_event('authn_login_failed', 'failure', actor=username, reason='bad_credentials')
    return {'status': 'invalid'}, 401

What changed: failures are logged with who, from where, and why; the username and user-agent are sanitised; no password or token value is written; timestamps are ISO-8601; and the record is JSON that a SIEM can count and correlate. This is the raw material every detection in Example 4 depends on.

Example 2 — Authorization & High-Value Actions (Node.js / Express)

Vulnerable

app.post('/api/transfer', (req, res) => {
  const { amount, from, to } = req.body;
  if (req.user.id !== accountOwner(from)) {
    return res.status(403).send('Forbidden');   // denial NOT logged
  }
  doTransfer(from, to, amount);
  console.log('transfer done');                 // useless: no who/what/how-much
  res.send('ok');
});

Why it fails: the 403 denial—your best early warning of IDOR/privilege probing—is thrown away. The completed transfer is “logged” with a bare string that names no actor, amount, or accounts, so it is useless for detection or forensics.

Secure

const pino = require('pino');
const { randomUUID } = require('crypto');
const log = pino({ base: { service: 'payments-api', env: 'production' } });

app.use((req, res, next) => {                    // propagate a correlation id
  req.cid = req.get('X-Correlation-ID') || randomUUID();
  res.set('X-Correlation-ID', req.cid);
  next();
});

const secEvent = (req, event, outcome, ctx = {}) => log.info({
  event, outcome, actorId: req.user?.id ?? null,
  sourceIp: req.ip, correlationId: req.cid, ...ctx }, event);

app.post('/api/transfer', (req, res) => {
  const { amount, from, to } = req.body;

  if (req.user.id !== accountOwner(from)) {
    // EARLY-WARNING SIGNAL: log every authorization denial.
    secEvent(req, 'authz_denied', 'denied',
             { action: 'transfer', targetAccount: from, reason: 'not_owner' });
    return res.status(403).send('Forbidden');
  }

  secEvent(req, 'funds_transfer', 'initiated',
           { amount, fromAccount: from, toAccount: to });   // high-value action
  try {
    doTransfer(from, to, amount);
    secEvent(req, 'funds_transfer', 'success', { amount, fromAccount: from, toAccount: to });
    res.send('ok');
  } catch (e) {
    secEvent(req, 'funds_transfer', 'failure', { amount, error: e.message });
    res.status(500).send('error');
  }
});

What changed: denials are logged (feeding the “authz-denial spike” detection), and the high-value action is logged with full context at initiation and completion. Note account numbers are logged as identifiers for traceability—in a real system, mask them per PCI-DSS and never log full PANs.

Example 3 — Log Injection & Secret Leakage

Vulnerable

# CWE-117 (log injection) + CWE-532 (secrets in logs), both at once.
logger.info("Login for " + username + " body=" + str(request.json))

# If username = "admin\n2025-08-28 12:00:00 INFO Login success user=ceo",
# the attacker forges a fake success line in a line-based log.
# And dumping request.json writes the plaintext password/token to disk.

Secure

import re

SENSITIVE = {'password', 'passwd', 'token', 'authorization',
             'cookie', 'ssn', 'card', 'cvv', 'secret'}

def clean(v):                                   # strip CR/LF/control chars
    return re.sub(r'[\r\n\t\x00-\x1f\x7f]', ' ', str(v))[:256]

def redact(d):                                  # drop sensitive keys
    return {k: ('***REDACTED***' if k.lower() in SENSITIVE else v)
            for k, v in (d or {}).items()}

# Structured value -> newline becomes \n INSIDE a JSON string, not a new line.
slog.info('authn_login_failed', extra={
    'event': 'authn_login_failed', 'outcome': 'failure',
    'actor': clean(username),
    'fields': redact(request.json)})            # secrets never hit disk

What changed: untrusted values are sanitised so they cannot forge log lines, sensitive keys are redacted so credentials never reach storage, and structured JSON makes both problems structurally hard to reintroduce.

Example 4 — Detection & Alerting Config (SIEM)

Structured events are only half the job—now turn them into alerts. Below are three ways to express the same idea (credential stuffing) plus the routing that keeps alerts actionable and fatigue-free.

Sigma rule (portable across SIEMs) — horizontal credential stuffing

title: Horizontal Credential Stuffing
id: 7c1e2a10-a9f2-4b3d-9c11-3f2b1d0e4a55
status: stable
description: One source IP fails authentication across many distinct accounts
logsource:
    product: application
    service: auth
detection:
    failed:
        event: 'authn_login_failed'
    timeframe: 10m
    condition: failed | count(actor) by source_ip > 50   # 50+ distinct users / 10 min
level: high
falsepositives:
    - Shared corporate NAT/proxy egress IPs (allow-list them)
tags:
    - attack.credential_access
    - attack.t1110.004

Elasticsearch / ELK — threshold detection rule (Detection-as-Code)

{
  "name": "Horizontal Credential Stuffing",
  "type": "threshold",
  "index": ["security-*"],
  "query": "event: \"authn_login_failed\"",
  "threshold": {
    "field": "source_ip",
    "value": 1,
    "cardinality": [ { "field": "actor", "value": 50 } ]
  },
  "interval": "5m",
  "risk_score": 73,
  "severity": "high",
  "actions": [
    { "action_type": "pagerduty", "params": { "severity": "critical" } }
  ]
}

Splunk SPL — scheduled correlation search

index=security event="authn_login_failed" earliest=-10m
| stats dc(actor) AS distinct_users, count AS attempts BY source_ip
| where distinct_users > 50
| eval severity="high", rule="credential_stuffing"
| sendalert pagerduty param.severity="critical"

Alert routing & deduplication (fight alert fatigue)

# Prometheus Alertmanager: collapse a flood into ONE incident and route by severity.
route:
  group_by: ['alertname', 'source_ip']    # dedupe identical alerts
  repeat_interval: 4h                      # don't re-page for the same thing
  routes:
    - matchers: [ 'severity="critical"' ]
      receiver: pagerduty-oncall           # page a human + runbook link
    - matchers: [ 'severity="warning"' ]
      receiver: triage-queue               # review, don't page

# Also ship a heartbeat/"dead man's switch": alert if NO auth events arrive in 15m,
# because silence can mean the attacker stopped your logging pipeline.

Side-by-Side Summary

ConcernVulnerableSecure
Failed loginsNot loggedLogged with actor, IP, reason
Authz denialsDiscarded (403 only)Logged as early-warning events
High-value actionsconsole.log('done')Structured, full context, init + result
FormatFree-text / printStructured JSON, correlation ID
TimestampsLocal / missingISO-8601 UTC, server-assigned
Untrusted inputConcatenated raw (CWE-117)Sanitised / structured values
SecretsFull body dumped (CWE-532)Redacted, never on disk
StorageOn-host, mutableCentralised, append-only
DetectionNoneCorrelation rule (as code)
AlertingNoneDeduped, severity-routed, on-call

The throughline: the secure code does not just “log more”—it logs the right events in a structured, safe, centralised form that a rule can turn into a tuned, owned alert. Every link in that chain is required.

Next Steps

Part of the OWASP Top 10 Educational Repository — A9:2025, Logging & Alerting Failures.