Command Palette

Search for a command to run...

Hectal
Case 2.2·Logs at ScaleSEV1

“Security found full card numbers in our logs. Twelve teams have read access. How many, since when, and where else?”

case name: Card numbers in the log store

Service
shoplite-api · loki
Impact
Full PANs written to logs for 6 days; PCI-DSS and privacy breach review required
Detected by
A security engineer's scheduled PII scan
Time to resolve
2 h to contain; days of follow-up

Skills you'll use on this case

LogQL regex filterscounting exposureredaction in the loggermasking at the collectordeletion and retention

00 It starts

Reproduce this incident in your lab, then work the case alongside the timeline below. Try each query yourself before reading its result.

terminal
$ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"pii"}'
── lab output ──
{"active":["pii"]}
Checkout now logs the full `card` field instead of `card_last4`, like a well-meaning debugging change would. k6 sends the test card 4111 1111 1111 1111.

01 The investigation

  1. 10:30

    REPORT

    Scheduled scan flags 16-digit numbers passing the Luhn check in shoplite logs

  2. 10:34

    QUERY

    Confirm: find lines containing something that looks like a card number

    A line filter |~ applies a regular expression (RE2 syntax) to the raw line, before any parsing, which makes it fast. \b is a word boundary. In reality you'd also match 13–19 digit ranges and separators.

    LogQL· Loki
    {container="shoplite"} |~ "\\b4[0-9]{15}\\b"
    result
    10:34:01 {"level":30,"msg":"checkout started","card":"4111111111111111","productId":3,...}
    10:34:01 {"level":30,"msg":"checkout started","card":"4111111111111111","productId":1,...}
    ...
  3. 10:38

    QUERY

    Size the exposure: how many lines, since when?

    Count matches per hour over the retention window. The first non-zero hour tells you when the bad change shipped, which is the start of the breach window for the incident report.

    LogQL· Loki
    sum(count_over_time({container="shoplite"} |~ "\\b4[0-9]{15}\\b" [1h]))
    result
    {}  21584    (per hour, flat since 2026-09-20 16:00)
  4. 10:45

    ACTION

    Contain first: mask at the collector so no NEW card numbers are stored

    The app fix needs a review and deploy; the collector change takes two minutes. See 'The fix'.

  5. 11:10

    HYPOTHESIS

    Where else did these lines go?

    Logs are copied everywhere: the collector's buffer, Loki, exported bug reports, a SIEM, someone's laptop from a docker logs > debug.txt. The incident isn't over when Loki is clean. Inventory every sink.

  6. 12:30

    RESOLVED

    App redacts at source; existing lines deleted; breach review opened with security

Root cause

A debugging change logged the full request field containing the card number. The logger had no redaction configured and the pipeline had no masking, so sensitive data flowed from one log.info call into long-term storage readable by many people.

02 The concepts behind it

Logs are a data store — treat them like one

Logs usually have weaker access control, longer retention, and more copies than your database, so sensitive data leaks there more often than anywhere else. Card numbers (PCI-DSS), passwords, tokens, session cookies, and personal data (GDPR, DPDP Act) must never be logged. 'Only engineers can see logs' isn't a control when there are hundreds of engineers.

Layers of defence

1) Don't log whole objects: log chosen fields (ShopLite normally logs card_last4). 2) Redact in the logger: pino's redact, logback masking converters, and Python logging filters replace known-sensitive paths even when someone logs the whole request. 3) Mask in the pipeline: regex masking at the collector catches what the first two miss. 4) Detect: scheduled scans for patterns (cards, emails, JWTs, keys). 5) Minimise retention and access.

Deleting from a log store is hard by design

Log stores are append-optimised, with compressed chunks that are shared by many lines. Loki supports targeted deletion (a delete request with a LogQL selector and time range, processed by the compactor) only when deletion is enabled in its configuration, and deletes happen asynchronously. Many systems only support expiry by retention. That's why prevention matters so much more than cleanup.

03 The fix

  1. 01Contain: mask card-like numbers in Alloy

    stage.replace replaces each CAPTURE GROUP in the expression with replace. This masks the first 12 digits of any 16-digit number beginning with 4, 5, or 3, keeping the last 4 for support lookups. Add it to the loki.process pipeline from Case 2.1 and restart Alloy.

    obs-lab/alloy/config.alloyadd to filehcl
      stage.replace {
        expression = "\\b([345][0-9]{11})[0-9]{4}\\b"
        replace    = "************"
      }
  2. 02Fix at the source: pino redaction

    Even if someone logs req.body again, these paths are replaced. Wildcards cover nested objects. Then switch the scenario off.

    obs-lab/shoplite/server.jsadd to filejs
    const log = pino({
      level: process.env.LOG_LEVEL ?? "info",
      redact: {
        paths: ["card", "*.card", "req.body.card", "password", "*.password", "req.headers.authorization"],
        censor: "[REDACTED]",
      },
    });
    terminal
    $ curl -s -X POST localhost:8080/admin/chaos -H 'content-type: application/json' -d '{"scenario":"pii","enabled":false}'
    ── expected output ──
    {"active":[]}
  3. 03Remove what was already stored

    Loki's delete API takes a LogQL selector plus a time range, and requires compactor retention and deletion to be enabled in Loki's config (check the docs for your version). Record the delete request ID in the incident, and verify with the same count query afterwards. For systems without targeted deletion, shorten retention for the affected streams and document the residual window.

    terminal
    $ curl -s -X POST -G 'localhost:3100/loki/api/v1/delete' \
    --data-urlencode 'query={container="shoplite"} |~ "\\b4[0-9]{15}\\b"' \
    --data-urlencode "start=$(date -d '7 days ago' +%s)"
    ── expected output ──
    (204 No Content — deletion scheduled; processed by the compactor)

04 Make sure it never surprises you again

  1. 01Scheduled PII detection

    Run the detection query as a Grafana alert (fire on any match in the last hour) or a nightly job. Add patterns for your own sensitive data: emails, phone numbers, JWTs (eyJ[A-Za-z0-9_-]+\.), cloud keys (AKIA[0-9A-Z]{16}).

    LogQL· Loki
    sum by (container) (count_over_time({container=~".+"} |~ "\\b[345][0-9]{15}\\b|AKIA[0-9A-Z]{16}" [1h])) > 0

05 Your turn: write the query

Run each one against your lab before revealing the answer. Share your own version in the comments; there's usually more than one correct query.

01

After the fix, prove no new lines contain full card numbers in the last 10 minutes.

02

Why is masking at the collector not enough on its own?

06 Interview questions from this case

01

How do you prevent sensitive data from ending up in logs?

02

You discover secrets in logs. What are your first steps?

0/4 · 0%