SiteShadow

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.

Browse OS command injection checks Read the SAST false-positives guide

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:

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

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.

2,011 checks
190 CWE mappings
30+ sanitizer patterns
31 heuristic checks

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.

Start free Walk the scanner Read the DOM-XSS / DOMPurify proof Read the multi-hop SQLi proof