Shipping

The command that leaked a secret was not on the list

A rule naming forbidden commands cannot hold, because any program that reads bytes can emit them. Enforcing a property of output needs an allowlist.

MobileByteSensei 10 min read
A vessel with blanking plates bolted over the ports somebody thought of
secretsguardrailssecurity

The intent was to list the key names in a credential properties file. Not the values — the names, to confirm the right fields were present.

A properties file is name=value per line, so taking the part before the first = is a metadata operation by construction. Except this file was malformed: one line, a raw value, no = anywhere in it. Given no delimiter, the command returned the whole line.

The secret was now in the transcript, and nothing had stopped it.

The case this post is about

One command, run to list the key names in a properties file, against a file that turned out to be malformed:

cut -d= -f1 upload_keystore.properties

No = anywhere in the line, so the field splitter returned it unchanged and a signing secret went into the transcript.

The rule in force said: never cat, head or tail a credential file. Three verbs, all the obvious ones, written by somebody imagining how a file gets read. cut was not among them, and neither are the twenty other commands that also emit file content.

This post follows that one command, because adding cut to the list is the wrong fix, and the right one required inverting the shape of the rule entirely.

The symptom

The first thing to sit with is that the command was correct. Splitting on = and taking field one cannot emit a value — that is a property of the operation, provable from its definition, and it is why reaching for it felt safe. The reasoning was sound and the conclusion was wrong, because the proof assumed a file shape that the file did not have.

That is the whole shape of this failure: a safety argument that holds for well-formed input and says nothing about malformed input. Those are exactly the files you are most likely to be poking at, because a well-formed one would not have needed investigating.

One port without a plate, because nobody wrote it on the list

The second thing is that a guard existed and did not fire. There was a rule, it was specific, and it was written in the natural way: never cat, head or tail a credential file. Every word of it is good advice. The command here was cut, which is not on that list, and the guard had nothing to say.

The third is the recovery, which is to say there is none. Once a value is in a transcript it has been disclosed. You cannot retract it, you cannot know with certainty where the transcript has been copied, and deleting your local copy changes nothing about that. The only real remedy is rotation — and for something like signing material, rotation is not a button. It is coordinated work across a vault, a keystore and whatever has already been published with the old key.

The fourth is how ordinary the moment was. This was not a risky operation anybody hesitated over. It was a two-second check, mid-task, on the way to something else, chosen because it was the safe way to look at the file. The whole event lasted one command and produced no error.

Why the obvious fix failed

Add cut to the list. The immediate reflex, and following it for thirty seconds is enough to kill it. If cut belongs on the list, so does awk, and sed, and grep, and od, xxd, strings, rev, tr, paste, base64, openssl, jq, python, node, dd, tee. The list does not converge, because the membership rule is any program that can read bytes and write bytes, which is very close to every program.

Plates going on one by one, while the vessel keeps finding new ports

A denylist is the right tool for a closed set of actions. This is not a closed set. Every denylist entry here is a correct observation and the collection of them can never be a control.

Scan the output and redact anything secret-shaped. Works well for things with a shape — a token with a recognisable prefix, a private key header, a structured credential. It does nothing for the cases that matter most, because a keystore password is an arbitrary string with no shape, and the value in this incident would have passed any pattern-based scan cleanly. Output scanning raises the floor and cannot be the floor.

Trust the operator's intent. The uncomfortable one, because the intent here was genuinely correct. The operation was chosen specifically to avoid emitting a value. What defeated it was the input, which nobody had looked at — and the reason nobody had looked at it is that looking at it was the dangerous thing being avoided. Intent is not a control, because it is formed before the input is known.

The actual mechanism

Stop asking is this command dangerous and start asking is this command on the list of operations that cannot emit content.

That inversion is the entire fix. It changes an unbounded question into a small one: there are not many operations that genuinely only return metadata, and they can be written down.

default:  any command touching a secret path is REFUSED
allowed:  the sanctioned secret-handling scripts
          metadata only — size, existence, listing, digest, ignore-status

A file's size, its existence, its permissions, its hash, whether it is covered by an ignore rule — all of these are answerable without a byte of content reaching anybody, and the list is short enough to enumerate completely. Everything else is refused, including things that are probably fine, because "probably fine" is what cut was.

The principle that makes this liveable, and the one I would keep if I kept nothing else:

Metadata always suffices to verify materialisation. If a check appears to need the value, the check is wrong.

Weighing the sealed vessel, which answers the question without opening it

Every legitimate question I have ever had about a provisioned secret — did it arrive, is it the right size, is it the same one as before, is it excluded from version control — is answerable from metadata. The urge to look at content is almost always the urge to eyeball something a comparison would answer better, and treating that urge as a signal of a badly-designed check rather than a legitimate need is what makes default-deny tolerable rather than infuriating.

Moving the contents through the designed transfer, with nothing opened to the room

There is a second defect in this story that the guard does not touch, and it would be dishonest to let the guard take all the credit for the lesson. The file should not have been malformed. Something produced a credential file containing a bare value where a name=value line belonged, and that malformation is what turned a safe operation into an unsafe one. A guard that refuses the read leaves that intact: the next thing to consume that file still gets a surprise, it just gets it somewhere quieter.

Both repairs are real and they are not substitutes. The guard addresses the blast radius — nothing reaches the transcript regardless of what the file contains. The malformation is a separate fix in whatever wrote the file, and I note it here because it is the kind of upstream cause that a satisfying downstream control makes very easy to stop looking for.

