A bash script in a CI job runs with `set -x`, and the trace both corrupts the stderr output another tool parses and prints an API token into a shared build log. How do you route bash's trace somewhere other than stderr, and what does that still leave you responsible for?
answer
- the trace does not have to go to stderr
- a variable naming a file descriptor
- open the fd first with exec
- unsetting it closes that descriptor
- redirecting is not redacting
basics
~20 sOpen a file descriptor for the trace and point BASH_XTRACEFD at it — exec 9>trace.log; BASH_XTRACEFD=9; set -x — and bash writes xtrace there instead of stderr, from bash 4.1 onward. The trace still contains expanded secrets, so its destination must be protected and the switch left off by default.
solid answer
~50 sBash 4.1 and later honour `BASH_XTRACEFD`: assign it an integer that is a valid file descriptor and all `set -x` output goes there instead of standard error. The pattern is `exec 9>"$trace_file"; BASH_XTRACEFD=9; set -x` — now stderr carries only the script's real diagnostics, and whatever parses that stream stays clean. Two cautions. First, do not set it to 2 and then unset it: bash documents that unsetting `BASH_XTRACEFD` closes the descriptor it names, so you would close stderr. Second, redirecting the trace does not sanitise it — commands are still printed expanded, so a token in a variable is still written in clear, now into a file whose permissions and lifetime you own. So the destination should be a private file rather than a shared log, tracing should be off unless an operator asks for it, and on macOS's bash 3.2 the variable does not exist at all.
code
bash · 13 lines#!/usr/bin/env bash
# Trace off by default; when asked for, send it to its own descriptor.
if [[ ${TRACE:-0} == 1 ]]; then
trace_file=$(mktemp -t deploy-trace.XXXXXX)
exec 9>"$trace_file"
BASH_XTRACEFD=9
PS4='+ ${BASH_SOURCE[0]##*/}:${LINENO}: '
set -x
echo "trace: $trace_file" >&2
fi
echo "real diagnostics still go to stderr" >&2
echo "normal output"go deeper
Know that xtrace goes to standard error by default, and that this is why a traced script's output gets tangled with its error messages. Recognise BASH_XTRACEFD as the variable that changes the destination.
Show the mechanics: open a descriptor with exec 9>file, set BASH_XTRACEFD=9, then set -x. Mention that it needs bash 4.1+ and that unsetting the variable closes the descriptor it names.
Reason about the pipeline: which consumer parses stderr, what an expanded command line discloses, where the trace file lives, who can read the build artifact, and why tracing should be gated behind an operator-settable flag rather than on by default.
Set the standard for secret handling in shell across the estate: secrets never as command-line arguments, traces never into shared logs, an agreed switch for on-demand tracing, and a policy for how long diagnostic output that may contain credentials is retained.
## The problem with stderr as the trace channel By default xtrace shares fd 2 with everything else the script and its children write there. That creates two distinct failures in an automated pipeline. **Stream corruption.** If a wrapper reads the script's stderr — a CI system that surfaces stderr as the failure reason, a log shipper that parses it, a caller doing `err=$(./script.sh 2>&1 >/dev/null)` — the trace is now interleaved into data someone is parsing. The script looks like it emitted thousands of garbage diagnostics. **Disclosure.** xtrace prints commands *after expansion*. Every secret that reaches a command line is printed: ```bash curl -sS -H "Authorization: Bearer $TOKEN" "$API/deploy" # + curl -sS -H 'Authorization: Bearer ghp_realvaluehere' https://api.example/deploy ``` CI systems that mask known secret values help, but they mask only values they were told about, and only in the streams they capture. ## Routing the trace: BASH_XTRACEFD Bash 4.1 introduced `BASH_XTRACEFD`. If it is set to an integer that is a valid file descriptor, bash writes xtrace output to that descriptor instead of to stderr. ```bash trace_file=$(mktemp -t deploy-trace.XXXXXX) exec 9>"$trace_file" BASH_XTRACEFD=9 set -x ``` After this, fd 2 carries only the script's own diagnostics and those of the commands it runs, while the trace accumulates in a private file you can attach to a build artifact, upload only on failure, or delete. The descriptor number is your choice; pick a high one to stay clear of anything the script itself opens. Because the descriptor is inherited by child processes, be deliberate about whether you want subprocesses holding it open. A gated form, so the default run is quiet: ```bash if [[ ${TRACE:-0} == 1 ]]; then exec 9>"${TRACE_FILE:-/dev/null}" BASH_XTRACEFD=9 set -x fi ``` ## The sharp edge: unsetting it Bash documents that unsetting `BASH_XTRACEFD`, or assigning it a new value, closes the file descriptor it previously named. The consequence people hit is: ```bash BASH_XTRACEFD=2 # "same as the default, harmless" ... unset BASH_XTRACEFD # closes fd 2 — stderr is now gone ``` From that point every write to stderr in the script and in its children fails. The rule is simple: never point `BASH_XTRACEFD` at 2 or at any descriptor you still need, and if you want the trace back on stderr, assign the empty string rather than closing something you care about. ## What redirection does not solve Routing is a plumbing fix, not a security control. The trace file still contains the expanded commands, so: - **Create it privately.** A trace in a world-readable location on a shared runner is the same disclosure with an extra step; the mechanics of creating a private temp file and cleaning it up on every exit path is its own discipline. - **Decide its lifetime.** Uploading the trace as a build artifact on failure is useful and is also publishing it — to whoever can read that build. - **Keep secrets off command lines where you can.** Pass them via environment variables consumed by the tool, a config file, or a here-document on stdin, so the sensitive value never appears as an argument that xtrace can print. `curl --config -` reading from stdin is the classic example. - **Scope the tracing.** Turn xtrace on around the region under investigation and off again, rather than tracing a whole deploy, so the volume stays reviewable. ## Portability `BASH_XTRACEFD` requires bash 4.1 or newer. macOS ships bash 3.2, where the variable is an ordinary, meaningless variable and the trace keeps going to stderr — silently, which is the dangerous failure. If a script must behave on both, test the version explicitly (`(( BASH_VERSINFO[0] >= 5 ))`, or check the major and minor pair) or achieve the split with plain redirection of the whole invocation instead: `bash -x ./script.sh 2>trace.log`, which sends *everything* on stderr to the file — a blunter tool, because it takes the script's real diagnostics with it.
- Why is `bash -x ./script.sh 2>trace.log` not equivalent to using BASH_XTRACEFD?That redirection captures the whole of fd 2 — the trace *and* every genuine error message from the script and its children — into the same file, so the diagnostics you actually wanted to see disappear from the console. BASH_XTRACEFD separates the two streams instead of merging them.
- How would you keep a token out of the trace entirely rather than relocating the trace?Keep it off the command line. Have the tool read it from an environment variable it consumes directly, from a config file, or from stdin — `curl --config -` with a here-document, for instance. xtrace prints arguments, so a secret that is never an argument is never traced, on any destination.
- What happens to BASH_XTRACEFD when your script runs a child bash script?The variable is an ordinary shell variable, so the child does not see it unless it is exported, but the open file descriptor itself is inherited. A child that enables its own xtrace therefore writes to stderr unless it sets the variable itself — one reason to decide deliberately whether to export the setup or close the descriptor before launching children.
saying these in an interview costs you the question
- Says redirecting the trace makes it safe to log
- Sets BASH_XTRACEFD=2 and later unsets it
- Assumes BASH_XTRACEFD works on macOS's stock bash
- Thinks 2>file separates trace from real errors
- Leaves xtrace on by default in production scripts