/user/kayd @ devops :~$ cat python-security.md

Python Security Cheatsheet Python Security Cheatsheet

QR Code linking to: Python Security Cheatsheet
Karandeep Singh
Karandeep Singh
• 8 minutes

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 covers the other half of the secret-leaking problem.

caution
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

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:

Expand your knowledge with Go Security Cheatsheet

it succeeds, and that is the problem exit 0
$ python3 delete_pod.py 'web-1; echo OWNED'deleting pod web-1OWNED# /bin/sh split the argument at the semicolon and ran the second half.# The exit code is 0, so nothing upstream will ever notice.$ echo $? → 0
the same input, passed as a list
$ 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.
git diff — delete_pod.pythe whole fix
@@ -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)
WantDo
Fail loudly on non-zero exitcheck=True
Capture output as textcapture_output=True, text=True
Never hang in CItimeout=30
A literal pipelinetwo Popens, or shell=True with no interpolation
Quote a value you must put in a shell stringshlex.quote(value)
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

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:

one file, both loaders
$ cat values.yamlcfg: !!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.
import pickle, json

pickle.load(open(path, "rb"))      # arbitrary code execution, by design
json.load(open(path))              # data only
caution
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).

Deepen your understanding in Bash Basics Cheatsheet

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

Secrets and Randomness

Docs: secrets — generate secure random numbers

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.

Explore this further in Go Security Cheatsheet

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

Passwords

Docs: hashlib.scrypt

# 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.

Discover related concepts in Go (Golang) Cheatsheet

SQL: Parameters, Not Formatting

# 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
note
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

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

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:

Enrich your learning with Go Security Cheatsheet

  • 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.
  • 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.
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

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.

Gain comprehensive insights from Bash sed & awk Cheatsheet

Dependencies

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

grep -rn "shell=True\|yaml.load(\|verify=False\|pickle.load\|eval(" --include=*.py .
pip install bandit pip-audit
bandit -r . -ll                 # medium severity and above, exits non-zero
pip-audit --strict              # known CVEs in the resolved environment
# .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?

Deepen your understanding in Bash Basics Cheatsheet

0

Similar Articles

More from development

Go (Golang) Cheatsheet

Go cheatsheet covering the go toolchain, syntax essentials, goroutines and channels, table-driven …

Bash Basics Cheatsheet

Quick-reference Bash basics cheatsheet: variables, parameter expansion, conditionals, loops, arrays, …

Python Boto3 Cheatsheet

Boto3 cheatsheet for AWS automation: sessions, S3, EC2, Lambda, DynamoDB, SSM, presigned URLs, …

Knowledge Quiz

Test your general knowledge with this quick quiz!

A set of multiple-choice questions to test your knowledge.

Take as much time as you need.

Your score will be shown at the end.

Get new articles by email

One DevOps article a week, plus the 18-cheatsheet PDF pack. No spam, one click to leave.

🍪 We use cookies for analytics. Read the privacy policy