“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
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.
01 The investigation
- 10:30
REPORT
Scheduled scan flags 16-digit numbers passing the Luhn check in shoplite logs
- 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.\bis a word boundary. In reality you'd also match 13–19 digit ranges and separators.LogQL· Loki{container="shoplite"} |~ "\\b4[0-9]{15}\\b"result10:34:01 {"level":30,"msg":"checkout started","card":"4111111111111111","productId":3,...} 10:34:01 {"level":30,"msg":"checkout started","card":"4111111111111111","productId":1,...} ... - 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· Lokisum(count_over_time({container="shoplite"} |~ "\\b4[0-9]{15}\\b" [1h]))result{} 21584 (per hour, flat since 2026-09-20 16:00) - 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'.
- 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. - 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
01Contain: mask card-like numbers in Alloy
stage.replacereplaces each CAPTURE GROUP in the expression withreplace. 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 theloki.processpipeline 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 = "************" }02Fix at the source: pino redaction
Even if someone logs
req.bodyagain, 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":[]}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
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· Lokisum 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.
After the fix, prove no new lines contain full card numbers in the last 10 minutes.
Why is masking at the collector not enough on its own?
06 Interview questions from this case
How do you prevent sensitive data from ending up in logs?
You discover secrets in logs. What are your first steps?