Go's os/exec package is often described as "injection-safe by design" because exec.Command calls the operating system's process-creation interface directly, passing arguments as a slice rather than a single string a shell has to reparse. That's true, and it's a real, meaningful difference from os.system() in C or subprocess.Popen(shell=True) in Python. But "safe by design" has an asterisk: Go shipped a documented fix for exactly this problem class in Go 1.19, released August 2022, when exec.LookPath stopped silently resolving relative-path binaries out of the current directory and started returning a new exec.ErrDot instead — a change made specifically to close a PATH-based binary-planting risk that existed in every Go version before it. Static analysis tooling has its own dedicated rule for the surviving risk: gosec's G204, mapped to CWE-78 (OS Command Injection), fires whenever exec.Command or exec.CommandContext receives a command or argument built from a variable instead of a literal. This piece walks through where the shell-free guarantee actually holds, where developers reintroduce the shell themselves, and what a static analyzer can and can't see when it flags a G204 finding.
Does exec.Command actually prevent shell injection?
Yes, for the specific attack it's designed to stop. exec.Command(name string, arg ...string) invokes the named binary through the OS's process-creation call with argv as discrete, pre-split strings — there is no shell in the middle to reinterpret ;, |, &&, or backticks as control characters. So exec.Command("git", "clone", userInput) is not exploitable via shell metacharacters even if userInput contains ; rm -rf / , because that whole string is passed to git as a single literal argument, not concatenated into a command line a shell parses. This is the core distinction from Python's os.system(cmd) or a Node child_process.exec() call, both of which hand a full string to /bin/sh -c. Go's design removes an entire vulnerability class by construction — but only for the argument-injection surface, not for every way user input can influence what gets executed.
How does the shell risk come back in Go code?
It comes back the moment a developer manually reintroduces a shell: exec.Command("sh", "-c", untrustedString) on Linux/macOS or exec.Command("cmd", "/C", untrustedString) on Windows. Once sh -c or cmd /C is the program being run, everything downstream is shell syntax again, and untrustedString is exactly as dangerous as it would be in a raw system() call — an attacker who controls any part of it can chain commands with ; or &&. This pattern shows up in real codebases most often when a developer wants shell features exec.Command doesn't give them for free — globbing, pipes, environment-variable expansion, or chaining several commands — and reaches for sh -c as the shortcut instead of composing multiple exec.Command calls or using Go's own os.Expand/filepath.Glob equivalents. The fix is almost always the same: build the argv slice directly (exec.Command("grep", "-r", pattern, dir)) instead of building a shell string and asking a shell to parse it back apart.
Can the executable path itself be attacker-controlled?
Yes, and this is the injection vector that argv-slicing doesn't protect against at all — it's argument injection and path-confusion territory (CWE-88 and CWE-78's binary-resolution edge cases), not shell metacharacter injection. If the first argument to exec.Command — the program name or path — is built from user input, or if a relative name like "convert" is looked up via PATH in a directory an attacker can write to, execution can be redirected to a completely different binary than the one the developer intended. This is precisely the class of bug Go 1.19 addressed: before that release, exec.LookPath (and by extension exec.Command with a bare relative name) could resolve to an executable found via a . entry in PATH or an implicit current-directory match, letting an attacker plant a malicious file named after a trusted command in a directory the victim process would later run from. Go 1.19's os/exec docs now describe this explicitly, and exec.ErrDot is returned instead of silently succeeding, requiring an explicit filepath.EvalSymlinks-style opt-in if the relative resolution is genuinely intended.
What does gosec's G204 rule actually detect?
gosec's G204 rule, defined in the rules/subproc.go source and documented as "Subprocess launched with a potential tainted input or cmd arguments" / "Subprocess launched with variable," triggers on exec.Command and exec.CommandContext calls where the command name or one of its arguments is a variable rather than a string literal, and maps the finding to CWE-78. It is a pattern-and-taint-adjacent check, not full interprocedural taint tracking: it flags the shape of the call — "this argument isn't a constant" — rather than proving the variable's value actually originates from an HTTP request or CLI flag. That's why G204 has a real, acknowledged false-positive rate discussed across public gosec issue threads (securego/gosec issues #831, #1174, and #684 among them): a filepath.Join-derived path built entirely from trusted config is technically "variable" and will still trigger G204, and teams commonly suppress it inline with a #nosec G204 comment once they've manually confirmed the input path is safe. The rule is a starting point for review, not a proof of exploitability on its own.
What's the safe-usage checklist for os/exec in production Go?
Five patterns cover most real-world cases. First, never build a shell string — pass each argument as its own slice element to exec.Command, so there's nothing for a shell to reparse. Second, avoid sh -c / cmd /C with any untrusted input; if you genuinely need shell features, validate against a strict allow-list first. Third, treat the executable name itself as sensitive — prefer an absolute path (/usr/bin/git rather than "git") or validate a resolved path against a known-good list rather than trusting PATH lookup on a relative name, especially since PATH-based resolution changed behavior across Go 1.19. Fourth, use exec.CommandContext with a bounded context.WithTimeout so a hung or hostile subprocess can't tie up a goroutine indefinitely. Fifth, allow-list arguments where possible — if a field should only ever be one of a known set of values (a branch name, a region code), validate it against that set before it ever reaches exec.Command, rather than relying on argv-slicing alone to make arbitrary input safe.
How Safeguard helps
Safeguard's application security testing traces untrusted input from source to sink across a codebase, mapping each finding to its CWE and giving developers the dataflow trace instead of a bare line number — the same model that lets a CWE-78 command-injection finding in a request handler get prioritized over one that only fires in a test fixture. Today, phase-1 SAST coverage spans JavaScript/TypeScript, Python, and Java, with Go and additional languages on the roadmap; teams running Go services can still use gosec's G204 output today and feed it into Safeguard's unified findings model alongside their JS, Python, and Java SAST results, SCA, and secrets scans, so a #nosec G204 suppression decision made six months ago doesn't quietly disappear from view. As Go SAST support lands, the same reachability and CWE-mapping approach will apply: a G204-class finding will be ranked by whether the tainted argument actually reaches exec.Command from an untrusted source, not just whether the call shape looks suspicious.