Playbook: Credential Leakage via Shell Init Files (declare -x / export at login)
Status: STABLE Related issue: #2984 (GITHUB_TOKEN re-exposure, root cause fix)
What triggers this problem
A credential file is loaded with plain source <file> (or . <file>) directly
in a shell init file (.bashrc, .bash_profile, .profile, /etc/profile.d/*.sh).
If that file’s lines use export VAR=value, sourcing it — even once, from
.bashrc — makes bash mark those variables declare -x (exported) for the
entire shell session. Because exported variables are inherited by every
child process spawned from that shell (subshells, git, curl, ssh to other
hosts, tmux panes, cron-adjacent runuser -l sessions, CI runner steps, etc.),
the credential silently propagates far beyond its intended scope. Nothing has
to go wrong for a leak to happen — any of those child processes can echo their
own environment, get logged, crash with an env dump, or get piped somewhere
that lands in chat/CI output, and the secret rides along.
This is exactly how GITHUB_TOKEN was re-exposed on 2026-06-29 (see #2984): a
child process invoked from a root/claude-runner shell on vps-i1 inherited
GITHUB_TOKEN because it had been unconditionally sourced and exported at
login, not because anything explicitly printed the token on purpose.
How to confirm it
Never cat/print the files below — only check variable names.
# 1. Does any shell-init file unconditionally source a credential file?
grep -n '^source \|^\. ' /root/.bashrc /root/.bash_profile /root/.profile \
/home/*/.bashrc /home/*/.bash_profile /home/*/.profile \
/etc/profile.d/*.sh /etc/bashrc /etc/profile 2>/dev/null
# 2. Does a fresh LOGIN shell already have sensitive vars exported?
# (names only — never pipe this through anything that echoes values)
bash -lc 'declare -x' | grep -oE 'declare -x [A-Z_]+' | grep -iE 'token|key|secret|password'
# 3. Same check for a specific user via su
su -s /bin/bash -l <user> -c 'declare -x' | grep -oE 'declare -x [A-Z_]+' | grep -iE 'token|key|secret|password'
# 4. Does root's *own* env already carry it (before running anything else)?
env | grep -c 'SUSPECT_VAR_NAME' # count only, 0/1 — never print the lineIf step 2 or 3 lists any credential-shaped name, shell init is auto-exporting it to every child process on that host — this is the bug.
Step-by-step fix
-
Back up before touching anything:
cp -a /root/.bashrc /root/.bashrc.bak-<issue> cp -a /home/<user>/.bashrc /home/<user>/.bashrc.bak-<issue> cp -a /root/.claude-env /root/.claude-env.bak-<issue> # or whatever the sourced file is -
Remove the unconditional
source <credential-file>line from every shell init file. Do not just delete the file — check first whether any script depends on it (see step 3), then replace the line with a comment explaining why it’s gone. Prefer editing via a small Python/sed pass over the file rather than eyeballing manual edits, so the change is exact and diffable. -
Check for legitimate consumers before deleting the underlying credential file itself:
grep -rln '<credential-file-basename>' /root /opt/p24-infra --include='*.sh' 2>/dev/null crontab -l 2>/dev/null | grep -i <credential-file-basename> crontab -l -u <user> 2>/dev/null | grep -i <credential-file-basename>A script that does
source /path/.claude-envinside its own scope (e.g. inside arunuser -l <user> -c '...'block, sourced right before use) is the correct, scoped pattern — leave those alone. The bug is specifically the shell-init-level auto-source that runs on every login/subshell, not a script explicitly loading what it needs. -
Strip genuinely dead credentials from the file (e.g. keys for a decommissioned service — Trello, Infisical) rather than leaving them at rest for no reason. Never touch the value of a credential that is still active/pending human rotation — that’s a separate Tier 3 action; only remove the auto-export mechanism and dead entries.
-
Do not dedupe or reorder remaining live
export VAR=lines if you can’t be sure which one is currently “in effect” (bash last-assignment-wins on sequential sourcing) — reordering can silently flip which value is live. Leave live credential lines exactly as found; only remove what’s provably dead or the auto-source mechanism itself.
Verify
Open a fresh SSH session / login shell (not a reused connection) and confirm the previously-leaking names are gone — by name, never by printing values:
ssh root@<host> "bash -lc 'declare -x' | grep -oE 'declare -x [A-Z_]+' | grep -iE 'token|key|secret|password'"
# Expect: no output (or only genuinely non-secret names)
su -s /bin/bash -l <user> -c "declare -x" | grep -oE 'declare -x [A-Z_]+' | grep -iE 'token|key|secret|password'
# Expect: no outputAlso re-run whatever legitimate consumer you found in step 3 (e.g. the nightly cron script) to confirm it still authenticates correctly — it should be unaffected since it sources the credential file explicitly in its own scope, not via shell init.
Escalation path
- If a currently-active credential (not yet rotated) was part of the leaking export, do not rotate it as part of this fix — file/track that separately as a Tier 3 human-action rotation item and cross-reference it in your fix. Removing the leak mechanism is independent of, and does not require, rotating the value.
- If you can’t determine whether a script depends on the shell-init auto-source
(as opposed to its own explicit
source), don’t guess — grep the actual consumer and read it before removing anything it needs.
Prevention
- Never put
source <file-with-export-lines>directly in.bashrc,.bash_profile,.profile, or any/etc/profile.d/*.shfor a file that contains credentials. Shell init runs on every interactive shell and every login subshell (runuser -l,su -l) — anything exported there is inherited by every descendant process for the life of that session. - If a credential is only needed by specific scripts, have those scripts
sourcethe file explicitly in their own scope (as the p24-infra-nightly.sh / claude-nightly.sh cron jobs already correctly do), not shell init. - If a credential is needed interactively and occasionally, use the CLAUDE.md
safe-extraction pattern: read the single key into a local variable on
demand, use it,
unsetit immediately after — never a blanketexportat login. - Prefer SOPS+age as the source of truth (
secrets/*.env.sops) over ad hoc plaintext credential files living outside the documented SOPS file map — those ad hoc files (like/root/.claude-envpredating this fix) tend to drift, accumulate dead entries, and aren’t covered bysecrets-sync.ymlrotation/distribution. - When decommissioning a service (Trello, Infisical, etc.), remove its
credential file and any
source/exportreferences to it as part of the decommission — don’t leave dead exported secrets sitting in shell init.