Python code auditing
TL;DR: Python audits cluster around dynamic execution (
eval/exec/compile), deserialization (pickle/yaml.load/shelve), template SSTI (Jinja2Template(x).render), ORM raw queries, and Werkzeug/Django middleware bugs. Cross-link python-dangerous-sinks for the sink catalogue; this note is the methodology.
What it is
Python web apps span Flask (small, explicit), Django (batteries-included), FastAPI (Pydantic-first), and Tornado/aiohttp/Starlette (ASGI). Each routes differently but shares the same underlying sinks. ML / data-science codebases add another tier of risk: pickle loads from “trusted” storage that wasn’t, Jupyter kernels with --allow-root, and notebook papermill parameterisation.
Preconditions / where it applies
- Source (.py, requirements.txt / pyproject.toml)
- Framework knowledge — Django URL routing differs from Flask blueprints
- Python version —
ast.literal_evalsafe,evalnot;pickle5extends 3.8+;asynciorace surface
Technique
- Map entry points.
- Flask:
@app.route,Blueprint.add_url_rule,@app.before_request. - Django:
urls.pypath()/re_path(), class-based views (View.dispatch), DRF@action. - FastAPI:
@app.get/post,APIRouter, dependency-injection chains. - Starlette:
Route(path, endpoint). - Celery tasks (
@shared_task) — also accept user data via task args; audit pickle serializer config.
- Flask:
- Trace sources.
request.form,request.args,request.json,request.files,request.headers,request.cookies,request.GET/POST(Django),request.body(raw). FastAPI Pydantic models — check forextra = "allow"permitting extra fields → mass-assignment. - Sink catalogue — see python-dangerous-sinks:
1 2 3 4 5 6 7 8 9 10
rg -n '\beval\(|\bexec\(|\bcompile\(' . rg -n 'pickle\.(loads?|Unpickler)|cPickle\.' . rg -n 'yaml\.load\([^,)]*\)|yaml\.unsafe_load' . # safe: yaml.safe_load rg -n 'subprocess\.(call|run|Popen|check_output)\(.*shell\s*=\s*True' . rg -n 'os\.(system|popen|exec[lv]p?e?)\(' . rg -n 'Jinja2.*Template\(|Environment\(.*autoescape\s*=\s*False' . rg -n '\.raw\(\s*[fr]?["\x27].*\{|cursor\.execute\(\s*[fr]?["\x27].*\{' . # f-string SQL rg -n 'requests\.(get|post)\(\s*request\.|urllib\.request\.urlopen\(' . rg -n 'send_file\(\s*request\.|safe_join\(' . rg -n 'marshal\.loads?|shelve\.open' .
- Pickle.
pickle.loads(x)on attacker bytes is RCE —__reduce__runs arbitrary code on deserialize. Audit Redis/memcached/queue payloads, session cookies (itsdangeroussigns but doesn’t prevent if key leaks), Celerypickleserializer. - YAML.
yaml.load(x)(PyYAML <6.0 default loader) deserialises arbitrary Python objects.yaml.safe_loadis required.ruamel.yamlalso hasunsafemodes. - SSTI. Jinja2
Environment().from_string(user_template).render(context)— RCE via{{ ''.__class__.__mro__[1].__subclasses__() }}chain. Django templates are more sandboxed but a{% include name %}tag with user-controllednameis still LFI. See ssti, python-ssti-jinja. - SQL injection. Django ORM
.filter(name=x)is safe;.raw("SELECT * FROM users WHERE name='%s'" % x)is not. SQLAlchemytext(f"...")interpolation = SQLi. f-strings incursor.executeare SQLi. - SSRF.
requests.get(request.json["url"])with no allowlist. Django’sURLValidatoris regex-only, doesn’t prevent DNS rebinding (dns-rebinding). - Mass assignment / Pydantic.
class Foo(BaseModel): name: strwithmodel_config = ConfigDict(extra='allow')lets attacker addis_adminand a downstreamObject.assign-style update propagates it. Useextra='forbid'. - Path traversal.
send_file(request.args.get('f'))doesn’t sanitise.safe_joinin Flask/Werkzeug returns None on traversal — must check return. - Format string.
"Hello {user.email}".format_map(request.args)lets{user.password}leak attribute access — see python-format-string. - Sandbox escape. Anything claiming “safe eval” (
asteval,simpleeval,RestrictedPython) has had escapes. Treat as RCE-equivalent unless run in a strict OS sandbox. sys.auditbypass. Hardening via audit hooks can be bypassed — see python-sys-audit-bypass.
Detection and defence
- Bandit + Semgrep
python.lang.security; both have low-FP RCE/SSRF rules. - Move all
pickletomsgpack/jsonwhere the format allows. - Force
yaml.safe_loadviaimport yaml; yaml.load = yaml.safe_loadin a sitecustomize hook (defence in depth, not a fix). - Pydantic
extra='forbid'org-wide; type-check binders. - For Django:
SECURE_*settings audit,ALLOWED_HOSTSnon-wildcard, CSP middleware.
References
- Bandit — Python AST-based linter
- PyYAML loader matrix
- OWASP Python Security Project
- Doyensec — Python deserialization
- See also: python-dangerous-sinks, python-deserialization, django-audit-patterns