CoursesPython for security automationSetup & the automation core

Running commands safely with subprocess

Argument lists, never shell=True with f-strings.

Intermediate25 min · lesson 2 of 14

Automation is mostly glue. Your Python script rarely does the security work itself. It runs the tools that already do it. It calls git to read a commit, ls to see what shipped, a secret scanner to check a diff, then reads what each tool printed and decides what to do next. The module that lets Python start another program is subprocess (short for sub-process, meaning a second program your script launches and then waits on). Learn one function from it well and you can drive almost any command-line tool. Learn it carelessly and you hand an attacker a way to run their own commands on your build server.

The one call to learn: subprocess.run

subprocess.run() starts a program, waits for it to finish, and hands back an object that describes what happened. The habit that keeps you safe is how you pass the command. Give it a list of separate strings, one word per item, never one long sentence. Think of a paper form with labelled boxes instead of a handwritten note. The list ["git", "rev-parse", "HEAD"] says the program is git, its first argument is rev-parse, its second is HEAD. You fill every box yourself, and nothing that arrives later can slip in a box of its own. Hold onto that picture. It is the whole security lesson in one sentence.

commit.py
# commit.py
import subprocess
result = subprocess.run(
["git", "rev-parse", "HEAD"],
capture_output=True, # keep the output instead of printing it
text=True, # give me strings, not raw bytes
check=True, # raise if git reports failure
timeout=10, # give up after 10 seconds
)
print("exit code:", result.returncode)
print("commit:", result.stdout.strip())
~/secopslog — bash
$ python3 commit.py
exit code: 0 commit: c4449d65a2bb0cfa826a2f2c95d8d544171fab58

Four keyword arguments earn their place on nearly every call. capture_output=True keeps the program's output for you instead of letting it spill onto your screen. text=True hands you that output as ordinary strings; without it you get raw bytes you would have to decode yourself. check=True raises an exception the moment the program reports failure, so a broken command stops your script loudly instead of sliding past unnoticed. timeout=10 gives the command ten seconds, then gives up.

Reading the result

subprocess.run gives you three things worth reading, and it helps to picture the finished command handing you two trays and a grade. returncode is the grade: the exit code, a single number every command returns when it ends. Zero means success, by a Unix convention older than most of us; anything else signals some kind of failure. stdout, short for standard output, is the first tray, the normal text a program prints. stderr, short for standard error, is the second tray, a separate channel programs use for warnings and errors so they never get mixed into the real results. That separation is why a stray warning line can never corrupt the commit hash you were trying to capture.

When a command fails

With check=True, a non-zero exit code turns into a CalledProcessError you can catch. The exception carries the same returncode and stderr, so you can log exactly what the tool complained about and react instead of guessing.

check_ref.py
# check_ref.py
import subprocess
try:
subprocess.run(
["git", "rev-parse", "--verify", "badref"],
capture_output=True, text=True, check=True,
)
except subprocess.CalledProcessError as e:
print("git failed with code", e.returncode)
print("stderr:", e.stderr.strip())
~/secopslog — bash
$ python3 check_ref.py
git failed with code 128 stderr: fatal: Needed a single revision

timeout behaves the same way. If the command runs past its limit, Python raises subprocess.TimeoutExpired and stops the child process for you. That one line is what stands between a healthy pipeline and a scanner that hangs on a dead host and never returns. Remember that subprocess.run() sets no time limit of its own. Leave timeout off and a program that freezes will freeze your script right along with it. Any command that touches the network or reads input you did not choose deserves an explicit timeout= and a caught subprocess.TimeoutExpired.

The trap: shell=True turns a filename into a weapon

There is a second way to call subprocess.run: hand it one string and add shell=True. Python then starts a real shell (/bin/sh on Linux, the same command interpreter your terminal uses) and asks it to read your string, exactly the way your terminal would. The shell is eager and powerful. It expands *, follows pipes written with |, and, most dangerously, treats a semicolon ; as "end of one command, start of the next." That last feature is where automation gets breached.

