Cheat sheet — Driving Tools Safely with subprocess¶
Companion to Module 05 — Driving Tools Safely · CC BY 4.0 — print it, pin it, share it.
Last reviewed: 2026-07
The safe default — a list, shell=False¶
import subprocess
# Args as a LIST. shell=False is the DEFAULT — you don't even pass it.
# target is handed straight to execve as ONE argument: no shell, no injection.
result = subprocess.run(
["nmap", "-sV", "-oX", "-", target], # target = "x; rm -rf /" is an inert (bad) hostname
capture_output=True, # collect stdout + stderr instead of inheriting the terminal
text=True, # decode bytes → str (utf-8) so result.stdout is a string
check=True, # raise CalledProcessError on non-zero exit (fail loud)
timeout=30, # kill + raise TimeoutExpired if the tool hangs
)
print(result.stdout) # stdout as text
print(result.returncode) # 0 on success (check=True guarantees this or it raised)
# Handle the two failure modes explicitly.
try:
result = subprocess.run(["whois", domain], capture_output=True, text=True,
check=True, timeout=15)
except subprocess.CalledProcessError as e:
print(f"tool exited {e.returncode}: {e.stderr}")
except subprocess.TimeoutExpired:
print("tool hung — killed at the timeout")
The anti-pattern to recognize on sight¶
# UNSAFE — DO NOT SHIP. This is what the copilot writes.
subprocess.run(f"nmap {target}", shell=True) # target="x; id" → runs `id`
subprocess.run(f"whois {domain}", shell=True) # domain="a.com; rm -rf ~" → RCE
os.system(f"vt domain {domain}") # same disease, older syntax
The moment a value flows through an f-string into shell=True (or os.system), /bin/sh
interprets ;, |, $(), backticks, &&, >. In a triage tool that value came from an alert —
attacker-influenced by definition. The fix is almost always delete shell=True and make the
string a list.
The rare must-build-a-string case — shlex.quote¶
import shlex
# Only when an API genuinely wants ONE shell string (not a list). Quote every
# untrusted piece. The list form above is still better — it removes the shell.
cmd = f"nmap -sV {shlex.quote(target)}" # "x; id" → "'x; id'", a single safe arg
subprocess.run(cmd, shell=True, check=True)
shlex.split("nmap -sV 'a b.com'") # inverse: parse a string INTO a safe list
# → ['nmap', '-sV', 'a b.com']
Pipes & redirection — in Python, not the shell¶
# "I need a pipe" is NOT a reason for shell=True. Wire two processes in Python.
p1 = subprocess.Popen(["nmap", "-oX", "-", target], stdout=subprocess.PIPE, text=True)
p2 = subprocess.run(["xmllint", "--format", "-"], stdin=p1.stdout,
capture_output=True, text=True)
p1.stdout.close() # let p1 get SIGPIPE if p2 exits early
# Redirection is an argument, not `> file`:
with open("scan.xml", "w") as fh:
subprocess.run(["nmap", "-oX", "-", target], stdout=fh, check=True)
# Globbing is pathlib, not the shell:
from pathlib import Path
for f in Path("captures").glob("*.pcap"):
subprocess.run(["tshark", "-r", str(f)], check=True)
Validate before you shell out¶
import re, ipaddress
DOMAIN = re.compile(r"^(?=.{1,253}$)([a-z0-9-]{1,63}\.)+[a-z]{2,}$", re.I)
def safe_target(indicator: str) -> str:
"""Allowlist the shape BEFORE any tool sees it. Reject the weird."""
indicator = indicator.strip()
if DOMAIN.match(indicator):
return indicator
try:
return str(ipaddress.ip_address(indicator)) # normalizes + validates
except ValueError:
raise ValueError(f"not a valid domain or IP: {indicator!r}")
subprocess.run(["nmap", "-sV", safe_target(target)], check=True, timeout=30)
Defense in depth: shell=False stops injection; validation shrinks the attack surface so a tool
only ever receives well-formed arguments.
Parse structured output, don't scrape text¶
import xml.etree.ElementTree as ET
# nmap -oX - writes XML to stdout. Parse THAT — it's stable across versions;
# scraped human text breaks your regex on the next release.
r = subprocess.run(["nmap", "-sV", "-oX", "-", target],
capture_output=True, text=True, check=True, timeout=60)
root = ET.fromstring(r.stdout)
for port in root.iterfind(".//port"):
state = port.find("state").get("state")
svc = port.find("service")
print(port.get("portid"), state, svc.get("name") if svc is not None else "?")
# JSON tools are even easier:
import json
r = subprocess.run(["vt", "domain", domain, "--format=json"],
capture_output=True, text=True, check=True, timeout=30)
data = json.loads(r.stdout)
Gotchas worth remembering¶
shell=True+ untrusted input = command injection. The single most damaging one-character mistake in Python security tooling. In a triage tool the input is always untrusted — it came from an alert.- The list form removes the shell entirely.
["nmap", target]handstargettoexecveas one argument — there is no/bin/shto interpret;or$(), so there's nothing to inject into. - The 5-second review tell: an f-string (or
%/+concat) feedingshell=Trueoros.system. Treat everysubprocesscall as guilty until provenshell=Falsewith list args. - Pipes/globs/redirection don't need
shell=True— wirePopenstdout→stdin, usestdout=fh, usepathlib.glob. Reaching for shell features usually means the work belongs in Python, where it's safer and testable. check=True+timeout=are not optional in tooling — fail loud on a bad exit, and never let a wrapped tool hang your pipeline forever.- Add a CI guard so it can't come back:
banditflagsshell=True(B602/B604) andos.system(B605);ruff's flake8-bandit rules (S602/S605) do the same. Fail the build on it.
Only shell out against systems you own or have explicit written permission to test.
Comments
Sign in with GitHub to comment. Choose the type: Feedback (errors or suggestions on this page) · Hints (help for fellow learners — no spoilers) · General (anything else).