Secrets Leaking Through "Harmless" Diagnostic Commands¶
The pattern¶
A class of secret exposure that has nothing to do with a system being compromised: a diagnostic or read-only-looking command is run to answer a narrow question (e.g. "what inventory groups is this host in?", "what SMTP config does this app have?", "does this change actually differ from what's live?"), and the command's actual output is far broader than the question — it includes every decrypted secret the tool has access to. If that output lands somewhere persistent (a log, a chat transcript, a support ticket, a terminal scrollback that gets screenshotted), every secret in it is now exposed, whether or not anyone reads that far.
This has now recurred well over half a dozen times on the same fleet, across at least four
distinct mechanisms. The command shape keeps changing — grep, sed line ranges, a bash
:- default-value probe, diff/cat, a decrypt-then-diff on two vault copies, grep
with context flags, and Ansible's own --check --diff — but the shape underneath never
does: a command chosen specifically to check presence, difference, or scope still prints
full content as a side effect.
1. ansible-inventory --host <host> / --list¶
These commands don't show you inventory membership — they resolve and print every
group_vars/host_vars variable for that host, fully decrypted, including every
vaulted secret in scope. Someone asking "is this host in group X?" and reaching for
ansible-inventory --host X gets a wall of plaintext credentials as a side effect of a
membership question.
Safe alternatives for the actual question being asked:
# Topology only, no variable values at all:
ansible-inventory --graph
# Just confirm group membership:
ansible -i inventory.yml <host> --list-hosts -l <group>
# Check one specific variable's value:
grep '<varname>' host_vars/<host>/vars.yml # only safe if that file isn't vault-encrypted
Similarly, ansible-vault view <file> prints the entire decrypted file to stdout — fine
for a human at an interactive terminal who then closes it, much less fine if that stdout
is being captured into a transcript, log file, or piped somewhere.
2. SELECT * on an application's own settings/config table¶
Less obvious, because it isn't a vault or secrets-management tool at all — it's just looking at a normal application database to check its configuration. Many self-hosted apps store integration credentials (SMTP passwords, API tokens, webhook secrets) as a column in an otherwise-ordinary settings table, sometimes in plaintext, sometimes alongside properly-encrypted columns in the same table (which makes it easy to assume the whole row is safe to dump). A query like:
...run to check "is SMTP configured?" also happens to return the plaintext SMTP password
column, because * doesn't discriminate between the column you meant to check and the
one you didn't.
Safe alternative: enumerate only the columns actually needed:
If you're not sure which columns are sensitive ahead of time, check the schema first
(DESCRIBE/\d) and explicitly exclude anything with password, token, secret,
key, or similar in the name — don't SELECT * and hope the sensitive columns aren't in
the result you happen to look at.
3. ansible-vault view <file> | grep -A/-B/-C <pattern>¶
A variant of #1 that looks safer because it is scoped down with grep — but a context
flag (-A after, -B before, -C* both) prints whole neighboring lines in full, not
just the matched line. Run to extract variable *names* only
(ansible-vault view vault.yml | grep -B2 "^vault_b2|^vault_restic"`, say), it instead
prints complete plaintext values — for the matched lines themselves, and for whatever
else happens to sit within the context window of a match, whether or not it was the
intended target. On 2026-09-21 this caught two Forgejo tokens that weren't part of the
search pattern at all, purely because they sat two lines above an unrelated match.
Safe alternative: never pipe decrypted vault content through a display command, even a scoped one. Extract names via a script that parses structurally and never prints a matched value — only the variable name, its length, or a hash:
import subprocess, re
plain = subprocess.run(['ansible-vault', 'view', 'vault.yml'],
capture_output=True, text=True).stdout
for m in re.finditer(r'^(\w+):', plain, re.MULTILINE):
print(m.group(1)) # names only — never print the matched line or its value
4. ansible-playbook --check --diff against a task with no no_log¶
The newest mechanism, and the first one that isn't a manually-run diagnostic command at
all — it's Ansible's own built-in --diff flag, doing exactly what it's documented to do,
against a task that was simply never told to suppress its output. Any
ansible.builtin.template (or community.general.ini_file, or similar) task that renders
a file containing a vault-sourced secret will print that secret's full old and new value
in the diff, every time --check --diff (or a real run with --diff) touches it — unless
the task has no_log: true.
This is worse than the other three in one respect: --check --diff before applying a
change is the recommended safe practice for previewing what a playbook run will do —
exactly the discipline this article otherwise recommends. Following good practice is what
triggers the leak here, not a shortcut.
The fix is structural, not behavioral. The other three mechanisms are fixed by
choosing a narrower command in the moment — a habit that has to be remembered every time.
This one is fixed once, at the task definition, and then can't recur for that task again
regardless of who runs --diff against it or why:
- name: Deploy app.ini
ansible.builtin.template:
src: app.ini.j2
dest: /path/to/app.ini
no_log: true # rendered file embeds a vault secret — required, not optional
notify: restart the-service
Audit every role in your inventory for this gap, don't wait for the diff to prove it's a
problem. A one-off fleet-wide sweep (2026-09-21) — every template/ini_file task,
cross-referenced against every template embedding a password/secret/token/api_key-
shaped Jinja variable — found three roles missing no_log on a secret-bearing task in one
pass. Two of the three had never actually been hit by a --diff (so nothing had leaked
yet), which is the point: the gap is latent until someone runs --diff for an unrelated,
completely reasonable reason.
Why the same mistake recurs¶
Every mechanism above shares a shape: the command looks narrower than it is. --host X
reads like "show me info about host X," not "decrypt and print every secret in scope for
host X." SELECT * reads like "show me the row," not "show me every column including the
ones you didn't ask about." grep -B2 reads like "just show me where this appears," not
"also print two lines of full context around it." --check --diff reads like "show me
what would change," not "print the literal before/after text of anything, secrets
included." The fix isn't remembering a list of forbidden commands — it's a standing habit:
before running any command against a secrets-containing store (a vault, an encrypted
inventory, an app's own settings table, an Ansible task that templates a secret), ask
whether the output could contain more than the specific field you need, and if so, scope
the query down first — or, where the risk lives in a task definition rather than a
one-off command, fix the task itself with no_log: true so the question never needs
asking again for that task.
This is especially relevant when an AI assistant (or any automation) is running commands on your behalf and its output is captured into a transcript, ticket, or log by default — there's no interactive terminal to glance at and think twice before scrolling further. The narrow-question-broad-answer gap that's a minor annoyance for a human at a keyboard is a guaranteed leak in that context.
If it happens anyway¶
- Treat every credential in the output as compromised, not just the one relevant to
the original question — a wide dump usually contains many unrelated secrets at once.
(2026-09-21: a
-B2grep aimed at three variable-name prefixes caught two completely unrelated Forgejo tokens purely by proximity.) - Rotate before assuming impact is low. Check whether the exposed value is actually scoped the way you think — see the companion article on Postfix/Dovecot SASL relay passwords silently doubling as real mailbox passwords, which is exactly this kind of "the blast radius is bigger than the command's stated purpose" trap.
- Check whether rotation is even self-service before promising a timeline. Some of what leaks is API-rotatable in minutes (most B2 application keys); some requires a trip to a web console with no programmatic path at all (a B2 account's master key, a healthchecks.io project API key). Confirm which before telling anyone "already fixed."
- Fix the query, not just the credential. Rotating the secret without changing the habit — or, for mechanism #4, the task definition — that exposed it just resets the clock until the next occurrence.
- If the exposure happened while fixing an earlier exposure, sweep the rest of the
codebase for the same gap before calling it done. The
no_loggap that caused a second exposure on 2026-09-21 existed on two other roles too, neither yet triggered. Finding your own mistake once is a reason to check whether it's systemic, not a reason to assume it's isolated.
Related¶
- Postfix SASL Relay Auth Can Silently Share Your IMAP Mailbox Password
- Ansible: Vault Password File Not Found
- Fleet-Security-Hardening — full incident log, including every occurrence referenced above
- vault-credential-reference — what every credential in the MajorAnsible vault is actually for and who consumes it