Lab: leak a secret into a build log
Publish a token to a log and to the process table in four lines, then fix both without changing what the job does.
CI is where secrets leak most often, and almost never through anything exotic. This lab does it two ordinary ways, then closes both. Everything runs locally; no CI provider is involved.
- 1
Write a job that does something entirely reasonable.
bashmkdir -p /tmp/cilab && cd /tmp/cilab cat > job.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail API_TOKEN="sk-live-9f2a-DO-NOT-LEAK" echo "--- deploying ---" set -x # someone added this to debug a failure curl -s -o /dev/null "https://example.com/deploy?token=$API_TOKEN" || true set +x echo "--- done ---" EOF chmod +x job.sh && ./job.sh - 2
Read the output.
set -xechoes each command after expansion, so the token is now in the build log.text--- deploying --- + curl -s -o /dev/null 'https://example.com/deploy?token=sk-live-9f2a-DO-NOT-LEAK' + set +x --- done --- - 3
Harvest it the way anyone with read access to the log would.
bash./job.sh 2>&1 | grep -o 'sk-live[^ "]*' - 4
Now the second leak, which people miss entirely. A secret passed as a command-line argument is visible in the process table while the command runs.
bashpython3 -c "import time; time.sleep(4)" --token=sk-live-VISIBLE-IN-PS & sleep 0.5; ps -o args= -p $! - 5
The token is right there in the arguments.
text... Python -c import time; time.sleep(4) --token=sk-live-VISIBLE-IN-PS - 6
Compare with passing it in the environment instead. Same process, same secret, and nothing in the process table.
bashAPI_TOKEN=sk-live-HIDDEN python3 -c "import time; time.sleep(4)" & sleep 0.5; ps -o args= -p $! - 7
Fix the log leak by redacting known values before anything is printed. This is what ::add-mask:: does in GitHub Actions and what the official 1Claw action emits before it exports anything.
bashcat > masked.sh <<'EOF' #!/usr/bin/env bash set -euo pipefail API_TOKEN="sk-live-9f2a-DO-NOT-LEAK" mask() { sed "s|${API_TOKEN}|***|g"; } { set -x curl -s -o /dev/null "https://example.com/deploy?token=$API_TOKEN" || true set +x } 2>&1 | mask EOF chmod +x masked.sh && ./masked.sh - 8
The command still runs and the value is gone from the output.
text+ curl -s -o /dev/null 'https://example.com/deploy?token=***' + set +x - 9
Clean up.
bashcd /tmp && rm -rf cilab
Take the two failures separately, because they need different fixes.
- The log leak is about output, and masking is the answer. It works only for values you have told the masker about, which is why the credential should come from somewhere that knows what it just handed you.
- The process-table leak is about invocation, and masking cannot help. The fix is to never put a secret in argv: use an environment variable, a header read from a file, or stdin.
Masking is a backstop, not a boundary. It catches the value it was given and nothing else, so a token that arrives by a path your masker does not know about is printed in full. Prefer not producing the value over redacting it afterwards.
Assume the build log is public even on a private repository. Logs get pasted into issues, forwarded to vendors and attached to support tickets, and none of those paths check who is reading.
Nothing in this lab required a vulnerability. A debugging flag someone forgot to remove, and a credential passed the most obvious way, were enough.
Check your understanding
3 questionsWhy did `set -x` leak the token?
Why can masking not fix the process-table leak?
Which is the more reliable defence?