CyberNugget

L60 · Logging for security

Module 9: Detection and Incident Response · ~20 min · Lesson 60 of 71

🎯 What you'll learn

  • Which events a service must log, and the five fields every security event needs
  • What must never reach a log, and how a redaction filter catches what slips through
  • How structured JSON logs and correlation IDs let you follow one request from the gateway (Kong in the examples) through every service
  • How attackers abuse logs (forged lines, Log4Shell-style lookups), and how to keep logs trustworthy and kept for the right time

🔥 Why this matters

Logging fails in two directions. In 2018 Twitter urged all its users to change their passwords after a bug wrote passwords to an internal log before hashing them. In 2024 Ireland's data protection regulator fined Meta €91 million over passwords found stored in plain text internally in 2019. Too much logging turns your logs into a breach. Too little means that when a token is stolen, nobody can answer "which accounts did it touch?" OWASP lists this as A09, Security Logging and Alerting Failures (L29).

1. What to log: security events, with five fields

Application logs answer "why did my code break?" Security logs answer "who did what, and did it work?" The OWASP Logging Cheat Sheet and ASVS list the events that matter. Platform logs (CloudTrail, EKS audit, L52) can't see them; only your code knows them.

Event Example (OWASP Logging Vocabulary name)
Authentication, success and failure authn_login_fail, authn_token_reuse
Authorization failures authz_fail: Bob asked for Alice's statement (L34)
Admin and privileged actions authz_admin, user_created, role or limit changes
Input validation failures input_validation_fail: schema rejections, bad IDs
High-value business actions Payee added, transfer limit raised, export run
Session events Logout, expiry, a revoked token used again

Every event needs five fields. Missing any one makes the line useless in an investigation:

 WHO      sub=C-2001, client=mobile-app, ip=203.0.113.24
 WHAT     action=statement.read, resource=STM-88213
 WHEN     2026-11-07T10:42:13.512Z  (UTC, synced clock)
 WHERE    service=statements-svc, pod=statements-7d9f-x2k,
          route=GET /v1/statements/{id}, env=prod
 OUTCOME  denied, reason=not_owner, status=404
          + corr=7c1e9a02-5f3b-4d7e-9a41-0b6c2d8e1f55

The client gets a bare 404 (L34); the log says why. A burst of not_owner from one caller is enumeration, which L62 turns into an alert.

2. What never to log

Never log Log instead
Passwords, even failed ones (often a real password with a typo) authn_login_fail and the username
Bearer tokens, refresh tokens, API keys, session IDs, cookies Token jti, client ID, or a short keyed hash
Full card numbers (PAN); CVV and PIN never, in any form Last four digits
SSNs, full bank account numbers, dates of birth Internal customer ID (C-2001)
Encryption keys, connection strings Key ID, host name
Whole request or response bodies The fields you need, chosen deliberately

PCI DSS lets you display at most the BIN and last four digits of a card number. For logs, last four is the safe default.

