One expression decides:
/secret|key|token|password|authorization/i,
tested against the key names in the data payload, not the
values. A match replaces the value with
[redacted] and leaves the key in place,
so the trail still shows that a field was there. It recurses
through nested objects and arrays, and stops at a depth limit
rather than following a cycle forever.
It runs at write time. Redacting on read would be the easier
change and the wrong one: the secret would still be sitting in
the database file, and the file is the thing that leaves the
building — copied to a laptop, attached to a bug report, handed
to whoever is debugging this on Friday. An API response that
hides a value the disk still holds is a courtesy, not a control.
It over-catches on purpose.
idempotencyKey contains "key" and gets
redacted along with everything else, and that is fine — losing a
harmless field from a trail is a cosmetic problem, and the
mistake in the other direction is not.
Now the part that matters: this is a denylist, and
denylists leak. It catches key names it knows. It will
not catch a live credential that an agent pasted into a
free-text description, because the key
is called "description" and nothing about the value says
otherwise. The real defence is not putting secrets in there in
the first place. Redaction is the backstop, not the plan, and
treating it as the plan is how a key ends up in an append-only
table that by design cannot be edited to remove it.
what the caller passes
appendAudit(db, {
projectId,
event: "policy.evaluated",
data: {
apiKey: "adeia_sk_live_9f3k…",
amountCents: 100,
},
});
what the row holds
{"apiKey":"[redacted]","amountCents":100}
A test asserts exactly this: that the string
amountCents is present in the stored
bytes and the secret is not.