TRENDING
A vintage glass syringe with a metal plunger and orange measurement marks lying on a plain white background
November 9, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
A green valet key and a black ignition key with a remote fob on a white background, the brand emblems blurred
November 9, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
Sepia 1900 postcard photograph of two riders on horses galloping neck and neck at the front of a field on a Hannover racecourse, with the rest of the pack behind them
November 9, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected
Two yellow commuter trains stand side by side on separate tracks next to a platform in a railway yard, with more tracks running in parallel on both sides and trees in the background
November 9, 2026
How to Run Parallel AI Coding Agents With git worktree and Check Their Branches for Conflicts Before You Merge
Car speedometer dial with a red needle pointing at about 60 miles per hour, or about 100 kilometers per hour on the inner scale
November 9, 2026
METR’s Anthropic Report Turns AI-Led Acceleration Into a 1.9x Slope Ratio That Reproduces From Its Charts, but the Ratio Runs From 1.1x to 2.9x With the Fit Function, and a Second METR Report Says Anthropic’s Public Risk Report Understated the Pace
09 Nov 2026
SXZ.io SXZ.io
  • Home
Search the Site
Popular Searches:
Technology Amazon AI
Recent Posts
Tall orange and white container cranes rising against a clear blue sky at a container terminal
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
The Alcatraz lighthouse at night with beams of light sweeping across the sky above the ruined warden’s house
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Stepped concrete headquarters building of Crédit Mutuel Arkéa at Le Relecq-Kerhuon in Brittany, seen across a lawn with bare trees and flowering shrubs
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
SXZ.io SXZ.io
  • Home

Categories

Articles 290 Posts
News 294 Posts
Learning Hub 263 Posts
Home/Learning Hub/How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
Learning Hub

How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap

Exploit a shell=True call with a harmless file canary, compare how cmd.exe, a POSIX shell and an argv list treat 13 hostile strings, reproduce Git option injection and the Windows .cmd trap, then...

November 8, 2026 38 Min Read
11

Command injection happens when a program builds a command line out of text it did not write, and that text contains something the operating system treats as an instruction instead of as data. In this tutorial we reproduce it safely on our own machine, find out exactly where it comes from, and fix it. Then we go after the two versions that survive the obvious fix: option injection, where a value that starts with a dash becomes a flag, and the Windows batch-file trap, where a .cmd file quietly puts cmd.exe back between your arguments and the program.

Table Of Content

  • Why this matters
  • Words we will use
  • Prerequisites
  • The idea: up to three parsers sit between your text and the program
  • Step 1: Create the lab folder and a stand-in program
  • Step 2: Watch a shell=True call get injected
  • Why it happens
  • Step 3: See what each layer receives
  • Step 4: Pass an argv list, and learn why shlex.quote is not the Windows fix
  • Step 5: Stop option injection, the bug that survives an argv list
  • What the output shows
  • The rules that come out of this
  • Step 6: Handle the Windows batch-file trap
  • What the output shows
  • What the docs say, and what I measured
  • What to do instead
  • Option 1: do not run a batch file with untrusted arguments
  • Option 2: allow only characters cmd.exe does not care about
  • Option 3: keep batch files off PATH
  • Step 7: Build saferun.py so the rules live in one place
  • Step 8: Test it, then break it on purpose
  • Step 9: Add a lint gate that keeps raw subprocess calls out
  • What the output shows
  • Step 10: Confirm everything end to end
  • Common mistakes
  • Where to go next
  • Sources and further reading

By the end you will have a short wrapper called saferun.py, a suite of 107 tests that attack it, a mutation check that proves the tests notice when each rule is removed (all 10 planted bugs were caught), and a lint rule that stops anyone from bypassing the wrapper. Every attack in this post only writes a harmless file named canary something, so nothing here can damage your machine.

Why this matters

MITRE files this bug class twice. CWE-78 is “Improper Neutralization of Special Elements used in an OS Command”, which is the classic shell injection. CWE-88 is “Improper Neutralization of Argument Delimiters in a Command”, which is what happens when the shell is gone but the value still gets read as an option. The OWASP cheat sheet on the topic puts the relationship in one sentence: “Every OS Command Injection is also an Argument Injection.” You need to defend against both.

It also matters more than it used to. Plenty of tools that AI agents call are thin wrappers around a command line program, and the arguments come from a model that has just read an untrusted web page or file. Treat those arguments exactly like a form field. The agent harness tutorial on this site builds the surrounding sandbox; this post fixes the layer underneath it.

Words we will use

  • A shell is a program such as cmd.exe or sh that reads a line of text, splits it into words, and decides which characters mean run another command, write to a file, or insert a variable.
  • argv is the list of separate arguments a program receives. ['git', 'show', 'HEAD'] is three arguments and no shell is involved in reading it.
  • A metacharacter is a character a shell treats as syntax, such as &, ;, |, >, $ or a backtick.
  • A canary is a file our attacks create. If a canary appears, the attack worked.
  • An allowlist describes what a value may look like and rejects everything else. A blocklist tries to list what is bad, and always misses something.

Prerequisites

  • Windows 11. The cmd.exe and batch-file results in Steps 2, 3 and 6 are Windows behavior. The lab was run on Python 3.13.14, and Steps 1 to 7 were repeated on Python 3.15.1 with identical output apart from the version line and commit hashes.
  • Git for Windows 2.55 (git on your PATH). It also ships a POSIX shell, sh.exe. The “POSIX shell” rows in this post come from that shell (bash 5.3.15 started as sh), not from a Linux or macOS machine, which I did not have for this run. On Linux and macOS, shell=True already means /bin/sh -c, and the lab helper picks /bin/sh automatically.
  • Python 3.13 or newer, and for Steps 8 and 9 the packages pytest==9.1.1, ruff==0.17.1 and bandit==1.9.4.
  • You should be comfortable running a Python script from a terminal and reading a function. You do not need to know anything about security.

The idea: up to three parsers sit between your text and the program

The mistake behind almost every injection is forgetting how many things read a command line before the program does any work. Here is the full path:

text you did not write
   |
   v
[1] a shell parses it            only if you ask for one: shell=True, os.system
   |
   v
[2] cmd.exe parses it again      only on Windows, only when the target is a .bat or .cmd file
   |
   v
[3] the program parses options   every program, with or without a shell
   |
   v
the program does its work

Each stage has its own attack and its own defense. Stage 1 is stopped by not using a shell, which means passing an argv list. Stage 2 is stopped by not handing untrusted text to batch files, or by letting through only characters cmd.exe does not care about. Stage 3 is stopped by validating the value and by telling the program where its options end. We will meet them in that order.

Step 1: Create the lab folder and a stand-in program

Make an empty folder, open a terminal in it, and create two files. The first is a tiny program that stands in for ping, git, ffmpeg or any other tool. All it does is print the arguments it received, which lets us see exactly what crossed each boundary. It also behaves the same on every operating system and needs no network.

# argv_echo.py - stands in for ping, git or ffmpeg: it shows the arguments it received
import sys

print("argv:", sys.argv[1:])

The second file is a helper module used by every later step. It builds the sandbox folder, defines the 13 hostile strings we will try, and provides attempt(), which runs the stand-in tool with one string in one of three ways and reports what the program saw and what else happened. Read it once; the comments say what each part is for.

# labkit.py - shared helpers for the lab steps
import os
import pathlib
import shutil
import subprocess
import sys

HERE = pathlib.Path(__file__).resolve().parent
os.chdir(HERE)
PY = sys.executable
PY_POSIX = PY.replace("\\", "/")
SH = "/bin/sh" if os.name != "nt" else r"C:\Program Files\Git\usr\bin\sh.exe"  # Git for Windows ships a POSIX shell
SANDBOX = HERE / "sandbox"
os.environ["DEMO_SECRET"] = "s3cr3t-token"
os.environ["PATH"] = os.pathsep.join([str(HERE / "tools"), os.path.dirname(PY), os.environ["PATH"]])

SANDBOX.mkdir(exist_ok=True)
for name in ("a.txt", "b.txt"):
    (SANDBOX / name).write_text("", encoding="utf-8")
shutil.copy(HERE / "argv_echo.py", SANDBOX / "argv_echo.py")

PAYLOADS = [
    ("ampersand", "example.org & echo INJECTED> canary_m.txt"),
    ("double ampersand", "example.org && echo INJECTED> canary_m.txt"),
    ("semicolon", "example.org ; echo INJECTED> canary_m.txt"),
    ("newline", "example.org\necho INJECTED> canary_m.txt"),
    ("dollar-paren", "$(echo INJECTED> canary_m.txt)"),
    ("backticks", "`echo INJECTED> canary_m.txt`"),
    ("redirect", "example.org > canary_m.txt"),
    ("percent variable", "%DEMO_SECRET%"),
    ("dollar variable", "$DEMO_SECRET"),
    ("caret", "a^b"),
    ("double quote", 'a"b'),
    ("star", "*"),
    ("space", "two words"),
]

BATCH_CASES = [
    ("plain word", "world"),
    ("space", "two words"),
    ("ampersand, no quote", "a & echo INJECTED> canary_bat.txt"),
    ("quote, then ampersand", 'x" & echo INJECTED> canary_bat.txt & "y'),
    ("percent variable", "%DEMO_SECRET%"),
    ("caret ampersand", "a^&b"),
]


def canaries():
    found = []
    for folder in (HERE, HERE / "app", SANDBOX):
        found += [p for p in folder.glob("canary*") if p.is_file()]
    return sorted(p.name for p in found)


def where(name):
    for folder in (HERE, HERE / "app", SANDBOX):
        if (folder / name).is_file():
            return folder / name
    return None


def reset():
    for name in canaries():
        where(name).unlink()


def show_files():
    return {name: where(name).read_text(errors="replace").strip() for name in canaries()}


def attempt(mode, payload):
    """Run the stand-in tool with one payload in one mode; return what the child saw and what else happened."""
    reset()
    tool = f'"{PY}" argv_echo.py'
    if mode == "cmd.exe":
        r = subprocess.run(f"{tool} {payload}", shell=True, cwd=SANDBOX, capture_output=True, text=True)
    elif mode == "sh -c":
        r = subprocess.run([SH, "-c", f'"{PY_POSIX}" argv_echo.py {payload}'], cwd=SANDBOX, capture_output=True, text=True)
    else:
        r = subprocess.run([PY, "argv_echo.py", payload], cwd=SANDBOX, capture_output=True, text=True)
    text = (r.stdout + r.stderr).strip()
    saw = text.splitlines()[0] if text else "(nothing on screen)"
    if text and not saw.startswith("argv:"):
        saw = "error text: " + saw[:44]
    files = show_files()
    notes = [f"wrote {n}" + (" (the child's output)" if c.startswith("argv:") else "") for n, c in files.items()]
    if "s3cr3t-token" in text or any("s3cr3t-token" in c for c in files.values()):
        notes.append("secret expanded")
    return saw, "; ".join(notes) or "-"

Check that the stand-in works:

python argv_echo.py hello

You should see this single line:

argv: ['hello']

If you see it, move on. If Python is not found, make sure the python command works in your terminal first. The helper adds the interpreter that runs each step to the front of PATH, so a script run with a specific Python also finds that Python when a batch file calls python later.

Step 2: Watch a shell=True call get injected

Here are three ways a developer might write a host-check function. The first two are the vulnerable shapes; the third is the one we will end up recommending.

# vulnerable.py - three ways to build a command line from text you did not write
import os
import subprocess
import sys

TOOL = f'"{sys.executable}" argv_echo.py'


def check_host_system(host):
    return os.system(f"{TOOL} {host}")


def check_host_shell(host):
    return subprocess.run(f"{TOOL} {host}", shell=True, capture_output=True, text=True)


def check_host_argv(host):
    return subprocess.run([sys.executable, "argv_echo.py", host], capture_output=True, text=True)

First, run them with a harmless host name to confirm they work and to record which interpreter and shell we are using:

# step01_normal.py - versions, then the three functions on a harmless host name
import platform
import subprocess
import sys

import vulnerable
from labkit import SH, reset

print("python :", sys.version.split()[0], "on", platform.system(), platform.release())
print("git    :", subprocess.run(["git", "--version"], capture_output=True, text=True).stdout.strip())
print("sh     :", subprocess.run([SH, "-c", "echo $0 $BASH_VERSION"], capture_output=True, text=True).stdout.strip())
reset()
print("os.system  ->", end=" ", flush=True)
vulnerable.check_host_system("example.org")
print("shell=True ->", vulnerable.check_host_shell("example.org").stdout.strip())
print("argv list  ->", vulnerable.check_host_argv("example.org").stdout.strip())
python step01_normal.py
python : 3.13.14 on Windows 11
git    : git version 2.55.0.windows.3
sh     : /usr/bin/sh 5.3.15(1)-release
os.system  -> argv: ['example.org']
shell=True -> argv: ['example.org']
argv list  -> argv: ['example.org']

All three print the same thing, which is why this bug survives code review: with friendly input, the vulnerable and the safe version are indistinguishable. Now give them a hostile host name. The payload is a host name followed by a second command that writes the word INJECTED into a file. We also try the same idea in a POSIX shell, where the command separator is a semicolon.

# step02_inject.py - the same functions with a hostile host name (the payload only writes a harmless file)
import functools
import subprocess

import vulnerable
from labkit import PY_POSIX, SH, reset, show_files

print = functools.partial(print, flush=True)  # keep our lines in order with output written by child programs
AMP = "example.org & echo INJECTED> canary_amp.txt"
SEMI = "example.org; echo INJECTED > canary_semi.txt"

reset()
print("=== os.system with an ampersand")
vulnerable.check_host_system(AMP)
print("files created:", show_files())

reset()
print("=== subprocess.run(shell=True) with an ampersand")
r = vulnerable.check_host_shell(AMP)
print("child printed:", r.stdout.strip())
print("files created:", show_files())

reset()
print("=== a POSIX shell (sh -c) with a semicolon")
r = subprocess.run([SH, "-c", f'"{PY_POSIX}" argv_echo.py {SEMI}'], capture_output=True, text=True)
print("child printed:", r.stdout.strip())
print("files created:", show_files())

reset()
print("=== the argv list with the ampersand text")
r = vulnerable.check_host_argv(AMP)
print("child printed:", r.stdout.strip())
print("files created:", show_files())
python step02_inject.py
=== os.system with an ampersand
argv: ['example.org']
files created: {'canary_amp.txt': 'INJECTED'}
=== subprocess.run(shell=True) with an ampersand
child printed: argv: ['example.org']
files created: {'canary_amp.txt': 'INJECTED'}
=== a POSIX shell (sh -c) with a semicolon
child printed: argv: ['example.org']
files created: {'canary_semi.txt': 'INJECTED'}
=== the argv list with the ampersand text
child printed: argv: ['example.org & echo INJECTED> canary_amp.txt']
files created: {}

Read the output carefully, because each line teaches something.

  • os.system and shell=True both created the canary. The stand-in tool still printed ['example.org'], so from the application’s point of view the call succeeded. The attack is invisible unless you look for its side effect. The Python documentation describes os.system as executing the command “in a subshell”, implemented by the C system() function.
  • A POSIX shell does the same with a semicolon. The Python docs say that on POSIX, shell=True is the equivalent of running ['/bin/sh', '-c', ...]. On Windows, CPython 3.13.14’s Lib/subprocess.py builds the line cmd.exe /c "..." instead, and adds no escaping for cmd.exe syntax.
  • The argv list kept the whole string as one argument. The last block shows the stand-in receiving a single odd-looking host name and no canary appearing.

Why it happens

A shell is an interpreter. When we write f"{TOOL} {host}" we are not building a command; we are building a program in the shell’s language, with the user’s text pasted into the middle of it. Anything in that text that is valid shell syntax becomes part of the program. That is the same shape as SQL injection, and the cure is the same: keep code and data separate. The argv list is Python’s version of a parameterized query.

Step 3: See what each layer receives

Before we fix anything, let us measure. We try 13 hostile strings against three ways of starting the stand-in program: cmd.exe through shell=True, the POSIX shell through sh -c, and a plain argv list. For each, we record what the program received and whether anything else happened. The script also prints the command line that Python builds on Windows from a list, because on Windows CreateProcess takes a single string, and Python assembles it with subprocess.list2cmdline using, in the words of its docstring, “the same rules as the MS C runtime”.

# step03_matrix.py - what the child program receives, mode by mode
import subprocess

from labkit import PAYLOADS, attempt

print("list2cmdline:", subprocess.list2cmdline(["argv_echo.py", "example.org & echo INJECTED> canary_m.txt"]))
for number, (name, payload) in enumerate(PAYLOADS, 1):
    print(f"\n{number:>2}. {name}: {payload!r}")
    for mode in ("cmd.exe", "sh -c", "argv list"):
        saw, notes = attempt(mode, payload)
        print(f"    {mode:<10} {saw:<50} {notes}")
