Skip to content

shell: true in spawn re-splits arguments and can expose secret values in ps #40

Description

@yosit

Summary

spawn(cmd, args, { shell: true }) in exec.ts:120 and run.ts:93 (v0.5.1) makes Node concatenate the command and its arguments into one string for /bin/sh. Two consequences:

  1. Any argument containing spaces is silently re-split, so wrapped commands do not run as written — they fail quietly with exit code 0.
  2. Secret values can end up in the child process's argv, where any user on the machine can read them with ps — which is the thing psst exists to prevent.

Node warns about exactly this pattern:

DEP0190: Passing args to a child process with shell option true can lead to
security vulnerabilities, as the arguments are not escaped, only concatenated.

Reproduction 1 — silent mangling

psst API_TOKEN -- sh -c 'echo hello'

Expected: hello
Actual: an empty line, exit 0

Because the final command line becomes sh -c echo hello, the inner shell runs echo with no arguments and $0=hello.

This gets worse with a realistic wrapper. Anything of the form:

psst API_TOKEN -- sh -c 'SOME_VAR=$API_TOKEN mytool run --file /tmp/script.js'

reduces to the inner shell executing a bare variable assignment as its command, so mytool is never invoked at all. It exits 0 and prints nothing, which reads like "the tool produced no output" rather than "the tool never ran". I lost a while to this before suspecting the wrapper.

The example in psst --help has the same defect:

psst STRIPE_KEY -- curl -H "Authorization: $STRIPE_KEY" https://api.stripe.com

The joined string re-splits at the space inside the header, so curl receives -H Authorization: plus a separate stray argument.

Reproduction 2 — secret visible in ps

echo 'sleep 6' > /tmp/slow.sh
export FAKE_CANARY=canary_abc123          # env fallback, no vault write needed
psst FAKE_CANARY -- sh /tmp/slow.sh '$FAKE_CANARY' &
sleep 2
ps -Ao pid,args | grep canary_abc123 | grep -v grep

Actual:

73532 sh /tmp/slow.sh canary_abc123

The outer shell expands $FAKE_CANARY while building the child's argv, so the plaintext value sits in the process table for the lifetime of that process. The quoting in the example is what a user writes so that psst — rather than their interactive shell — receives the literal $NAME, i.e. it is the documented usage pattern, not an unusual one.

Root cause

// exec.ts:117
const child = spawn(cmd, args, {
  env,
  stdio: shouldMask ? ["inherit", "pipe", "pipe"] : "inherit",
  shell: true,        // <-- concatenation + a second round of shell parsing
});

run.ts:90 is identical.

Suggested fix

Drop shell: true. Argv is then passed as an array: nothing is re-split, and no shell expands anything while constructing it.

Side-by-side check:

import { spawn } from "node:child_process";
const env = { ...process.env, SECRET: "canary_abc123" };
const shell = process.argv[2] === "shell";
const c = spawn("sh", ["-c", 'echo "child sees: $SECRET"'],
                { env, stdio: ["inherit","pipe","pipe"], shell });
c.stdout.on("data", d => process.stdout.write("out: " + d));
shell:true   → out: (blank)                     exit 0
shell:false  → out: child sees: canary_abc123   exit 0

Without shell: true the documented pattern also becomes both correct and safe, because the expansion happens inside the child shell, against a string, and never in an argv:

psst API_TOKEN -- sh -c 'curl -H "Authorization: $API_TOKEN" https://example.com'

Output masking is unaffected — it operates on the piped streams, not on how the child is spawned.

Behaviour change worth calling out

Without a shell, the command must be a real executable rather than a builtin or a string containing shell syntax (&&, ;, globs, redirections). That is the correct contract for an argv-based runner, and users who want shell semantics wrap explicitly in sh -c '...' — which, unlike today, will then actually work. Worth a line in the README and an update to the curl example.

Suggested regression tests

  • an argument containing a space arrives at the child as one argument
  • sh -c 'echo hello' prints hello
  • a secret's value never appears in /proc/<pid>/cmdline (or ps output) of the spawned child
  • masking still redacts a secret that the child prints to stdout and stderr

Happy to send a PR if useful.

Environment: psst 0.5.1 (Homebrew), macOS (arm64), Bun runtime.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions