Part 6 · Professional Python · Module 43

Debugging & Secure Code

Two professional skills that beginners rarely get taught. One is finding bugs methodically instead of guessing. The other is not writing the bugs attackers look for. This module covers pdb, including Python 3.14's live attach, then works through the vulnerabilities that show up most in real Python code and has you fix each one.

A debugging mindset

A bug is a gap between what you believe the code does and what it actually does. Debugging means finding where your belief is wrong. Changing things at random until the symptom goes away leaves the wrong belief in place. The reliable method is the scientific one:

  1. Reproduce. Get a reliable way to trigger the bug, ideally as a failing test. If you can't reproduce it, you can't confirm you've fixed it.
  2. Isolate. Shrink the input and the code path until the bug is small enough to fit in your head. Halving works well here: halve the input, or git bisect halves the history.
  3. Hypothesise. State a specific, falsifiable guess: "the sensor sends the string "n/a" for a failed reading, and sum() can't add it".
  4. Verify. Test the guess with a debugger, an assertion, or a log line. Then fix the cause, keep the failing test (now passing) as a regression test, and check you haven't broken anything else.

Isolation can be automated. This cell shrinks a failing input by repeatedly removing halves that still trigger the bug, a simplified form of delta debugging:

The minimal input, ['n/a'], confirms the hypothesis. Now step through a fixed version and watch which readings reach cleaned, including the edge case where none do:

Remember: reproduce, isolate, hypothesise, verify. Then keep the reproduction as a regression test.

Reading tracebacks deeply

A traceback is the call stack at the moment an exception was raised. Read it from the bottom up. The last line is the exception type and message. Above it is the frame that raised it, then the frame that called that one, and so on up to the entry point. The ~~~^^^ markers underline the exact sub-expression that failed.

When one exception leads to another, Python shows both, joined by one of two sentences:

raise New(...) from None hides the context when it would only be noise. Since Python 3.11, exc.add_note("...") attaches extra lines of context to an exception as it passes through each layer, without changing its type:

Remember: read tracebacks from the bottom up. "Direct cause" means a deliberate from, and "during handling" means the handler itself failed. Add context with add_note().

pdb: breakpoints, post-mortem, and remote attach

pdb is Python's built-in interactive debugger. It pauses your program so you can inspect variables, walk up and down the stack, and step one line at a time. You can start it three ways:

pixi run python -m pdb app.py        # start under the debugger, stop at the first line
pixi run python -m pdb -c continue app.py   # run until an exception, then stop there (post-mortem)
PYTHONBREAKPOINT=0 pixi run python app.py   # disable every breakpoint() call
def mean(values: list[float]) -> float:
    total = sum(values)
    breakpoint()          # built-in since 3.7: pauses here and opens pdb
    return total / len(values)
CommandMeaning
n(ext)Run the current line and stop at the next one in this function
s(tep)Step into a call on the current line
c(ontinue)Run until the next breakpoint
r(eturn)Run until the current function returns
l(ist) / llShow source around the current line / the whole function
p expr / pp exprPrint / pretty-print an expression
w(here)Show the stack (the current frame is marked >)
u(p) / d(own)Move to the caller's frame / back toward the error
b file:line, b func, b line, condSet a (conditional) breakpoint
cl(ear) [n]Clear breakpoints
a(rgs)Show the current function's arguments
interactOpen a full Python REPL in the current frame's namespace
q(uit)Abort the program

Post-mortem debugging means opening the debugger after a crash, positioned at the frame that raised. In the REPL, run the failing call and then import pdb; pdb.pm(). In code, use pdb.post_mortem(exc.__traceback__). The cell below drives a real pdb post-mortem session with scripted commands, typing what you would type:

The session shows that values is empty in mean, and moving up to report shows why: every element of data was None. That took two commands, and you didn't have to edit the code or add any print statements.

Attaching to a running process (Python 3.14)

Sometimes the bug only appears in a long-running process, such as a stuck worker or a server that slowly leaks memory, and you can't restart it under a debugger. Python 3.14 adds safe external debugger attach (PEP 768). The interpreter now has a well-defined point where it can run code injected from outside, and pdb uses it:

$ pixi run python worker.py &        # a long-running process, PID 48213
$ pixi run python -m pdb -p 48213    # attach pdb to it, live
(Pdb) w
(Pdb) p len(self.queue)
$ pixi run python -m asyncio ps 48213     # 3.14: list asyncio tasks in a running process
$ pixi run python -m asyncio pstree 48213 # ...as a tree of awaits

