Choose your stack
Fix OS command and argument injection in Node.js and Express
Run the program directly with its arguments as a list - execFileSync(tool, ['--', name]) - so no shell parses them, and put '--' before any user operand so a leading dash cannot pose as an option.
Open the fix card Python · FlaskFix OS command and argument injection in Python and Flask
Pass a list to subprocess.run with the default shell=False - subprocess.run([tool, '--', name]) - so no shell parses the arguments, and put '--' before any user operand so a leading dash cannot pose as an option.
Open the fix card Java · Spring BootFix OS command and argument injection in Java and Spring Boot
Build the process from a List so ProcessBuilder runs it directly - new ProcessBuilder(List.of(tool, "--", name)) - and no shell parses the arguments; the "--" makes a leading-dash operand data, not an option.
Open the fix card C# · ASP.NET CoreFix OS command and argument injection in C# and ASP.NET Core
Start the process with ProcessStartInfo.ArgumentList and UseShellExecute = false - psi.ArgumentList.Add("--"); psi.ArgumentList.Add(name) - so no shell parses the arguments, and the '--' makes a leading-dash operand data, not an option.
Open the fix card TypeScript · Next.jsFix OS command injection in TypeScript and Next.js
Run the program directly with its arguments as a list, execFileSync(tool, ['--', name]), so no shell parses them, and put '--' before any user operand so a leading dash cannot pose as an option.
Open the fix card Go · net/httpFix OS command and argument injection in Go and net/http
Run the program itself with each argument as its own string: exec.CommandContext(ctx, tool, "--", name). os/exec never invokes a shell unless you call sh -c, so metacharacters stay characters, and "--" keeps a name that starts with a dash from posing as an option.
Open the fix cardStacks without a card
- Python and FastAPI: no card yet. The nearest is Fix OS command and argument injection in Python and Flask, in the same language.
- Python and Django: no card yet. The nearest is Fix OS command and argument injection in Python and Flask, in the same language.
- Ruby on Rails: a card is planned (coverage row APP-01).
- PHP and Laravel: a card is planned (coverage row APP-01).
How it happens
- The code builds a command string from user input and hands it to a shell (Node child_process.exec, Python subprocess with shell=True, Java sh -c, C# /bin/sh -c). The shell, not the program, parses the string.
- Shell metacharacters start new work: ; and a newline separate commands, | pipes into another, && and || chain, and $( ) and backticks substitute a command's output. Any one of them turns the input into extra commands that run with the app's privileges.
- Argument injection is the quieter route: even with no shell, passing an operand that begins with a dash lets it pose as an option. A value like --output=/path or -o /path makes a tool write a file the caller never intended, and tools such as git, tar and find have far more dangerous flags.
- Hand-made fixes fail. Wrapping a value in double quotes inside a shell string still lets $( ) and backticks run, a metacharacter blocklist always misses one (newlines, globbing, or the leading dash it was never about), and quoting breaks on a name that itself contains a quote.
Fix principles
- Do not call a shell. Run the program directly and pass each argument as its own element of a list (execFile/spawn, subprocess.run([...]) without shell=True, ProcessBuilder(List), ProcessStartInfo.ArgumentList), so the OS never reparses your data.
- End option parsing with a "--" argument before any user-supplied operand, so a value that starts with a dash is treated as data, not as a flag.
- Allowlist anything that selects part of the command itself - the program name, a subcommand, a flag or an enumerated option value - and reject everything else with a 400; a value cannot be passed through as free text.
- Validate the operands you do pass: constrain type, length and character set to what the feature actually needs.
- Do not escape or blocklist your way out of a shell. Quoting and metacharacter filters depend on the shell dialect and on predicting every payload, and they do nothing about argument injection.
- Run the tool with least privilege and keep its stderr away from clients, so a call you missed does less damage and leaks less.
How the cards prove it
- Run each card's tests in three modes: the fixed implementation must pass, and the vulnerable and the plausible-but-wrong implementations must fail. run-all.mjs does this for every card.
- The exploit tests send a separator, a pipe, a chain, a command substitution, backticks and a newline, plus an argument-injection value that starts with a dash, and require that a harmless marker file was never created. Functional tests keep names with spaces, apostrophes, double quotes and non-ASCII characters working.
- Static analysis helps but is uneven: Bandit B602 and Semgrep's subprocess-shell-true and ProcessBuilder rules flag the shell call, but Semgrep's Node rule misses the node:child_process import, Semgrep CE has no C# rule, CA3006's taint pass did not activate, and none of them model argument injection - so the marker-file regression tests are the real control (each card's scannerRules records exactly what fired).
References
- OWASP OS Command Injection Defense Cheat Sheet
- OWASP Top 10:2025 A05 Injection
- CWE-78: Improper Neutralization of Special Elements used in an OS Command ('OS Command Injection')
- CWE-88: Improper Neutralization of Argument Delimiters in a Command ('Argument Injection')
- CAPEC-88: OS Command Injection
- CAPEC-6: Argument Injection
- PortSwigger Web Security Academy: OS command injection
Attack patterns:
Practice in the browser
- OS command injection lab: Run git without a shell · Practitioner, JavaScript
Guides on this weakness
Keep untrusted values out of executable syntax
Choose a safe interface for each interpreter and bound the work performed by parsers and converters.
Run external programs without command or argument injection
Run programs from an argument list with no shell, end options before user values, and keep untrusted arguments away from Windows batch files.
Keep untrusted CI work outside the release boundary
Review workflow authority, persistent state and artifact handoffs before contributor-controlled code reaches privileged jobs.
ASVS 5.0 verification records: requirement, test and evidence another engineer can repeat
Scope edition-qualified requirements, connect them to implementation and tests, and separate a passing check from an unsupported assurance claim.
Keep generated output out of executable contexts
Choose safe rendering and narrow operations so model text cannot become browser code, database syntax or an unrestricted command.
Apply CISA Secure by Design: principles, pledge goals and evidence
What CISA's Secure by Design guidance and pledge ask of software manufacturers as of October 2026, and how to turn each of the three principles, the seven pledge goals and the Product Security Bad Practices into engineering actions, metrics and evidence.
Case study: the Nx s1ngularity compromise and its AI-assisted payload (2025)
How a pull-request-title injection in a GitHub Actions workflow on an outdated branch gave an attacker the Nx npm token in August 2025, how the malicious versions tried to turn developers' own AI command-line tools into file-search agents, and the workflow, publishing and agent controls that stop the same chain.