Level 5 · In the Wild

Security Basics 🔐

You do not need to be a security expert. You do need to not make the six mistakes that account for most of the damage, and they are all avoidable in one line each.

1. Secrets never go in code

# WRONG, and it is now in your git history forever
API_KEY = "sk-abc123realkeyhere"
import os

# right: from the environment, and it fails loudly if missing
API_KEY = os.environ["OPENAI_API_KEY"]

# or with a default and a clear error
key = os.environ.get("OPENAI_API_KEY")
if not key:
    raise SystemExit("Set OPENAI_API_KEY. See README.")
🚨 git history is forever

Deleting a key in a later commit does not remove it: it is still in the history, and on every clone. If you push a secret, revoke it immediately, then clean the history if you must. Bots scan public commits within seconds, and cloud keys have produced five-figure bills overnight.

# .gitignore, from the very first commit
.env
*.key
*.pem
secrets.json
.venv/

2. Never store passwords, store hashes

import hashlib
import secrets

# WRONG in three different ways
def terrible(password):
    return password                                   # plain text
def bad(password):
    return hashlib.md5(password.encode()).hexdigest() # broken, and unsalted
def still_wrong(password):
    return hashlib.sha256(password.encode()).hexdigest()  # fast, so brute-forceable


# Acceptable with the standard library only:
def hash_password(password, iterations=600_000):
    """PBKDF2 with a random salt. Slow on purpose."""
    salt = secrets.token_bytes(16)
    digest = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
    return f"pbkdf2_sha256${iterations}${salt.hex()}${digest.hex()}"


def verify_password(password, stored):
    """Check a password against a stored hash, in constant time."""
    algorithm, iterations, salt_hex, digest_hex = stored.split("$")
    digest = hashlib.pbkdf2_hmac(
        "sha256", password.encode(), bytes.fromhex(salt_hex), int(iterations)
    )
    return secrets.compare_digest(digest.hex(), digest_hex)


stored = hash_password("swordfish")
algorithm, iterations, salt, digest = stored.split("$")
print(algorithm, iterations, f"salt={len(salt)} hex chars", f"hash={len(digest)} hex chars")
print(verify_password("swordfish", stored))
print(verify_password("Swordfish", stored))
pbkdf2_sha256 600000 salt=32 hex chars hash=64 hex chars
True
False

Three ideas are doing the work there:

🔑 In production, use a library

argon2-cffi or bcrypt. Argon2 won the Password Hashing Competition and is memory-hard, which resists GPU cracking in a way PBKDF2 does not. The code above is correct and is the best you can do without installing anything; a library is better.

3. Injection: never build commands from input

import sqlite3

conn = sqlite3.connect(":memory:")
conn.execute("CREATE TABLE users (name TEXT, admin INTEGER)")
conn.execute("INSERT INTO users VALUES ('Guybrush', 0)")

user_input = "' OR '1'='1"

unsafe = conn.execute(f"SELECT * FROM users WHERE name = '{user_input}'").fetchall()
safe = conn.execute("SELECT * FROM users WHERE name = ?", (user_input,)).fetchall()

print("unsafe returned:", len(unsafe), "rows")
print("safe returned:  ", len(safe), "rows")
unsafe returned: 1 rows
safe returned:   0 rows

The same rule, in three places:

import subprocess

filename = "report.txt; rm -rf ~"

# WRONG: shell=True with interpolated input runs whatever they typed
# subprocess.run(f"cat {filename}", shell=True)

# right: a list of arguments, no shell involved
result = subprocess.run(["echo", filename], capture_output=True, text=True)
print(result.stdout.strip())
report.txt; rm -rf ~

Passing a list means the operating system runs echo with one argument that happens to contain a semicolon. There is no shell to interpret it, so there is nothing to inject into.

4. eval, exec and pickle are code execution

# Never do this with anything a user can influence
user_input = "2 + 2"
# print(eval(user_input))        # fine here, catastrophic with hostile input

# because this also "works":
# eval("__import__('os').system('rm -rf ~')")

# The safe way to evaluate a literal:
import ast
print(ast.literal_eval("[1, 2, {'a': 3}]"))

try:
    ast.literal_eval("__import__('os')")
except ValueError as err:
    print("refused:", type(err).__name__)
[1, 2, {'a': 3}]
refused: ValueError
🥒 pickle is not a data format, it is a program format

Unpickling data executes code contained in it. Loading a pickle from an untrusted source is equivalent to running a script from that source. The official documentation says so in a red box. Use JSON for anything crossing a trust boundary, and reserve pickle for data your own program wrote and only your own program reads.

import json

# JSON can only describe data, so parsing it can never execute anything
print(json.loads('{"name": "Guybrush", "insults": 8}'))

try:
    json.loads("__import__('os')")
except json.JSONDecodeError:
    print("JSON refuses to parse code. That is the feature.")
{'name': 'Guybrush', 'insults': 8}
JSON refuses to parse code. That is the feature.

5. Randomness: the right module matters