Secrets rarely get logged on purpose. They leak through side doors:

  • Exception messages. MongoDB's duplicate-key error includes the value: E11000 ... index: ssn_1 dup key: { ssn: "123-45-6789" }.
  • Whole objects. log.info("req={}", request) calls toString(), which may include every field.
  • URLs. A token in a query string (?access_token=...) ends up in gateway (Kong), load balancer and proxy logs.
  • Debug wire logging. HTTP client wire logging at DEBUG (Apache HttpClient's wire logger) prints every header, Authorization included.
  • Scheduler task logs. Airflow, for example, masks connection passwords and Variables with names such as password, secret or api_key, but not a token a DAG fetched and printed.

3. Structured logs and correlation IDs

A text line like Login failed for alice from 203.0.113.24 must be parsed with a regex before you can search it. A structured log writes each event as JSON, with named fields:

{"ts":"2026-11-07T10:42:13.512Z","level":"WARN",
 "event":"authz_fail","service":"statements-svc",
 "user":"C-2001","resource":"STM-88213","outcome":"denied",
 "reason":"not_owner","corr":"7c1e9a02-5f3b-4d7e-9a41-0b6c2d8e1f55",
 "trace_id":"4bf92f3577b34da6a3ce929d0e0e4736"}

Spring Boot 3.4+ can write JSON natively (logging.structured.format.console=ecs), or use logstash-logback-encoder; in Python, structlog or python-json-logger. Agree on field names across teams (ECS or OpenTelemetry).

A correlation ID is one ID attached to every log line a single request produces, in every service it touches. At the gateway, Kong's correlation-id plugin adds one (here X-Correlation-ID, as in L43), and tracing adds a W3C traceparent header (00-<trace-id>-<span-id>-<flags>):

 client
   │ GET /v1/statements/STM-88213
   │ (X-Correlation-ID from the client? strip it, see below)
   ▼
 Kong ── correlation-id plugin: X-Correlation-ID: 7c1e9a02-...
   │     opentelemetry plugin:  traceparent: 00-4bf9...-00f0...-01
   │     http-log: corr=7c1e9a02-...
   ▼
 statements-svc ── reads header → logging context (MDC)
   │               every log line: "corr":"7c1e9a02-..."
   │ calls downstream, FORWARDS both headers
   ▼
 accounts-svc ──── same IDs in its logs ──▶ MongoDB query log

In Java, a servlet filter puts the ID in SLF4J's MDC (mapped diagnostic context); in Python, a contextvars variable plus a logging filter does the same.

The security catch: a header the client sent is attacker input. Kong's correlation-id plugin keeps an existing header instead of replacing it. A client can then reuse a victim's ID to muddle an investigation, or send a newline or 10 KB of junk. On public routes, remove the incoming header first (for example with request-transformer) and let Kong generate a fresh one. Services should still accept only a well-formed UUID. The W3C spec likewise says you may restart traces at trust boundaries, and must keep personal data out of trace headers.

4. Log injection: when the input writes the log

A login form takes this username, with a newline inside it:

 username = "alice\nINFO login ok user=admin"

 text log, before:              JSON log, after:
 INFO login failed user=alice   {"msg":"login failed user=alice
 INFO login ok user=admin         \nINFO login ok user=admin"}
      └─ forged line                  └─ one event, \n escaped

That's log injection (CWE-117). It can fake events, hide real ones, or break your parser. JSON encoders escape \r and \n for free. For text logs, encode them: Log4j 2 %enc{%m}{CRLF}, or Logback %replace(%msg){'[\r\n]', '_'}. Also remember who reads logs: a web log viewer that renders <script> has stored XSS (L32), and a terminal will act on ANSI escape codes.

The worst case is a logger that interprets the data. Log4Shell (L36) happened because Log4j 2 expanded ${jndi:...} lookups inside logged messages. Keep log4j-core on a current 2.x release, and treat any logging feature that evaluates expressions in messages as a bug.

5. Tamper resistance and retention

An attacker who gets in will try to delete the evidence. Logs are only trustworthy if they leave the place the attacker can reach, quickly:

 pod stdout ─▶ node file ─▶ Fluent Bit ─▶ CloudWatch Logs / S3
 (app can     (rotated,     (DaemonSet)    in a log archive
  write)       root can                    account: services
               edit)                       can write, nobody
                                           in prod can delete
        seconds here ◀─────────────────▶ kept for the retention period

Ship off the node within seconds, into an account your workloads can't administer (L52's deep dive). Use UTC and synced clocks (Amazon Time Sync), or timelines won't line up.

Retention is a decision, not a default. A new CloudWatch log group keeps data forever unless you set a period. PCI DSS v4.0 (10.5.1) requires at least 12 months of audit log history, with the latest three months immediately available; a compliance team may require longer for audit trails. Privacy law pushes the other way for debug logs full of personal data. Set it per log type.

🧪 Worked example: a redaction filter as a safety net

Your first defense is not passing secrets to the logger at all. Redaction catches what slips through. In Python, attach the filter to the handler: a filter on a logger isn't applied to records from its child loggers.

PATTERNS = [
    (re.compile(r"(?i)\bbearer\s+[\w.~+/=-]+"), "Bearer [REDACTED]"),
    (re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[SSN]"),
    (re.compile(r"\b(?:\d[ -]?){9,15}(\d{4})\b"), r"[PAN ...\1]"),
]
SECRET_FIELDS = {"password", "token", "authorization", "ssn", "pan"}

class RedactFilter(logging.Filter):
    def filter(self, record):
        msg = record.getMessage()               # merges %s args
        for rx, repl in PATTERNS:
            msg = rx.sub(repl, msg)
        record.msg, record.args = msg, None
        for k in SECRET_FIELDS & record.__dict__.keys():
            setattr(record, k, "[REDACTED]")    # extra={...} fields
        return True                             # keep, redacted

Real output (Python 3.15):

INFO upstream call Authorization: Bearer [REDACTED]
INFO kyc check ssn=[SSN] card=[PAN ...1111]

The Java equivalent with logstash-logback-encoder masks named JSON fields and regex matches:

<encoder class="net.logstash.logback.encoder.LogstashEncoder">
  <decorator
      class="net.logstash.logback.mask.MaskingJsonGeneratorDecorator">
    <defaultMask>[REDACTED]</defaultMask>
    <paths>password,token,authorization,ssn</paths>
    <value>(?i)bearer\s+[\w.~+/=-]+</value>
  </decorator>
</encoder>

Limits: regexes miss formats you didn't predict and can hit innocent 16-digit order IDs (a Luhn check helps). Stack traces are formatted later, so this filter never sees them. Unit-test the filter like any other security control (L59).

🛠 Try it (optional, ~5 min)

Save the filter as redact_demo.py, adding import logging, re at the top and this at the bottom:

handler = logging.StreamHandler()
handler.addFilter(RedactFilter())
handler.setFormatter(logging.Formatter("%(levelname)s %(message)s"))
log = logging.getLogger("payments.api")
log.addHandler(handler); log.setLevel(logging.INFO)
log.info("call Authorization: Bearer eyJhbGciOi.fake.sig")
log.info("kyc ssn=%s card=%s", "123-45-6789", "4111 1111 1111 1111")
log.info("login failed user=%s", "alice\nINFO login ok user=admin")

Run python3 redact_demo.py. The first two lines come out redacted. The third prints two lines: a forged login ok. Now add .replace("\n", "\\n") to the filter's final msg and run it again.

🧠 Key takeaways

  • Log authentication, authorization failures, admin actions, validation failures and high-value business actions, each with who, what, when, where and outcome.
  • Never log passwords, tokens, session IDs, full PANs or SSNs; log an ID, a hash or the last four instead.
  • Structured JSON makes logs searchable and escapes newlines; correlation IDs tie one request together across the gateway and every service.
  • Treat incoming correlation IDs as input: strip or validate them at the edge.
  • Ship logs off the box fast, to a place prod can't delete, and set retention on purpose.

📖 Words to know

Term Meaning
Security event A log entry about authentication, authorization, admin actions or other security-relevant behavior
Structured log A log event written as named fields (usually JSON) instead of free text
Correlation ID One ID carried through every service a request touches and written in each log line
traceparent The W3C Trace Context header carrying trace ID, parent span ID and flags
MDC Mapped diagnostic context: per-request fields that SLF4J/Logback add to every log line
Log injection Input that forges or corrupts log entries, often using CR/LF characters (CWE-117)
Redaction Replacing sensitive values with a mask before a log event is written
Retention How long logs are kept before deletion, set per log type

⏭ Next time

L61: MITRE ATT&CK, the shared map of attacker tactics and techniques that defenders use to decide which of these log events matter most.

Deep dive 1

What the gateway writes down: keeping secrets out of Kong's logs

Every request through Kong can be logged by http-log, file-log, tcp-log and similar plugins. They all use Kong's log serializer, which builds one JSON object per request. It's your best record of who called what, and an easy place to leak credentials.

What's in the object

From Kong's documentation (fields vary a little by version):

 {
   "request":  { method, uri, url, querystring,
                 headers: {...every request header...}, size },
   "response": { status, headers, size },
   "route", "service", "consumer", "authenticated_entity",
   "client_ip", "upstream_uri", "latencies", "started_at",
   "correlation_id"   ← added when correlation-id is on
 }

Kong's PDK docs say the Authorization and Proxy-Authorization request headers are redacted by default, and Kong support says there's no setting to turn that off. Everything else is logged as sent:

Where a credential hides Logged by default?
Authorization: Bearer ... No, shows as REDACTED
apikey: header (key-auth's default name) Yes
Cookie: header with a session ID Yes
?access_token=... in the URL Yes, in querystring, url and upstream_uri
A custom header such as X-Partner-Token Yes

Removing fields

custom_fields_by_lua runs Lua in the log phase. Kong's docs say dots in the key create nested fields and returning nil removes a field. Applying that to nested headers is the natural reading of the docs, but verify it on your version:

plugins:
- name: http-log
  config:
    http_endpoint: http://log-collector.logging.svc:8080/kong
    custom_fields_by_lua:
      request.headers.apikey: "return nil"
      request.headers.cookie: "return nil"
      request.headers.x-partner-token: "return nil"

Two traps. Fields set this way are shared with logging plugins that run later, because they edit the same log table. And the query string appears in three places, so removing querystring alone leaves the token in url and upstream_uri. The real fix is upstream: don't accept credentials in query strings at all (L22, L25).

Also set hide_credentials: true on key-auth, so Kong strips the API key before proxying and your services never see (or log) it. Because the serializer reads headers late in the request, this often removes it from Kong's own log too; check on your version.

Worked example: checking your own gateway on localhost

Run Kong in DB-less mode in Docker on your laptop with a file-log plugin writing to /dev/stdout, a dummy route, and key-auth with a fake consumer key (container name kong-l60). Then:

curl -s 'localhost:8000/demo?access_token=FAKE-QS-TOKEN' \
  -H 'apikey: FAKE-KEY-123' \
  -H 'Authorization: Bearer FAKE.JWT.VALUE' \
  -H 'Cookie: SESSION=FAKE-SESSION'
docker logs kong-l60 2>&1 | tail -1 | grep -oE 'FAKE[-A-Z0-9.]*'

Each FAKE... string that grep prints is a credential the gateway's logs would keep. Fix the config, rerun, and repeat until only the expected one-line request summary remains.

🧠 Extra takeaways

  • Kong redacts Authorization and Proxy-Authorization, not API key headers, cookies or query strings.
  • Remove fields with custom_fields_by_lua returning nil, and remember the query string appears three times.
  • hide_credentials: true keeps API keys away from upstream services and their logs.
  • Test what a gateway logs with fake credentials on localhost; don't assume.
Deep dive 2

Tamper-evident audit trails: hash chains you can verify

L52 protects logs by putting them where nobody can delete them. Sometimes you also need to prove an audit trail wasn't edited: a regulator asks who raised a customer's transfer limit, and the answer must hold up. An application-level audit trail can make edits detectable with a hash chain.

How a chain works

Each record stores the hash of the previous record, and its own hash covers both:

  rec 1                  rec 2                  rec 3
 ┌───────────────┐      ┌───────────────┐      ┌───────────────┐
 │ event: add    │      │ event: limit  │      │ event: delete │
 │ prev: 000...0 │  ┌──▶│ prev: h1      │  ┌──▶│ prev: h2      │
 │ hash: h1 ─────┼──┘   │ hash: h2 ─────┼──┘   │ hash: h3      │
 └───────────────┘      └───────────────┘      └───────────────┘
   h_n = SHA-256( h_(n-1) + canonical JSON of event_n )

Change record 2 and its hash no longer matches. Recompute it, and record 3's prev no longer matches. To hide an edit, you must rewrite everything after it.

Worked example: a payee audit trail

GENESIS = "0" * 64

def entry_hash(prev: str, event: dict) -> str:
    body = json.dumps(event, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256((prev + body).encode()).hexdigest()

def verify(path: str) -> str:
    prev = GENESIS
    with open(path) as f:
        for n, line in enumerate(f, 1):
            rec = json.loads(line)
            if rec["prev"] != prev or \
               rec["hash"] != entry_hash(prev, rec["event"]):
                return f"BROKEN at line {n}"
            prev = rec["hash"]
    return f"OK, head={prev[:12]}"

Three events (payee.add, limit.change, payee.delete by u-ops-7) were appended, then tampered with. Real output:

original                             OK, head=d39901fb3258
"limit.change" edited to look benign BROKEN at line 2
line 2 deleted                       BROKEN at line 2
last line deleted                    OK, head=99c38bc812cb

The last row is the weakness: chopping off the end, or rewriting the whole chain, still verifies. sort_keys matters too: the hash must be computed over a canonical form, or the same event can hash differently.

Anchoring the head

To close that gap, regularly copy the latest hash (the head) somewhere the writer can't change:

  • Write it every few minutes to an S3 bucket with Object Lock in the log archive account (L52). The verifier then checks that the chain still contains each anchored head.
  • Or sign checkpoints with an asymmetric KMS key (kms:Sign) that the audit service can use but not export. Anyone with the public key can verify them (L17).

CloudTrail's log file validation does the same: hourly digest files hold hashes of the log files and chain to the previous digest, signed by AWS. AWS also sold a managed ledger database (QLDB) built on the same idea before retiring it in 2025. For MongoDB, L53's audit log records database operations; a hash chain records business events that only your service understands.

🧠 Extra takeaways

  • A hash chain makes edits and deletions in the middle of a log detectable.
  • Truncation and full rewrites still verify unless you anchor the head somewhere the writer can't touch.
  • Hash a canonical serialization, or verification fails on harmless re-encoding.
  • Tamper evidence complements, not replaces, storage nobody in prod can delete.

The quiz for this lesson and the Tests section need JavaScript.