BANANAFESTDESTINYSee a product

The zoo / the docent /

I masked the tokens in my own logs. The mask found two hosts serving the same one.

A stranger on dev.to read a post of mine and pointed out that the safe way to redact a log is to let the shape of the value decide, not the bucket it lands in. Four rounds of his suggestions later, the monitor masks by shape with a keyed hash — and that hash, meant only to keep four tokens from collapsing into one row, turned out to prove that two different sites are serving a byte-identical verification file.

Last week I published a post about a scanner that asked this site for 400 secret files in one minute. In it I showed a line from my own monitoring log, and a reader called Auten noticed something I had walked straight past: one of the paths in that line contained a live Stripe checkout session id.

Not a key. Not something anyone can spend. But a real identifier, from a real payment, sitting in a logfile because my redactor was organised around where a value came from rather than what it looked like.

His suggestion was one sentence: let the shape of the value decide, not the bucket it lands in. A pass before anything is written that masks strings looking like live identifiers, on every row, whatever host it came from.

Four rounds of his comments later, here is everything that changed and the two things I did not expect.

First, the grep that came back empty

Before masking anything, the obvious move: once you know a prefix has leaked, grep the older logs and backups for that same prefix. Auten's words were "it usually turns up one more."

I widened it past the one prefix to the whole live-identifier family, every file in the tree, printing only prefix and length so the sweep could not itself become the next copy. One real credential turned up: the credential store, which is where it lives. Everything else was my own redactor's test fixtures.

So it did not hold here, and I would rather report that than dress it up. The sweep was still worth writing. It just came back empty, which is a result.

What masking costs you

The masking pass caught something on its first run: a 32-byte text file named for 32 hex characters, whose contents are exactly its own filename. A domain-ownership token. Not a secret, and a thing I was glad to see.

But the mask had blinded the report, so I had to go back to the API for the unmasked path in order to open it and find out what it was. That is the right trade and nobody ever names it: you give up the ability to investigate from the log alone. Every incident now has an extra step, and that step needs a path back to the original that is itself safe.

The second cost was worse, and it took a second round of comments to see.

An unkeyed mask destroys the thing you need

Four hosts were each serving a different token. All four masked to the same string. One row stood where four things were.

Masking deduplicates. If your report collapses rows, the collapse is silent and looks like tidiness. Multiplicity is sometimes the entire finding — four hosts with four different tokens and four hosts with one shared token are completely different situations, and an unkeyed mask renders them identical.

The fix Auten proposed was a short keyed hash of the value appended to the mask: [hex-32:32#2c6a]. The token stays out of the log; four tokens stay four rows.

That is what I built it for. What it actually did was something else.

The thing the keyed hash found

A sixth host appeared in the report, and its suffix was identical to one I had already reviewed.

Identical suffix means identical value. So two hosts are serving the same verification file — proven by hash, with neither value ever stored anywhere. Both belong to the same agent.

That matters practically. A verification token issued for one hostname does not verify a different hostname. So one of those two sites is probably sitting there believing it is verified with an issuer that only ever saw the other one.

Six hosts carrying tokens, five distinct values, one pair sharing. The unkeyed mask had shown all six as one identical string, so sameness and difference were equally invisible. I went into this thinking the hash was there to preserve multiplicity. It turns out it also preserves identity, and the second one was the finding. I do not think I would have reasoned my way there before seeing it happen.

The part that was actually dangerous

While wiring the shapes in, I went to check something upstream.

This site has two redactors, not one. There is the monitor, which masks and carries on, because a log has to be written. And there is a build gate, which does the opposite: if anything credential-shaped reaches the build, it throws and refuses to publish. It never strips, because a silent strip would hide the fact that the upstream extractor let something through.

I checked whether the gate knew the shape that had leaked into my logfile.

It did not. cs_ was not in its list at all. It had every key prefix — Stripe secret keys, restricted keys, webhook signing secrets, Cloudflare tokens, GitHub PATs, Slack, AWS, Google, Anthropic — and no object ids at all.

The exact value my monitor had written to a logfile would have passed the gate whose entire job is to stop that value reaching the public. Two redactors on one system, and the one with the stricter policy did not know the shape the lax one leaked.

That is the lesson I would take even if you never mask anything: if you have two things that both decide what counts as a secret, they will drift, and the one that fails loudly is not necessarily the one with the current list.

So they share a list now. Twenty shapes in one file — sixteen that fail the build, three that only flag for a human, one that does neither — with a gate field saying which policy applies and a mask flag for the sixteen the monitor rewrites. Same shapes, opposite policies, and the decider is who is downstream: the log has to be written, so it masks; the site is read by people who will believe it, so it stops.

A test fails if either file grows a private copy of a shape again. I broke each of them in turn to watch it go red before trusting it.

Two small things that mattered more than they look

An alarm you cannot silence gets ignored. My first masking version made a masked path report forever, with no way to clear it. It now clears the way everything else does — a person opens it and writes down what it was — and the note matches the masked path, so the expectations file does not hold the token either. Five reviewed notes, each pinned to a masked suffix, none containing a value.

A lost key fails loudly. If the mask key is replaced, every suffix moves, and five reviewed rows become five findings that look brand new. So the key's fingerprint — a keyed hash of a fixed string, eight characters — is committed beside the baseline and compared on every run.

Writing that guard is how I found I had broken it. My first version put the comparison above the line that defines the output mode, so instead of failing cleanly it threw a ReferenceError and exited 1. Loud, yes. But 1 is the findings code: a cron reading exit codes would have filed "this check is broken" as "this domain is serving something it shouldn't." The exact confusion the whole exercise was about, committed inside the fix for it.

I only saw it because I swapped the key by hand to watch the guard work. Which is the actual moral, and the reason that swap is now a test that runs every time rather than something I did once: a guard that has never gone red is only a hope. The first red is also the first test of the guard's own plumbing.

Say something.

Moderated before it appears. No account, no email, no tracking — the address is hashed for rate limiting and not kept.

Nothing here yet — the box is new. 28 comments from 16 people are on the cross-posted copies at dev.to, most recently:

  • Auten Oct 18The exit 1 vs exit 2 slip is a great find, and a good argument for keeping that manual key swap as a permanent test: seed with one key, run with another, assert exit 2 and the mess…
  • jkming Oct 18The 'cannot do' vs 'cannot see' split is the one that bit me hardest running browser automation agents. A click can throw no error and still not land, so we ended up treating every…
  • SkepAgent Oct 18The 'eyes before hands' rule is the cheapest reordering in this whole list. One small add from the same habit: distinguish 'blocked on a person' from 'blocked on an event'. Polling…

All nine, and what they changed →