How to check logs for pii

Send requests carrying known synthetic values, then scan every file the service writes, not only the application log. grep -n -o -E finds the addresses and a key-aware Node pass names the field that held each one. In the run below the same address was redacted under user.email and printed in full under ticket.contact.email.

Why check this

This runs on staging before sign-off, and again after any change to a logger, a serializer or a log shipper. A redaction list is written against the payload shape of the day it was added, and it goes stale silently: nothing errors when a field moves, it only stops being covered.

The concrete failure it prevents is a support ticket body reaching a third-party log platform with a customer address and a card number in it, under a field name nobody put on the redaction list. Once shipped, that data is in someone else's retention policy, not yours.

Prerequisites

// pii-service.mjs  Node 22. npm i pino
import { createServer } from 'node:http';
import { appendFileSync } from 'node:fs';
import pino from 'pino';

const app = pino(
  { redact: ['user.email'], timestamp: pino.stdTimeFunctions.isoTime },
  pino.destination({ dest: './app.log', sync: true })
);
const access = (req, status) =>
  appendFileSync('./access.log',
    `${new Date().toISOString()} 127.0.0.1 "${req.method} ${req.url}" ${status}\n`);
const body = async (req) => { let s = ''; for await (const c of req) s += c; return s ? JSON.parse(s) : {}; };

createServer(async (req, res) => {
  const url = new URL(req.url, 'http://127.0.0.1');
  let status = 404;
  if (url.pathname === '/orders') {
    const b = await body(req);
    app.info({ user: { email: b.email }, payment: { card: b.card } }, 'order created');
    status = 201;
  } else if (url.pathname === '/support') {
    const b = await body(req);
    app.info({ ticket: { contact: { email: b.email }, text: b.text } }, 'ticket opened');
    status = 201;
  } else if (url.pathname === '/profile') {
    app.info({ user: { email: url.searchParams.get('email') } }, 'profile read');
    status = 200;
  }
  res.writeHead(status, { 'content-type': 'application/json' });
  res.end(status === 404 ? '{}' : '{"ok":true}');
  access(req, status);
}).listen(8317, '127.0.0.1', () => console.log('pii-service on 127.0.0.1:8317'));

