skip to content

A Go library you are reviewing shells out with os/exec — what does that cost your service?

level: seniorimportance: should knowfreq 32%

answer

  1. the static binary is no longer self-contained
  2. resolved against PATH at runtime
  3. go.mod records none of it
  4. argv is readable by other processes
  5. no shell unless the library asks for one

basics

~20 s

It adds a runtime dependency that go.mod does not record and the compiler cannot check. Your otherwise self-contained Go binary now needs an external program present and on PATH wherever it runs, and a missing or different one fails only at runtime, in production.

solid answer

~40 s

The main cost is that it breaks the property people deploy Go for: a single self-contained binary. `os/exec` resolves the program through `exec.LookPath` against `PATH` **at runtime**, so the dependency is invisible to `go.mod`, invisible to the compiler, and discovered when the call first fires — typically in a minimal production image that has no such tool, returning `exec.ErrNotFound`. That program's version is now part of your correctness story and you did not pin it. There are process-level concerns too: arguments are visible to anything reading the process table, so a secret in argv leaks; the child inherits your environment unless `Cmd.Env` is set. `exec.Command` does not use a shell, so injection needs the library to have built a `sh -c` string — worth checking, but not the default risk.

code

go · 6 lines
go
cmd := exec.CommandContext(ctx, "git", "rev-parse", "HEAD")
cmd.Env = []string{"PATH=/usr/bin"} // do not hand the child every secret
out, err := cmd.Output()
if errors.Is(err, exec.ErrNotFound) {
	// The binary is fine; the runtime image is missing a program.
}

go deeper

for a junior

Know that os/exec runs external programs and that the program is found on PATH when the call happens, not when you compile. A binary that shells out is no longer self-contained.

for a middle

Explain the mechanics: LookPath resolution and exec.ErrNotFound, that exec.Command does not use a shell, that the child inherits the environment unless Cmd.Env is set, and what CommandContext actually kills.

for a senior

Reason about it in production terms — tests green on laptops, first call failing in a slim image, secrets visible in argv, an unpinned tool version driving your behaviour — and propose a containment you would defend in review.

for a principal

Decide the rule for the organisation: whether libraries that shell out are allowed in services at all, and if they are, how the external tool gets versioned, imaged and owned rather than assumed.

## Why this is a bigger deal in Go than elsewhere Go's deployment story is a static binary you copy onto a machine. Everything the program needs is linked in, `go.mod` records it, and the compiler verifies it. A dependency that calls `os/exec` punches a hole in all three of those at once: the external program is not in `go.mod`, is not verified at build time, and is not in the binary. You now have an undeclared runtime prerequisite, introduced by a library rather than by you, that nothing in the build will ever tell you about. ## Resolution happens at runtime `exec.Command("git", "rev-parse", "HEAD")` does not resolve `git` when you compile. `exec.Command` records a lookup error in the `Cmd` and the resolution effect surfaces when you `Run`/`Start`, via `exec.LookPath` against the process's `PATH`. If the program is absent you get an `*exec.Error` wrapping `exec.ErrNotFound`. Recent Go releases also refuse to run a program found via a relative path entry in `PATH` — returning `exec.ErrDot` — which closes an old attack where a directory you happened to be in supplied the binary. The practical shape of this failure: it passes every test on developer laptops, where the tool is installed, and fails on the first request in a slim runtime image. It is a deployment-topology bug wearing a library's clothes. ## The version you did not pin Even when the tool is present, its *version* is now part of your behaviour. Output formats change, flags get deprecated, exit codes shift. Your `go.sum` pins the library that shells out and pins nothing about the thing it shells out to. Any library that parses the output of an external command has effectively taken a dependency you cannot express in the module system. ## Process-level exposure - **Arguments are public.** On a typical host, any process able to read the process table can see the full argv of another process. A library that passes a token or password as a command-line argument leaks it, even though nothing was logged. - **The child inherits your environment** unless `Cmd.Env` is set explicitly, which means every secret in the environment is handed to a program you did not write. - **File descriptors and working directory.** `Cmd.Dir` and `Cmd.ExtraFiles` control what else the child sees; a library that sets neither runs the child in your working directory with your defaults. - **Cancellation is shallow.** `exec.CommandContext` sends a kill to the child when the context is done; the child's own children are not in scope, so a wrapper script that spawns something long-lived outlives the request. `Cmd.WaitDelay` bounds how long `Wait` lingers when the child holds pipes open after that. ## What about injection? Worth checking, but be precise: `exec.Command(name, args...)` execs the named program directly with the given argument vector. There is no shell, so metacharacters in an argument are just bytes in that argument — no globbing, no `;`, no pipes. Command injection requires the library to have deliberately assembled a string and run it through `sh -c` or an equivalent. That does happen, and it is the specific thing to grep for; but treating every `os/exec` call as an injection hole misreads how the package works, and a candidate who says otherwise is guessing. ## Reviewing it, and containing it First establish whether it is in your build at all: `go list -deps -f '{{if not .Standard}}{{.ImportPath}} {{join .Imports " "}}{{end}}' ./...` and look for `os/exec`. Then read the call sites in the module cache: which program, does any argument come from untrusted input, is a shell involved, is a context passed, are `Cmd.Env` and `Cmd.Dir` set, and does anything sensitive travel in argv. Then decide the containment. In rough order of preference: use a pure-Go alternative if one exists; use the library but not the code path that shells out, and say so in review so nobody enables it later; make the external tool an explicit, versioned part of the runtime image so the dependency is at least written down and pinned; or, if the shelled-out work is small, do it yourself in Go and drop the dependency. The one option that is not acceptable is accepting it silently, because the failure lands on whoever is on call the first time the image changes. ## Common mistakes - Claiming `exec.Command` runs a shell and is therefore injectable by default. It does not, and it is not. - Assuming a missing tool is a build-time error. It surfaces at the first call, at runtime. - Believing killing the context cleans up everything the child started. It kills the child.

  • Does exec.Command make the library vulnerable to command injection by default?
    No. It execs the named program directly with the argument vector you supply, so shell metacharacters in an argument stay inside that argument — no globbing, no pipes, no command separators. Injection requires the library to have built a command string and handed it to sh -c. That is what to grep for; assuming it by default misreads the package.
  • A test suite passes on laptops and the exec call fails in the deployed image. Why is that the normal outcome?
    Because developer machines have the tool installed and PATH pointed at it, while a slim runtime image contains only your binary. Resolution happens at the first call through exec.LookPath, so nothing before that point can notice. You get an exec.Error wrapping exec.ErrNotFound on the first real request, not at build or start-up.
  • If you accept the dependency, what do you change about how the service is shipped?
    Make the external program an explicit, versioned part of the runtime image and treat its version as a pinned input rather than an accident of the base image. Add a start-up check that resolves it once and fails loudly, so the missing-tool case is a start-up failure with a clear message rather than a request-time error weeks later.

saying these in an interview costs you the question

  • Says exec.Command runs the arguments through a shell
  • Expects a missing external program to be a build error
  • Ignores that argv is visible in the process table
  • Assumes context cancellation cleans up the child's children
  • Treats the external tool's version as not part of the contract