Moving a value, when you genuinely must, goes through designed paths that never pass it through a display: an intake that writes to temporary storage and shreds it, or a command substitution that hands the value directly to the consumer. The guard does not make moving secrets impossible. It makes moving them through the transcript impossible, which is a different and much narrower thing.

The fix

Enforcement sits in front of command execution, and the shape matters more than the detail:

if the command touches a secret-shaped path
    and it is not a sanctioned secret-handling script
    and it is not in the metadata allowlist

        refuse

The allowlist shape is the entire design. A denylist of commands that read files is a list nobody finishes — the leak that prompted this came from cut, which was on nobody's list:

SECRET_PATHS='secrets/live/|\.(keystore|jks|p12|p8|pem)$|service-account.*\.json|\.env\.local'   # <!-- preflight-ignore --> a regex, not a value

# ALLOWLIST: metadata only. Everything here answers "does it exist / how big",
# and nothing here can emit content.
METADATA='^(wc|stat|test|ls|find|shasum|md5|file|git check-ignore)\b'   # <!-- preflight-ignore --> a regex, not a value
SANCTIONED='^secrets-[a-z-]+\.sh\b'   # <!-- preflight-ignore --> a regex, not a value

guard() {
  local cmd="$1"
  printf '%s' "$cmd" | grep -qE "$SECRET_PATHS" || return 0      # not a secret path

  printf '%s' "$cmd" | grep -qE "$SANCTIONED" && return 0
  printf '%s' "$cmd" | grep -qE "$METADATA"   && return 0

  cat >&2 <<'MSG'
refusing: command touches a secret-shaped path and is not metadata-only.
  content NEVER reaches the transcript. To verify materialisation use:
    wc -c <path>   stat <path>   test -f <path>   shasum <path>
  to move a value: secrets handoff   |   secrets push --stdin
MSG
  return 2
}

The command that actually leaked, and what happens now:

$ field-split -d= -f1 secrets/live/upload_keystore.properties
refusing: command touches a secret-shaped path and is not metadata-only.
  content NEVER reaches the transcript. To verify materialisation use:
    wc -c <path>   stat <path>   test -f <path>   shasum <path>

That command was intended to print key names. The file was malformed — one raw value, no = — so it printed the secret instead. A verb denylist would have had to anticipate cut; the allowlist did not need to anticipate anything. refuse, exit non-zero, name the path and the alternative


The allowlist carries one deliberate hole worth naming out loud: reading the first few bytes is
permitted, because identifying what *kind* of key a file holds requires its header and a handful of
bytes cannot carry a credential. That is a judgement call with a real if small cost, and it is in
there on purpose rather than by oversight.

The bypass is human-only and cannot be produced by the thing being guarded. That constraint does most
of the work: a guard that an automated process can lift is a guard with an API, and the first
genuinely inconvenient refusal is when it gets called.

And the refusal has to name what to do instead. "Refused" sends somebody looking for a way around.
"Refused — use the handoff for intake, or command substitution to pass it to a consumer" sends them
to the designed path, which is the only outcome that actually reduces risk.

## How we proved it

**The incident is the evidence, and it is precise.** A command was run to print key names from a
credential properties file. The file was malformed — a bare value with no `=` — so the field-splitting
operation returned the entire line. The value reached the transcript. No guard fired, because the
guard enumerated commands and this command was not among them.

Three things are provable from that single event: the operation was chosen for safety reasons, the
guard was well-written and insufficient, and the gap between them was a category error rather than an
oversight. That is more than a frequency count would have told me.

**The response was rotation**, not a note. A value that has been disclosed is disclosed; the only
thing that restores the property you wanted is a new value. For signing material that is coordinated
work rather than a single command, and that cost is the honest measure of what the leak was worth.

**What this does not cover:** commands typed in a human's own terminal. The enforcement sits in the
tooling path, so a person with a shell can read whatever they can read. That is the correct boundary
— you cannot guard a person's own machine from them — and it means the control's real scope is
automated and assisted work, which is where this failure happened and is not where all such failures
happen.

**And a limitation I am still uneasy about:** the path patterns. The guard recognises secret-shaped
paths by convention — directories and extensions that credentials live in. A credential sitting
somewhere unconventional is not covered at all, and nothing detects that case. The allowlist is sound;
the set of things it is applied to is a pattern match, and that is the soft part of an otherwise hard
control.

## What this generalizes to

When you are enforcing a property of **output** rather than a property of an action, a denylist
cannot work.

Actions are enumerable: you can list the four ways to merge a pull request. Outputs are not, because
anything that can read and write is a potential path, and the list of such things in a general-purpose
environment is effectively open. Any rule of the form "never use X to look at Y" is a correct
observation that will be defeated by the next tool somebody reaches for — not maliciously, but because
they picked a reasonable instrument that nobody had thought to name.

So enumerate what is permitted. It feels more restrictive and is usually smaller than people expect,
because the set of operations that genuinely cannot leak is short and the set that can is unbounded.
And when the allowlist feels too tight, the useful question is not what to add to it — it is why a
check in your workflow needs more than metadata, because that is nearly always where the real defect
is.

One post a week, no digest

New writing when it lands. No roundups, no course pitch, no "10 tips".

Follow by RSS