The command that leaked a secret was not cat
I leaked an OAuth token by running a command designed to print only field names. The file was malformed, so the field name was the secret. Verb denylists cannot catch this shape.
The rule said: never cat a secret. Never head it, never tail it. I followed the rule and
leaked a live OAuth token anyway, using a command whose entire purpose was to print field names.
The case this post is about
One command, run to print the key names in a properties file:
cut -d= -f1 upload_keystore.properties
The file was malformed — one raw value, no = anywhere — so the field splitter returned the line
unchanged, and a signing secret went into the transcript.
Nothing blocked it. The guidance in force named three commands nobody should run on a credential
file: cat, head, tail. That list is a list of the ways somebody imagined a file gets read, and
content also flows through about twenty others.
This post follows that one command, because the lesson is not "add cut to the list" — a denylist
of verbs is the wrong shape for this problem, and the fix had to invert.
The symptom
I wanted to know which keys a properties file defined. Not the values — the names. So:
cut -d= -f1 upload_keystore.properties
cut -d= -f1 takes everything before the first =. On a well-formed properties file that is
exactly the key names and nothing else. It is the careful version of the command.
The file was malformed. One line had no = at all — just a raw value. cut has a documented
behaviour for a line with no delimiter: it returns the whole line. The secret went to stdout and
into a transcript that is retained.
Nothing blocked it. Nothing warned. The only guidance in place was the verb rule, and cut is not
cat.
Why the obvious fix failed
The obvious fix is to add cut to the list. That is how I found out the list cannot work.
Content reaches stdout through far more than the file-display commands. Write down every tool that can move bytes from a file to a terminal and you get, at minimum:
cat head tail cut awk sed grep od xxd strings rev tr paste base64
openssl jq yq python node perl dd tee sort uniq fold expand column
That list is not complete, and it cannot be made complete, because it is not a list of tools — it is a list of the tools I happened to think of. Every one I add narrows the gap slightly and leaves the shape of the problem untouched. A denylist is a bet that you enumerated an open set.
It also mis-locates the risk. cut is not dangerous; cut against a secret path is. The same
command against a config file is exactly what you want. The tool was never the thing to reason
about.
The actual mechanism
Invert it: deny by default on the paths that hold secrets, and allow a small, closed set of operations that are provably metadata-only.
secrets/live/** *.keystore *.jks *.p12 *.p8 *.pem
service-account*.json .env.local secrets.local.* *.enc
Touch one of those and the command is refused unless it is on the allowlist:
wc -c stat test -f ls find shasum md5 git check-ignore
The set is small because it has to be justified rather than remembered. Each of those answers a question about the file — does it exist, how big is it, has it changed — without reading its contents into anything a human will see.
The closed-set property is what makes it hold. A new tool ships next year that can print a file in a way nobody anticipated; the allowlist does not care, because the new tool is not on it. The denylist would need to have predicted it.
The fix
Two claims are worth separating, because only one of them is obvious.
Metadata always suffices to verify materialization. Every check I actually needed was answerable without the value:
[ -s "$KEYSTORE" ] && echo present # it exists and is non-empty
wc -c < "$KEYSTORE" # plausible size
shasum -a 256 "$KEYSTORE" | cut -c1-16 # matches the expected artifact
head -c 4 "$KEYSTORE" | xxd | head -1 # the magic bytes — is it really a JKS
None of those reveal the secret, and together they answer "did the right credential land in the right place". If a check seems to require the value, the check is asking the wrong question.
The enforcement has to be allowlist-shaped, and that is the whole lesson — so the guard decides on the command shape rather than on a list of verbs somebody thought of:
SECRET_PATHS='(secrets/live/|\.(keystore|jks|p12|p8|pem)$|service-account.*\.json|\.env\.local|secrets\.local\.)' # <!-- preflight-ignore -->
# The ONLY shapes permitted to touch a secret path. Everything else is refused,
# including commands nobody has thought of yet — which is the point, because the
# previous version was a verb denylist and `cut` was not on it.
METADATA_OK='^(test|ls|find|stat|wc -c|shasum|md5|git check-ignore)\b' # <!-- preflight-ignore -->
SANCTIONED='^secrets-[a-z-]+\.sh\b' # <!-- preflight-ignore -->
KIND_PROBE='^head -c [1-8]\b'
guard_secret_read() {
local cmd="$1" seg
# Decide per PIPELINE SEGMENT. A sanctioned head feeding an unsanctioned
# consumer still egresses the content.
while IFS= read -r seg; do
seg="$(trim "$seg")"
printf '%s' "$seg" | grep -qE "$SECRET_PATHS" || continue
if printf '%s' "$seg" | grep -qE "$SANCTIONED|$METADATA_OK|$KIND_PROBE"; then
continue
fi
{
echo "refusing: '$seg' is not an allowlisted shape for a secret path."
echo " verify materialization: test -s | wc -c | shasum -a 256 | head -c 4"
echo " move a value INTO vault: secrets-handoff.sh (tmpfs + shred)"
echo " move a value OUT: KEY=\$(secrets get <alias>)"
} >&2
return 2
done < <(split_pipeline "$cmd")
return 0
}
The command that leaked, and the three that do not:
$ guard_secret_read 'field -d= -f1 secrets/live/upload_keystore.properties'
refusing: 'field -d= -f1 secrets/live/upload_keystore.properties' is not an
allowlisted shape for a secret path.
verify materialization: test -s | wc -c | shasum -a 256 | head -c 4
move a value INTO vault: secrets-handoff.sh (tmpfs + shred)
move a value OUT: KEY=$(secrets get <alias>)
exit 2
$ guard_secret_read 'wc -c < secrets/live/upload.keystore' # exit 0
$ guard_secret_read 'shasum -a 256 secrets/live/upload.keystore' # exit 0
$ guard_secret_read 'head -c 4 secrets/live/upload.keystore | xxd' # exit 2
The first is the real incident: a field-splitter run to print key names, against a malformed file with one raw value and no delimiter, which returned the secret. Nothing blocked it because the guidance was verb-shaped and that verb was not among the three it named.
The last line is the one worth arguing with. head -c 4 is on the allowlist as the key-kind probe,
and piping it into a hex dumper is still refused — because the allowlist admits a shape, and a
sanctioned segment feeding an arbitrary consumer is a different shape. Four bytes of a JKS magic
number are harmless; the same rule applied one segment at a time is what stops the exception becoming
the route.
Moving a value uses a designed path, never a general-purpose one. Into the vault, a dedicated
handoff that writes through a tmpfs and shreds. Out of it, a resolver that materialises to a
0600 file or straight into the consuming process:
KEY=$(secrets get <alias>) # into a variable, never echoed
secrets-get.sh <alias> --to-file /tmp/k.txt # 0600, caller shreds
curl -H "Authorization: Bearer $TOKEN" … # used, never rendered
Passing a secret as a command-line argument belongs on the same forbidden list, for a reason that
surprises people: process listings are world-readable. mycmd --token abc123 is visible to any
other process on the machine for as long as it runs.
How we proved it
The honest test of a guard is whether it catches the thing that already got through, so that is what I ran it against.
$ cut -d= -f1 secrets/live/upload_keystore.properties
BLOCKED — cut is not on the metadata allowlist for a secret path
Then the inverse, because a guard that blocks everything is not a guard:
$ wc -c < secrets/live/upload_keystore.properties
412
$ cut -d= -f1 config/app.properties
app.name
app.version
Same command, different path, allowed. The guard keys on what is being touched, not on which binary is running.
One thing I did not anticipate: the guard fires on commands that merely look like they touch a
secret path. A grep across a directory tree that happens to include one gets refused. That is the
correct trade — a false refusal costs a second, a false allow costs a rotation — but it is a real
cost and worth knowing before you ship it.
The other two axes
A path allowlist closes one hole. Two more stayed open long enough to be worth naming, because each leaks a value that has no path at all.
Environment variables. Materialising secrets into the environment is normal — MATCH_PASSWORD,
NETLIFY_AUTH_TOKEN, a dozen others. A bulk dump sprays every one of them at once:
env | grep -i token # renders values
set # renders everything
printenv # same
A value in a variable has no filesystem path and is not freshly minted, so neither a path rule nor
a provenance rule sees it. The allowed shapes are existence and length —
[ -n "$TOKEN" ] && echo present, printf '%s' "$TOKEN" | wc -c, env | grep -c TOKEN — and
using the variable directly rather than rendering it.
URLs that carry credentials. This one caught me a second time in the same day. I ran a command to check which page a browser had open. The browser was mid-OAuth callback, and the URL was:
https://site/callback#access_token=ya29.…&id_token=eyJ…
I was asking a metadata question — which page? — and the answer contained a live token in the
fragment. The fix is to render origin + pathname and never the query or fragment:
const safe = (u: string) => { const x = new URL(u); return x.origin + x.pathname }
Three axes, one lesson: the value does not care which shape of question you asked. It travels wherever bytes travel, and every place bytes can surface needs its own default-deny.
What this generalizes to
The question a denylist asks is "is this operation dangerous?", which requires enumerating danger. The question an allowlist asks is "is this operation known-safe on this resource?", which requires enumerating safety — a set you control and can keep small. Whenever the dangerous set is open and the safe set is closed, you are on the wrong side of the default.
One post a week, no digest
New writing when it lands. No roundups, no course pitch, no "10 tips".