Back

API10:2019 Insufficient Logging & Monitoring - Code Examples

Each section below pairs a blind implementation (an attack would leave no usable trace) with a observable one that emits structured security events. The final sections show the alerting and SIEM configuration that turns those events into real-time detection.

Python (structlog)

Blind

from flask import Flask, request, jsonify

app = Flask(__name__)

@app.route('/api/v1/auth/login', methods=['POST'])
def login():
    user = request.json.get('user')
    if not check_password(user, request.json.get('pass')):
        return jsonify({'error': 'invalid credentials'}), 401   # nothing logged
    return jsonify({'token': issue_token(user)})

@app.route('/api/v1/invoices/<id>')
def invoice(id):
    inv = load_invoice(id)
    if inv.owner != current_user():
        return jsonify({'error': 'forbidden'}), 403              # denial not recorded
    return jsonify(inv.to_dict())

A credential-stuffing run and a BOLA enumeration walk both produce only anonymous 401/403 responses—no actor, no object, nothing to alert on.

Observable

import re, structlog
from flask import Flask, request, jsonify, g

app = Flask(__name__)
log = structlog.get_logger()

CONTROL = re.compile(r'[\r\n\t\x00-\x1f]')
def safe(v):                       # prevent log injection (CRLF forging)
    return CONTROL.sub(' ', str(v))[:256]

@app.before_request
def add_context():
    g.request_id = request.headers.get('X-Request-Id') or new_id()
    g.source_ip = request.headers.get('X-Forwarded-For', request.remote_addr)

@app.route('/api/v1/auth/login', methods=['POST'])
def login():
    user = request.json.get('user')
    if not check_password(user, request.json.get('pass')):
        log.warning('auth.failure', outcome='failure',
                    auth_subject=safe(user), source_ip=safe(g.source_ip),
                    endpoint='/api/v1/auth/login', status=401,
                    request_id=g.request_id)          # secrets are NEVER logged
        return jsonify({'error': 'invalid credentials'}), 401
    log.info('auth.success', outcome='success', auth_subject=safe(user),
             source_ip=safe(g.source_ip), request_id=g.request_id)
    return jsonify({'token': issue_token(user)})

@app.route('/api/v1/invoices/<id>')
def invoice(id):
    inv = load_invoice(id)
    if inv.owner != current_user():
        log.warning('authz.denied', outcome='failure',
                    auth_subject=current_user(), object_id=safe(id),
                    endpoint='/api/v1/invoices/{id}', status=403,
                    source_ip=safe(g.source_ip), request_id=g.request_id)
        return jsonify({'error': 'forbidden'}), 403
    return jsonify(inv.to_dict())

Now every failed login and every denied object carries the subject, source, endpoint, and object id—exactly the fields a detection needs to spot stuffing and enumeration.

Node.js (Express + pino)

Blind

const express = require('express');
const app = express();
app.use(express.json());

app.post('/api/v1/auth/login', (req, res) => {
    if (!checkPassword(req.body.user, req.body.pass))
        return res.status(401).json({ error: 'invalid credentials' }); // silent
    res.json({ token: issueToken(req.body.user) });
});

Observable

const express = require('express');
const pino = require('pino');
const app = express();
app.use(express.json());

// redact never keeps secrets out of the log stream
const log = pino({ redact: ['req.headers.authorization', '*.pass', '*.token'] });

const clean = v => String(v).replace(/[\r\n\t\x00-\x1f]/g, ' ').slice(0, 256);

app.use((req, res, next) => {
    req.ctx = {
        request_id: req.get('X-Request-Id') || newId(),
        source_ip: req.get('X-Forwarded-For') || req.ip,
    };
    next();
});

app.post('/api/v1/auth/login', (req, res) => {
    if (!checkPassword(req.body.user, req.body.pass)) {
        log.warn({ event: 'auth.failure', outcome: 'failure',
                   auth_subject: clean(req.body.user),
                   source_ip: req.ctx.source_ip, endpoint: '/api/v1/auth/login',
                   status: 401, request_id: req.ctx.request_id });
        return res.status(401).json({ error: 'invalid credentials' });
    }
    log.info({ event: 'auth.success', outcome: 'success',
               auth_subject: clean(req.body.user),
               source_ip: req.ctx.source_ip, request_id: req.ctx.request_id });
    res.json({ token: issueToken(req.body.user) });
});

app.get('/api/v1/invoices/:id', (req, res) => {
    const inv = loadInvoice(req.params.id);
    if (inv.owner !== currentUser(req)) {
        log.warn({ event: 'authz.denied', outcome: 'failure',
                   auth_subject: currentUser(req), object_id: clean(req.params.id),
                   endpoint: '/api/v1/invoices/{id}', status: 403,
                   source_ip: req.ctx.source_ip, request_id: req.ctx.request_id });
        return res.status(403).json({ error: 'forbidden' });
    }
    res.json(inv);
});

