Summary
When userEnvProbe is enabled (the default, loginInteractiveShell), the CLI reads the container user's whole environment (cat /proc/self/environ) and passes every variable back to docker exec as a -e NAME=VALUE argument. This happens on every lifecycle hook (onCreateCommand, postCreateCommand, postStartCommand, …) and on every devcontainer exec.
So any secret that is already in the container's environment ends up in plaintext on the host process command line, where any local user can read it with ps. That includes secrets injected with runArgs: ["--env-file", …], containerEnv or an image ENV. Those variables are already part of the container's Config.Env, which docker exec inherits, so re-sending them on the command line gains nothing.
The --secrets-file feature has the same problem, even with "userEnvProbe": "none". Lifecycle commands merge the secrets into the exec env (const env = { ...(await remoteEnv), ...(await secrets) }), so each secret appears as -e NAME=VALUE on the host argv while the hook runs.
Where
On main (3e363f6):
-
src/spec-shutdown/dockerUtils.ts toDockerExecArgs (~L400-415): Object.keys(env).forEach(key => execArgs.push('-e', ${key}=${env[key]}))
-
src/spec-common/injectHeadless.ts probeUserEnv (~L777-789, cat /proc/self/environ) feeds remoteEnv. runLifecycleCommand (~L518) merges remoteEnv and secrets into that env.
- In the published 0.89.0 bundle (
dist/spec-node/devContainersSpecCLI.js) the same logic is the minified function KN: r&&Object.keys(r).forEach(a=>g.push("-e",${a}=${r[a]})).
Repro (dummy values only)
@devcontainers/cli 0.89.0, Docker Desktop on macOS, image alpine:3.
mkdir probe && cd probe
umask 077
printf 'ARGV_PROBE_VAR=DUMMY_NOT_SECRET\n' > dummy.env
printf '{"image":"alpine:3","overrideCommand":true,"runArgs":["--env-file","%s/dummy.env"]}\n' "$PWD" > .devcontainer.json
devcontainer up --workspace-folder .
devcontainer exec --workspace-folder . sleep 6 &
sleep 3; ps -axww -o command | grep '^docker exec'
Observed (other values elided):
docker exec -i -u root -e HOSTNAME=… -e SHLVL=… -e HOME=… -e PAGER=… -e LC_COLLATE=… -e PATH=… -e LANG=… -e CHARSET=… -e ARGV_PROBE_VAR=DUMMY_NOT_SECRET -w /workspaces/probe <id> sleep 6
| Arm |
docker exec argv contains ARGV_PROBE_VAR=DUMMY_NOT_SECRET |
Var visible inside the container |
default userEnvProbe |
yes (1 process) |
yes |
"userEnvProbe": "none" |
no (0) |
yes (inherited from Config.Env) |
--secrets-file arm: config {"image":"alpine:3","overrideCommand":true,"userEnvProbe":"none","postStartCommand":"sleep 8"} with --secrets-file secrets.json containing {"SECRETSFILE_PROBE_VAR":"DUMMY_NOT_SECRET_2"}. While the hook ran, the host showed docker exec -i -u root -e SECRETSFILE_PROBE_VAR=DUMMY_NOT_SECRET_2 -w /workspaces/probe-secretsfile <id> /bin/sh -c sleep 8.
We found this in a real setup where Doppler secrets were injected with --env-file. Every secret showed up in the host process table during devcontainer up and devcontainer exec.
Suggested fix
Any of these, in order of preference:
- Pass variables by name, not by value. Use
docker exec -e NAME (no =), so docker reads the value from the CLI process's own environment, and spawn the docker child with { ...process.env, ...env }. Values never appear on argv. This also covers --secrets-file and remoteEnv.
- Or use
docker exec --env-file <tmpfile>, written mode 0600 and removed after the exec starts.
- At minimum, don't re-send probed variables whose value already equals the container's
Config.Env value. docker exec inherits those, so dropping them changes nothing. Only the delta the login shell adds would remain (e.g. PATH), which is rarely sensitive.
Workaround for users: set "userEnvProbe": "none" and run commands that need the login environment through bash -lc. This does not help with --secrets-file.
Summary
When
userEnvProbeis enabled (the default,loginInteractiveShell), the CLI reads the container user's whole environment (cat /proc/self/environ) and passes every variable back todocker execas a-e NAME=VALUEargument. This happens on every lifecycle hook (onCreateCommand,postCreateCommand,postStartCommand, …) and on everydevcontainer exec.So any secret that is already in the container's environment ends up in plaintext on the host process command line, where any local user can read it with
ps. That includes secrets injected withrunArgs: ["--env-file", …],containerEnvor an imageENV. Those variables are already part of the container'sConfig.Env, whichdocker execinherits, so re-sending them on the command line gains nothing.The
--secrets-filefeature has the same problem, even with"userEnvProbe": "none". Lifecycle commands merge the secrets into the exec env (const env = { ...(await remoteEnv), ...(await secrets) }), so each secret appears as-e NAME=VALUEon the host argv while the hook runs.Where
On
main(3e363f6):src/spec-shutdown/dockerUtils.tstoDockerExecArgs(~L400-415):Object.keys(env).forEach(key => execArgs.push('-e',${key}=${env[key]}))src/spec-common/injectHeadless.tsprobeUserEnv(~L777-789,cat /proc/self/environ) feedsremoteEnv.runLifecycleCommand(~L518) mergesremoteEnvandsecretsinto thatenv.dist/spec-node/devContainersSpecCLI.js) the same logic is the minified functionKN:r&&Object.keys(r).forEach(a=>g.push("-e",${a}=${r[a]})).Repro (dummy values only)
@devcontainers/cli0.89.0, Docker Desktop on macOS, imagealpine:3.Observed (other values elided):
docker execargv containsARGV_PROBE_VAR=DUMMY_NOT_SECRETuserEnvProbe"userEnvProbe": "none"Config.Env)--secrets-filearm: config{"image":"alpine:3","overrideCommand":true,"userEnvProbe":"none","postStartCommand":"sleep 8"}with--secrets-file secrets.jsoncontaining{"SECRETSFILE_PROBE_VAR":"DUMMY_NOT_SECRET_2"}. While the hook ran, the host showeddocker exec -i -u root -e SECRETSFILE_PROBE_VAR=DUMMY_NOT_SECRET_2 -w /workspaces/probe-secretsfile <id> /bin/sh -c sleep 8.We found this in a real setup where Doppler secrets were injected with
--env-file. Every secret showed up in the host process table duringdevcontainer upanddevcontainer exec.Suggested fix
Any of these, in order of preference:
docker exec -e NAME(no=), so docker reads the value from the CLI process's own environment, and spawn thedockerchild with{ ...process.env, ...env }. Values never appear on argv. This also covers--secrets-fileandremoteEnv.docker exec --env-file <tmpfile>, written mode 0600 and removed after the exec starts.Config.Envvalue.docker execinherits those, so dropping them changes nothing. Only the delta the login shell adds would remain (e.g.PATH), which is rarely sensitive.Workaround for users: set
"userEnvProbe": "none"and run commands that need the login environment throughbash -lc. This does not help with--secrets-file.