# Python Security Cheatsheet

- URL: https://karandeepsingh.ca/cheatsheets/python/python-security/
- Author: Karandeep Singh
- Published: 2025-09-09
- Tags: python, security
- Summary: The security rules I apply to every Python script that touches a server, covering subprocess injection, unsafe deserialisation, secrets and randomness, SQL parameters, TLS, and dependency checks.

Most of the security bugs I have found in automation scripts are not clever. They are a string passed to a shell, a config file loaded with the wrong function, or a token that ended up in CloudWatch. This sheet is the short list of rules I check before a Python script gets anywhere near a server, with the safe form next to the one that bites. The [Python Logging Cheatsheet](https://karandeepsingh.ca/cheatsheets/python/python-logging/) covers the other half of the secret-leaking problem.

**danger:**
The three that cause real incidents, in order: **`shell=True` with an interpolated string**, **`yaml.load` on a file you did not write**, and **a secret in a log line**. Everything else on this sheet is a distant fourth.

## subprocess Without a Shell

> **Docs:** [subprocess security considerations](https://docs.python.org/3/library/subprocess.html#security-considerations)

```python
import subprocess, shlex

# NO: the string is parsed by /bin/sh
subprocess.run(f"kubectl delete pod {name}", shell=True)
#   name = "web; rm -rf /tmp/x"  ->  two commands run

# YES: a list, no shell, arguments passed straight to execve
subprocess.run(["kubectl", "delete", "pod", name], check=True, timeout=30)
```

Both of these were run with the same input. The first one is the bug:

```text
$ python3 delete_pod.py 'web-1; echo OWNED'
# exits with status 0
| deleting pod web-1
| OWNED
# /bin/sh split the argument at the semicolon and ran the second half.
# The exit code is 0, so nothing upstream will ever notice.
```

```text
$ python3 delete_pod_safe.py 'web-1; echo OWNED'
deleting pod web-1; echo OWNED
# One argument, handed to execve untouched. No shell, nothing to split.
```

```diff
# delete_pod.py
@@ -3,4 +3,4 @@ import subprocess
 name = sys.argv[1]
-subprocess.run(f"kubectl delete pod {name}", shell=True)
+subprocess.run(["kubectl", "delete", "pod", name], check=True, timeout=30)
```

| Want | Do |
|------|-----|
| Fail loudly on non-zero exit | `check=True` |
| Capture output as text | `capture_output=True, text=True` |
| Never hang in CI | `timeout=30` |
| A literal pipeline | two `Popen`s, or `shell=True` with **no** interpolation |
| Quote a value you must put in a shell string | `shlex.quote(value)` |

```python
r = subprocess.run(["aws", "sts", "get-caller-identity"],
                   capture_output=True, text=True, check=True, timeout=15)
identity = json.loads(r.stdout)
```

**warning:**
`shlex.quote()` is a patch, not a fix. It is correct for POSIX `sh` and wrong for Windows `cmd`. If you find yourself reaching for it, check whether the list form would do instead — it almost always does.

## Deserialisation: safe_load, and Never pickle

> **Docs:** [PyYAML loading safely](https://pyyaml.org/wiki/PyYAMLDocumentation)

```python
import yaml

cfg = yaml.safe_load(open("values.yaml"))   # scalars, lists, dicts only
cfg = yaml.load(open("values.yaml"))        # can construct any Python object
```

A file carrying a `!!python/object/apply` tag runs that callable the moment the
full loader touches it. `safe_load` has no such tags:

```text
$ cat values.yaml
cfg: !!python/object/apply:os.system ["echo OWNED-BY-CONFIG"]
$ python3 -c 'import yaml; print(yaml.safe_load(open("values.yaml")))'
ConstructorError: could not determine a constructor for the tag
  'tag:yaml.org,2002:python/object/apply:os.system'
$ python3 -c 'import yaml; print(yaml.load(open("values.yaml"), Loader=yaml.UnsafeLoader))'
OWNED-BY-CONFIG
{'cfg': 0}
# The echo ran before the dict was even returned. The 0 is its exit status,
# stored as the value of cfg — which is how little the loader cares.
```

```python
import pickle, json

pickle.load(open(path, "rb"))      # arbitrary code execution, by design
json.load(open(path))              # data only
```

**danger:**
`pickle` is not a file format, it is a small program the unpickler executes. Never load a pickle you did not write yourself, and never one that crossed a network or a shared bucket. Use JSON for data interchange.

Others in the same family: `eval()`, `exec()`, `os.system()`, `marshal`, and `xml` against untrusted input (use `defusedxml`).

## Secrets and Randomness

> **Docs:** [secrets — generate secure random numbers](https://docs.python.org/3/library/secrets.html)

```python
import secrets

secrets.token_urlsafe(32)      # URL-safe token, ~43 chars
secrets.token_hex(16)          # 32 hex chars
secrets.choice(alphabet)       # unguessable pick
secrets.compare_digest(a, b)   # constant-time comparison

import random
random.choice(alphabet)        # predictable: never for anything secret
```

`random` is a Mersenne Twister. Observe enough of its output and you can reconstruct the state and predict the rest — fine for jitter and sampling, disqualifying for tokens.

```python
# Comparing a token with == leaks its length and prefix through timing.
if secrets.compare_digest(provided_token, expected_token):
    ...
```

## Passwords

> **Docs:** [hashlib.scrypt](https://docs.python.org/3/library/hashlib.html#hashlib.scrypt)

```python
# NO: fast hashes are built to be fast, which is the wrong property here
hashlib.sha256(password.encode()).hexdigest()
hashlib.md5(password.encode()).hexdigest()

# YES: a slow, salted KDF
import hashlib, os
salt = os.urandom(16)
key = hashlib.scrypt(password.encode(), salt=salt, n=2**14, r=8, p=1, dklen=32)

# Better still, if you can take the dependency:
#   argon2-cffi  ->  argon2.PasswordHasher().hash(password)
#   bcrypt       ->  bcrypt.hashpw(pw, bcrypt.gensalt())
```

SHA-256 and MD5 are correct for checksums and content addressing, and wrong for passwords for exactly the reason they are good at checksums.

## SQL: Parameters, Not Formatting

```python
# NO: all three build SQL out of a value
cur.execute(f"SELECT * FROM pods WHERE name = '{name}'")
cur.execute("SELECT * FROM pods WHERE name = " + name)
cur.execute("SELECT * FROM pods WHERE name = %s" % name)

# YES: the driver sends the value separately from the statement
cur.execute("SELECT * FROM pods WHERE name = %s", (name,))          # psycopg
cur.execute("SELECT * FROM pods WHERE name = ?", (name,))           # sqlite3
cur.execute("SELECT * FROM pods WHERE name = %(n)s", {"n": name})   # named
```

**info:**
A table or column name cannot be a bound parameter — only values can. If one has to be dynamic, check it against an allow-list you wrote, never against user input directly.

## TLS and HTTP

```python
import requests

requests.get(url, timeout=10)                 # verify=True is the default
requests.get(url, timeout=10, verify=False)   # NO: accepts any certificate
requests.get(url, timeout=10, verify="/etc/ssl/corp-ca.pem")  # private CA
```

**warning:**
`verify=False` turns an encrypted channel into one anybody on the path can read and rewrite — and it prints a warning that people then suppress. A self-signed or internal certificate is a reason to pass the CA bundle with `verify=`, not to stop checking.

- Always pass `timeout=`. Without it a request can hang forever and hold a worker, which is a denial of service you did to yourself.
- `allow_redirects=False` when the redirect target matters, so a server cannot steer you somewhere else.
- A `Session` keeps headers across requests — including an `Authorization` header you did not mean to send to a redirected host.

## Secrets at Rest and in Transit

```python
import os
token = os.environ["API_TOKEN"]        # KeyError if missing: fail loudly
token = os.environ.get("API_TOKEN")    # None, then a confusing error later

# Pull from the platform rather than baking it in
import boto3
sm = boto3.client("secretsmanager")
secret = sm.get_secret_value(SecretId="prod/api")["SecretString"]
ssm = boto3.client("ssm")
val = ssm.get_parameter(Name="/prod/token", WithDecryption=True)["Parameter"]["Value"]
```

Rules that survive contact with a real repository:

- No secret in source, in a default argument, or in a committed `.env`.
- No secret in a log line, an exception message or a traceback — see the [logging sheet](https://karandeepsingh.ca/cheatsheets/python/python-logging/).
- No secret in a URL; query strings end up in access logs and browser history.
- Never print a whole `boto3` response or `os.environ` at DEBUG.

```python
def mask(s: str, keep: int = 4) -> str:
    """Last 4 characters only, for log lines."""
    return "*" * max(0, len(s) - keep) + s[-keep:] if s else ""
```

## Files and Paths

```python
from pathlib import Path

# NO: "../../etc/passwd" escapes the directory
target = base / user_supplied_name

# YES: resolve, then prove it is still inside
target = (base / user_supplied_name).resolve()
if not target.is_relative_to(base.resolve()):   # 3.9+
    raise ValueError("path escapes the base directory")

import tempfile
with tempfile.NamedTemporaryFile(delete=False) as fh:   # not /tmp/fixed-name
    fh.write(data)

os.chmod(path, 0o600)        # a secret on disk is owner-only
```

`tempfile` picks an unpredictable name; a fixed `/tmp/myapp.json` is a symlink attack waiting for a multi-user host.

## Dependencies

```bash
pip install pip-audit && pip-audit        # known CVEs in the environment
pip install --require-hashes -r requirements.txt
pip download --no-deps pkg && sha256sum pkg-*.whl
```

- Pin versions in `requirements.txt`; a range means a different build tomorrow.
- Check the package name before installing. Typosquats (`python-dateutil` vs `dateutil`) are a live supply-chain route.
- `pip install` runs arbitrary code from `setup.py` at install time, so this is not a theoretical risk.

## A Quick Audit of Your Own Code

```bash
grep -rn "shell=True\|yaml.load(\|verify=False\|pickle.load\|eval(" --include=*.py .
```

**Wiring the same checks into CI**
```bash
pip install bandit pip-audit
bandit -r . -ll                 # medium severity and above, exits non-zero
pip-audit --strict              # known CVEs in the resolved environment
```

```yaml
# .github/workflows/security.yml
- run: pip install bandit pip-audit
- run: bandit -r . -ll
- run: pip-audit --strict
```

Bandit finds the patterns on this sheet by rule id: `B602` (`shell=True`),
`B506` (`yaml.load`), `B301` (`pickle`), `B303` (weak hashes), `B501`
(`verify=False`). Silence a checked false positive inline with
`# nosec B602 - argument is an allow-listed constant`, never project-wide.

**tip:**
That `grep` is four of the five rules on this sheet in one line. It is worth putting in CI before it is worth putting in a code review checklist.

## Common Pitfalls

- **`assert` for validation.** Assertions are stripped by `python -O`, so a security check written as `assert user.is_admin` disappears in an optimised run. Use `if ... raise`.
- **Catching `Exception` around a permission check**, turning a denial into a silent pass.
- **`tarfile.extractall()` on an archive you did not build** — entries can carry `../` paths. Use `filter="data"` (3.12+) or validate each member.
- **Debug mode in production.** A framework traceback page prints source, locals and often environment variables.
- **`input()` in an automation script**, which blocks forever in CI and is a prompt nobody sees.

**Question:** Run that grep against the repository you deployed most recently. If it returns a line, do you know who controls the value being interpolated?

## Related Cheatsheets

- [Python Logging Cheatsheet](https://karandeepsingh.ca/cheatsheets/python/python-logging/) — where secrets most often leak
- [Python Boto3 Cheatsheet](https://karandeepsingh.ca/cheatsheets/python/python-boto3/) — Secrets Manager, SSM and IAM from Python
- [Python Basics & CLI Cheatsheet](https://karandeepsingh.ca/cheatsheets/python/python-basics-cli/) — the syntax underneath this

## Quiz

1. Why is `subprocess.run(f"kubectl delete pod {name}", shell=True)` dangerous?
   a) It is slower than the list form
   b) A name containing `;` or `$()` is interpreted by the shell and runs as a second command
   c) shell=True is deprecated
   d) It only works on Linux
   Answer: b. With shell=True the string is handed to /bin/sh, so shell metacharacters in any interpolated value become executable. Passing a list and leaving shell=False means the arguments are passed to execve directly and are never parsed by a shell.

2. What is the practical difference between `random.choice()` and `secrets.choice()`?
   a) None, secrets is just an alias
   b) random is a predictable PRNG; secrets uses the OS CSPRNG and is the one for tokens
   c) secrets is faster
   d) random only works with integers
   Answer: b. random is a Mersenne Twister seeded predictably, and observing enough output reveals its state and therefore all future values. Anything that must be unguessable — tokens, passwords, reset links — needs secrets, which draws from the operating system's CSPRNG.

3. Why must config files be loaded with `yaml.safe_load()` rather than `yaml.load()`?
   a) safe_load is faster on large files
   b) Plain load can construct arbitrary Python objects, so a crafted file can execute code
   c) load does not support nested keys
   d) They are identical in modern PyYAML
   Answer: b. The full loader resolves tags such as !!python/object/apply, which instantiate arbitrary callables. A YAML file from any source you do not fully control can therefore run commands. safe_load only builds plain scalars, lists and dicts.

4. Which is the correct way to run a query with a user-supplied value?
   a) cur.execute(f"SELECT * FROM pods WHERE name = '{name}'")
   b) cur.execute("SELECT * FROM pods WHERE name = %s", (name,))
   c) cur.execute("SELECT * FROM pods WHERE name = " + name)
   d) cur.execute("SELECT * FROM pods WHERE name = %s" % name)
   Answer: b. Only the second passes the value as a bound parameter, so the driver sends it separately from the SQL text and it can never be parsed as SQL. The other three build a string and are injectable.