Java (Spring Boot + SLF4J/Logback + MDC)

Observable

// A filter puts request-scoped context on the MDC so every log line carries it
@Component
class ContextFilter extends OncePerRequestFilter {
    protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
                                    FilterChain chain) throws IOException, ServletException {
        MDC.put("request_id", header(req, "X-Request-Id", UUID.randomUUID().toString()));
        MDC.put("source_ip",  header(req, "X-Forwarded-For", req.getRemoteAddr()));
        try { chain.doFilter(req, res); } finally { MDC.clear(); }
    }
}

@RestController
class InvoiceController {
    private static final Logger log = LoggerFactory.getLogger(InvoiceController.class);

    @GetMapping("/api/v1/invoices/{id}")
    public ResponseEntity<?> get(@PathVariable String id, Principal p) {
        Invoice inv = service.load(id);
        if (!inv.getOwner().equals(p.getName())) {
            // structured event: subject + denied object; MDC adds request_id + source_ip
            log.warn("authz.denied outcome=failure auth_subject={} object_id={} " +
                     "endpoint=/api/v1/invoices/{id} status=403",
                     p.getName(), sanitize(id));
            return ResponseEntity.status(403).body(Map.of("error", "forbidden"));
        }
        return ResponseEntity.ok(inv);
    }

    // strip CR/LF so a crafted id cannot forge a log line
    private static String sanitize(String v) {
        return v.replaceAll("[\\r\\n\\t\\x00-\\x1f]", " ");
    }
}
<!-- logback-spring.xml: emit JSON so fields (incl. MDC) are machine-parseable -->
<appender name="JSON" class="ch.qos.logback.core.ConsoleAppender">
  <encoder class="net.logstash.logback.encoder.LogstashEncoder">
    <includeMdcKeyName>request_id</includeMdcKeyName>
    <includeMdcKeyName>source_ip</includeMdcKeyName>
  </encoder>
</appender>

Alerting & SIEM Configuration

Structured events are only half the job—these rules turn them into real-time detection. Thresholds shown are illustrative starting points; tune them to your own baseline.

ElastAlert 2 (against Elasticsearch/OpenSearch): 401 spike = credential stuffing

name: auth-failure-spike-per-source
type: frequency
index: api-logs-*
num_events: 20                 # 20+ failed logins ...
timeframe:
  minutes: 1                   # ... within 1 minute ...
filter:
  - term: { event: "auth.failure" }
query_key: source_ip          # ... from a single source IP
alert:
  - "slack"
realert:
  minutes: 10

Sigma rule (portable): authorization-denial enumeration (BOLA sweep)

title: API Authorization Denial Enumeration
logsource:
  product: api
  category: application
detection:
  denial:
    event: "authz.denied"
    status: 403
  timeframe: 5m
  condition: denial | count() by auth_subject > 50   # one subject, 50+ denials
level: high
description: One subject accrues many 403s across distinct object ids -> BOLA enumeration

Prometheus / Alertmanager: rate-of-error and throttle spikes

groups:
  - name: api-abuse
    rules:
      - alert: HighAuthFailureRate
        expr: sum(rate(api_auth_failures_total[5m])) by (source_ip) > 1
        for: 2m
        labels: { severity: high }
        annotations:
          summary: "Elevated 401s from {{ $labels.source_ip }} (possible credential stuffing)"

      - alert: RateLimitAbuse
        expr: sum(rate(api_throttled_total[5m])) by (client_id) > 0.5
        for: 5m
        labels: { severity: medium }
        annotations:
          summary: "Client {{ $labels.client_id }} repeatedly hitting rate limits"

Splunk SPL: one token seen from many sources (stolen-token replay)

index=api_logs event=auth.success
| bucket _time span=5m
| stats dc(source_ip) as ips values(source_ip) as sources by token_id, _time
| where ips > 3
| eval detection="token used from multiple IPs in 5m (possible replay)"

What Changed, and Why

GapBlindObservable
Auth failuresSilent 401Structured auth.failure with subject + source
Authz denialsSilent 403authz.denied with subject + object id
FormatFree text or nothingConsistent JSON, machine-parseable
ContextNo actor/objectrequest id, client, subject, source IP, endpoint, object id
Log injectionRaw input concatenatedCR/LF stripped; structured fields
SecretsTokens/PII risk in logsRedaction; identifiers and outcomes only
DetectionNobody watchingTuned SIEM/alerting rules on 401/403/429, token spread

Next Steps