Say you build the command with an f-string (Python's format-string syntax, where {name} drops a variable straight into the surrounding text) and the value comes from somewhere you do not control: a filename, a form field, a webhook payload.

danger.py
# danger.py -- DO NOT SHIP THIS
import subprocess
# This value came from a user; you did not choose it.
untrusted = "app.py; rm -f secrets.env"
subprocess.run(f"wc -l {untrusted}", shell=True)

You meant to count the lines in one file. Watch what the shell actually does with it. The ls before and after show the damage.

~/secopslog — bash
$ ls python3 danger.py ls
app.py danger.py secrets.env 1 app.py app.py danger.py

The shell saw two commands split by that semicolon. It ran wc -l app.py, printed 1 app.py, then obediently ran rm -f secrets.env and deleted your secrets file. Your Python never intended a second command; the shell invented one from the input. This is command injection, catalogued as CWE-78 (Common Weakness Enumeration entry 78, the industry's catalog number for operating-system command injection), one of the oldest and most damaging bugs there is.

f-string + shell=True = command injection
The moment an outside value lands inside a command string you run with shell=True, an attacker can end your command and start their own with a single ; or |. A filename, a Git branch name, a scan target pulled from an API (Application Programming Interface, one program handing data to another): treat every bit of it as hostile. There is no amount of hand-written escaping you will get right every single time. Do not build the string at all.

The argument list is immune

Switch to the list form and the danger evaporates, because no shell is involved. The operating system starts the program you named and passes the rest as arguments, one item as exactly one argument. Nothing is present to read the semicolon, so the whole hostile string becomes a single, harmless filename that happens not to exist.

safe.py
# safe.py
import subprocess
untrusted = "app.py; rm -f secrets.env"
result = subprocess.run(["wc", "-l", untrusted], capture_output=True, text=True)
print("exit code:", result.returncode)
print(result.stderr.strip())
~/secopslog — bash
$ python3 safe.py
exit code: 1 wc: 'app.py; rm -f secrets.env': No such file or directory

wc looked for a file literally named app.py; rm -f secrets.env, failed to find it, and returned a non-zero exit code. secrets.env is untouched. Same input, same tool, opposite outcome, and the only change was passing a list instead of a string with shell=True.

One filename, two futures
subprocess.run(cmd)
cmd contains an untrusted value: app.py; rm -f secrets.env
List form (safe)
["wc", "-l", name]
OS runs wc directly; the whole value is ONE argument; no shell reads the ; so nothing else runs
String + shell=True (unsafe)
f"wc -l {name}"
/bin/sh -c parses the text; the ; ends wc and starts rm -f secrets.env, so a second command runs: command injection (CWE-78)
The only difference is list form versus string plus shell=True. That difference decides whether an attacker can run their own command on your box.

When shell=True is actually fine

shell=True is not forbidden. It is fine when the entire command string is a fixed literal you typed yourself, with no outside value spliced in, and you genuinely need a shell feature the list form cannot give you: a pipe, a glob, a redirect, or a shell builtin. subprocess.run("git log --oneline | head -5", shell=True) is acceptable because every character came from you. The rule is not "never use a shell." The rule is "never let untrusted data reach one." If you are ever forced to combine the two, wrap each value with shlex.quote(), which seals a hostile string inside safe quoting so the shell reads it as one token. Even then, reaching for the list form and doing the pipe in Python is safer and usually clearer.

Putting it together: a scanner gate

Here is the payoff. A DevSecOps job that comes up constantly is failing a build when a scanner finds something. bandit is a SAST tool (Static Application Security Testing, meaning it reads your source code without running it and flags risky patterns). Point it at the very mistake from earlier.

deploy.py
# deploy.py
import subprocess
def run_backup(filename):
subprocess.run(f"tar -czf backup.tgz {filename}", shell=True)
~/secopslog — bash
$ bandit -q deploy.py
>> Issue: [B602:subprocess_popen_with_shell_equals_true] subprocess call with shell=True identified, security issue. Severity: High Confidence: High CWE: CWE-78 (https://cwe.mitre.org/data/definitions/78.html) Location: ./deploy.py:5:4 4 def run_backup(filename): 5 subprocess.run(f"tar -czf backup.tgz {filename}", shell=True)

The scanner caught the same shell=True footgun and labelled it CWE-78, High severity. Now wrap the scanner in Python so a pipeline can act on it. Run bandit with an argument list, read its exit code, and turn a finding into a failed build.

gate.py
# gate.py: fail the build when the scanner finds a High-severity issue
import subprocess, sys
result = subprocess.run(
["bandit", "-q", "-r", "deploy.py"],
capture_output=True, text=True,
)
if result.returncode == 0:
print("scan clean")
sys.exit(0)
highs = result.stdout.count("Severity: High")
print(f"scan failed: {highs} high-severity issue(s)")
sys.exit(1)
~/secopslog — bash
$ python3 gate.py
scan failed: 1 high-severity issue(s)

bandit exits non-zero when it finds problems, so gate.py exits non-zero too, and any CI system (Continuous Integration, the service that runs your checks automatically on every push) reads that as a red build. That is the shape of almost every automation you will write: run a tool with a list, read its exit code and output, decide, and pass a clear signal up the chain. Get the list-versus-string habit right and every one of those scripts is safe by construction.

Quick check
01A web form lets users pick a file to scan. A user submits the value foo.py; rm -rf / and your code runs subprocess.run(["grep", "password", user_value], capture_output=True, text=True). What happens?
Incorrect — There is no shell here. A list never goes through /bin/sh, so ; is never treated as a separator.
Correct — The list form passes user_value straight to grep as a single argument, so the ; and rm -rf / are only harmless characters in a filename that does not exist.
Incorrect — Special characters inside a normal Python string are fine. There is no syntax problem, and the command runs.
Incorrect — check is not even set here, and there is no rm to stop. The list form never created a second command in the first place.
02The lesson says the rule is not "never use a shell." According to it, when is subprocess.run(..., shell=True) genuinely acceptable?
Incorrect — shlex.quote helps only if you are forced to combine the two, and the lesson still prefers the list form; quoting alone is not the green light.
Incorrect — injection appends an entirely new command, so the original command being read-only does nothing to stop an attacker adding ; rm.
Incorrect — check and timeout only watch the process you launched; they cannot un-run a second command the shell has already started.
Correct — the lesson's rule is "never let untrusted data reach a shell," so a hard-coded string such as git log --oneline | head -5 is fine.
03A pipeline step runs subprocess.run(['nmap', '-p', '443', target], capture_output=True, text=True, check=True). Most runs are fine, but when target is an unreachable host the whole pipeline occasionally hangs for hours. What is the fix the lesson points to?
Correct — the lesson stresses that run() has no default timeout, so any command touching the network deserves an explicit timeout= and a caught TimeoutExpired.
Incorrect — shell=True adds an injection risk and provides no timeout; the shell does not time out your command for you.
Incorrect — check=True only affects what happens after the command returns, so it has nothing to do with a command that never returns.
Incorrect — CalledProcessError fires only on a non-zero exit, and a hung process never exits, so that handler never runs.

Related