
Python 3.10 loses upstream patches on 31 October 2026. I opened PEP 619 for that date.A Flask test that never swapped bob onto alice_invoice.id is the failure. This page is how the team that ships the app tests three hatches. It is not a lab.
Keep the secure coding checklist next to this page. Read IDOR when the miss is an id without an owner check, injection when a string reaches the shell, and Flask locks when the app is Pallets. This page is the pytest shape for those three.
Three hatches a pytest job can see
A hired review is a person with two accounts and a window. That buy sits on the steps page. This page is the job you already own. It proves three things on every pull request:
- Authz. Bob’s session on Alice’s invoice is 403 or 404, and her secret is absent from the body.
- Pickle. No
pickle.loads,pickle.load(, oryaml.load(withoutSafeLoaderin app code. - Shell. No
subprocessstring withshell=True, and noos.system.
Those three are CWE-639 or CWE-284, CWE-502, and CWE-78. I am using those CWE ids as labels, not as a scanner export. A Bandit rule that flags pickle is a belt. The pytest is the proof. Python 3.12 and 3.13 still have the same three hatches. A new interpreter did not close them. Get off 3.10 before the October cut. That is hygiene. It is not a substitute for the job.
| Hatch | What the test proves | What it does not prove |
|---|---|---|
| Owner check | Bob is denied Alice’s row | Every new route you add next week |
| Pickle | No loader in the tree | A worker you did not grep |
| Shell | Children get a list | A leading-dash flag the child honors |
The third row needs a human note when a tool treats a filename as a flag. Put -- before user paths when the child documents that. The injection page is the longer argument. This page only requires the list form.
Swap Bob onto Alice’s invoice
The miss is a query that only asks for the id. can_read_invoice is the named check. It compares org, then owner or a finance role you minted. The route calls it. The test calls the route as Bob. I am not walking a bypass. I am showing the deny.
def can_read_invoice(actor, invoice) -> bool:
if actor.org_id != invoice.org_id:
return False
if actor.id == invoice.owner_id:
return True
return actor.role == "finance"
Two fixtures, one object. alice owns alice_invoice. bob shares the org or does not, depending on the case you are proving. The IDOR page wants a 404 when the row is another tenant, so you do not confirm the id exists. Inside one org, 403 is honest if your product already exposes the id in a list. Pick one and keep it. The test asserts both the status and the absence of INV-ALICE.
def login(client, user):
resp = client.post(
"/login",
data={"email": user.email, "password": user.password},
follow_redirects=True,
)
assert resp.status_code == 200
def test_bob_denied_alice_invoice(client, alice, bob, alice_invoice):
login(client, bob)
resp = client.get(f"/invoices/{alice_invoice.id}")
assert resp.status_code in (403, 404)
assert b"INV-ALICE" not in resp.data
login and test_bob_denied_alice_invoice are the named pair. Mint the users in fixtures you own. Do not point this at production. Do not put a customer cookie in the log. A 200 with Alice’s number is a failed build. A 302 to login means login did not stick. Fix the fixture before you argue about authz.
Add the sibling case: Alice can read her own row. A deny-only suite will go green if every request 500s. test_alice_reads_own_invoice asserts 200 and INV-ALICE. Then add the PATCH: Bob sends owner_id or role and you expect 400. Extra fields are API3 language on the types page. Here it is one test.
def test_bob_cannot_reassign(client, bob, alice_invoice):
login(client, bob)
resp = client.patch(
f"/invoices/{alice_invoice.id}",
json={"owner_id": bob.id},
)
assert resp.status_code in (400, 403, 404)
Wire can_read_invoice in the handler, not in a decorator you forget on the next blueprint. Grep for Invoice.query.get( and db.session.get(Invoice without a following check. Those lines are the review, not a payload.
Refuse pickle.loads in the tree
I opened the 3.13 pickle page on 22 August 2026. The warning is still the first thing after the title: the module is not secure, and you only unpickle data you trust. The next sentence says a crafted stream can run code during load. That is CWE-502. The 3.12 docs print the same banner. mpyne named it in 2013. Tutorials still treat pickle.loads as a cache codec.
import json
from pathlib import Path
def load_job(raw: bytes) -> dict:
data = json.loads(raw.decode("utf-8"))
if not isinstance(data, dict):
raise ValueError("job must be an object")
return data
def save_job(path: Path, data: dict) -> None:
path.write_text(json.dumps(data, separators=(",", ":")), encoding="utf-8")
load_job and save_job are the named pair. A Redis string, a queue body, or a celery result that used to be a pickle becomes text you can print in a ticket. HMAC the blob if you need tamper evidence. Do not unpickle and then hope a find_class allowlist will hold. The next vendor pickle still calls loads.
# BAD: do not ship
# obj = pickle.loads(redis.get(key))
PyYAML is the sibling. Use yaml.safe_load, or yaml.load(stream, Loader=yaml.SafeLoader). Leave yaml.FullLoader and a bare yaml.load(stream) out. Marshal is for .pyc files. Do not use it as a network codec. A job queue that still pickles a traceback is the same hatch with a library name on it.
AUTHZ bobSid + alice_invoice.id
403 or 404, no INV-ALICE
can_read_invoice is the symbol
CACHE load_job(raw) -> dict
json.loads only
no REDUCE, no class
CHILD run_convert([bin, src, dest])
argv is data
shell stays false
subprocess gets a list
CWE-78 is untrusted text reaching a shell. CPython’s subprocess security section says the library will not pick a shell for you, so metacharacters stay characters when args is a sequence. The hatch is shell=True, or a string you interpolate and then hand to Popen.
3.12 changed one Windows case. I opened that same page: when shell=True, the search order no longer prefers the current directory for cmd.exe. It uses %COMSPEC% and %SystemRoot%\System32\cmd.exe. That is a belt. Keep shell=True off a user filename anyway.
import subprocess
from pathlib import Path
CONVERT = Path("/usr/bin/convert")
def run_convert(src: Path, dest: Path) -> None:
if not src.is_file():
raise ValueError("missing source")
subprocess.run(
[str(CONVERT), str(src), str(dest)],
check=True,
capture_output=True,
)
run_convert is the named helper. The binary is a path you wrote. User values are extra list items. shell stays off. A test that only checks the return code will miss a string hatch. Grep is the test for this one.
# BAD: do not ship
# subprocess.run(f"convert {src} {dest}", shell=True)
os.system and os.popen are the same hatch with worse names. shlex.split on a user string, then run, still lets the user pick the binary. Allowlist the executable. capture_output=True keeps child stdout off your logs until you decide to write it. Log the return code and a truncated stderr, not a command line you interpolated.
One job, three asserts
Put the swap, the grep, and the version pin in one CI job. A style linter will not see a new pickle.loads in a worker. Bandit flags pickle and shell=True. If CI already runs Bandit, keep those two rules on. If it does not, the rg line is enough.
rg -n "pickle\\.loads|pickle\\.load\\(|yaml\\.load\\(|shell\\s*=\\s*True|os\\.system|os\\.popen" \
--glob '!venv' --glob '!.venv' --glob '!__pycache__' --glob '!tests'
A hit on pickle.loads is a failed job unless the line is in a vendor wheel you already ticketed. A hit on shell=True needs a human to say the line contains no user text and why a list could not replace it. Leave that note in the PR. Silence is a fail.
python --version
# Expect: 3.12.x or 3.13.x, not 3.10.x after 31 Oct 2026
Name the helpers in the review checklist: can_read_invoice, load_job, run_convert. If a new route uses a raw get and a new worker uses pickle, the names make the miss greppable. Scattered one-line checks are how the next invoice export ships without a deny.
Prove it on your checkout
You are not walking an exploit. You are proving your own fixtures and your own tree.
- In the app you maintain, mint
aliceandbobon staging you operate, or in pytest only. - Run
test_bob_denied_alice_invoice. Expect 403 or 404 and noINV-ALICE. - Run the rg line. Expect no
pickle.loadsand noshell=Truein app code. - Call
run_convertwith two paths you created. Expect a list in the audit log, not a shell string.
If step 2 returns 200, stop and fix can_read_invoice before you add more tests. If step 3 hits a queue library, replace the result backend with JSON, or with a type the worker constructs from fields you listed. Do not “just sign” a pickle and keep the loader. HMAC proves the writer held a key. It does not make REDUCE a parser.
For a cache you already wrote with pickle, add a version byte, write JSON beside the old key, read JSON first, and delete the pickle key after one TTL. The point is the loader goes away.
Questions we keep getting
Is pickle safe if I HMAC the blob first?
HMAC proves the writer held the key. It does not make the loader a data parser. If that key leaks, the stream is code again. Prefer load_job. If you must keep pickle for a private file on disk that never leaves the host, treat a leaked key as code execution and rotate the host.
Does shlex.quote make shell=True acceptable?
The 3.13 docs mention shlex.quote when you already invoked a shell. A list with shell=False does not need that quote. Use run_convert. Quote is leftover work for a pipeline you have not split yet.
Can I skip the Bob test if IDs are UUIDs?
No. Unguessable ids are not an owner check. The IDOR page is the longer argument. test_bob_denied_alice_invoice still has to run. A UUID only changes how you mint the fixture.