The low-level API is sys.remote_exec(pid, script_path). It asks the target process to run a Python file at its next safe point, which is how profilers and debuggers can attach without unsafe memory hacks:

Remember: use breakpoint() to pause, n/s/c to move, p/pp/w/u/d to inspect, and pdb.pm() after a crash. In 3.14, python -m pdb -p PID attaches to a live process.

faulthandler, logging, tests, and IDE debuggers

faulthandler: tracebacks when Python itself dies

A segmentation fault in a C extension, or a deadlock, kills or freezes the interpreter before any Python traceback can print. faulthandler installs low-level handlers that dump every thread's Python stack even then:

pixi run python -X faulthandler app.py      # or PYTHONFAULTHANDLER=1
import faulthandler
import sys

faulthandler.enable()                                          # crash -> dump stacks to stderr
faulthandler.dump_traceback_later(30, repeat=True, file=sys.stderr)  # still running after 30s? dump stacks
run_batch_job()
faulthandler.cancel_dump_traceback_later()

Logging for debugging

A debugger shows one moment in detail. Logs show the whole history, including runs on servers you can't attach to. With the structlog setup from M42, leave useful log.debug("cache_miss", key=key) events in the code and switch the level to debug when you're investigating. Bind identifiers (request_id, job_id) so you can filter a single failing run out of millions of lines.

Debugging tests with pytest

FlagUse
-xStop at the first failure
-k "parse and not slow"Run only tests whose names match the expression
--lf / --ffRun only the last failures / run them first
--pdbOpen pdb post-mortem at the failing assertion
--traceOpen pdb at the start of each selected test
-l (--showlocals)Print local variables in failure tracebacks
-sDon't capture output (see prints and logs live)
-vvFull assertion diffs
pixi run pytest tests/unit -x --lf --pdb       # re-run what failed, stop at the first, debug it
pixi run test -k "test_parse_port" -l          # extra args are appended to the task's command

IDE debuggers

VS Code (with the Python extension, which uses debugpy) and PyCharm give pdb's features a GUI: click in the gutter to set breakpoints, hover to inspect, and watch expressions. Point the IDE at the Pixi interpreter, .pixi/envs/default/bin/python (on Windows, .pixi\envs\default\python.exe), so it sees the same packages your tasks do.

{
  "version": "0.2.0",
  "configurations": [
    {"name": "Debug tests", "type": "debugpy", "request": "launch", "module": "pytest",
     "args": ["tests/unit", "-x"], "python": "${workspaceFolder}/.pixi/envs/default/bin/python"}
  ]
}

Remember: faulthandler covers crashes and hangs, logs give you the history, and pytest -x --lf --pdb is the fastest test-debugging loop. Point your IDE at the Pixi interpreter.

Input validation and injection

Security starts with one idea: all input is untrusted. Input means more than form fields. It includes URLs, headers, file names, uploaded files, environment variables, database rows written by others, and messages from queues. A trust boundary is where data crosses from outside your control to inside it, and that's where you validate.

SQL injection

Injection happens when data is mixed into a string that's later interpreted as code: SQL, a shell command, HTML. Then the attacker writes code instead of data. Run this vulnerable login, then the fixed one:

The fix isn't escaping quotes by hand, and it isn't a regex filter. It's parameterized queries: the SQL text and the values travel separately, so the database never parses a value as SQL. Every database driver supports them (? in sqlite3, %s in psycopg). Table and column names can't be parameters. If those come from users, check them against an allow-list.

Shell injection

The same bug happens with the operating system shell. subprocess.run(f"wc -l {path}", shell=True) hands the whole string to /bin/sh, so a "file name" like notes.txt; rm -rf ~ runs two commands. Pass a list of arguments and leave shell at its default (False). The program receives each list element as exactly one argument and no shell parses anything.

Remember: keep code and data apart. Use ? placeholders for SQL and argument lists without shell=True for subprocess, and validate against an allow-list at every trust boundary.

Safe templating with 3.14 t-strings

f-strings are dangerous for building SQL or HTML because they produce a finished str immediately. By the time any function sees the result, the data and the code are already mixed together. Python 3.14's template strings (PEP 750) fix this. A t"..." literal produces a string.templatelib.Template that keeps the static text and the interpolated values separate. A processing function then decides how to handle each value safely.

The same technique turns a t-string into a parameterized SQL query. Exercise 5 has you build it:

Remember: an f-string produces a finished string, while a t-string keeps the template and values separate so a function can escape or parameterize each value.

Unsafe deserialization and path traversal

pickle and yaml.load run code

pickle isn't a data format. It's a small program that the unpickler executes. Through __reduce__, a pickle can tell Python to call any function with any arguments while loading. Unpickling untrusted bytes, such as a cookie, an upload, or a message from a queue, gives the sender code execution:

Path traversal

A download endpoint does open(UPLOADS / filename). An attacker requests filename = "../../etc/passwd". Joining paths doesn't confine them: .. climbs out, and an absolute path replaces the base entirely. The fix is to resolve the final path (which collapses .. and follows symlinks) and check it's still inside the base directory:

Remember: never unpickle untrusted bytes, so use JSON with validation and yaml.safe_load. Confine paths with resolve() and is_relative_to().

Secrets, passwords, tokens, and timing attacks

Secrets management

API keys, database passwords, and signing keys must never appear in source code. Git history keeps them forever, even after you delete the line. Read them from the environment, injected by your deployment platform or CI secret store, and fail fast if one is missing:

import os

DATABASE_URL = os.environ["DATABASE_URL"]      # KeyError at startup beats a confusing failure later
API_TIMEOUT = float(os.environ.get("API_TIMEOUT", "10"))

Hashing passwords

Never store passwords, and never store them encrypted either, since anyone with the key can reverse them. Store a slow, salted hash. A general-purpose hash like SHA-256 is the wrong tool because it is designed to be fast. A GPU can try billions of guesses per second against a stolen hash. Without a salt (random bytes unique to each user), identical passwords produce identical hashes, and precomputed tables crack them instantly.

The standard library has two good key-derivation functions. hashlib.pbkdf2_hmac("sha256", pw, salt, iterations) is tuned by iteration count, and current OWASP guidance for PBKDF2-HMAC-SHA256 is 600,000. hashlib.scrypt(pw, salt=salt, n=2**14, r=8, p=1) is also memory-hard, which makes GPU attacks more expensive. Store the algorithm, parameters, salt, and hash together, for example as pbkdf2_sha256$600000$<salt hex>$<hash hex>, so you can raise the cost later. Dedicated libraries such as argon2-cffi wrap this pattern for you.

Tokens: the secrets module

Password-reset links, API keys, and session ids must be unpredictable. The random module is a Mersenne Twister. It's fine for simulations, but after an attacker sees enough outputs they can predict the rest. Use secrets, which draws from the operating system's cryptographic random source:

Timing attacks and hmac.compare_digest

a == b on strings stops at the first differing character. If an attacker can measure response times precisely enough, a guess with more correct leading characters takes slightly longer, so they can recover a secret one character at a time. hmac.compare_digest(a, b) takes the same time however many characters match. The cell counts comparison steps instead of measuring time:

Remember: keep secrets in the environment and never in git, and rotate any that leak. Store passwords as salted pbkdf2 or scrypt hashes. Make tokens with secrets and compare them with hmac.compare_digest.

Dependency auditing and the OWASP Top 10

Your dependencies are your code

Most of the code you run, you didn't write. A vulnerability in a dependency is a vulnerability in your app, and a malicious package (a typo-squatted name like reqeusts, or a hijacked maintainer account) is an attack on it. Defences:

$ pixi run pip-audit
No known vulnerabilities found

The OWASP Top 10, mapped to Python

OWASP (the Open Worldwide Application Security Project) publishes the most common categories of web application risk. The table uses the widely referenced 2021 categories. The 2025 edition reorders them and gives more weight to software supply-chain failures, but the defences are the same.

RiskWhat it looks like in PythonDefence
A01 Broken Access ControlGET /invoices/{id} returns any user's invoiceCheck ownership on every request; deny by default
A02 Cryptographic Failuressha256 or md5 passwords, homemade crypto, HTTP not HTTPSpbkdf2/scrypt/argon2; vetted libraries; TLS everywhere
A03 Injectionf-string SQL, shell=True, unescaped HTML, eval(user_input)Parameters, argument lists, escaping/t-strings, never eval
A04 Insecure DesignUnlimited password-reset attemptsThreat-model features; rate limits
A05 Security Misconfigurationdebug=True in production, tracebacks shown to usersEnvironment-specific config, generic error pages
A06 Vulnerable ComponentsOld packages with known CVEsLockfile, pip-audit, scheduled updates
A07 Authentication FailuresPredictable tokens from random, == on secretssecrets, hmac.compare_digest, MFA
A08 Software & Data Integritypickle.loads on cookies; unsigned updatesJSON + validation; signatures; trusted publishing
A09 Logging & Monitoring FailuresNo record of failed logins; passwords in logsStructured security events, redaction, alerts
A10 Server-Side Request ForgeryFetching a user-supplied URL reaches http://169.254.169.254/Allow-list hosts, block internal ranges, timeouts

