# Security review checklist for AI-generated Python

> Source: https://learn-python.com/review/security/
> Part of Learn Python, free to read.

Two things are true at once. Coding agents write more secure code than the average tutorial they learned from — they use parameterised queries by default, they hash passwords properly, they do not roll their own crypto. And they introduce a specific, repeatable set of vulnerabilities at a volume that human review does not scale to.

The second is the operative one. **Your review process has to be mechanical, because your attention is the resource that ran out.**

## Turn on the machine checks first

Before any of the manual review below, get the automated coverage. Most of what follows is caught for free.

```bash
uv add --dev ruff pip-audit
```

```toml pyproject.toml
[tool.ruff.lint]
select = ["S", "B", "BLE", "ASYNC", "E", "F"]   # S = bandit ruleset
[tool.ruff.lint.per-file-ignores]
"tests/**" = ["S101", "S105", "S106"]
```

```bash
uv run ruff check .        # every edit, via a hook
uv run pip-audit           # in CI
```

Add secret scanning to pre-commit (`gitleaks`, `detect-secrets`) and you have covered the majority of what actually goes wrong.

## The manual checklist

### Injection: is any input reaching a shell, a query, or a template unparameterised?

```bash
git diff | grep -nE "shell=True|os\.system|f\".*SELECT|f\".*INSERT|eval\(|exec\("
```

The recurring Python instances:

- `subprocess.run(f"...", shell=True)` — pass a list, leave `shell=False`.
- f-string SQL where the surrounding code has no ORM. Parameterise. Always.
- `eval()` / `exec()` on anything derived from input. There is no safe version of this.
- Jinja templates rendered from user-controlled strings — `Template(user_input)` is remote code execution, not templating.

### Deserialisation: what is being loaded, and from where?

`pickle.loads`, `yaml.load` without `SafeLoader`, `marshal`, `dill`, and `torch.load` on an untrusted checkpoint all execute arbitrary code by design. Generated caching, config-loading and model-loading code reaches for these constantly.

Rule: **if it crossed a trust boundary, it is JSON.**

### Secrets: is anything hardcoded, logged, or in an error path?

```bash
git diff | grep -niE "api_key|secret|token|password|bearer|BEGIN .*PRIVATE KEY"
```

Two specifically generated patterns worth naming:

```python
# 1. the "example" that ships
API_KEY = os.getenv("API_KEY", "sk-test-abc123")     # real key as a default

# 2. the debug log that survives
logger.info("calling %s with %s", url, headers)      # headers include Authorization
```

The second is the one that gets missed, because it is not in the diff as a secret — it is in the diff as a log line.

### Authorisation: is it checked, and is it checked in the right place?

Generated endpoints frequently authenticate — is this a valid user — and forget to authorise — is this *their* resource.

```python
@router.get("/invoices/{invoice_id}")
async def get_invoice(invoice_id: str, user: User = Depends(current_user)):
    return await db.get_invoice(invoice_id)     # any logged-in user, any invoice
```

This is the single most common real vulnerability in generated web code, it is invisible to every linter, and the test that catches it is one line:

```python
async def test_cannot_read_another_users_invoice(client, alice, bobs_invoice):
    r = await client.get(f"/invoices/{bobs_invoice.id}", headers=alice.auth)
    assert r.status_code == 404      # not 403 — do not confirm existence
```

Write that test for every resource-scoped endpoint. It is the highest value-per-line test in a web codebase.

### SSRF: does anything fetch a URL that came from outside?

```python
@router.post("/import")
async def import_from_url(url: str):
    r = await client.get(url)        # now fetches your cloud metadata endpoint
```

Generated "import from URL", webhook and avatar-fetching features almost never validate the target. Allowlist schemes and hosts, resolve and check the IP, and disable redirects.

### Crypto and randomness

- `random` for tokens, session ids, password resets, filenames. Use `secrets`.
- `md5`/`sha1` for anything security-relevant.
- Hand-rolled comparison of secrets — use `hmac.compare_digest`.
- Encryption without an authenticated mode. If you are choosing a cipher mode by hand, stop and use `cryptography`'s recipes layer.

### Errors and information disclosure

`debug=True`, full tracebacks returned to clients, exception messages that include the SQL, the file path or the internal hostname. Generated error handlers are helpful to a fault.

## The one that is new: prompt injection

If the Python you are writing *is* an LLM application, there is a class of bug that no linter has heard of.

Any text your application feeds a model — a scraped page, a user message, a PDF, a database row someone else wrote, an MCP tool result — can contain instructions. If your model has tools, those instructions can be actions.

```python
# every one of these is untrusted input, not data
context = fetch_page(url)
context += load_pdf(upload)
context += db.query("SELECT bio FROM users WHERE ...")
answer = await agent.run(system=SYSTEM, context=context, tools=[send_email, query_db])
```

The mitigations that hold up:

- **Least privilege on tools.** The blast radius of an injection is exactly the set of tools available. A read-only agent cannot be made to exfiltrate.
- **Confirm side effects.** Anything that sends, pays, deletes or publishes goes through a human, or through a deterministic check the model cannot argue with.
- **Separate the channels.** Untrusted content goes in a clearly delimited region, never concatenated into the system prompt.
- **Constrain the output.** If the model's job is to return a category, validate it against an enum in Python. Do not let free text become control flow.
- **Watch the combination.** Fetching untrusted content *and* having a write tool in the same session is the dangerous configuration. Either alone is usually fine.

There is more on the testing side of this in [testing LLM-powered Python](/ai/evals/).

## The review, as a command

```bash
git diff | grep -nE "shell=True|eval\(|exec\(|pickle|yaml\.load\(|md5|sha1\(|random\.|verify=False|debug=True|except Exception"
git diff | grep -niE "api_key|secret|token|password|BEGIN .*PRIVATE"
uv run ruff check . && uv run pip-audit
```

Three commands, thirty seconds, and it catches most of this page. The two it cannot catch — missing authorisation and prompt injection — are the two worth spending your actual attention on.

:::promo manning
:::

## Common questions

### Is AI-generated code less secure than human code?

Mixed, and the framing is not very useful. On the basics it is often better than the median human code, because it defaults to parameterised queries and proper password hashing. On authorisation logic and on anything requiring knowledge of *your* trust boundaries, it is worse — and it produces code at a volume that overwhelms the review process that used to catch these things.

### What is the single highest-value check?

For a web application, the authorisation test: for every endpoint that returns a resource, one test asserting that a different user gets a 404. No static analysis finds this class of bug, it is the most common real vulnerability in generated web code, and the test is one line.

### Does prompt injection apply if my app only summarises text?

Yes, but the impact is small — the worst case is a bad summary. The risk scales entirely with what the model can *do*. Summarisation with no tools is close to harmless; summarisation with an email tool attached is a data exfiltration channel.

### Do I need a paid security scanner?

For a small team, `ruff`'s bandit rules plus `pip-audit` plus secret scanning in pre-commit covers most of what a paid tool would flag, at no cost. The gap that paid tools fill is cross-file dataflow analysis, which starts to matter on large codebases with many contributors.