Steps

  1. Step 1.

    Start the target. Pick a port nothing else on your machine is using.

    node pii-service.mjs
    
    pii-service on 127.0.0.1:8317
  2. Step 2.

    Drive three requests that carry the seed values through three different code paths: a JSON body, a free-text field, and a query string.

    curl -s -X POST http://127.0.0.1:8317/orders -H 'content-type: application/json' \
      -d '{"email":"dana.roswell@ordermail.test","card":"4539598123456787"}' \
      --next -s -X POST http://127.0.0.1:8317/support -H 'content-type: application/json' \
      -d '{"email":"dana.roswell@ordermail.test","text":"card 4539598123456787 was declined, call +1 555 0147"}' \
      --next -s 'http://127.0.0.1:8317/profile?email=dana.roswell@ordermail.test&plan=pro'
    
    {"ok":true}{"ok":true}{"ok":true}
  3. Step 3.

    Read the application log.

    cat app.log
    
    {"level":30,"time":"2026-09-12T07:27:49.547Z","pid":44816,"hostname":"kharkivlad","user":{"email":"[Redacted]"},"payment":{"card":"4539598123456787"},"msg":"order created"}
    {"level":30,"time":"2026-09-12T07:27:49.550Z","pid":44816,"hostname":"kharkivlad","ticket":{"contact":{"email":"dana.roswell@ordermail.test"},"text":"card 4539598123456787 was declined, call +1 555 0147"},"msg":"ticket opened"}
    {"level":30,"time":"2026-09-12T07:27:49.551Z","pid":44816,"hostname":"kharkivlad","user":{"email":"[Redacted]"},"msg":"profile read"}

    Line 1 carries a [Redacted] token and a card number in the clear on the same line. A redaction marker is evidence about one field, never about the line.

  4. Step 4.

    Search both files for the seed address. Name the access log explicitly: it is written by different code and no logger rule applies to it.

    grep -n -o -E "[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}" app.log access.log
    
    app.log:2:dana.roswell@ordermail.test
    access.log:3:dana.roswell@ordermail.test

    Two hits for one address. The application log was redacted on the route that used user.email and not on the route that used ticket.contact.email. The access log kept the query string of the third request whole.

  5. Step 5.

    Run a key-aware pass, so each hit is reported with the field that held it. Save this as scan-pii.mjs.

    // scan-pii.mjs  usage: node scan-pii.mjs app.log access.log
    import { readFileSync } from 'node:fs';
    
    const RULES = [
      ['email', /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g],
      ['card', /\b(?:\d[ -]?){13,19}\b/g],
      ['phone', /\+\d[\d ()-]{7,}\d/g],
    ];
    const luhn = (s) => {
      const d = [...s.replace(/\D/g, '')].reverse().map(Number);
      return d.length >= 13 && d.reduce((a, x, i) => a + (i % 2 ? (x * 2 > 9 ? x * 2 - 9 : x * 2) : x), 0) % 10 === 0;
    };
    const walk = (v, path, out) => {
      if (v && typeof v === 'object') { for (const k of Object.keys(v)) walk(v[k], path ? `${path}.${k}` : k, out); return; }
      for (const [name, re] of RULES) for (const hit of String(v).match(re) ?? []) {
        if (name === 'card' && !luhn(hit)) continue;
        out.push([name, path, hit]);
      }
    };
    for (const file of process.argv.slice(2)) {
      readFileSync(file, 'utf8').split('\n').filter(Boolean).forEach((line, i) => {
        const out = [];
        try { walk(JSON.parse(line), '', out); } catch { walk(line, 'raw', out); }
        for (const [name, path, hit] of out) console.log(`${file}:${i + 1}  ${name.padEnd(5)}  ${path.padEnd(20)}  ${hit}`);
      });
    }
    
    node scan-pii.mjs app.log access.log
    
    app.log:1  card   payment.card          4539598123456787
    app.log:2  email  ticket.contact.email  dana.roswell@ordermail.test
    app.log:2  card   ticket.text           4539598123456787 
    app.log:2  phone  ticket.text           +1 555 0147
    access.log:3  email  raw                   dana.roswell@ordermail.test

    Five findings, four field names, and the Luhn filter kept timestamps and process ids out of the card rows. ticket.text is the row that matters: two values arrived inside a message a user typed, where no field-level rule would have looked.

  6. Step 6.

    Measure how deep the redaction list reaches. Save this as redact-paths.mjs and run it.

    // redact-paths.mjs  what a pino redact list does and does not cover
    import pino from 'pino';
    const payload = {
      user: { email: 'a@ordermail.test' },
      ticket: { contact: { email: 'a@ordermail.test' } },
      request: { body: { user: { email: 'a@ordermail.test' } } },
    };
    for (const paths of [['user.email'], ['*.email'], ['*.*.email'], ['user.email', '*.*.email']]) {
      const out = [];
      pino({ redact: paths, base: null, timestamp: false }, { write: (c) => out.push(c) }).info(payload);
      console.log(JSON.stringify(paths) + '\n  ' + out.join('').trim());
    }
    
    ["user.email"]
    {"level":30,"user":{"email":"[Redacted]"},"ticket":{"contact":{"email":"a@ordermail.test"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}}
    ["*.email"]
    {"level":30,"user":{"email":"[Redacted]"},"ticket":{"contact":{"email":"a@ordermail.test"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}}
    ["*.*.email"]
    {"level":30,"user":{"email":"a@ordermail.test"},"ticket":{"contact":{"email":"[Redacted]"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}}
    ["user.email","*.*.email"]
    {"level":30,"user":{"email":"[Redacted]"},"ticket":{"contact":{"email":"[Redacted]"}},"request":{"body":{"user":{"email":"a@ordermail.test"}}}}

    * stands for exactly one key, not for any depth. *.email covers depth two and leaves depth three alone, *.*.email swaps which one is covered, and the two together still miss request.body.user.email. Every depth a payload can reach needs its own entry.

How to read the result

| What you see | What it means | What to do | | --- | --- | --- | | A hit in access.log only | The value travelled in the URL, where the logger never sees it | Move the parameter into the body or the path, and stop logging the query string. | | A hit under a key the redact list does not name | The payload shape moved and the list did not | Add the path, then add a test that fails when a new shape appears. | | A hit in a free-text field | A user typed it, or a service copied a body into a message | Field-level redaction cannot fix this. Match on the value, not on the key. | | [Redacted] on one key, raw data on the next | The list covers that one field | Read the whole line before calling it clean. |

Common mistakes

Sign: The application log is clean and the address still leaked.Cause: The access log is written by different code, and the logger's redaction never reaches it. In the run above the same address appears at app.log line 2 and access.log line 3, the second time because it rode in a query string.
Sign: A redact path stops matching after a refactor that only moved a field.Cause: pino's wildcard matches exactly one key. `*.email` redacted user.email and left ticket.contact.email in the clear, and adding `*.*.email` reversed which one was covered. There is no recursive wildcard, so the list is bound to the object shape it was written against.
Sign: A review of field names says the payload holds no card data.Cause: The card and the phone number arrived inside ticket.text, a free-text field a user typed into. Scanning by key name finds neither. A value-level pass with a Luhn check found both.

What to check next

FAQ

What counts as PII in a log line?

Anything that identifies a person on its own or combined with the rest of the line: address, phone, full name, national id, card number, precise location, and the client IP address, which several jurisdictions treat as personal data. Session and user ids count when your own systems can resolve them to a person.

How does PII masking in logs work?

A logger rewrites named paths before serialisation, as pino's redact does. It is a key-based mechanism, so it covers the fields it was told about at the depth it was told about. Free text and values that arrive under a new key pass through untouched.

Can I check for PII in logs without a scanner?

For one known value, yes. Seed a request with a synthetic address and grep the log files for it, as step 4 does. That proves a route, not a service. Anything wider needs a pass over every field, because you cannot grep for values you have not seen.

Where does PII enter logs when nobody logs it on purpose?

Query strings copied into an access line, request bodies attached to error objects, free-text fields, and any place a whole object is passed to the logger instead of named fields. All four cases print data that no line of code names.

Verified

Verified by Maks Vernynode 22.23.2pino 9.14.0curl 8.21.0grep GNU grep 3.0

Each output block is what the command above it printed on that date, on the host named in the step. Figures read from a live site move between runs. Compare the shape of the answer rather than the digits, and see the methodology for how a page is re-verified.

intermediate12 minpublished updated Maks Verny