How it arises in Django
Management commands that shell out. A command under management/commands/ wraps pg_dump, rsync or an image tool. Its arguments come from add_arguments() and arrive in handle() as options[...]. Run from a terminal, the person typing already has a shell, so building the command line with an f-string and shell=True does no harm. The same command becomes a remote entry point the day a view or an admin action starts it.
call_command() with request data. django.core.management.call_command(name, *args, **options) passes *args to the command's argument parser, in the same style as the command line; the documentation's example is call_command('flush', '--verbosity=0'). So a positional value taken from a request that starts with -- is parsed as an option. Even with no shell anywhere, the caller can switch on flags the view never meant to expose.
Uploaded file names. UploadedFile.name is the name the browser sent. A view that hands it to a converter as an argument has given the client a way to pass an option to that program, and with shell=True, a way to run another one.
Celery tasks. The view validates nothing because "the task does the work". The task trusts its arguments because "the view checked them". The command is built in the worker, far from the request.
Vulnerable and fixed
An admin action that exports one app's data for a support ticket:
import subprocess
from django.http import HttpResponse
def export_app(request):
app_label = request.POST["app"]
out = "/srv/exports/" + request.POST["ticket"] + ".json"
subprocess.run(f"python manage.py dumpdata {app_label} > {out}", shell=True, check=True)
return HttpResponse("ok")
The fixed view has no shell and no argument parsing of request data. The app label is checked against the installed apps, the ticket number is forced to digits, and the options are passed to call_command() as keywords, which are not parsed as command-line text:
from django.apps import apps
from django.core.management import call_command
from django.http import HttpResponse, HttpResponseBadRequest
def export_app(request):
app_label = request.POST["app"]
ticket = request.POST["ticket"]
if app_label not in {a.label for a in apps.get_app_configs()} or not ticket.isdigit():
return HttpResponseBadRequest("unknown app or ticket")
call_command("dumpdata", app_label, output=f"/srv/exports/{ticket}.json")
return HttpResponse("ok")
What Django and Python already do for you
subprocess does not choose a shell on its own. The Python documentation states that, because of this, all characters, including shell metacharacters, can safely be passed to child processes when you give it a list. Django adds forms and validators that make the allowlist a one-liner, and call_command() accepts options as keyword arguments so you do not have to build option strings at all.
What es-python adds
The interesting property in a Django project is that es-python grades the same line of code differently depending on who supplied the data. A management command run by an operator takes its values from the process's own command line; an event there is graded Info, because whoever typed the line could run the command anyway. Reach the same subprocess.run() from a view or a Celery message and the data came from the network, so the event is Critical. You see which of your operator tools have become request-reachable without auditing every caller.
The labels describe the shape. subprocess shell means the program ran sh -c on a string containing request data, as in the vulnerable view. shell injection marks an untrusted argument that contains a shell metacharacter, and argument injection one that starts with -. A base subprocess exec event records every program started with request data in its arguments, so the fixed code still shows up if it runs a program, without the sharper labels. All of them carry the command injection or argument injection impact tag.
Under the Block network code execution template the shell forms raise a RuntimeError in the view before the command starts.
Review checklist
- Search for
shell=True,os.system(andos.popen(across apps, commands and tasks. - List which management commands are started from views, signals or tasks.
- Pass request values to
call_command()as keyword options, never as positional strings. - Put
--before file name arguments for programs that support it. - Validate in the task as well as in the view.
References
- Reference: Django documentation: django-admin and manage.py, call_command() (checked 2026-10-12)
- Reference: Django documentation: How to create custom django-admin commands (checked 2026-10-12)
- Reference: Django documentation: Uploaded Files and Upload Handlers (checked 2026-10-12)
- Reference: Python documentation: subprocess, Security Considerations (checked 2026-10-12)