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 line

If 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

  1. 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
  2. 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.

  3. 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-env inside its own scope (e.g. inside a runuser -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.

  4. 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.

  5. 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 output

Also 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/*.sh for 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 source the 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, unset it immediately after — never a blanket export at 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-env predating this fix) tend to drift, accumulate dead entries, and aren’t covered by secrets-sync.yml rotation/distribution.
  • When decommissioning a service (Trello, Infisical, etc.), remove its credential file and any source/export references to it as part of the decommission — don’t leave dead exported secrets sitting in shell init.