Command injection static analysis
Why subprocess(shell=True) is the trap pattern-only SAST misses
Command injection rarely shows up in os.system anymore. Modern Python code reaches
for subprocess, which is safer when called with an argument list,
and re-opens the bug the moment shell=True turns it back into a shell-formed string.
SiteShadow detects the trap pattern in the free tier, recognizes shlex.quote() and
args-list calls as sanitizers, and the Pro H06 heuristic catches the architecture risk
(subprocess + web framework) that makes this class of bug likely.
The vulnerable flow
A common shape: a web handler accepts a filename, hands it to a subprocess call, and trusts that
subprocess is “the safe one.”
# Vulnerable: filename flows from request to a shell-formed subprocess call
from flask import request
import subprocess
@app.route("/thumbnail", methods=["POST"])
def make_thumbnail():
filename = request.form["filename"]
cmd = f"convert {filename} thumbnail.png"
subprocess.run(cmd, shell=True)
return {"status": "ok"}
The dangerous operation is here:
subprocess.run(cmd, shell=True)
The reason it’s dangerous starts at the top:
request.form["filename"]
A POST with filename=foo.png; rm -rf /home runs convert
and a destructive rm. The semicolon is interpreted by
/bin/sh, which is exactly what shell=True asks for.
Why subprocess feels safe (and isn’t, with shell=True)
The Python docs explicitly warn against shell=True with untrusted input, but the
ergonomics push developers toward it: writing a command as a string is faster than thinking in
argv lists. Many tutorials still teach the string form. The result is a recurring class of bug
in real codebases.
Pattern-only SAST rules tend to fall into one of two failure modes here:
- Over-match: flag every
subprocess.runcall regardless ofshell=Trueor args-list shape. Developers see false positives on safe code, tune the rule out, then miss real bugs. - Under-match: only flag
os.system, the older API that documentation taught developers to avoid, and miss the modernsubprocess.run(cmd, shell=True)form entirely.
What SiteShadow looks for
The free-tier I01 Injection Risk Patterns check flags
subprocess.run(...shell=True) when a user-controlled value is in scope. The
F01 Foot-gun APIs check covers exec(, pickle.loads(,
yaml.load(, and child_process.exec( in the same family. Both are in
the free 23 single-file pattern checks.
The Pro tier adds H06 Shell commands in web app: when
subprocess imports appear in the same file as a web framework
(flask, django, fastapi, express), H06
fires even if the specific call site doesn’t look obviously tainted. The architecture
itself is the signal, user input has a path to a subprocess call somewhere in this file,
whether the static analysis can fully trace it or not.
The fix is structural, not a wrapper
Unlike XSS where you can wrap the dangerous value with DOMPurify.sanitize(),
command injection’s fix changes the call shape:
# Safe: pass argv list instead of a shell-formed string
import subprocess
@app.route("/thumbnail", methods=["POST"])
def make_thumbnail():
filename = request.form["filename"]
subprocess.run(["convert", filename, "thumbnail.png"])
return {"status": "ok"}
The args-list form bypasses /bin/sh entirely. No shell metacharacters get
interpreted. Even if filename contains ;rm -rf /home, the args list
treats it as a single literal argument to convert, which will fail with
“file not found” rather than running the destructive command.
When you really need a shell
Some workflows genuinely need shell features (pipes, globbing, redirects). For those,
shlex.quote() is the only safe path:
# Safe with shell: every untrusted value goes through shlex.quote()
import shlex
import subprocess
def transcode(filename, output):
cmd = f"convert {shlex.quote(filename)} | jpegtran -optimize > {shlex.quote(output)}"
subprocess.run(cmd, shell=True)
SiteShadow recognizes shlex.quote() as a sanitizer in the path. The same I01
finding that fired on the unwrapped version goes silent on this one, the taint is
cleared by the recognized sanitizer.
The sanitizers SiteShadow recognizes for command injection
shlex.quote(value), the canonical Python escape for shell-formed arguments; clears taint when wrapped around an untrusted valuesubprocess.run([list, of, args]), bypasses/bin/shentirely; shell metacharacters are not interpretedsubprocess.run(cmd)withoutshell=True, same args-list semantics, no shell interpretationProcessBuilderin Java, args-list equivalent (Runtime.exec(String)is the trap;Runtime.exec(String[])is the fix)child_process.execFile()andspawn(cmd, args)in Node.js, same args-list semantics as Pythonsubprocess.runwith a list
The architecture signal: H06 (Pro)
H06 fires when subprocess, os.system, or
child_process appears in a file that also imports a web framework. The reasoning
is direct: if your file accepts HTTP requests and can shell out, user input has a path to that
shell-out somewhere, even if the path goes through helpers, business logic, or template
rendering. The static call graph might not connect the source to the sink, but the
architecture says they’re on a collision course.
Free tier covers the direct trap (subprocess.run(...shell=True) with user input
in scope). Pro adds the H06 architecture signal that catches the same class of bug when the
flow is too distant for free-tier patterns to see.
Where SiteShadow fits
SiteShadow is built for developers who want security feedback inside their workflow. It combines taint tracking, regex security rules, heuristic flow checks (H01–H14), and AI/LLM rule families to surface real risks while the code is still fresh in the editor. The free tier catches the most common command-injection traps; Pro adds the multi-hop and architecture-level signals.
I01 and F01 are in the free tier alongside the 21 other OWASP Top 10 pattern checks SiteShadow ships free. Self-serve sign-in with your work SSO.