python step03_matrix.py
list2cmdline: argv_echo.py "example.org & echo INJECTED> canary_m.txt"

 1. ampersand: 'example.org & echo INJECTED> canary_m.txt'
    cmd.exe    argv: ['example.org']                              wrote canary_m.txt
    sh -c      argv: ['example.org']                              wrote canary_m.txt
    argv list  argv: ['example.org & echo INJECTED> canary_m.txt'] -

 2. double ampersand: 'example.org && echo INJECTED> canary_m.txt'
    cmd.exe    argv: ['example.org']                              wrote canary_m.txt
    sh -c      argv: ['example.org']                              wrote canary_m.txt
    argv list  argv: ['example.org && echo INJECTED> canary_m.txt'] -

 3. semicolon: 'example.org ; echo INJECTED> canary_m.txt'
    cmd.exe    (nothing on screen)                                wrote canary_m.txt (the child's output)
    sh -c      argv: ['example.org']                              wrote canary_m.txt
    argv list  argv: ['example.org ; echo INJECTED> canary_m.txt'] -

 4. newline: 'example.org\necho INJECTED> canary_m.txt'
    cmd.exe    argv: ['example.org']                              -
    sh -c      argv: ['example.org']                              wrote canary_m.txt
    argv list  argv: ['example.org\necho INJECTED> canary_m.txt'] -

 5. dollar-paren: '$(echo INJECTED> canary_m.txt)'
    cmd.exe    (nothing on screen)                                wrote canary_m.txt) (the child's output)
    sh -c      argv: []                                           wrote canary_m.txt
    argv list  argv: ['$(echo INJECTED> canary_m.txt)']           -

 6. backticks: '`echo INJECTED> canary_m.txt`'
    cmd.exe    (nothing on screen)                                wrote canary_m.txt` (the child's output)
    sh -c      argv: []                                           wrote canary_m.txt
    argv list  argv: ['`echo INJECTED> canary_m.txt`']            -

 7. redirect: 'example.org > canary_m.txt'
    cmd.exe    (nothing on screen)                                wrote canary_m.txt (the child's output)
    sh -c      (nothing on screen)                                wrote canary_m.txt (the child's output)
    argv list  argv: ['example.org > canary_m.txt']               -

 8. percent variable: '%DEMO_SECRET%'
    cmd.exe    argv: ['s3cr3t-token']                             secret expanded
    sh -c      argv: ['%DEMO_SECRET%']                            -
    argv list  argv: ['%DEMO_SECRET%']                            -

 9. dollar variable: '$DEMO_SECRET'
    cmd.exe    argv: ['$DEMO_SECRET']                             -
    sh -c      argv: ['s3cr3t-token']                             secret expanded
    argv list  argv: ['$DEMO_SECRET']                             -

10. caret: 'a^b'
    cmd.exe    argv: ['ab']                                       -
    sh -c      argv: ['a^b']                                      -
    argv list  argv: ['a^b']                                      -

11. double quote: 'a"b'
    cmd.exe    argv: ['ab']                                       -
    sh -c      error text: /usr/bin/sh: -c: line 1: unexpected EOF whil -
    argv list  argv: ['a"b']                                      -

12. star: '*'
    cmd.exe    argv: ['*']                                        -
    sh -c      argv: ['a.txt', 'argv_echo.py', 'b.txt']           -
    argv list  argv: ['*']                                        -

13. space: 'two words'
    cmd.exe    argv: ['two', 'words']                             -
    sh -c      argv: ['two', 'words']                             -
    argv list  argv: ['two words']                                -

This table is worth studying slowly, because it shows that the two shells disagree about almost everything:

  • Chaining. & and && chain commands in both shells. A semicolon chains in the POSIX shell, but in cmd.exe it is just another separator between words, so the program received extra arguments. Notice, though, that the > redirect in the same string still worked: the file holds the stand-in’s own output.
  • Newlines. The POSIX shell ran the second line. cmd.exe ran only the first.
  • Substitution. $(...) and backticks run commands in the POSIX shell. cmd.exe ignores them as syntax, yet its redirect still created a file with an odd name such as canary_m.txt).
  • Variables leak without any chaining. %DEMO_SECRET% expanded in cmd.exe and $DEMO_SECRET expanded in the POSIX shell, and each one handed the secret to the program. A host name is a surprising place to find an environment variable, which is the point.
  • Silent rewriting. cmd.exe deleted the caret from a^b and the double quote from a"b, and the POSIX shell refused the lone quote with a syntax error. The star expanded to the three file names in the folder, and a space split one value into two arguments in both shells.
  • The argv list is boring, which is what we want. All 13 strings arrived as exactly one argument, unchanged.

Step 4: Pass an argv list, and learn why shlex.quote is not the Windows fix

The fix for stage 1 is to stop using a shell. Pass a list, leave shell at its default of False, and the operating system never sees a command line that something else has to parse. The Python documentation states the consequence plainly: “all characters, including shell metacharacters, can safely be passed to child processes.” We now check that claim against all 13 strings, and we also test the fix many developers reach for first, shlex.quote.

# step04_fix.py - the argv list, and why shlex.quote is not the fix on Windows
import shlex
import subprocess

from labkit import PAYLOADS, PY, PY_POSIX, SANDBOX, SH, attempt, reset, show_files

print("=== argv list: every payload from step 3")
clean = 0
for name, payload in PAYLOADS:
    saw, notes = attempt("argv list", payload)
    ok = saw == f"argv: {[payload]}" and notes == "-"
    clean += ok
    print(f"{'ok  ' if ok else 'FAIL'} {name:<18} {saw[:60]}")
print(f"{clean} of {len(PAYLOADS)} payloads arrived as one unchanged argument and wrote nothing")

print("\n=== shlex.quote, then a shell")
payload = "example.org & echo INJECTED> canary_q.txt"
quoted = shlex.quote(payload)
print("shlex.quote gives:", quoted)
reset()
r = subprocess.run(f'"{PY}" argv_echo.py {quoted}', shell=True, cwd=SANDBOX, capture_output=True, text=True)
print("cmd.exe  child printed:", (r.stdout + r.stderr).strip().replace("\n", " | "), "| files:", show_files())
reset()
r = subprocess.run([SH, "-c", f'"{PY_POSIX}" argv_echo.py {quoted}'], cwd=SANDBOX, capture_output=True, text=True)
print("sh -c    child printed:", (r.stdout + r.stderr).strip().replace("\n", " | "), "| files:", show_files())
reset()
python step04_fix.py
=== argv list: every payload from step 3
ok   ampersand          argv: ['example.org & echo INJECTED> canary_m.txt']
ok   double ampersand   argv: ['example.org && echo INJECTED> canary_m.txt']
ok   semicolon          argv: ['example.org ; echo INJECTED> canary_m.txt']
ok   newline            argv: ['example.org\necho INJECTED> canary_m.txt']
ok   dollar-paren       argv: ['$(echo INJECTED> canary_m.txt)']
ok   backticks          argv: ['`echo INJECTED> canary_m.txt`']
ok   redirect           argv: ['example.org > canary_m.txt']
ok   percent variable   argv: ['%DEMO_SECRET%']
ok   dollar variable    argv: ['$DEMO_SECRET']
ok   caret              argv: ['a^b']
ok   double quote       argv: ['a"b']
ok   star               argv: ['*']
ok   space              argv: ['two words']
13 of 13 payloads arrived as one unchanged argument and wrote nothing

=== shlex.quote, then a shell
shlex.quote gives: 'example.org & echo INJECTED> canary_q.txt'
cmd.exe  child printed: argv: ["'example.org"] | files: {"canary_q.txt'": 'INJECTED'}
sh -c    child printed: argv: ['example.org & echo INJECTED> canary_q.txt'] | files: {}

Two results matter here.

First, all 13 payloads arrived as one unchanged argument and wrote nothing. That is the fix for shell injection. If you take only one habit from this post, make it this one: build a list, never a string.

Second, shlex.quote protected the POSIX shell, where the whole string stayed one argument, and failed completely on cmd.exe: the canary was created, with a stray single quote glued to its name. cmd.exe does not treat single quotes as quoting at all. The shlex documentation says so directly: “The shlex module is only designed for Unix shells.” The subprocess docs hedge the same way, saying shlex.quote() works “on some platforms”. There is no equivalent escape function for cmd.exe in the standard library, so on Windows the honest answer to the question of how to escape text for the shell is not to use the shell.

If you think you need a shell feature, you almost certainly do not. Use glob for wildcards, os.path.expanduser for a leading ~, shutil.which for finding a program, and two Popen objects joined by a pipe for |. Each of those keeps data as data.

Step 5: Stop option injection, the bug that survives an argv list

With the shell gone, one parser is still reading our text: the program’s own option parser. Almost every command line tool treats an argument that starts with a dash as a flag, and some flags do dangerous things. This is the CWE-88 half of the problem, and it works with shell=False.

To see it, we need something realistic that is not a toy. Imagine a code review bot or an agent tool that can list the tags of a remote and show a commit. Both are one-line wrappers around git. First, a script that builds a tiny origin repository called repo and a clone of it called app:

# setup_repo.py - build a tiny origin repository called repo and a clone of it called app
import os
import pathlib
import shutil
import stat
import subprocess

HERE = pathlib.Path(__file__).resolve().parent
GIT = ["git", "-c", "user.name=Lab", "-c", "[email protected]",
       "-c", "init.defaultBranch=main", "-c", "core.autocrlf=false"]


def force_remove(func, path, _error):
    os.chmod(path, stat.S_IWRITE)  # git object files are read-only on Windows
    func(path)


for name in ("repo", "app"):
    if (HERE / name).exists():
        shutil.rmtree(HERE / name, onexc=force_remove)

subprocess.run(GIT + ["init", "-q", "repo"], cwd=HERE, check=True)
(HERE / "repo" / "a.txt").write_text("one\n", encoding="utf-8")
subprocess.run(GIT + ["-C", "repo", "add", "a.txt"], cwd=HERE, check=True)
subprocess.run(GIT + ["-C", "repo", "commit", "-q", "-m", "first"], cwd=HERE, check=True)
subprocess.run(GIT + ["-C", "repo", "tag", "v1"], cwd=HERE, check=True)
subprocess.run(GIT + ["clone", "-q", "repo", "app"], cwd=HERE, check=True)
print("created repo and app (a clone whose origin is repo)")
python setup_repo.py
created repo and app (a clone whose origin is repo)

Now the two helpers. Neither uses a shell, and both pass the caller’s text straight to git as one list element:

# git_tools.py - two helpers that pass the caller's text straight to git
import subprocess


def list_tags(remote):
    return subprocess.run(["git", "ls-remote", "--tags", remote], cwd="app", capture_output=True, text=True)


def show_commit(ref):
    return subprocess.run(["git", "show", "--stat", "--format=%s", ref], cwd="app", capture_output=True, text=True)

The next script calls them normally, then with two hostile values, then tries two ways of telling Git where options end. The first hostile value uses --upload-pack, which the Git documentation describes as “Specify the full path of git-upload-pack on the remote host.” That is a legitimate feature for SSH remotes, and for a local repository the “remote host” is this machine. The second uses --output, which the diff options page describes as “Output to a specific file instead of stdout.”

# step05_git.py - option injection: text that starts with a dash is an option, with or without a shell
import subprocess

import git_tools
from labkit import reset, show_files


def tags(r):
    return [line.split("\t")[1] for line in r.stdout.splitlines() if "\t" in line]


print("=== normal use")
print("list_tags('origin') ->", tags(git_tools.list_tags("origin")))
print(git_tools.show_commit("HEAD").stdout.strip())

reset()
print("\n=== option injection in git ls-remote: --upload-pack names a program to run")
remote_payload = "--upload-pack=python -c \"open('canary_up.txt','w').write('INJECTED')\" #"
r = git_tools.list_tags(remote_payload)
print("remote argument:", remote_payload)
print("git exit status:", r.returncode, "| git said:", r.stderr.strip().splitlines()[0])
print("files created:", show_files())

reset()
print("\n=== option injection in git show: --output writes a file")
ref_payload = "--output=../canary_show.txt"
r = git_tools.show_commit(ref_payload)
print("git exit status:", r.returncode, "| text on screen:", repr(r.stdout.strip()))
print("files created:", {k: v.splitlines()[0] for k, v in show_files().items()})

print("\n=== separators: -- versus --end-of-options")
CASES = [
    ("ls-remote --", ["git", "ls-remote", "--tags", "--", "{}"], remote_payload, "origin"),
    ("ls-remote --end-of-options", ["git", "ls-remote", "--tags", "--end-of-options", "{}"], remote_payload, "origin"),
    ("show --", ["git", "show", "--stat", "--format=%s", "--", "{}"], ref_payload, "HEAD"),
    ("show --end-of-options", ["git", "show", "--stat", "--format=%s", "--end-of-options", "{}"], ref_payload, "HEAD"),
]
for label, template, hostile, benign in CASES:
    for kind, text in (("hostile", hostile), ("benign", benign)):
        reset()
        argv = [text if part == "{}" else part for part in template]
        r = subprocess.run(argv, cwd="app", capture_output=True, text=True)
        lines = (r.stdout.strip() or r.stderr.strip()).splitlines()
        shown = lines[0].strip()[:70]
        files = list(show_files()) or "-"
        print(f"{label:<27} {kind:<8} exit {r.returncode:<3} {len(lines):>2} lines  {shown:<72} files: {files}")
reset()
python step05_git.py
=== normal use
list_tags('origin') -> ['refs/tags/v1']
first

 a.txt | 1 +
 1 file changed, 1 insertion(+)

=== option injection in git ls-remote: --upload-pack names a program to run
remote argument: --upload-pack=python -c "open('canary_up.txt','w').write('INJECTED')" #
git exit status: 128 | git said: fatal: Could not read from remote repository.
files created: {'canary_up.txt': 'INJECTED'}

=== option injection in git show: --output writes a file
git exit status: 0 | text on screen: ''
files created: {'canary_show.txt': 'first'}

=== separators: -- versus --end-of-options
ls-remote --                hostile  exit 128  1 lines  fatal: strange pathname '--upload-pack=python -c "open('canary_up.txt'   files: -
ls-remote --                benign   exit 0    1 lines  e01a54040168422ed24ed42fa5b3d63dda279c63	refs/tags/v1                    files: -
ls-remote --end-of-options  hostile  exit 128  1 lines  fatal: strange pathname '--upload-pack=python -c "open('canary_up.txt'   files: -
ls-remote --end-of-options  benign   exit 0    1 lines  e01a54040168422ed24ed42fa5b3d63dda279c63	refs/tags/v1                    files: -
show --                     hostile  exit 0    1 lines  first                                                                    files: -
show --                     benign   exit 0    1 lines  first                                                                    files: -
show --end-of-options       hostile  exit 128  1 lines  fatal: option '--output=../canary_show.txt' must come before non-optio   files: -
show --end-of-options       benign   exit 0    4 lines  first                                                                    files: -

What the output shows

  • Normal use works. The tag list contains refs/tags/v1 and the commit view shows the subject and the diffstat.
  • The --upload-pack value ran a program. Git exited with status 128 and said it could not read from the remote, because our program is not a Git server. But canary_up.txt exists, so the program ran first. The only evidence an application would see is an error that looks like a network failure.
  • The --output value wrote a file and hid it. Exit status 0, nothing on screen, and a file with a name the caller chose. That is an arbitrary file write with a polite success code.
  • The separator table is the useful part. For ls-remote, both -- and --end-of-options turned the hostile value into a harmless error about a strange pathname while origin still worked. For show, the two behave differently: after --, the hostile value was silently ignored but the benign HEAD lost its diffstat (one line of output instead of four), because Git now read HEAD as a file name. With --end-of-options the hostile value was refused with an error and HEAD displayed normally.

The Git CLI documentation explains the difference: “Because -- disambiguates revisions and paths in some commands, it cannot be used for those commands to separate options and revisions. You can use --end-of-options for this”. It also gives the general advice we are following: “When writing a script that is expected to handle random user-input, it is a good practice to make it explicit which arguments are which by placing disambiguating -- at appropriate places”.

The rules that come out of this

  1. Validate with an allowlist shaped like the thing you expect: a remote name, a revision, a host name. Reject everything else before it reaches a process.
  2. Reject a leading dash in any value that is not supposed to be an option. It costs nothing and stops this whole class.
  3. Use the separator that fits the tool. -- for file and operand arguments, --end-of-options for Git revisions. Read the tool’s documentation rather than assuming. The OWASP cheat sheet warns that for curl, “later arguments are still URL operands: -- does not prevent additional transfers”.
  4. Do not try to blocklist dangerous flags. Every tool has its own list, and the list grows.

Step 6: Handle the Windows batch-file trap

This step is Windows only. A batch file is a text file ending in .bat or .cmd. Windows cannot run one without cmd.exe, so when you pass a batch file to CreateProcess, the operating system starts cmd.exe for you. In the words of the researcher who named this problem BatBadBut, “CreateProcess() implicitly spawns cmd.exe when executing batch files (.bat, .cmd, etc.), even if the application didn’t specify them in the command line.” So a call that looks like stage 3 of our diagram, an argv list and no shell, silently grows a stage 2.

Many command line tools on Windows ship a small batch file in front of the real program. Ours is a stand-in. Create a folder called tools and put this file in it:

@echo off
python "%~dp0..\argv_echo.py" %*

Save it as tools\export_report.cmd. It passes every argument on to the stand-in program. Now the script that attacks it. It tries six values, each with shell=False and with shell=True, because Python’s own documentation suggests the second one. It also checks how Python finds the file.

# step06_batch.py - an argv list and shell=False are not enough for a .cmd file
import pathlib
import shutil
import subprocess

from labkit import BATCH_CASES, HERE, reset, show_files

REPORT = str(HERE / "tools" / "export_report.cmd")

print("shutil.which finds:", pathlib.Path(shutil.which("export_report")).name)
print("command line Python builds for the quote case:",
      subprocess.list2cmdline(["export_report.cmd", 'x" & echo INJECTED> canary_bat.txt & "y']))
try:
    subprocess.run(["export_report", "world"])
except FileNotFoundError as error:
    print("subprocess.run without the extension:", type(error).__name__)


def go(text, shell):
    reset()
    r = subprocess.run([REPORT, text], shell=shell, capture_output=True, text=True)
    out = (r.stdout + r.stderr).strip()
    saw = out.splitlines()[0] if out else "(nothing printed)"
    notes = []
    if show_files():
        notes.append("wrote " + ", ".join(show_files()))
    if "s3cr3t-token" in out:
        notes.append("secret expanded")
    if "is not recognized" in out:
        notes.append("cmd.exe tried to run a second command")
    return saw[:42], "; ".join(notes) or "-"


for name, text in BATCH_CASES:
    print(f"\n{name}: {text!r}")
    for shell in (False, True):
        saw, notes = go(text, shell)
        print(f"    shell={str(shell):<5} {saw:<44} {notes}")
reset()
python step06_batch.py
shutil.which finds: export_report.CMD
command line Python builds for the quote case: export_report.cmd "x\" & echo INJECTED> canary_bat.txt & \"y"
subprocess.run without the extension: FileNotFoundError

plain word: 'world'
    shell=False argv: ['world']                              -
    shell=True  argv: ['world']                              -

space: 'two words'
    shell=False argv: ['two words']                          -
    shell=True  argv: ['two words']                          -

ampersand, no quote: 'a & echo INJECTED> canary_bat.txt'
    shell=False argv: ['a & echo INJECTED> canary_bat.txt'   -
    shell=True  argv: ['a & echo INJECTED> canary_bat.txt'   -

quote, then ampersand: 'x" & echo INJECTED> canary_bat.txt & "y'
    shell=False argv: ['x"']                                 wrote canary_bat.txt; cmd.exe tried to run a second command
    shell=True  argv: ['x"']                                 wrote canary_bat.txt; cmd.exe tried to run a second command

percent variable: '%DEMO_SECRET%'
    shell=False argv: ['s3cr3t-token']                       secret expanded
    shell=True  argv: ['s3cr3t-token']                       secret expanded

caret ampersand: 'a^&b'
    shell=False argv: ['a']                                  cmd.exe tried to run a second command
    shell=True  argv: ['a']                                  cmd.exe tried to run a second command

What the output shows

  • Finding the file. shutil.which("export_report") returned export_report.CMD because it honors PATHEXT. A plain subprocess.run(["export_report", "world"]) failed with FileNotFoundError, because CreateProcess only appends .exe on its own. Python will not pick a batch file by accident, but code that resolves a name with shutil.which will, and our wrapper in Step 7 does exactly that, so it has to check.
  • Spaces and a quoted ampersand are safe. The first three cases arrived intact. Python wraps a value that contains a space in double quotes, and inside quotes cmd.exe leaves & alone.
  • A double quote breaks out. Python escapes a double quote with a backslash, which is the C runtime rule. The line it built is printed above: the value ends up as "x\" & echo INJECTED> canary_bat.txt & \"y". cmd.exe does not treat a backslash as an escape character, so to cmd.exe that backslash-quote closes the string, and what follows runs as commands. The canary appeared. The BatBadBut write-up explains the root cause: “the command prompt doesn’t use the backslash as an escape character”, and it uses the caret instead.
  • A percent variable leaks a secret with no quote or ampersand. cmd.exe expands %DEMO_SECRET% while reading the command line, even inside quotes, and the program received the secret.
  • A caret re-arms an ampersand. In a^&b, cmd.exe consumed the caret during its first pass, and then the batch file’s %* handed a&b to a second pass, where the ampersand separated commands and cmd.exe tried to run a program named b. That explanation is my reading of this output; your batch file may quote its arguments differently, which is exactly why the next section refuses to guess.

What the docs say, and what I measured

The subprocess documentation has a Windows paragraph under “Security Considerations”: batch files “may be launched by the operating system in a system shell regardless of the arguments passed to this library”, which “could result in arguments being parsed according to shell rules, but without any escaping added by Python.” It then suggests: “If you are intentionally launching a batch file with arguments from untrusted sources, consider passing shell=True to allow Python to escape special characters.”

In the output above, shell=True made no difference at all: the same canary, the same leaked secret, the same second command. The docs page itself also says, a few screens earlier, “You do not need shell=True to run a batch file or console-based executable.” The CPython issue behind the note (gh-114539, opened in January 2024 and closed that April) shows the disagreement: a commenter named Ry0taK wrote that using shell=True “doesn’t escape arguments properly”, and in August 2024 another commenter reported that the page contradicts itself. The issue text records the Python Security Response Team’s conclusion that “we can’t safely detect and handle this situation without causing new issues”, so the outcome was a documentation note rather than new escaping. The sentence is still in the 3.13 and 3.15 docs I fetched on 2026-11-08, and the BatBadBut write-up lists Python’s status as “Documentation update”. The practical lesson: on Windows, Python does not make batch-file arguments safe, with or without shell=True, so your code has to.

What to do instead

Option 1: do not run a batch file with untrusted arguments

Call the real program (tool.exe, or python script.py, or node cli.js) instead of the shim. This removes stage 2 entirely and is the only option that needs no clever rules.

Option 2: allow only characters cmd.exe does not care about

If you must run the batch file, accept arguments that match [A-Za-z0-9._-]{1,64} and nothing else. No space, quote, percent sign, caret, ampersand, pipe, angle bracket, parenthesis, exclamation mark or newline can get through. The BatBadBut article describes how to escape arbitrary text for cmd.exe, and it takes several steps, including rewriting % as %%cd:~,%. Refusing is easier to get right than escaping.

Option 3: keep batch files off PATH

The same article advises users to consider “moving the batch files to a directory that is not included in the PATH environment variable”, so that they only run when someone gives the full path. Option 3 prevents the accidental case and does nothing about the deliberate one, so combine it with Option 1 or 2.

Step 7: Build saferun.py so the rules live in one place

Fixing each call site by hand does not scale, and the next developer will write shell=True again. We put every rule from Steps 4 to 6 in one small module, and the rest of the codebase calls it. The rules, in the order the code applies them:

  1. argv lists only. A single string is refused before anything starts.
  2. The program is never untrusted. A subclass of str named Untrusted marks text from outside. The program name may not be one. The Python team’s own view, recorded in the CPython issue, is that an untrusted program name is already vulnerable.
  3. Untrusted values pass an allowlist. value(text, pattern) uses re.fullmatch, not re.match with a $ at the end, because the Python documentation says $ “matches the end of the string or just before the newline at the end of the string”, so a pattern ending in $ accepts a trailing newline.
  4. Untrusted values may not start with a dash. That is the Step 5 rule, enforced again at the last moment.
  5. Batch files are refused unless the caller passes allow_batch=True, and even then every argument must match the Step 6 character set.
  6. Housekeeping. The program is resolved with shutil.which, standard input is closed, a timeout applies, shell is False, and the child receives only a short list of environment variables so a secret in our environment stays here.

Part one defines the exception, the marker class, the constants and value():

# saferun.py
"""A guard around subprocess.run: argv lists only, resolved programs, and no surprises from cmd.exe."""
import os
import re
import shutil
import subprocess
from pathlib import Path


class UnsafeCommand(ValueError):
    """Raised before anything starts when a command breaks a rule."""


class Untrusted(str):
    """Text that came from a user, a file, the network, or a model."""


BATCH_SUFFIXES = {".bat", ".cmd"}
BATCH_SAFE_ARG = re.compile(r"[A-Za-z0-9._-]{1,64}")  # nothing cmd.exe treats as special
KEEP_ENV = {"PATH", "SYSTEMROOT", "TEMP", "TMP", "HOME", "USERPROFILE", "LANG", "LC_ALL"}


def value(text, pattern):
    """Accept untrusted text only if all of it matches an allowlist pattern."""
    if not isinstance(text, str) or re.fullmatch(pattern, text) is None:
        raise UnsafeCommand(f"value does not match {pattern}")
    return Untrusted(text)

Part two finds the program, refuses batch files by default, and builds the minimal environment:

# saferun.py (continued)
def resolve(program, allow_batch=False):
    """Find the program on PATH and refuse batch files unless the caller opted in."""
    found = shutil.which(program)
    if found is None:
        raise UnsafeCommand(f"program not found: {program}")
    if Path(found).suffix.lower() in BATCH_SUFFIXES and not allow_batch:
        raise UnsafeCommand(f"{Path(found).name} is a batch file; cmd.exe would re-read its arguments")
    return found


def minimal_env():
    """Pass the child only what it needs, so a stray secret in our environment stays here."""
    return {k: v for k, v in os.environ.items() if k.upper() in KEEP_ENV}

Part three is the function the rest of the code calls. Append it to the same file:

# saferun.py (continued)
def run(argv, *, allow_batch=False, timeout=10.0, cwd=None):
    if isinstance(argv, (str, bytes)) or not argv:
        raise UnsafeCommand("argv must be a non-empty list, not one string")
    if not all(isinstance(arg, str) for arg in argv):
        raise UnsafeCommand("every argument must be a string")
    if isinstance(argv[0], Untrusted):
        raise UnsafeCommand("the program itself must not come from untrusted text")
    program = resolve(argv[0], allow_batch)
    batch = Path(program).suffix.lower() in BATCH_SUFFIXES
    for arg in argv[1:]:
        if isinstance(arg, Untrusted) and arg.startswith("-"):
            raise UnsafeCommand(f"untrusted value looks like an option: {arg}")
        if batch and BATCH_SAFE_ARG.fullmatch(arg) is None:
            raise UnsafeCommand(f"batch file argument outside the safe set: {arg!r}")
    return subprocess.run([program, *argv[1:]], shell=False, stdin=subprocess.DEVNULL, capture_output=True,
                          text=True, encoding="utf-8", errors="replace", timeout=timeout, cwd=cwd,
                          env=minimal_env(), check=False)

Now rewrite the four helpers on top of it. Each one has an allowlist pattern shaped like what it expects, and the developer, not the user, places --end-of-options. Note that REMOTE accepts the name of a remote that is already configured in the clone, not a URL. Accepting URLs is a separate problem with its own tutorial: preventing server-side request forgery.

# tools_safe.py - the same four jobs, now routed through saferun
import sys

import saferun
from saferun import value

HOST = r"(?!-)[A-Za-z0-9.-]{1,253}"
REMOTE = r"(?!-)[A-Za-z0-9_-]{1,40}"          # the name of a remote already configured in the clone
REVISION = r"(?!-)[A-Za-z0-9._/~^-]{1,100}"
REPORT = r"[A-Za-z0-9_-]{1,40}"


def check_host(host):
    return saferun.run([sys.executable, "argv_echo.py", value(host, HOST)])


def list_tags(remote):
    return saferun.run(["git", "ls-remote", "--tags", "--end-of-options", value(remote, REMOTE)], cwd="app")


def show_commit(ref):
    return saferun.run(["git", "show", "--stat", "--format=%s", "--end-of-options", value(ref, REVISION)], cwd="app")


def export_report(name):
    return saferun.run(["export_report", value(name, REPORT)], allow_batch=True)

Finally, rerun every attack against the new helpers, then probe the second layer on purpose. The second block uses a sloppy pattern, .*, which lets anything through the allowlist, to prove that saferun.run still refuses the dangerous shapes on its own.

# step07_safe.py - the same attacks against the guarded tools, then the legitimate calls
import saferun
import tools_safe
from labkit import BATCH_CASES, PAYLOADS, PY_POSIX, reset, show_files
from saferun import UnsafeCommand, value

REMOTE_PAYLOAD = "--upload-pack=python -c \"open('canary_up.txt','w').write('INJECTED')\" #"
ATTACKS = (
    [("check_host: " + name, tools_safe.check_host, text) for name, text in PAYLOADS]
    + [("list_tags: upload-pack", tools_safe.list_tags, REMOTE_PAYLOAD),
       ("list_tags: a URL", tools_safe.list_tags, "https://example.org/x.git"),
       ("show_commit: --output", tools_safe.show_commit, "--output=../canary_show.txt"),
       ("show_commit: semicolon", tools_safe.show_commit, "HEAD; echo INJECTED")]
    + [("export_report: " + name, tools_safe.export_report, text) for name, text in BATCH_CASES[1:]]
)
refused = 0
for label, func, text in ATTACKS:
    reset()
    try:
        result = func(text)
        verdict = f"RAN, child printed {result.stdout.strip()[:40]!r}"
    except UnsafeCommand as error:
        refused += 1
        verdict = f"refused: {str(error).replace(PY_POSIX, 'python')[:52]}"
    print(f"{label:<34} {verdict:<66} files: {list(show_files()) or '-'}")
print(f"\n{refused} of {len(ATTACKS)} attack calls refused before anything started")

print("\n=== second layer: what saferun.run refuses even when the allowlist is sloppy (pattern .*)")
SLOPPY = r".*"
LAYER_TWO = [
    ("one string as argv", "git status", {}),
    ("option as the remote", ["git", "ls-remote", "--tags", value(REMOTE_PAYLOAD, SLOPPY)], {"cwd": "app"}),
    ("--output as the ref", ["git", "show", "--stat", value("--output=../canary_show.txt", SLOPPY)], {"cwd": "app"}),
    ("batch file, no opt-in", ["export_report", "world"], {}),
    ("batch file, quote and &", ["export_report", value(BATCH_CASES[3][1], SLOPPY)], {"allow_batch": True}),
    ("batch file, %VAR%", ["export_report", value("%DEMO_SECRET%", SLOPPY)], {"allow_batch": True}),
    ("untrusted program name", [value("git", SLOPPY), "status"], {}),
]
for label, argv, kw in LAYER_TWO:
    reset()
    try:
        saferun.run(argv, **kw)
        verdict = "RAN"
    except UnsafeCommand as error:
        verdict = "refused: " + str(error)[:72]
    print(f"{label:<26} {verdict:<84} files: {list(show_files()) or '-'}")

print("\nlegitimate calls:")
reset()
print("check_host('example.org')  ->", tools_safe.check_host("example.org").stdout.strip())
print("list_tags('origin')        ->", [l.split("\t")[1] for l in tools_safe.list_tags("origin").stdout.splitlines()])
print("show_commit('HEAD')        ->", tools_safe.show_commit("HEAD").stdout.strip().replace("\n", " | "))
print("export_report('world')     ->", tools_safe.export_report("world").stdout.strip())
python step07_safe.py
check_host: ampersand              refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: double ampersand       refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: semicolon              refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: newline                refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: dollar-paren           refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: backticks              refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: redirect               refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: percent variable       refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: dollar variable        refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: caret                  refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: double quote           refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: star                   refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
check_host: space                  refused: value does not match (?!-)[A-Za-z0-9.-]{1,253}            files: -
list_tags: upload-pack             refused: value does not match (?!-)[A-Za-z0-9_-]{1,40}             files: -
list_tags: a URL                   refused: value does not match (?!-)[A-Za-z0-9_-]{1,40}             files: -
show_commit: --output              refused: value does not match (?!-)[A-Za-z0-9._/~^-]{1,100}        files: -
show_commit: semicolon             refused: value does not match (?!-)[A-Za-z0-9._/~^-]{1,100}        files: -
export_report: space               refused: value does not match [A-Za-z0-9_-]{1,40}                  files: -
export_report: ampersand, no quote refused: value does not match [A-Za-z0-9_-]{1,40}                  files: -
export_report: quote, then ampersand refused: value does not match [A-Za-z0-9_-]{1,40}                  files: -
export_report: percent variable    refused: value does not match [A-Za-z0-9_-]{1,40}                  files: -
export_report: caret ampersand     refused: value does not match [A-Za-z0-9_-]{1,40}                  files: -

22 of 22 attack calls refused before anything started

=== second layer: what saferun.run refuses even when the allowlist is sloppy (pattern .*)
one string as argv         refused: argv must be a non-empty list, not one string                               files: -
option as the remote       refused: untrusted value looks like an option: --upload-pack=python -c "open('can    files: -
--output as the ref        refused: untrusted value looks like an option: --output=../canary_show.txt           files: -
batch file, no opt-in      refused: export_report.CMD is a batch file; cmd.exe would re-read its arguments      files: -
batch file, quote and &    refused: batch file argument outside the safe set: 'x" & echo INJECTED> canary_ba    files: -
batch file, %VAR%          refused: batch file argument outside the safe set: '%DEMO_SECRET%'                   files: -
untrusted program name     refused: the program itself must not come from untrusted text                        files: -

legitimate calls:
check_host('example.org')  -> argv: ['example.org']
list_tags('origin')        -> ['refs/tags/v1']
show_commit('HEAD')        -> first |  |  a.txt | 1 + |  1 file changed, 1 insertion(+)
export_report('world')     -> argv: ['world']

All 22 attack calls were refused before any process started, and no canary appeared. The second block shows each rule firing by itself with a different message, which makes failures easy to diagnose. The legitimate calls at the bottom still work, which matters as much: a guard that blocks everything is not a fix.

Step 8: Test it, then break it on purpose

A guard that nobody tests slowly stops guarding. We write attack tests, which assert that hostile input is refused, rather than only checking that good input works. A few design choices make the suite short and hard to fool:

  • An autouse fixture fails any test that leaves a canary file behind. One assertion therefore covers every attack in every test.
  • The 13 hostile strings from Step 3 run through the wrapper and through the helper, so both layers are tested.
  • For the batch rule, the test tries every printable character that is not a letter, a digit, a dot, an underscore or a hyphen, which is 35 characters including every kind of whitespace. All must be refused.
  • Refusal tests pass match=, so they check why a call was refused, not only that it was. The first test shows why this matters: with only pytest.raises(UnsafeCommand), a version of saferun that wrongly accepts one string would still pass, because the first letter of the string is not a program and saferun raises UnsafeCommand: program not found: p anyway. I checked that by running the mutant.
# test_saferun.py - attack tests for saferun and the guarded tools
import ast
import string
import subprocess

import pytest

import saferun
import tools_safe
from labkit import BATCH_CASES, HERE, PAYLOADS, PY, canaries, reset
from saferun import UnsafeCommand, Untrusted, value

SAFE_CHARS = set(string.ascii_letters + string.digits + "._-")
SPECIALS = [c for c in string.printable if c not in SAFE_CHARS]  # punctuation and every kind of whitespace
OPTIONS = ["--output=../canary_show.txt", "--upload-pack=touch canary_up.txt #", "-n", "--help", "-oProxyCommand=x"]


@pytest.fixture(autouse=True)
def no_files_left_behind():
    reset()
    yield
    leftovers = canaries()
    reset()
    assert not leftovers, f"an attack left files behind: {leftovers}"


def test_one_string_is_refused():
    with pytest.raises(UnsafeCommand, match="not one string"):
        saferun.run("python argv_echo.py hello")


def test_missing_program_is_refused():
    with pytest.raises(UnsafeCommand, match="not found"):
        saferun.run(["no-such-program-xyz"])


def test_program_cannot_be_untrusted():
    with pytest.raises(UnsafeCommand, match="program itself"):
        saferun.run([Untrusted(PY), "argv_echo.py"])


@pytest.mark.parametrize("name,text", PAYLOADS)
def test_metacharacters_stay_inert(name, text):
    result = saferun.run([PY, "argv_echo.py", text], cwd=HERE / "sandbox")
    assert ast.literal_eval(result.stdout.strip().removeprefix("argv: ")) == [text]


@pytest.mark.parametrize("text", OPTIONS)
def test_untrusted_option_is_refused(text):
    with pytest.raises(UnsafeCommand, match="looks like an option"):
        saferun.run([PY, "argv_echo.py", Untrusted(text)])


def test_value_accepts_and_marks():
    assert isinstance(value("origin", r"[a-z]+"), Untrusted)


@pytest.mark.parametrize("text", ["", "a b", "a;b", "origin\n", "../x", "--x"])
def test_value_rejects(text):
    with pytest.raises(UnsafeCommand):
        value(text, r"(?!-)[A-Za-z0-9._-]{1,20}")


def test_batch_file_is_refused_by_default():
    with pytest.raises(UnsafeCommand, match="batch file"):
        saferun.run(["export_report", "world"])


def test_batch_file_runs_with_a_plain_name():
    result = saferun.run(["export_report", "world"], allow_batch=True)
    assert result.stdout.strip() == "argv: ['world']"


@pytest.mark.parametrize("char", SPECIALS)
def test_batch_refuses_every_special_character(char):
    with pytest.raises(UnsafeCommand, match="safe set"):
        saferun.run(["export_report", "a" + char + "b"], allow_batch=True)


@pytest.mark.parametrize("name,text", BATCH_CASES[1:])
def test_batch_refuses_hostile_arguments(name, text):
    with pytest.raises(UnsafeCommand, match="safe set"):
        saferun.run(["export_report", text], allow_batch=True)


def test_timeout_stops_a_slow_child():
    with pytest.raises(subprocess.TimeoutExpired):
        saferun.run([PY, "-c", "import time; time.sleep(5)"], timeout=0.5)


def test_environment_is_scrubbed():
    result = saferun.run([PY, "-c", "import os; print(os.environ.get('DEMO_SECRET', 'absent'))"])
    assert result.stdout.strip() == "absent"


@pytest.mark.parametrize("name,text", PAYLOADS)
def test_check_host_refuses_every_payload(name, text):
    with pytest.raises(UnsafeCommand):
        tools_safe.check_host(text)


def test_check_host_accepts_a_host_name():
    assert tools_safe.check_host("example.org").stdout.strip() == "argv: ['example.org']"


@pytest.mark.parametrize("text", OPTIONS + ["origin\n", "https://example.org/x.git"])
def test_list_tags_refuses_hostile_remotes(text):
    with pytest.raises(UnsafeCommand):
        tools_safe.list_tags(text)


def test_list_tags_finds_the_tag():
    assert "refs/tags/v1" in tools_safe.list_tags("origin").stdout


@pytest.mark.parametrize("text", OPTIONS + ["HEAD; echo INJECTED"])
def test_show_commit_refuses_hostile_refs(text):
    with pytest.raises(UnsafeCommand):
        tools_safe.show_commit(text)


def test_show_commit_shows_head():
    assert tools_safe.show_commit("HEAD").stdout.startswith("first")


@pytest.mark.parametrize("name,text", BATCH_CASES[1:])
def test_export_report_refuses_hostile_names(name, text):
    with pytest.raises(UnsafeCommand):
        tools_safe.export_report(text)


def test_export_report_runs():
    assert tools_safe.export_report("world").stdout.strip() == "argv: ['world']"
python -m pip install pytest==9.1.1
python -m pytest -q -p no:cacheprovider
............................................................................................ [ 85%]
...............                                                                              [100%]
107 passed in 1.64s

Green tests only prove the code and the tests agree. To find out whether the tests would notice a regression, we plant bugs. The script below copies the lab into a scratch folder ten times, deletes or weakens one rule in each copy, and counts how many tests fail. A mutant that no test notices is a rule nobody is guarding.

# mutations.py - break saferun.py on purpose, one line at a time, and count the tests that notice
import pathlib
import re
import shutil
import subprocess
import sys
import tempfile

HERE = pathlib.Path(__file__).resolve().parent
MUTANTS = [
    ("shell=False becomes shell=True", "shell=False", "shell=True"),
    ("batch files always allowed", " and not allow_batch", " and False"),
    ("batch allowlist widened", 'r"[A-Za-z0-9._-]{1,64}"', 'r"[A-Za-z0-9._&%^\\" -]{1,64}"'),
    ("leading-dash rule removed", 'isinstance(arg, Untrusted) and arg.startswith("-")', "False"),
    ("one string accepted as argv", "isinstance(argv, (str, bytes)) or not argv", "not argv"),
    ("untrusted program allowed", "isinstance(argv[0], Untrusted)", "False"),
    ("whole environment passed on", "env=minimal_env()", "env=None"),
    ("timeout dropped", "timeout=timeout, ", ""),
    ("PATH lookup skipped", "found = shutil.which(program)", "found = program"),
    ("fullmatch becomes match with $", "re.fullmatch(pattern, text) is None", "re.match(pattern + '$', text) is None"),
]
FILES = ["saferun.py", "tools_safe.py", "labkit.py", "argv_echo.py", "setup_repo.py", "test_saferun.py"]


def trial(old, new):
    with tempfile.TemporaryDirectory(ignore_cleanup_errors=True) as scratch:
        folder = pathlib.Path(scratch)
        for file in FILES:
            shutil.copy(HERE / file, folder / file)
        shutil.copytree(HERE / "tools", folder / "tools")
        if old:
            text = (folder / "saferun.py").read_text(encoding="utf-8")
            assert text.count(old) == 1, f"{old!r} occurs {text.count(old)} times"
            (folder / "saferun.py").write_text(text.replace(old, new), encoding="utf-8", newline="\n")
        subprocess.run([sys.executable, "setup_repo.py"], cwd=folder, capture_output=True, check=True)
        result = subprocess.run([sys.executable, "-m", "pytest", "-q", "--tb=no", "-p", "no:cacheprovider"],
                                cwd=folder, capture_output=True, text=True, timeout=600)
    summary = result.stdout.strip().splitlines()[-1]
    failed = re.search(r"(\d+) failed", summary)
    return int(failed.group(1)) if failed else 0, summary


failing, summary = trial("", "")
print(f"baseline: {summary}")
caught = 0
for name, old, new in MUTANTS:
    failing, summary = trial(old, new)
    caught += failing > 0
    print(f"{'caught  ' if failing else 'SURVIVED'} {name:<34} {failing:>3} tests fail")
print(f"\n{caught} of {len(MUTANTS)} planted bugs were caught")
python mutations.py
baseline: 107 passed in 1.62s
caught   shell=False becomes shell=True       2 tests fail
caught   batch files always allowed           1 tests fail
caught   batch allowlist widened              8 tests fail
caught   leading-dash rule removed            5 tests fail
caught   one string accepted as argv          1 tests fail
caught   untrusted program allowed            1 tests fail
caught   whole environment passed on          1 tests fail
caught   timeout dropped                      1 tests fail
caught   PATH lookup skipped                 44 tests fail
caught   fullmatch becomes match with $       2 tests fail

10 of 10 planted bugs were caught

All 10 planted bugs were caught, and the counts are informative. Skipping the PATH lookup fails 44 tests, because without shutil.which the name export_report has no extension and Windows reports FileNotFoundError; a large part of the suite depends on resolving the program properly. Widening the batch allowlist fails 8 tests and removing the leading-dash rule fails 5. The timeout, the environment, the untrusted program name, the one-string check and the batch switch each have exactly one test pointing at them, which is fine for a rule with exactly one behavior.

The most interesting result is the first one. Changing shell=False to shell=True was caught by only two tests: the newline payload, which cmd.exe truncated, and the caret payload, which it rewrote from a^b to ab. The ampersand payloads all passed, because Python quotes any value that contains a space and cmd.exe leaves a quoted ampersand alone. A suite that only tried ampersands would have called shell=True safe on Windows. That is the argument for trying many kinds of hostile text rather than one famous one.

Step 9: Add a lint gate that keeps raw subprocess calls out

The wrapper only helps if people use it. Linters can enforce that, but we should measure what they actually catch rather than assume. The sample file below holds twelve ways of starting a process, each labeled with a number in a trailing comment. The script runs Ruff’s security rules (the S family) and Bandit over it, prints what each tool says about each line, then tries a different approach: Ruff’s TID251 rule (named banned-api), configured to forbid the process launchers outright.

# lint_samples/samples.py
import asyncio
import os
import subprocess


def shapes(host, remote, name):
    os.system(f"ping {host}")  # 1 os.system with an f-string
    os.popen("ping " + host)  # 2 os.popen with concatenation
    subprocess.run(f"ping {host}", shell=True)  # 3 shell=True with an f-string
    subprocess.check_output("ping " + host, shell=True)  # 4 shell=True with concatenation
    subprocess.run(["ping", host])  # 5 list with a bare program name
    subprocess.run(["git", "ls-remote", remote])  # 6 list, option injection possible
    subprocess.run(["git", "ls-remote", "--", remote])  # 7 list, hardened with a separator
    subprocess.run(["C:/tools/export_report.cmd", name])  # 8 list, batch file
    asyncio.create_subprocess_shell(f"ping {host}")  # 9 asyncio through a shell
    os.spawnl(os.P_WAIT, "/bin/ping", "ping", host)  # 10 os.spawnl
    subprocess.Popen(["ping", host], shell=False)  # 11 Popen
    os.startfile(name)  # 12 os.startfile, Windows only
# step09_lint.py - what Ruff and Bandit say about twelve call shapes, then a Ruff ban that covers them all
import json
import pathlib
import re
import shutil
import subprocess
import sys

HERE = pathlib.Path(__file__).resolve().parent
SAMPLES = HERE / "lint_samples" / "samples.py"
SCRIPTS = pathlib.Path(sys.executable).parent

print("ruff", subprocess.run([str(SCRIPTS / "ruff"), "--version"], capture_output=True, text=True).stdout.strip(),
      "| bandit", subprocess.run([str(SCRIPTS / "bandit"), "--version"], capture_output=True, text=True).stdout.split()[1])
ruff = subprocess.run([str(SCRIPTS / "ruff"), "check", "--isolated", "--select", "S", "--output-format", "json", str(SAMPLES)],
                      capture_output=True, text=True)
ruff_hits = {}
for item in json.loads(ruff.stdout):
    ruff_hits.setdefault(item["location"]["row"], []).append(item["code"])
bandit = subprocess.run([str(SCRIPTS / "bandit"), "-q", "-f", "json", str(SAMPLES)], capture_output=True, text=True)
bandit_hits = {}
for item in json.loads(bandit.stdout)["results"]:
    bandit_hits.setdefault(item["line_number"], []).append(item["test_id"])

print("\n=== Ruff S rules and Bandit on twelve call shapes")
for lineno, line in enumerate(SAMPLES.read_text(encoding="utf-8").splitlines(), 1):
    m = re.search(r"# (\d+) (.*)", line)
    if m:
        ruff_codes = " ".join(sorted(ruff_hits.get(lineno, []))) or "-"
        bandit_codes = " ".join(sorted(bandit_hits.get(lineno, []))) or "-"
        print(f"{int(m.group(1)):>2} {m.group(2):<38} Ruff: {ruff_codes:<10} Bandit: {bandit_codes}")

print("\n=== Ruff TID251 ban on subprocess and friends")
BAN = HERE / "lint_ban"
BAN.mkdir(exist_ok=True)
shutil.copy(HERE / "saferun.py", BAN / "saferun.py")
shutil.copy(SAMPLES, BAN / "samples.py")
(BAN / "ruff.toml").write_text("""\
[lint]
select = ["TID251"]

[lint.flake8-tidy-imports.banned-api]
"subprocess".msg = "Use saferun.run()."
"os.system".msg = "Use saferun.run()."
"os.popen".msg = "Use saferun.run()."
"os.spawnl".msg = "Use saferun.run()."
"os.startfile".msg = "Use saferun.run()."
"asyncio.create_subprocess_shell".msg = "Use saferun.run()."

[lint.per-file-ignores]
"saferun.py" = ["TID251"]
""", encoding="utf-8", newline="\n")
print((BAN / "ruff.toml").read_text(encoding="utf-8").rstrip())
out = subprocess.run([str(SCRIPTS / "ruff"), "check", "--config", str(BAN / "ruff.toml"), "--output-format", "concise",
                      str(BAN / "saferun.py"), str(BAN / "samples.py")], capture_output=True, text=True, cwd=HERE)
print("\n$ ruff check --config lint_ban/ruff.toml lint_ban/saferun.py lint_ban/samples.py")
for line in (out.stdout + out.stderr).replace(str(HERE) + "\\", "").replace("\\", "/").strip().splitlines():
    print(line)
print("exit status:", out.returncode)
python -m pip install ruff==0.17.1 bandit==1.9.4
python step09_lint.py
ruff ruff 0.17.1 | bandit 1.9.4

=== Ruff S rules and Bandit on twelve call shapes
 1 os.system with an f-string             Ruff: S605       Bandit: B605
 2 os.popen with concatenation            Ruff: S605       Bandit: B605
 3 shell=True with an f-string            Ruff: S602       Bandit: B602
 4 shell=True with concatenation          Ruff: S602       Bandit: B602
 5 list with a bare program name          Ruff: S603 S607  Bandit: B603 B607
 6 list, option injection possible        Ruff: S603 S607  Bandit: B603 B607
 7 list, hardened with a separator        Ruff: S603 S607  Bandit: B603 B607
 8 list, batch file                       Ruff: S603       Bandit: B603
 9 asyncio through a shell                Ruff: -          Bandit: -
10 os.spawnl                              Ruff: S606       Bandit: B606
11 Popen                                  Ruff: S603 S607  Bandit: B603 B607
12 os.startfile, Windows only             Ruff: S606       Bandit: B606

=== Ruff TID251 ban on subprocess and friends
[lint]
select = ["TID251"]

[lint.flake8-tidy-imports.banned-api]
"subprocess".msg = "Use saferun.run()."
"os.system".msg = "Use saferun.run()."
"os.popen".msg = "Use saferun.run()."
"os.spawnl".msg = "Use saferun.run()."
"os.startfile".msg = "Use saferun.run()."
"asyncio.create_subprocess_shell".msg = "Use saferun.run()."

[lint.per-file-ignores]
"saferun.py" = ["TID251"]

$ ruff check --config lint_ban/ruff.toml lint_ban/saferun.py lint_ban/samples.py
lint_ban/samples.py:4:8: TID251 `subprocess` is banned: Use saferun.run().
lint_ban/samples.py:8:5: TID251 `os.system` is banned: Use saferun.run().
lint_ban/samples.py:9:5: TID251 `os.popen` is banned: Use saferun.run().
lint_ban/samples.py:16:5: TID251 `asyncio.create_subprocess_shell` is banned: Use saferun.run().
lint_ban/samples.py:17:5: TID251 `os.spawnl` is banned: Use saferun.run().
lint_ban/samples.py:19:5: TID251 `os.startfile` is banned: Use saferun.run().
Found 6 errors.
exit status: 1

What the output shows

  • Ruff and Bandit agree, row by row. Both flag shell use (S602 and S605 against B602 and B605). Both flag every list call, with S603 or B603, and add S607 or B607 when the program name has no path.
  • They cannot tell a vulnerable list call from a hardened one. Rows 6 and 7 get identical findings even though row 7 has the separator. The Ruff documentation for S603 says so openly: “Prone to false positives as it is difficult to determine whether the passed arguments have been validated.” Row 8, the batch file, gets the same single finding and no hint about cmd.exe.
  • Row 9 slipped through both tools. In this run (Ruff 0.17.1, Bandit 1.9.4), asyncio.create_subprocess_shell produced no finding from the security rules.
  • The ban list does not need to classify anything. The Ruff documentation describes TID251 like this: “Projects may want to ensure that specific modules or module members are not imported or accessed.” Our configuration forbids subprocess as a whole (flagged once, at the import on line 4, which covers rows 3 to 8 and 11) and names the other launchers separately: rows 1, 2, 9, 10 and 12. That is six findings, all in samples.py. saferun.py itself is exempted by the per-file-ignores entry and has no findings. The exit status is 1, which fails a CI job.

A ban list is a policy, not a proof. If your code base can start processes in other ways (for example os.exec*, os.posix_spawn or pty.spawn), add those names to the list; I only tested the entries shown here. Run ruff check --config with this file in CI on your whole project, and every process launch has to go through saferun.run or carry a visible exemption that a reviewer can question.

Step 10: Confirm everything end to end

Run these checks in order from the lab folder. Each one should match what you saw above.

  1. python step02_inject.py shows the vulnerable functions creating canary files, and the argv list creating none. This proves the lab still attacks what it claims to attack.
  2. python step07_safe.py reports 22 of 22 attack calls refused before anything started, seven refusals from the second layer, and four legitimate calls that still work.
  3. python -m pytest -q -p no:cacheprovider reports 107 passed.
  4. python mutations.py reports 10 of 10 planted bugs were caught.
  5. python step09_lint.py lists six TID251 findings in samples.py and none in saferun.py.
  6. Finally, look for stray files. Running dir canary* in the lab folder, in app and in sandbox should find nothing, because every step cleans up after itself.

I also repeated Steps 1 to 7 on Python 3.15.1, the python.org embeddable build, and the output matched line for line apart from the Python version and the commit hash that Git prints. In particular, shell=True still made no difference for the batch file on 3.15.1. The pytest, mutation and lint steps ran on 3.13.14 only.

Common mistakes

  • Passing a list with shell=True on Linux or macOS. The Python documentation says that with a sequence, “any additional items will be treated as additional arguments to the shell itself”, not to your command. The call is both wrong and still shell-parsed.
  • Believing shell=True makes batch files safe. The documentation suggests it; the measurements above show it changes nothing.
  • Using shlex.quote on Windows. It is for Unix shells and failed against cmd.exe in Step 4.
  • Building argv by splitting user text. text.split() or shlex.split(text) lets the user choose the arguments, including options.
  • Blocklisting metacharacters. The two shells treat different characters as special, and cmd.exe adds % and ^. Describe what is allowed instead.
  • Ending an allowlist pattern with $ and calling re.match. $ also matches before a trailing newline. Use re.fullmatch.
  • Letting the child inherit your environment. A host name was enough to read DEMO_SECRET in Step 3. Pass only what the program needs.
  • Forgetting the timeout. A child that waits forever ties up a worker; see the tutorial on timeouts and deadlines.
  • Validating the shape but not the meaning. A well-formed remote name can still point somewhere you did not intend, and a well-formed path can still climb out of its folder. Those are separate problems; see path traversal and server-side request forgery.

Where to go next

  • Replace the command with a library call where you can. The OWASP cheat sheet calls this the primary defense: “The primary defense is to avoid calling OS commands directly. Built-in library functions are a very good alternative to OS Commands, as they cannot be manipulated to perform tasks other than those it is intended to do.”
  • Watch process launches at run time. CPython raises an audit event named subprocess.Popen whenever the subprocess module starts a process, which an audit hook can log or block. The audit hooks tutorial shows how hooks work.
  • Apply the same separation of code and data elsewhere. Parameterized SQL and Python t-strings are the same idea in other languages. Windows has a sibling of Step 6 in argument injection in a custom URI protocol handler.
  • Run the tool with the least privilege it needs. A separate low-privilege account or container limits the damage if a rule is ever missed.

Sources and further reading

  • Python documentation: subprocess (Security Considerations; the 3.15 page carries the same Windows batch-file paragraph), shlex, re and os.
  • CPython issue gh-114539, “subprocess security considerations needs a Windows-specific exception”.
  • RyotaK, BatBadBut: You can’t securely execute commands on Windows, GMO Flatt Security, April 9, 2024.
  • OWASP, OS Command Injection Defense Cheat Sheet.
  • MITRE, CWE-78 and CWE-88.
  • Git documentation: gitcli, git-ls-remote and diff options.
  • Ruff rules S602, S603, S606, S607 and TID251.

Tags:

Application SecurityArgument InjectionCommand InjectionPythonSubprocessWindows

Share

A green valet key and a black ignition key with a remote fob on a white background, the brand emblems blurred
Previous Post

Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up

Stepped concrete headquarters building of Crédit Mutuel Arkéa at Le Relecq-Kerhuon in Brittany, seen across a lawn with bare trees and flowering shrubs
Next Post

Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes

No Comment! Be the first one.

Leave a Reply Cancel reply

Your email address will not be published. Required fields are marked *

Latest
08 Nov
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
08 Nov
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
Trending
November 8, 2026
How to Fix Python ModuleNotFoundError and Relative Import Errors, Break Circular Imports, and Stop Local Files From Shadowing the Standard Library
November 8, 2026
WIRED’s “Free Chinese AI Models Are Getting Scary Good at Hacking” Rests on Two Cyber Ranges: DeepSeek Is Level With the Leader on One, the GLM It Names Finished Last of Seven, and Three Claude Runs Stopped by Refusals Appear Only in the Chart Data
November 8, 2026
Crédit Mutuel Arkéa Says Attackers Reached Its Own Account Through a Service Provider’s Platform; the On-Chain Trail Attributed to the Theft Shows $126 Million in USDC Reaching 17 Wallets in 28 Hours, Most of It Moving On Within Minutes
November 8, 2026
How to Stop Command Injection in Python subprocess Calls, Including Git Option Injection and the Windows .cmd Trap
November 8, 2026
Cloudflare’s Trusted Agent Delegation Essay Names 14 Site Problems, but the Identity Standard It Prefers Defines No Delegation, and Its First Shipped Fix Sees Agents Only at Login and Sign-Up
November 8, 2026
CloudLinux Says an Exploit Hit a CloudLinux 8 Server on November 4 Through Linux Kernel Bug CVE-2026-98276, Fixed Upstream Since September, and Red Hat Still Lists Every Supported RHEL Kernel as Affected

Related Posts

A laptop wrapped in a chain and padlock, illustrating least-privilege controls for AI agents.
Learning Hub

How to Secure Tool-Using AI Agents Before They Touch Production

June 8, 2026
Colorful sticky notes arranged on an office wall, symbolizing governance checklists and planning.
Learning Hub

AI Governance for Agentic Apps: A Practical Checklist for Builders

June 8, 2026
A technician connects green fiber optic cables at a data center, representing a private production inference endpoint.
Learning Hub

How to Deploy a Fine-Tuned LLM Behind a Private Production Inference Endpoint

June 8, 2026
Narrow aisle behind black supercomputer racks in a data center
Learning Hub

Kubernetes SELinux Volume Labeling: What Cluster Operators Should Audit Before v1.37

June 8, 2026
SXZ.io SXZ.io
  • [email protected]

Categories

Articles
Learning Hub
News

All Rights Reserved by SXZ.io ©2026