Remember: lock and audit your dependencies, restrict where they can come from, and work through the OWASP list as a review checklist for every feature.

Practice: find and fix the vulnerability

Each starter contains working but vulnerable code. Keep the behaviour and remove the vulnerability. The tests attack your code.

find_user(conn, name) returns (name, email) for an exact name match, or None. It builds SQL with an f-string. Rewrite it with a parameterized query.

Hint

Use a ? placeholder and pass (name,) as the second argument to execute.

Show solution
import sqlite3

def find_user(conn, name):
    return conn.execute("SELECT name, email FROM users WHERE name = ?", (name,)).fetchone()

read_upload(base_dir, filename) returns the text of a file inside base_dir. Make it raise PermissionError whenever the resolved path is outside base_dir, including via .. and absolute paths. Legitimate subdirectories like "2026/report.txt" must still work.

Hint

Resolve both the base and the target, then use target.is_relative_to(base). A string startswith check fails the sibling-prefix test.

Show solution
from pathlib import Path

def read_upload(base_dir, filename):
    base = Path(base_dir).resolve()
    target = (base / filename).resolve()
    if not target.is_relative_to(base):
        raise PermissionError(f"{filename!r} is outside the upload directory")
    return target.read_text()

The starter stores sha256(password) and compares with ==. Replace it:

  • hash_password(password, iterations=600_000) returns "pbkdf2_sha256$<iterations>$<salt hex>$<hash hex>", using a fresh 16-byte salt from secrets.token_bytes (or os.urandom) and hashlib.pbkdf2_hmac("sha256", ...).
  • verify_password(password, stored) recomputes with the stored salt and iterations and compares with hmac.compare_digest. It returns False (never raises) for malformed stored strings.
Hint

Split the stored value on "$" inside a try that catches ValueError (wrong part count, bad int, bad hex). Call hmac.compare_digest through the module (hmac.compare_digest(...)).

Show solution
import hashlib
import hmac
import secrets

def hash_password(password, iterations=600_000):
    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):
    try:
        algo, iters, salt_hex, digest_hex = stored.split("$")
        iterations = int(iters)
        salt = bytes.fromhex(salt_hex)
        expected = bytes.fromhex(digest_hex)
    except ValueError:
        return False
    if algo != "pbkdf2_sha256" or iterations < 1:
        return False
    actual = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, iterations)
    return hmac.compare_digest(actual, expected)

The vulnerable code was subprocess.run(f"wc -l {path}", shell=True). Write line_count_command(path) that returns the safe argument list ["wc", "-l", "--", path] for subprocess.run. Raise ValueError if path is empty, starts with -, or contains a NUL character ("\x00"). Shell metacharacters like ; are fine, because they stay inside one argument.

Hint

Validate first, then return a plain list.

Show solution
def line_count_command(path):
    if not path or path.startswith("-") or "\x00" in path:
        raise ValueError(f"unsafe path: {path!r}")
    return ["wc", "-l", "--", path]

Write sql(template) that turns a 3.14 t-string into (query, params) for sqlite3. Static parts are kept as they are, each interpolation becomes ?, and its value is appended to the params tuple. Raise TypeError if template isn't a Template (for example a plain str from an f-string).

Example: sql(t"SELECT * FROM users WHERE name = {name}") returns ("SELECT * FROM users WHERE name = ?", (name,)).

Hint

Iterate over the template. Strings are appended to a list; for Interpolation items append "?" and collect item.value.

Show solution
from string.templatelib import Interpolation, Template

def sql(template):
    if not isinstance(template, Template):
        raise TypeError("sql() requires a t-string Template, not a str")
    parts = []
    params = []
    for item in template:
        if isinstance(item, Interpolation):
            parts.append("?")
            params.append(item.value)
        else:
            parts.append(item)
    return "".join(parts), tuple(params)

User preferences arrive in a cookie and used to be loaded with pickle.loads. Write load_prefs(text) that parses JSON and validates it strictly:

  • It must be an object whose keys are a subset of {"theme", "font_size"}.
  • theme must be "light" or "dark" (default "light").
  • font_size must be an int (not bool) from 8 to 32 inclusive (default 14).

