skip to content

With exec.Cmd, how do you keep a loaded secret out of the child process's environment?

level: seniorimportance: should knowfreq 45%

answer

  1. look at the field's zero value
  2. nil does not mean empty here
  3. there is a method that returns the list
  4. an allowlist ages better than a denylist

basics

~20 s

If Cmd.Env is nil the child inherits the parent's entire environment, secrets included. Set Cmd.Env explicitly to only what the child needs — filter a copy from Cmd.Environ() — or pass a file path rather than the value.

solid answer

~50 s

The rule is in the field's zero value: `exec.Cmd.Env` nil means "use the current process's environment", so an entrypoint binary that loaded credentials hands every one of them to whatever it launches, without anyone writing a line to that effect. The fix is to make the child's environment explicit — take a copy with `cmd.Environ()`, drop the keys the child has no business seeing, assign the result to `cmd.Env`. Do not move the secret into the argument list instead: `argv` is visible to other processes on the host. `os.Unsetenv` before spawning removes the variable from the copy Go hands out, but it does not rewrite the kernel's snapshot of the initial environment. Better still, keep the value out of the environment entirely: the secret lives in a file with tight permissions, and the environment carries only its path.

code

go · 9 lines
go
cmd := exec.Command("/usr/local/bin/app")
keep := make([]string, 0, 16)
for _, kv := range cmd.Environ() {
	if strings.HasPrefix(kv, "DB_PASSWORD=") {
		continue // loaded by this entrypoint; the child reads a file instead
	}
	keep = append(keep, kv)
}
cmd.Env = append(keep, "DB_PASSWORD_FILE=/run/secrets/db")

go deeper

for a junior

Remember the default: leaving exec.Cmd's Env field nil means the child inherits the parent's whole environment, and setting Env replaces it rather than adding to it.

for a middle

Show how to build the child's environment explicitly from a copy, and explain why an environment variable is ambient state that every package and every child can read.

for a senior

Reason about the whole exposure surface for an entrypoint that loads credentials — inherited environment, argv, /proc, crash reports — and pick controls per path, defending an allowlist over a denylist.

for a principal

Set the pattern for the fleet: how secrets reach a process, what an entrypoint is allowed to pass on, and who reviews an exception when a vendor binary demands an environment variable.

## The default is inheritance `os/exec` describes `Cmd.Env` as the environment of the process: if it is nil, the new process uses the current process's environment. That single sentence is the whole trap. A pattern this leaf cares about — a small entrypoint binary that authenticates, fetches or decrypts credentials and then starts the real application — will, if you write the obvious `exec.Command(...).Run()`, pass every credential the entrypoint loaded into the child's environment. The child may be a third-party binary, a shell script, a language runtime that prints its environment on startup, or something whose crash handler attaches the environment to a report. Nothing in your code says "give it the database password", and that is exactly why it survives review. ## Making the child's environment explicit `Cmd.Environ()` returns a copy of the environment the command would run with as currently configured, which is the right starting point for a filter: ```go cmd := exec.Command("/usr/local/bin/app") keep := make([]string, 0, 16) for _, kv := range cmd.Environ() { if strings.HasPrefix(kv, "DB_PASSWORD=") { continue } keep = append(keep, kv) } cmd.Env = keep ``` A stricter version is an allowlist: start from an empty slice and add the handful of variables the child genuinely needs (`PATH`, `HOME`, `TZ`, its own configuration), so a new secret added to the parent's environment next quarter is excluded by default rather than by remembering to extend a denylist. If you replace the process outright rather than forking, the same discipline applies — `syscall.Exec` takes the environment as an explicit argument, so there is no inheritance to forget. ## Three adjacent mistakes **Moving it to argv.** `exec.Command(bin, "--token", tok)` looks like it avoids the environment, and it is worse: command lines are readable by other processes on the same host and are captured by process accounting, audit logs and monitoring agents. Neither argv nor the environment is a private channel. **Trusting os.Unsetenv to erase it.** `os.Unsetenv("DB_PASSWORD")` removes the key from the copy of the environment Go maintains, so later `os.Getenv` and `os.Environ` calls — and children spawned afterwards — do not see it. It does not rewrite the block the kernel recorded when this process was executed, which on Linux remains visible through `/proc/<pid>/environ` to a process running as the same user. Unsetting is a tidy-up, not an erasure, and the value is still a `string` in your heap either way. **Passing it on stdin without thinking about the failure path.** Handing the secret over a pipe (`cmd.Stdin`, or an extra file descriptor via `cmd.ExtraFiles`) is a genuinely better channel than the environment, because it is point-to-point and leaves no ambient copy. Just make sure the child actually consumes it, and that a child that exits early does not leave the parent blocked on a write. ## The shape to argue for The arrangement that holds up in a postmortem is: the secret is delivered as a file with restrictive permissions; the process reads it with `os.ReadFile` into a `[]byte` at startup; the environment carries only the *path*; anything the process launches gets an explicitly constructed environment; and the value itself is a named type whose `String` method masks it so a struct dumped into an error cannot spill it. Each of those closes a different diagnostic path — the environment dump, the inherited child, the crash report — and the reason to do all four is that the one you skip is the one that appears in the incident. When you are the person writing that postmortem, the question you will be asked is not "was the secret encrypted" but "how many processes and how many files could see it, and who decided that". An explicit `cmd.Env` is a one-line answer to a large part of it.

  • Why not pass the secret as a command-line argument instead?
    Because a process's command line is not private: on Linux it is readable through `/proc/<pid>/cmdline`, it shows up in process listings, and it is routinely captured by process accounting, audit trails and monitoring agents. Moving a secret from the environment to argv trades one ambient copy for a more widely readable one. A pipe on stdin or an extra file descriptor is the point-to-point alternative.
  • Does calling os.Unsetenv before spawning the child solve it?
    Partly. It removes the key from the environment Go maintains, so later `os.Getenv` and `os.Environ` calls and any child spawned afterwards will not see it. It does not rewrite the environment block the kernel recorded at exec time, which stays visible through `/proc/<pid>/environ` for the process owner, and the value is still a string in your heap. Setting `Cmd.Env` explicitly is the deliberate control.
  • An allowlist or a denylist for the child's environment?
    Allowlist. A denylist encodes today's list of secret variable names, and the failure mode is silent: the next variable someone adds is inherited by default and nobody notices until it appears somewhere it should not. Starting from an empty environment and adding the few variables the child actually needs fails in the safe direction — a missing variable is a loud startup error.

saying these in an interview costs you the question

  • Thinks a nil Cmd.Env means the child gets an empty environment
  • Moves the secret into command-line arguments instead
  • Believes os.Unsetenv erases the value from the operating system
  • Filters with a denylist of known secret names and calls it done
  • Assumes the child process is trusted because you launched it