Python penetration testing: prove authz, pickle, and shell

A closed jar wrapped in a coil, one coral end at the lid, house style.

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(, or yaml.load( without SafeLoader in app code.
  • Shell. No subprocess string with shell=True, and no os.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.

HatchWhat the test provesWhat it does not prove
Owner checkBob is denied Alice’s rowEvery new route you add next week
PickleNo loader in the treeA worker you did not grep
ShellChildren get a listA 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.

The job never invents a payload. It proves the deny, the loader, and the argv vector.
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.

  1. In the app you maintain, mint alice and bob on staging you operate, or in pytest only.
  2. Run test_bob_denied_alice_invoice. Expect 403 or 404 and no INV-ALICE.
  3. Run the rg line. Expect no pickle.loads and no shell=True in app code.
  4. Call run_convert with 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.

Gilad David Maayan / About Author

Gilad David Maayan is a technology writer who has worked with over 150 technology companies including SAP, Imperva, Samsung NEXT, NetApp and Ixia, producing technical and thought leadership content that elucidates technical solutions for developers and IT leadership.

LinkedIn