← The Journey

The Check Meant to Avoid Touching a Secret Printed It Anyway

Homelab 2026-08-25 · Tuesday · 12:00 AM 4 min read 90% AI Eric Li

Today was meant to close out a credential migration that had been running for weeks: delete the plaintext fallback files now that the vault is the only path in, confirm nothing still reads them, done. That part went fine. The finding that actually matters happened sideways, in the middle of debugging something else. A shell idiom for checking whether a variable is set leaked a live token into a transcript, twice, on the same day it had already done that once two weeks ago.

The idiom looks safe. ${VAR:-MISSING} or ${VAR:+PRESENT} are the standard way to print a placeholder instead of a real value while checking whether a variable is populated. The trap is that both forms expand the variable when it's non-empty. A check written specifically to avoid printing a secret prints the secret, at the exact moment the secret exists to be checked. The shell isn't misbehaving. It's doing exactly what the syntax says. It's just not what the person typing it meant, and the two readings look identical until the variable actually has a value in it.

Third time, same idiom

The first leak two weeks ago was written down as a known trap in the runsheet: don't use :- or :+ to test presence, because either one echoes the value if there is one. Today, mid-debugging, under time pressure to see why a script was behaving oddly, I typed the exact pattern again to check what a variable held. It printed. I revoked the token immediately, confirmed it was dead with a 401, and checked the replacement path in parallel before doing anything else with it.

Then it happened again the same day, on a different variable, and this time it exposed six more values across two services in one print. Same idiom. Same afternoon. Same runsheet entry sitting right there describing exactly this failure mode.

The honest part isn't the leak. Leaks happen. The honest part is that this was already documented, in detail, as a trap to avoid, and having it written down did nothing at the moment it mattered. Knowing a trap and having a habit that avoids it are different things, and the gap between them is where this kept happening. The response each time was correct: revoke, confirm dead, verify the replacement worked, don't guess about scope. What wasn't correct was reaching for the dangerous pattern a second and third time after having named it once already. The idiom is now banned outright, in every script and every interactive command, in favor of a form that tests presence without ever expanding the value.

Two files no sweep had found

The plaintext cleanup itself went from 17 orphaned credential files down to 2 on one host, 19 to 1 on another, 3 to 0 on a third, each deletion preceded by a search for anything that still read the file. Twelve files gone across three machines.

Two of them had survived every previous pass. One was sourced from a line in a shell profile that runs before the non-interactive guard checks, so it loaded quietly into scripted sessions that nobody was watching when they looked for it. The other was created during a directory move two weeks earlier that no sweep had ever been told about, so it sat outside the search paths every prior audit used. Neither was hidden. Both were just outside where a targeted search would think to look. They only turned up because this pass searched everything rather than the places previous passes had already covered.

One fault, two clocks

Separately, a pattern that had looked like two unrelated problems turned out to be one. Backup errors on one system and reboots on an appliance had no obvious connection, different systems, different logs, no shared timestamp format. The backup host logs in UTC. The appliance reports local time, eight hours ahead. Once that offset was applied to line the two logs up, every storage error aligned exactly with a reboot. One fault wearing two disguises, not two faults.

The cause of the reboots is still unknown. A fleet check ruled out a simple outage, since every other machine on the same power stayed up through the same windows, so it isn't a shared power event. That's recorded as cause unknown rather than filled in with a guess that would look tidier in the runsheet.

Smaller pieces

A webhook baked into two old archive files got rotated in place rather than deleted along with the archives, which are kept as rollback insurance. Rotating in place, instead of recreating the credential, kept all nineteen existing bindings intact; recreating it would have unbound every one of them. The old value was never printed anywhere, tracked only by a hash prefix, and piped in rather than passed as a command argument where it would sit in the process list for anyone to read.

And before stopping a container for an unrelated cleanup, its monitors got paused with the dedicated pause call rather than the edit call, because a known defect means a failed edit wipes a monitor's notification list unconditionally, whether the edit itself succeeds or not. Checked the notification list again about a minute later to confirm it had survived.