import random
import secrets

random.seed(42)
print("predictable:", random.randint(1000, 9999))
random.seed(42)
print("predictable:", random.randint(1000, 9999))      # same again

print("token:", len(secrets.token_urlsafe(32)))
print("choice from a set:", secrets.choice(["a", "b", "c"]) in {"a", "b", "c"})
predictable: 2824
predictable: 2824
token: 43
choice from a set: True

random is a Mersenne Twister: excellent statistically, and completely predictable once you have seen enough output. Its own documentation says it must not be used for security. Password reset tokens, session ids, API keys and one-time codes all need secrets.

6. Your dependencies are your attack surface

# check what you have installed against known vulnerabilities
$ pip install pip-audit
$ pip-audit

Found 2 known vulnerabilities in 1 package
Name    Version  ID                  Fix Versions
------- -------- ------------------- ------------
requests 2.19.1  GHSA-x84v-xcm2-53pg 2.31.0
PARANOIA[Medium: Success]

You will notice none of this required cryptography knowledge. Six habits: secrets in the environment, hashes not passwords, parameters not string building, JSON not pickle, secrets not random, and audited dependencies.

That is not everything. It is the part that accounts for most of the damage done to small projects, and every one of them is a single line of difference.

The checklist

Exercise 1

Audit this login code

Find five problems.

import hashlib

USERS = {"guybrush": "5f4dcc3b5aa765d61d8327deb882cf99"}


def login(username, password):
    hashed = hashlib.md5(password.encode()).hexdigest()
    if username in USERS and USERS[username] == hashed:
        return True
    print(f"Failed login for {username} with password {password}")
    return False
Reveal solution
  1. MD5. Cryptographically broken, and far too fast for password hashing regardless.
  2. No salt. That hash is the well-known MD5 of 'password'; identical passwords produce identical hashes across every user and every site.
  3. The password is logged in plain text on failure. Now your log file is a credential dump, and users type their real password into the wrong box constantly.
  4. == for hash comparison is not constant time. Use secrets.compare_digest.
  5. The error distinguishes cases if you extend it: 'no such user' versus 'wrong password' tells an attacker which usernames exist. Say 'invalid username or password' for both.

Bonus: no rate limiting, so an attacker may try as fast as the network allows.

Exercise 2

Make the config loader safe

This reads a config file. Make it safe against hostile input while keeping the feature.

def load_config(path):
    with open(path) as f:
        return eval(f.read())
Reveal solution
import ast
import json
from pathlib import Path


def load_config(path):
    """Load a config file. JSON first, then a Python literal, never eval."""
    text = Path(path).read_text(encoding="utf-8")

    try:
        return json.loads(text)
    except json.JSONDecodeError:
        pass

    try:
        return ast.literal_eval(text)      # data only: no calls, no imports
    except (ValueError, SyntaxError) as err:
        raise ValueError(f"{path} is not valid JSON or a Python literal") from err


Path("config.json").write_text('{"ship": "Sea Monkey", "crew": 12}', encoding="utf-8")
Path("config.py").write_text("{'ship': 'Sea Monkey', 'crew': 12}", encoding="utf-8")

print(load_config("config.json"))
print(load_config("config.py"))

Path("evil.py").write_text("__import__('os').getcwd()", encoding="utf-8")
try:
    load_config("evil.py")
except ValueError as err:
    print("refused:", err)
{'ship': 'Sea Monkey', 'crew': 12}
{'ship': 'Sea Monkey', 'crew': 12}
refused: evil.py is not valid JSON or a Python literal

ast.literal_eval parses the same syntax but only permits literals: strings, numbers, tuples, lists, dicts, sets, booleans and None. There is no way to express a function call, so there is nothing to exploit.

Exercise 3

Threat model a small app

You built a web app where users upload a CSV and get a chart back. List what could go wrong.

Reveal solution
  • A 10GB upload fills the disk. Limit the size before reading.
  • A zip bomb or a CSV with a billion columns exhausts memory. Stream, and cap rows and columns.
  • A filename like ../../etc/passwd escapes your upload folder. Never trust an uploaded filename: generate your own.
  • CSV injection. A cell starting with = becomes a formula when the output is opened in Excel, and can exfiltrate data. Prefix suspicious cells with an apostrophe on export.
  • Slow requests as a denial of service. One user uploading large files repeatedly starves everyone. Rate limit, and process out of band.
  • Uploaded data left on disk containing someone's personal information. Delete it, and know your retention obligations.
  • Errors leaking tracebacks with file paths and library versions. Log the detail, show the user a reference number.

This exercise is threat modelling, and it is mostly just asking 'what does the worst possible user do here' for each input. Doing it on paper for ten minutes finds more real issues than any scanner.

🎉 That is Level 5

Automation, HTTP, scraping, web apps, databases, data, charts, games, desktop apps, packaging, performance and security. You have now seen the whole landscape of what Python is used for. Level 6 builds one thing properly with all of it: your own AI assistant.

+100 XP