A few weeks ago I was reviewing logs from a side project and found an email address sitting right there in plain text, in a log line I'd written months earlier and completely forgotten about. Nothing bad happened, but it bugged me enough that I went looking for a fix. What I found was a pile of tools that all had the same shape: "sure, we'll redact your logs, just tell us which fields to redact first."
Pino has a built-in redact option. There's fast-redact. There's mask-json. All good tools, all doing exactly what they say. But every single one of them wants a list of paths up front — req.headers.authorization, user.password, body.creditCard, and so on. Which means the day someone on your team adds a new field with a customer's phone number in it, nothing redacts it, because nobody told the tool to look for it.
That gap — detecting PII by what it looks like instead of by what it's named — is what I ended up building. It's called piiguard, and it's a thin wrapper around Winston and Pino, not a new logger.
What it actually does
You wrap your existing logger, and it auto-detects emails, credit card numbers, SSNs, phone numbers, JWTs, and API-key-shaped strings by pattern, plus a list of common secret field names (password, token, apiKey, etc.) by key name. Zero config to get the baseline working.
import pino from 'pino';
import { wrap } from 'piiguard';
const logger = wrap(pino(), { adapter: 'pino' });
logger.info({ email: 'jane@example.com', password: 'hunter2' }, 'user signed up');
// -> { "email": "j***@e***.com", "password": "[REDACTED]", "msg": "user signed up" }
Two things worth calling out in that output. First, the email is partially masked, not fully redacted — I went back and forth on this, but landed on partial masking as the default because full [REDACTED] everywhere makes your logs useless for actually debugging anything. You still want to know "oh, this is the jane@ account having trouble" without the full address sitting in plaintext. Second, the password is fully redacted no matter what, because there's no safe way to partially reveal a credential. That distinction — PII gets masked, secrets get nuked — runs through the whole design.
The part that was harder than I expected
Getting the regex patterns right was the easy 20%. The other 80% was making sure the wrapper didn't break the logger it was wrapping, which turned out to have more sharp edges than I assumed going in.
Pino's hooks only work at construction time. I initially tried hooking into hooks.logMethod via logger.child({}, { hooks: {...} }), assuming child loggers could add hooks retroactively. They can't — Pino only accepts that option when you first create the logger. So instead I wrap the actual log methods (info, error, etc.) directly, which works regardless of how the original logger was constructed, at the cost of one extra function call per log line.
Winston carries internal symbols on the info object. Winston attaches Symbol.for('level') and Symbol.for('message') to the log object internally, and my first version of the redaction walker rebuilt the object using Object.entries(), which only sees string keys. Result: symbols silently dropped, and the rest of the format pipeline downstream had no idea what to do with the mangled object. Log lines just... stopped appearing, no error, nothing. Took me a minute to figure out why.
Error objects were the sneaky one. message and stack on a plain new Error(...) aren't enumerable own properties, so my object walker skipped straight past them. Which means if you did logger.error(err, 'request failed') and err.message contained something like "failed for jane@example.com", none of it got redacted — because the walker never even saw it as a field to check. The whole point of the library, silently failing on one of the most common logging patterns there is.
Buffers exploded into garbage. Object.entries() on a Buffer iterates every single byte as a numeric key. A ten-byte buffer became a ten-key object like { '0': 104, '1': 101, ... }. Not a redaction bug exactly, just a "this tool corrupts your binary payloads" bug, which is arguably worse.
None of these showed up in my first round of unit tests, because my unit tests imported straight from src/, not from the actual built and packaged output. The fix that mattered more than any individual bug fix was making myself install the real published tarball into a separate throwaway project and run it there, the way an actual consumer would. That's where the Winston symbol bug and the Error/Buffer bugs actually surfaced.
A TypeScript lesson I didn't expect
Here's a fun one. wrap() needs to behave differently depending on the adapter — for Pino it hands you back a wrapped logger, for Winston it hands you back a Format object to plug into format.combine(...). I typed it with a single generic:
function wrap<T>(loggerOrWinston: T, options: PiiGuardOptions): T
Which is wrong, because the Winston branch doesn't return T at runtime — it returns something else entirely. TypeScript didn't catch this until someone actually tried to use it against the real winston types, at which point it threw a genuinely confusing error about Format being incompatible with typeof winston.
The fix was proper overloads, one per adapter. But while fixing that I ran into something I hadn't seen before: my internal duck-types for "something that looks like a Pino logger" used Record<string, unknown>, which works completely fine as a plain type annotation, but fails as a generic constraint (T extends Record<string, unknown>) against real class instances that don't declare an explicit index signature — which Pino's actual Logger type doesn't. Assignability and constraint-satisfaction aren't the same check in TypeScript, and they diverge in exactly this case. I only found it because I typechecked against the real pino/winston type declarations instead of trusting my own hand-rolled test doubles.
Where it's at now
It's up on npm as piiguard, MIT licensed, TypeScript throughout, dual ESM/CJS build. Pino and Winston are optional peer dependencies so you only pull in whichever one you actually use.
npm install piiguard
# or
yarn add piiguard
It's genuinely a small library — the whole point was to do one narrow thing well rather than become a logging framework. If you've got PII quietly leaking into logs because nobody remembered to add a field to a redact list, this is built for exactly that problem.
Repo's on GitHub if you want to look at how it's put together or file an issue: github.com/Dreamyplayer/piiguard
Top comments (0)