Internal tools are where shell injection hides. Nobody expects an attacker to run mytool archive, so a command like subprocess.run(f"tar czf {out} {src}", shell=True) ships without a second thought. Then someone points the tool at a directory created by a CI job, a file uploaded by a customer, a branch name from a pull request, or a value from a shared config file — and a string you treated as data is parsed as code. This guide explains exactly how that happens, then shows the layered fixes: argument lists so no shell is involved, -- so the called program cannot mistake data for options, shlex.quote for the rare case a shell is genuinely needed, and validation at the edge. It belongs to the subprocess topic.
Prerequisites
- Python 3.10+ on Linux or macOS for the demonstrations (Windows notes are included).
- A CLI that runs external programs, ideally through a helper like the one in calling external commands safely with subprocess.
- An understanding that "the user" of a CLI includes every source of its input: arguments, files, environment, config and other programs' output.
How data becomes a command
With shell=True, Python runs /bin/sh -c <string>. Everything in that string is shell syntax. If any part of it came from outside your code, the author of that part is writing shell script alongside you.
A concrete demonstration, safe to run in an empty directory:
# injection_demo.py — run in an empty scratch directory
import subprocess
from pathlib import Path
Path("notes.txt").write_text("hello\n")
name = "notes.txt; echo INJECTED > pwned.txt"
# Vulnerable: the semicolon ends the wc command and starts another.
subprocess.run(f"wc -l {name}", shell=True)
print("pwned.txt exists:", Path("pwned.txt").exists()) # True
Path("pwned.txt").unlink()
# Safe: one argument, no shell. wc just fails to find a strangely named file.
subprocess.run(["wc", "-l", name])
print("pwned.txt exists:", Path("pwned.txt").exists()) # False
The payload does not need a semicolon. $(...) and backticks run commands inside an argument, | pipes into another program, > overwrites files, & backgrounds a process, a newline starts a new command, and * expands to filenames. Filenames can legally contain every one of those characters on Linux.
Where untrusted input hides in internal tools
"But only our own engineers run this" is the usual defence, and it misses where the data comes from rather than who types the command. A few places that routinely feed hostile or merely unexpected strings into internal CLIs:
- Branch and tag names. Anyone who can open a pull request chooses them, and CI jobs pass them to release scripts. Git allows
$,;, backticks and parentheses in ref names. - Filenames from elsewhere. Uploaded files, extracted archives, synced buckets and generated artefacts all carry names you did not pick.
- Output of another program. A tool that reads a list of hostnames or container IDs from an API and passes them on is only as safe as that API's data.
- Config in a repository. A
.mytool.tomlis edited by every contributor with commit rights. - Environment variables in CI. Pipeline variables are often settable by people who cannot change the pipeline itself.
In each case the person running the tool is trustworthy and the input is not. Security for a CLI is about the second, not the first.
The recipe: four layers
1. Argument lists, always
Pass a list and leave shell at its default of False. Python then calls execve() directly and each list element becomes exactly one element of the child's argv. There is no parser to confuse, so there is nothing to escape.
import subprocess
from pathlib import Path
def archive(src: Path, out: Path) -> None:
subprocess.run(["tar", "-czf", str(out), "-C", str(src.parent), "--", src.name], check=True)
Most code that uses shell=True does so for a feature that has a direct Python equivalent:
| Shell feature | Instead, in Python |
|---|---|
cmd1 | cmd2 | two Popen objects, stdout=PIPE into stdin= |
> out.txt | stdout=open("out.txt", "w") |
*.log | sorted(Path(".").glob("*.log")) |
~/x | Path.home() / "x" |
$VAR | os.environ["VAR"] or env= |
cd dir && cmd | cwd="dir" |
cmd1 && cmd2 | two run(..., check=True) calls |
2. -- before untrusted positional data
An argument list stops the shell from interpreting your data. It does not stop the program from interpreting it. If a value starts with -, most programs treat it as an option. That is "argument injection", and it is real: git accepts --upload-pack=<command>, tar accepts --checkpoint-action=exec=<command>, find accepts -exec, and rsync accepts -e.
By POSIX convention, -- means "end of options; everything after this is a positional argument". Put it before any data you did not write. For programs that do not support --, make relative paths unambiguous by prefixing ./, which also works for filenames beginning with a dash:
def safe_path_arg(p: Path) -> str:
s = str(p)
return s if p.is_absolute() or not s.startswith("-") else f"./{s}"
Git is a special case worth knowing: a revision argument (git log <rev>) cannot be protected with --, because -- there separates revisions from paths. Validate revisions instead — git check-ref-format --branch for branch names, or git rev-parse --verify --end-of-options <rev> on git 2.24+.
3. When you truly need a shell: shlex.quote
Occasionally a shell is the point: you are generating a command for the user to copy, running a user-configured hook, or executing over SSH where the remote side always uses a shell. Then quote every interpolated value with shlex.quote, which wraps it in single quotes and escapes embedded single quotes so a POSIX shell sees exactly one word:
import shlex
host = "build-01"
path = "/srv/data/it's here; rm -rf /"
remote = f"du -sh -- {shlex.quote(path)}"
subprocess.run(["ssh", host, remote], check=True)
print(remote) # du -sh -- '/srv/data/it'"'"'s here; rm -rf /'
Two caveats. shlex.quote targets POSIX shells; cmd.exe and PowerShell have entirely different rules and the standard library has no quoter for them — on Windows, avoid the shell. And quoting only helps if you quote every value; one forgotten interpolation undoes it. shlex.join(argv) quotes a whole list at once, which is the right way to print a command for the user.
A cleaner alternative for fixed scripts is to pass values through the environment. The script text is a constant you wrote; the data never touches the command string:
subprocess.run(
["sh", "-c", 'cp -- "$SRC" "$DEST" && echo copied'],
env={**os.environ, "SRC": user_src, "DEST": user_dest},
check=True,
)
4. Validate at the edge
Finally, reject values that can never be legitimate before they reach a subprocess at all. If a parameter is a branch name, an environment name or a container tag, it has a known shape — enforce it where you parse arguments, as covered in advanced argument validation strategies:
import re
import typer
ENV_NAME = re.compile(r"^[a-z][a-z0-9-]{0,30}$")
def env_name(value: str) -> str:
if not ENV_NAME.fullmatch(value):
raise typer.BadParameter("use lowercase letters, digits and dashes")
return value
Validation is defence in depth, not the primary control. Allow-lists are strong; deny-lists of "dangerous characters" always miss something.
UX considerations
Security fixes should not make the tool worse to use, and done well they make it better:
- Filenames with spaces just work. Argument lists fix the most common user-facing bug of shell strings — paths with spaces or quotes — at the same time as the security hole.
- Show commands in copy-pasteable form. When you log what you are about to run (for
--verboseor--dry-run), printshlex.join(argv)so the user can paste it into a shell and get the same behaviour. - Explain rejected input. "invalid environment name 'prod;ls': use lowercase letters, digits and dashes" teaches the rule; "invalid input" does not.
- Keep hooks explicit. If your tool runs user-configured shell commands (a
post_buildhook), document that the value is executed by a shell and is trusted configuration — and never interpolate other data into it.
Testing the behaviour
Injection tests are cheap and worth having as a permanent regression suite. Feed hostile values through the real code path and assert that nothing outside the intended operation happened:
# tests/test_injection.py
import subprocess
import sys
from pathlib import Path
import pytest
HOSTILE = [
"a; touch PWNED",
"$(touch PWNED)",
"`touch PWNED`",
"a | touch PWNED",
"a\ntouch PWNED",
"--output=PWNED",
]
def count_lines(path: str, cwd: Path) -> subprocess.CompletedProcess[str]:
code = "import sys; print(len(open(sys.argv[1]).readlines()))"
return subprocess.run([sys.executable, "-c", code, path], cwd=cwd,
capture_output=True, text=True)
@pytest.mark.parametrize("value", HOSTILE)
def test_hostile_names_are_just_names(tmp_path, value):
count_lines(value, tmp_path)
assert not (tmp_path / "PWNED").exists()
def test_real_file_with_awkward_name(tmp_path):
p = tmp_path / "it's a file; really.txt"
p.write_text("1\n2\n")
assert count_lines(p.name, tmp_path).stdout.strip() == "2"
Add a lint rule so new shell=True calls are caught in review: Ruff's S602–S605 rules (from flake8-bandit) flag subprocess calls with a shell and os.system. Enabling them is part of configuring Ruff for a CLI project.
Conclusion
Shell injection in a CLI is almost always the same mistake: building a command string from data and handing it to a shell. Remove the shell with argument lists and the whole class disappears; add -- before untrusted positionals to stop option injection; reserve shlex.quote or environment variables for the rare cases a shell is required; and validate structured values where they enter your program. Put a lint rule and a hostile-input test suite behind it so the fix stays fixed.
Frequently asked questions
Is shell=True safe if the string is a constant?
Yes. subprocess.run("make clean && make", shell=True) with no interpolated data is not injectable. The danger begins the moment any part of the string is computed. Many teams still ban it by lint rule and allow exceptions with an inline comment, which keeps the review question visible.
Does using pathlib.Path objects protect me?
Only in the sense that you are probably passing them in an argument list. A Path interpolated into an f-string is just text, and a path can contain any character except NUL and / in a component.
What about environment variables and config files as input?
Treat them exactly like arguments. A .mytool.toml in a repository is controlled by whoever can commit to that repository, which is why tools that run commands from project config — like pre-commit hooks — document that you are trusting the repository.
Is os.popen or commands.getoutput any different?
Both always use a shell. os.popen is a thin wrapper around subprocess.Popen(cmd, shell=True); commands no longer exists in Python 3. Replace them with subprocess.run and an argument list.