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.
- Debug systematically: reproduce, isolate, hypothesise, verify
- Read chained tracebacks and attach context with
add_note() - Drive pdb fluently, including post-mortem and 3.14 remote attach (
python -m pdb -p PID) - Use faulthandler, logging, pytest's debugging flags, and IDE debuggers
- Prevent injection (SQL, shell), unsafe deserialization, and path traversal, and template safely with t-strings
- Handle secrets, password hashing, tokens, and constant-time comparison correctly
- Audit dependencies and map the OWASP Top 10 onto Python code
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:
- 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.
- 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 bisecthalves the history. - Hypothesise. State a specific, falsifiable guess: "the sensor sends the string
"n/a"for a failed reading, andsum()can't add it". - 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:
- "The above exception was the direct cause of the following exception": someone wrote
raise New(...) from original. The original is stored in__cause__. This is deliberate translation, for example from a low-levelValueErrorto a domainConfigError. - "During handling of the above exception, another exception occurred": a new exception
happened inside an
exceptblock. The original is in__context__. This usually means the error handling itself has a bug.
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)
| Command | Meaning |
|---|---|
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) / ll | Show source around the current line / the whole function |
p expr / pp expr | Print / 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, cond | Set a (conditional) breakpoint |
cl(ear) [n] | Clear breakpoints |
a(rgs) | Show the current function's arguments |
interact | Open 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
| Flag | Use |
|---|---|
-x | Stop at the first failure |
-k "parse and not slow" | Run only tests whose names match the expression |
--lf / --ff | Run only the last failures / run them first |
--pdb | Open pdb post-mortem at the failing assertion |
--trace | Open pdb at the start of each selected test |
-l (--showlocals) | Print local variables in failure tracebacks |
-s | Don't capture output (see prints and logs live) |
-vv | Full 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.
- Allow-list, don't block-list. Define what valid input looks like
(
re.fullmatch(r"[a-z0-9_]{3,20}", name)) instead of trying to list every bad character. - Parse into types. Turn a string into an
int, adate, or an enum member at the boundary, and pass typed values inward. - Fail closed. If you're not sure input is valid, reject it with a clear error.
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:
- For data from outside, use JSON (
json.loadsonly ever builds dicts, lists, strings, numbers, booleans, andNone), then validate the structure. - PyYAML's
yaml.loadwith an unsafe loader can construct arbitrary Python objects. Always useyaml.safe_load. Ruff flags the unsafe form asS506. - Use pickle only for data your own process wrote and nobody else could have modified. If it has to cross a
trust boundary, sign it with
hmacand check the signature first.
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"))
- For local development, keep values in a
.envfile that is listed in.gitignore(this repo's.gitignorelists.envand.env.*). Commit a.env.examplewith placeholder values instead. - If a secret is ever committed, rotate it (issue a new one and revoke the old one). Rewriting history doesn't help, because it may already have been cloned or scraped.
- Keep secrets out of logs, tracebacks, and error pages. Some structured traceback renderers capture local variables, so redact them.
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:
- Pin with a lockfile.
pixi.lockrecords exact versions and sha256 hashes, so a package that changes on the server fails to install instead of silently replacing the real one. Upgrade deliberately (M41). - Audit for known vulnerabilities.
pip-auditchecks installed Python packages against the Python Packaging Advisory Database and OSV. Add it as a dev tool and run it in CI:pixi add --feature dev pip-audit, thenpixi run pip-audit. - Restrict sources. This repository has a
pixi-policy.tomlthat allows only the conda-forge channel, forbids git and URL dependencies, and lists the licenses currently in use. It's a written, reviewable policy for what may enter the environment. - Add fewer dependencies. Every package is attack surface. The standard library (M40) often has what you need.
$ 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.
| Risk | What it looks like in Python | Defence |
|---|---|---|
| A01 Broken Access Control | GET /invoices/{id} returns any user's invoice | Check ownership on every request; deny by default |
| A02 Cryptographic Failures | sha256 or md5 passwords, homemade crypto, HTTP not HTTPS | pbkdf2/scrypt/argon2; vetted libraries; TLS everywhere |
| A03 Injection | f-string SQL, shell=True, unescaped HTML, eval(user_input) | Parameters, argument lists, escaping/t-strings, never eval |
| A04 Insecure Design | Unlimited password-reset attempts | Threat-model features; rate limits |
| A05 Security Misconfiguration | debug=True in production, tracebacks shown to users | Environment-specific config, generic error pages |
| A06 Vulnerable Components | Old packages with known CVEs | Lockfile, pip-audit, scheduled updates |
| A07 Authentication Failures | Predictable tokens from random, == on secrets | secrets, hmac.compare_digest, MFA |
| A08 Software & Data Integrity | pickle.loads on cookies; unsigned updates | JSON + validation; signatures; trusted publishing |
| A09 Logging & Monitoring Failures | No record of failed logins; passwords in logs | Structured security events, redaction, alerts |
| A10 Server-Side Request Forgery | Fetching 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 fromsecrets.token_bytes(oros.urandom) andhashlib.pbkdf2_hmac("sha256", ...).verify_password(password, stored)recomputes with the stored salt and iterations and compares withhmac.compare_digest. It returnsFalse(never raises) for malformedstoredstrings.
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"}. thememust be"light"or"dark"(default"light").font_sizemust be anint(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
Quiz
Recap
- Debug scientifically: reproduce, isolate (halve the input or the history), hypothesise, verify, then keep a regression test.
- Read tracebacks from the bottom up.
frommeans direct cause and "during handling" means the handler failed. Useadd_note()to add context. - pdb:
breakpoint(),n s c r,p pp w u d,b/cl,interact, andpdb.pm(). In 3.14,python -m pdb -p PIDattaches to a live process. - faulthandler for crashes and hangs, structured logs for history,
pytest -x --lf --pdbfor failing tests, and IDE debuggers pointed at the Pixi interpreter. - Injection: parameterized SQL, argument-list subprocess calls without
shell=True, and t-strings for safe templating. - Never unpickle untrusted data. Confine paths with
resolve()andis_relative_to(). - Secrets come from the environment and leaked ones get rotated. Passwords are stored as pbkdf2 or scrypt
hashes, tokens come from
secrets, and comparisons usehmac.compare_digest. - Lock, audit, and restrict dependencies, and use the OWASP Top 10 as a review checklist.
Going further
- pdb, faulthandler, and traceback
- PEP 768: Safe external debugger interface and PEP 750: Template strings
- secrets, hashlib, and hmac
- subprocess security considerations and pickle's warning
- OWASP Top 10 and the Password Storage Cheat Sheet
- pip-audit and ruff's flake8-bandit (S) rules
- pytest: handling failures and debugging