Repository navigation
fix(command-injection): judge process commands by what the input controls - #217
Merged
Merged
Conversation
…rols Every tainted argument of subprocess calls was a high command injection, whether or not a shell runs it. The rule now reads the command a flow reaches: a shell string, a string naming the program or the program itself stays high; an element of an argument list run without a shell is an option, medium unless a documented option of the program runs a command it chooses; a constant prefix that keeps the input from being an option, or an operand after -- for a program that honours it, is refuted; a value after an option the model does not describe stays a hotspot to review. TaintDetector gains an assess hook returning the verdict and the severity, since a hotspot verdict only lowers the confidence. List and tuple displays keep the place of their unpacked elements in the IR, so that the order of a command survives *(...) parts. Closes #208
Review of the first version: a constant prefix refuted the value of an option that runs it (sh -c "echo $x", ssh -o ProxyCommand=..., find -exec), and ssh operands after the host, a remote shell command, were read as plain operands. A value an established option takes is now judged by what the option does with it, whatever its prefix: run it (high), keep it as data (git commit -m, gh --title: refuted), or an effect not established (medium); after an option the model does not describe, the finding stays for review. Operands a program runs as a command (ssh after the host, the program of sudo, env, xargs, timeout..., arguments of a batch file) are high. The option model also covers cmd /c and powershell -Command, shell flags grouped with -c, tar -I, the program given by a constant executable=, sys.executable, git.exe and python3.12; shell given at its position is read; -- counts anywhere before the input, and ssh is no longer taken to end its options there. Refs #208
…ands Second review: a shell's -c is a flag whose first operand is the command, so grouped and separate forms (sh -ce, bash -c -e, sh -c --) were refuted; ssh operands after the first remote word are shell text even when they start with -; find -exec sh -c x and similar commands inside a command were judged as free elements; an element of unknown value before the input led to a refutation. Programs now declare the operands they run: shell text (ssh after the host, powershell, a shell's first operand after -c) or a command of its own (the program of sudo, env, xargs, timeout..., what follows find -exec), read again with that program's model. An element of unknown value before the input keeps the finding for review. An inert reading needs the subcommand established and covers one element only; git clone -u, case-insensitive Windows options, a glued cmd /c, and an executable that cannot be read are handled. Refs #208
…unknown programs Third review: a shell option the model does not describe (bash -O extglob, --rcfile f, sh +e) hid -c, so sh -c injections were refuted; NAME=VALUE before the program of env or sudo was taken for the program; with the program unknown, a constant prefix refuted an operand it may run; a deep chain of wrappers raised RecursionError. Reading the options before a shell's operands now stops at an option it cannot read, the shells' + options and bash long options are modelled, env and sudo skip their assignments, an unknown program keeps the finding for review, and shutil.which(name) names the program. Commands inside commands are read to a bounded depth, and element texts and options are read once per command. Refs #208
A script path chosen by the input was refuted as an operand when it had a
constant prefix (bash f"/tmp/{x}.sh"), unlike the program of a wrapper.
Interpreters now declare the script their first operand names: sh, bash,
dash, zsh, ksh, python, perl, ruby, node and php, unless an option gives the
code (-c, -m, -e, --eval, -r). The input there is high, kept for review:
execution is possible, and its exploitability depends on the file the input
chooses; so is the module of python -m. The code options of perl, ruby, node
and php run their value, and python's flags are modelled so that its options
can be read.
Refs #208
…options Review of the interpreter script model: python -W, which imports the module its category names, was medium; a whole first operand of python or node, which may be a code option and its value, became a hotspot; the interpreter's own options were applied to the script's arguments (node app.js -e x read as node -e); versioned or distribution names (nodejs, pypy3, perl5.36) were not recognised; perl -M, ruby -r, node --require and php -B/-R/-E were missing; bash -s reads its script from the standard input. Arguments after a script or code are now read as the script's own: its options, of unknown meaning, kept for review after an option and refuted behind a constant prefix. An input that starts the script operand keeps the high reading of a code option when the interpreter has one. Refs #208
…ter code Review of the script arguments: perl, ruby, node and php read options after -e CODE until their first operand, so perl -e CODE -e x is Perl code, while the arguments reading took it for the script's; python and the shells read none after the code. A shell's lone - ends its options, and the operand after it is the script. The flags and value options of perl, ruby, node, php and PowerShell are modelled, so that a script argument behind them is no longer kept for review; php -f, -d, -c, -z and PowerShell -File choose code to examine; pwsh reads its first operand as a script; node short options take no glued value; pyw and -dbg names are recognised. Whole-word options are matched before being read as grouped short options. Refs #208
Review of the interpreter options: pwsh -Command, -c and -EncodedCommand were read as giving code followed by script arguments, while every element after them is the command, so pwsh -Command Get-Item v$x was refuted. A code option that takes the rest of the command line now leaves its elements to that reading. node -pe, an alias of --print --eval, takes its code from the next element. Refs #208
This was referenced Oct 7, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #208.
command-injectionreported every tainted argument ofsubprocess.run,call,check_call,check_outputandPopenas ahighcommand injection, whether or not a shell runs the command and whatever the argument's place. This change implements the expectations fixed in #208, with the decision taken in review on the case 5 / case 7 overlap: the option model decides, not a fixed priority.Behaviour
run(cmd, shell=True)(by keyword or at its position),os.system(cmd)high, unchangedrun(cmd)high, unchanged: it names the programrun([prog, "-l"]),executable=proghigh, unchanged["ssh", "host", f"cat {x}"],["ssh", "host", "ls", f"-l{x}"],["powershell", f"Get-Item {x}"], a shell's first operand after-c(["sh", "-ce", f"echo {x}"]), any argument ofbuild.bathigh["bash", f"/tmp/{x}.sh"],["python3", f"scripts/{x}.py"],["perl", f"{x}.pl"],["python3", "-m", f"pkg.{x}"]high, hotspot: execution is possible, exploitability depends on the file the input chooses. Not when an option gives the code (-c,-m,-e,--eval,-r) or a shell reads it from its input (-s). An input that starts the operand (f"{x}.pl") may be a code option and its value (-e<code>#.pl):high. The elements after a script or code are its own arguments: options of unknown meaning,medium(hotspot after an option), refuted behind a constant prefix["sudo", x],["timeout", "5", x],["find", ".", "-exec", "sh", "-c", x, ";"]high; its arguments are read with that program's model (["sudo", "ls", x]ismedium)shellfrom an expression or**kwargsrun(cmd, shell=flag)high, hotspotrun(["ls", x])medium;highwhen a documented option of the program fits in one element there (git pushaccepts--receive-pack=…)-["git", "tag", f"v{x}"]f"--output={x}",f"-I{x}",f"/c{x}"highwhen it runs its value (--upload-pack=,tar -I,cmd /c),mediumwhen its effect is not establishedf"--{x}"--["git", "log", "--", "a", x]--(git, tar, rsync, rm); otherwise case 4["cmd", "/c", f"dir {x}"],[sys.executable, "-c", f"print({x})"],["git", "commit", "-m", f"release {x}"]high; keeps it as data (git commit -m,gh --title, only with the subcommand established and for one element), refuted; effect not established,medium["xcrun", "notarytool", "--keychain", x],["tool", "-c", f"echo {x}"],["tool", flag, f"echo {x}"]["rm", "-rf", x](grouped short options read as getopt does)Severity is set per case; the
hotspotverdict lowers the confidence only. The evidence of each finding says what the input controls (…; the input is a whole element, and an option such as git --receive-pack=... runs the program it names).Design
taint/commands.pyreads the command a flow reaches from its list or tuple display, element by element, nested unpacked literals included; an unpacked iterable of unknown contents stands for any number of elements. A display with any other use than the call (append,insert,+=, item assignment, an alias…) spells no command, and the flow keeps the previoushighresult; so do shapes the module cannot read (a value of unknown origin, a flow reaching the sink in a callee, the tainted value also passed asexecutable=or another keyword such asinput=).BuildListandBuildTuplegainunpacked_at, the place of each*iterableamong the display's items. The lowering separated plain and unpacked elements and lost their order, so["xcrun", "notarytool", *(["--keychain", x])]could not be read. The field defaults to();substitutekeeps it, and no other consumer rebuilds these instructions.TaintDetector.assess(exportedAssessment) returns a flow's verdict and severity; by default it pairsjudge's verdict with the rule'sseverity, sossrf,open-redirectand plugin detectors keep their behaviour. The plugin API version is unchanged since the hook is additive.OPTIONS: whether an option takes a value (next element,=, glued to a short option, every following element forcmd /candpowershell -Command, or the elements up to;or+forfind -exec) and what the program does with it. It may run a command: git-c,--upload-packand its short form-u,--receive-pack,--exec; ssh-o; tar--to-command,--checkpoint-action,--use-compress-program,-I; rsync-e,--rsh; find-execand its family; python-c, and-mand-W(a module, kept for review); perl-e/-E/-M/-m, ruby-eand-r(kept for review), node-e/--eval/-p/--printand-r/--require/--import(kept for review), php-r/-B/-R/-E; cmd/cand/k; powershell and pwsh-Command,-EncodedCommand, matched whatever the case. It may keep the value as inert data: git commit and tag-m/--message; gh release--title/--notes, gh workflow--ref. Or it takes no value: the flags of ssh, rm, python and the shells, whose-cis a flag.OPERANDS: the operands a program runs, from a given one on. Interpreters (sh, bash, dash, zsh, ksh, python, perl, ruby, node, php) run the script their first operand names unless an option gives the code; the input there ishigh, kept for review. Some are shell text: ssh after the destination, powershell, and the first operand of sh, bash, dash, zsh and ksh when-cis among their options. Others are a command of their own: the program of sudo, doas, env, xargs, nohup, nice and time (afterNAME=VALUEassignments for env and sudo), or of timeout after the duration. Such a command is read again with that program's model, as are the elements afterfind -exec. When the options before cannot be read, the element is assumed to be such an operand..exeremoved, lower case, interpreter versions and distribution names dropped (python3.12,pythonw,pypy3,nodejs,perl5.36,php8.2;pyread as python),sys.executableread as python. A constantexecutable=names the program, and so doesshutil.which("name"). Anexecutablethat is given but cannot be read leaves the program unknown, and an unknown program keeps every standalone element for review. A.bat/.cmdprogram makes every argumenthigh, since cmd.exe parses them again.--to-command,--checkpoint-actionand-Irun a program in GNU tar; bsdtar (the macOStar) lacks the first two and reads-Ias an include-patterns file. They are kept: a false positive on bsdtar is preferred to a false negative on GNU tar. git--config-envis left out (its danger needs control of an environment variable).Reviews
An independent review of the first version found two blocking false negatives: a constant prefix refuted the value of an option that runs it (
["sh", "-c", f"echo {x}"],["ssh", "-o", f"ProxyCommand={x}"],["find", ".", "-exec", f"sh -c {x}", ";"]), and ssh was taken to end its options at--while its operands after the host are a remote shell command. A second review of the fixed version found more:-cread as an option taking a value, sosh -ce …,bash -c -e …andsh -c -- …were refuted;find -exec sh -c x;git clone -u;cmd /c;executable=.All of them are fixed and covered by tests.
A third review confirmed those fixes and found more, also fixed and covered by tests:
bash -O extglob,--rcfile f,sh +e) hid-c, sosh -cinjections were refuted;NAME=VALUEbefore the program ofenvorsudowas taken for the program;[extra, f"echo {x}"], an unreadable positionalexecutable), a constant prefix refuted an operand the program may run. It is now kept for review, andshutil.which("ssh")names the program;RecursionErrorand stopped the scan. Commands inside commands are now read to a depth of 32, and deeper elements are taken as operands the program runs (high);A fourth review, of the interpreter script model, found
python -Wreportedmedium, a whole first operand of python or node reported only as a hotspot, the interpreter's options applied to the script's arguments (node app.js -e xread asnode -e), versioned and distribution names not recognised, missing code options (perl -M,ruby -r,node --require,php -R) andbash -s. All are fixed and covered by tests.A fifth review found that perl, ruby, node and php read options after
-e CODEuntil their first operand (perl -e CODE -e xis Perl code, checked on the real binaries), while python and the shells read none; that a shell's lone-ends its options; that unmodelled flags of perl, ruby, node and php kept their scripts' arguments for review;pwsh, which reads its first operand as a script, andpyw/-dbgnames. All are fixed and covered by tests.A sixth review found a regression of that fix: pwsh
-Command,-cand-EncodedCommandwere read as code followed by script arguments, while every element after them is the command; andnode -pe(--print --eval, code in the next element) was read as-pwith a glued value. Both are fixed and covered by tests.Validation
New
tests/test_command_arguments.py: 172 tests, covering every case of the table (each checks severity, verdict and confidence) and the order of unpacked elements in the IR.**kwargs,args=, tuples) pass on every version and pin the previous result.tests/test_detectors.py: the manifest ofcommand-injectionnow requiresir.ssa,ir.defuseandabstract.module_strings, likessrf. Only that assertion was updated, with prior approval.Full suite: 1875 passed, 1 failed (
test_packaging::test_the_version_has_one_source_of_truth, the known stale local install).ruff check,mypyandgit diff --checkpass, as doesruff format --checkon the new and rewritten files and on the changed lines.test_architectureholds: the plugin imports no parser.Regression snapshots are unchanged (read-only diff of every repository). The local
deps.pydifference of full-stack-fastapi-template also appears onmainwith Python 3.14.luigi
sge.py:296issubprocess.call(["rm", "-rf", self.tmp_dir]), with a value read from a process. It stays avulnerability, nowmedium:-rand-ftake no value.mitmproxy was rescanned with the configuration of the 2026-10-02 reference: commit
be75825, enterprise plugins atac35460,[email protected], no baseline. Compared with its 87 results, on top of the fixes already merged, the 8 highcommand-injectionfindings become:git commit -m, an inert message;f"v{version}"operands;gh release create, a tag operand and--title;gh workflow run --ref.mediumhotspots to review:build.py:178and:200, values afterxcrun notarytool --keychain, an option the model does not describe.release.py:132stayshighfor both of its flows:branch, read fromgit branch --show-current, is a free element ofgit push, which accepts--receive-pack=. Git refuses branch names starting with-, which the engine cannot know.tag_name, fromargv(f"v{version}"), followsbranch, an element of unknown value that may be an option taking it. It is kept for review (hotspot) at the severity of a free element ofgit push.The scan goes from 87 to 75 results, and
highfrom 18 to 4: F004, F049 and the two flows ofrelease.py:132. Among the components, only thecommand-injectionplugin digest differs.Not covered
Command injectionand the rule idcommand-injection. Naming the option cases (command-injection: severity of option injection in argument lists run without a shell #208 "Rule naming") is left for a follow-up decision.-cis also applied after the subcommand, sogit commit -c xis reportedhigh, a false positive.-com) are not recognised; they are kept for review.