Return a dict with both keys filled in. Raise ValueError for anything else, including invalid JSON (json.JSONDecodeError is already a ValueError subclass).

Hint

Check isinstance(data, dict), then set(data) - ALLOWED, then each value. Remember isinstance(True, int) is True.

Show solution
import json

ALLOWED = {"theme", "font_size"}

def load_prefs(text):
    data = json.loads(text)
    if not isinstance(data, dict):
        raise ValueError("preferences must be a JSON object")
    unknown = set(data) - ALLOWED
    if unknown:
        raise ValueError(f"unknown keys: {sorted(unknown)}")
    theme = data.get("theme", "light")
    size = data.get("font_size", 14)
    if theme not in {"light", "dark"}:
        raise ValueError("theme must be 'light' or 'dark'")
    if isinstance(size, bool) or not isinstance(size, int) or not 8 <= size <= 32:
        raise ValueError("font_size must be an integer from 8 to 32")
    return {"theme": theme, "font_size": size}

Mini-project: security-review a vulnerable app

Mini-projectAudit and fix "notebin"

notebin is a tiny notes service, deliberately full of vulnerabilities. Copy it into a new Pixi workspace as src/notebin/app.py. Your job, as the security reviewer, is to find every issue, write a failing test that demonstrates each one, fix it, and write a short report.

import hashlib
import pickle
import random
import sqlite3
import subprocess
from pathlib import Path

SECRET_KEY = "dev-secret-123"
EXPORTS = Path("exports")
db = sqlite3.connect("notebin.db")


def register(username, password):
    pw_hash = hashlib.md5(password.encode()).hexdigest()
    db.execute(f"INSERT INTO users VALUES ('{username}', '{pw_hash}')")
    print(f"registered {username} with password {password}")


def login(username, password):
    pw_hash = hashlib.md5(password.encode()).hexdigest()
    row = db.execute(f"SELECT * FROM users WHERE name='{username}' AND pw='{pw_hash}'").fetchone()
    return row is not None


def make_reset_token():
    return str(random.randint(100000, 999999))


def check_api_key(given):
    return given == SECRET_KEY


def load_session(cookie_bytes):
    return pickle.loads(cookie_bytes)


def export_note(note_id, filename):
    subprocess.run(f"sqlite3 notebin.db 'SELECT body FROM notes WHERE id={note_id}' > exports/{filename}", shell=True)


def download(filename):
    return (EXPORTS / filename).read_text()


def handle_error(exc):
    import traceback
    return {"status": 500, "body": traceback.format_exc()}
Reveal the issue list (after you've done your own review)
  1. SECRET_KEY hard-coded in source → read it from the environment and rotate it (A05/A07).
  2. MD5 password hashing, unsalted → pbkdf2_hmac/scrypt with a salt (A02).
  3. SQL built with f-strings in register and login → ? parameters (A03).
  4. print logs the plaintext password → structlog event without secrets (A09).
  5. random reset token, only 6 digits → secrets.token_urlsafe(32) with an expiry (A07).
  6. == on the API key → hmac.compare_digest (timing attack).
  7. pickle.loads on a cookie → signed JSON with validation (A08).
  8. shell=True with note_id and filename → argument lists, an int(note_id), and Python file writing (A03).
  9. download path traversal → resolve() + is_relative_to() (A01).
  10. handle_error returns the traceback to the client → log it, return a generic message with a request id (A05).
  11. Module-level DB connection to a relative path → configuration plus explicit lifecycle (design).
  • Every issue has a pytest test that failed before your fix and passes after
  • No f-string SQL and no shell=True remain (enable ruff S rules and get zero findings)
  • Passwords use pbkdf2 or scrypt with per-user salts, and verification uses hmac.compare_digest
  • Tokens come from secrets, secrets come from os.environ, and .env is gitignored
  • Sessions use JSON with schema validation and an HMAC signature, not pickle
  • download("../notebin.db") raises PermissionError
  • Errors are logged with structlog and clients see a generic message
  • pixi run pip-audit reports no known vulnerabilities
  • A one-page SECURITY_REVIEW.md lists each finding, its OWASP category, and the fix

Stretch goals

  • Render notes as HTML with a t-string render_html() that escapes every value.
  • Add rate limiting to login (at most 5 failures per user per 15 minutes).
  • Run the app, attach with python -m pdb -p PID, and inspect a live request.

Quiz

Recap

Going further