Repository navigation
Description
Problem
command-injection reports every tainted argument of subprocess.run, call, check_call, check_output and Popen identically (high severity, verdict: vulnerability), whether or not a shell interprets it and whatever the argument's position.
Observed on mitmproxy (engine 4365d6e): 8 high-severity findings in release/release.py and release/build.py, all list-form calls to a fixed executable (git, gh, xcrun) without a shell.
Reproduction
import subprocess
import sys
version = sys.argv[1]
subprocess.run(["git", "tag", f"v{version}"], check=True) # reported: high, vulnerability
subprocess.run(["git", "commit", "-m", f"release {version}"], check=True) # reported: high, vulnerability
subprocess.run(f"git tag v{version}", shell=True, check=True) # reported: high, vulnerability (correct)Root cause
bundled/models/python_stdlib/python_stdlib.py declares the subprocess functions as Sink(..., TaintKind.COMMAND) with no argument restriction, so every argument position is a command sink. Nothing in src/ reads the shell keyword.
Decided expectations
A list without a shell prevents shell interpretation, not option injection: an attacker-controlled element can still be read as an option (git push --receive-pack=<cmd>). What decides the result is what the attacker still controls in the final argument and whether a dangerous behaviour is established.
Severity rules
highwhen the attacker controls the executable, a string interpreted by a shell, or an option whose dangerous behaviour is established (see Established dangerous options).mediumby default for a controlled option (or option value) when the executable is fixed and no shell interprets the command.- Severity is set explicitly for each case. The
hotspotverdict is not used to lower severity: in the current code (plugins/detectors.py) it only lowers the confidence and keeps the rule's severity. Verdict and confidence express the strength of the evidence, never the severity.
Cases
"Free element" means an element whose whole value the attacker controls, so it may start with -.
| # | Case | Example | Expected result |
|---|---|---|---|
| 1 | Shell interprets a tainted string | run(cmd, shell=True), os.system(cmd) |
Command injection, high, vulnerability (unchanged) |
| 2 | Tainted string without a shell | run(cmd) |
Command injection, high: the string names the executable |
| 3 | Tainted first element, or executable= |
run([prog, "x"]) |
Command injection, high: the attacker chooses the program |
| 4 | Free later element | run(["git", "push", "origin", branch]) |
Option injection. high if the model establishes that this executable, at this position, accepts a dangerous option in a single element (--receive-pack=<cmd>); medium otherwise |
| 5 | Constant prefix that does not start with - |
f"v{version}", f"release {version}" |
The attacker controls the tail of a positional value, not an option: no option-injection finding. A positional value whose dangerous use is established for that executable is reported high |
| 5b | Constant prefix naming an option, tainted value | f"--output={path}", f"-o{path}" |
Controlled option value: medium; high if that option's dangerous behaviour is established (f"--upload-pack={x}") |
| 5c | Constant prefix that leaves the option name open | f"-{flag}", f"--{name}" |
The attacker chooses the option: as case 4 |
| 6 | Element after a -- end-of-options marker |
run(["git", "log", "--", path]) |
Most programs honour --, not all: treated as a positional value (case 5) only when the model establishes that the executable honours it; otherwise reported as case 4 |
| 7 | Value right after an option that may take an argument | ["--keychain", keychain], ["-m", msg] |
If the model establishes that the preceding option consumes the value: treated as case 4 for that option's value (medium; high if the option is an established dangerous one). If it cannot be established: still reported, as a finding to review, with the evidence stating that the consumption is unknown; never dropped |
| 8 | shell given by a non-constant expression |
run(cmd, shell=flag) |
As case 1 (high), with lower confidence |
Case 7 reading: "treated as case 4" means a controlled option value at case 4's severity; the unknown case keeps a finding whose evidence asks for review. To be confirmed in review of the implementation if this reading is wrong.
Established dangerous options
A dangerous behaviour is established only by model data, never inferred: an entry names the executable, optionally the subcommand, the option, and the documented behaviour (the option runs a command chosen by its value, or reads a configuration that does). Initial entries to review in the implementation:
git:-c,--config-env(global);--upload-pack(clone,fetch,pull,ls-remote);--receive-pack,--exec(push).ssh:-o(ProxyCommand,LocalCommand).tar:--to-command,--checkpoint-action,--use-compress-program,-I.find:-exec,-execdir,-ok,-okdir.rsync:-e,--rsh.
Which options consume the following element (case 7) comes from the same model; without an entry, consumption is unknown.
Rule naming
To choose once the cases above are implemented: cases 1 to 3 remain command-injection; cases 4 to 7 are option injection and may become a distinct rule (argument-injection) or a distinct verdict of command-injection. The severities above apply either way.
Acceptance criteria
- Each case above, including 5b, 5c and both outcomes of case 7, is covered by a regression test that checks severity, verdict and confidence.
- Case 1 keeps its current result; cases 2, 3 and 8 are still reported as command injections at
high. - The effect on mitmproxy is measured, not assumed: a new scan at commit
be75825with the same configuration (enterprise plugins atac35460,[email protected], no baseline) is compared with the 87 results of the 2026-10-02 report. For instancerelease.py:132passesbranch, a bare value read fromgit, togit push: with thegit pushentries above it stayshigheven though git refuses branch names starting with-, which the engine cannot know.build.py:178and:200pass values after--keychain,--team-id,--apple-idand--passwordofxcrun notarytool, which no model entry describes: they stay as findings to review (case 7).
Activity
[-]command-injection: argument lists run without a shell are reported as command injections[/-][+]command-injection: decide the expected results for argument lists run without a shell[/+]SizzleUnrlsd commentedon Oct 3, 2026
Description revised. The first version proposed reporting every later list element as a hotspot. That was not enough: a list without a shell still allows option injection, and a
hotspotverdict keeps the rule'shighseverity today (only the confidence is lowered), so it would not have reduced the number of high findings by itself. The issue now lists the behaviours and severities to settle before choosing a rule name, and the effect on mitmproxy is to be measured on the same commit and configuration rather than assumed.[-]command-injection: decide the expected results for argument lists run without a shell[/-][+]command-injection: severity of option injection in argument lists run without a shell[/+]SizzleUnrlsd commentedon Oct 4, 2026
Description updated with the decided expectations:
mediumby default for a controlled option when the executable is fixed and no shell interprets the command;highwhen the attacker controls the executable, a shell-interpreted string, or an option whose dangerous behaviour is established by model data. Severity is set per case and is not lowered through thehotspotverdict. Case 7 is treated as case 4 only when the model establishes that the preceding option consumes the value, otherwise it stays a finding to review; a constant prefix is judged by what the attacker still controls in the final argument (cases 5, 5b, 5c). The rule name is chosen after these cases are implemented.fix(command-injection): judge option values by what the option does
SizzleUnrlsd commentedon Oct 6, 2026
Decision on the overlap of cases 5 and 7 (review of #217): neither has a fixed priority; the option model decides. After an option whose semantics are not established, the value is kept for review (medium, hotspot), prefix or not. Once the option's effect is established, it decides the result whatever the prefix: a value the program only keeps as data is refuted (
git commit -m, so mitmproxy F021 is refuted), a value it runs stays high (cmd /c,python -c,sh -c, even with a constant prefix). Operands a program runs as a command (ssh after the host, the program of a wrapper such as sudo, any argument of a batch file) are high as case 3.fix(command-injection): read operands a program runs, and nested comm…
fix(command-injection): read shell options, environment assignments, …
fix(command-injection): report the script an interpreter runs, to review
fix(command-injection): read script arguments apart from interpreter …
fix(command-injection): read the options interpreters keep reading af…
fix(command-injection): keep pwsh -Command elements as the command
Merge pull request #227 from CoreTrace/test/pin-command-cases-219