An AI agent hit a Postgres RLS permission error and "fixed" it by setting the policy to USING (true) — making the entire table publicly readable. No warning. No test failure. Green CI. Researcher Reya Vir documented exactly this pattern via Equixly: agents resolving access errors by removing the access control.
That's the shape of the problem. Not exotic bugs — old bugs at higher velocity, wrapped in confident-looking syntax, merged by experienced engineers who trusted the diff.
This is the checklist I run before any LLM-generated code touches production data, plus the grep one-liners I run first so I know where to look.
Why LLM-generated code fails differently
The vulnerability classes are the same ones we've been shipping since 1999: SQL injection, XSS, broken auth, hardcoded secrets, insecure deserialization. Checkmarx's framing is the right one — it's the same classes of issues, only faster and at greater scale.
What changes is the surface area and the social dynamics:
- The code looks idiomatic. It compiles, the tests pass, the linter is happy.
- Agents will actively remove security controls to resolve errors. The
USING (true)move is the cleanest example — it silently disables row-level security and produces zero feedback signals. - The dangerous merger isn't a non-technical builder shipping a side project. It's a senior engineer approving a 400-line PR at 4 PM because the diff reads cleanly and CI is green.
The other thing worth saying out loud: checklists that just list OWASP categories are useless. "Check for SQL injection" is a reading comprehension exercise. You need detection patterns.
Run these before you read a single line
Triage first. Before any manual review, paste these at the repo root. They take under a minute and tell you where to spend your attention.
# 1. SQL string concatenation / f-string interpolation
rg -nP '(execute|query)\s*\(\s*["\x27].*(\+|%s.*%|f["\x27]).*(SELECT|INSERT|UPDATE|DELETE)' \
--type py --type js --type ts
# 2. innerHTML / dangerouslySetInnerHTML on dynamic input
rg -nP '\.innerHTML\s*=|dangerouslySetInnerHTML' --type js --type ts --type html
# 3. Hardcoded secrets
rg -nP '(api[_-]?key|secret|password|token)\s*[:=]\s*["\x27][A-Za-z0-9_\-]{16,}' \
-g '!*.lock' -g '!*.test.*'
# 4. Routes without auth decorators (Flask/FastAPI heuristic)
rg -nB1 '^(async\s+)?def\s+\w+\(' --type py | \
rg -B1 '@(app|router)\.(get|post|put|delete)' | rg -v 'login_required|Depends\('
# 5. pickle.loads on non-static input
rg -nP 'pickle\.loads?\s*\(' --type pyThen npm audit --omit=dev or pip-audit — not because you'd skip it normally, but because LLMs reach for convenience libraries you wouldn't have picked. A smaller JWT library with a known alg=none confusion bug is exactly the kind of dependency an agent surfaces and a human merges.
The unsafe pattern vs the safe pattern
LLMs produce string-concatenated queries with full confidence. Nothing in the output signals risk. Here's the exact shape, and the fix.
# UNSAFE — what the agent wrote
def get_user_orders(conn, user_id, status):
query = f"SELECT * FROM orders WHERE user_id = {user_id} AND status = '{status}'"
with conn.cursor() as cur:
cur.execute(query)
return cur.fetchall()
# SAFE — parameterized, psycopg2 handles escaping
def get_user_orders(conn, user_id: int, status: str):
with conn.cursor() as cur:
cur.execute(
"SELECT * FROM orders WHERE user_id = %s AND status = %s",
(user_id, status),
)
return cur.fetchall()A common reflex is "I'll validate the input." Whitelist validation reduces the surface area, but it doesn't substitute for parameterization. Injection still arrives through HTTP headers, cookies, and API fields that bypass your validation path. Parameterize at the driver. Validate on top.
While you're in Python: pickle.loads() on anything that isn't a static, controlled byte string is categorically unsafe. LLMs reach for it because it's idiomatic. Replace with JSON or a schema-validated format.
The pre-ship checklist
Paste this into your PR template. The point is to make it a gate, not a suggestion.
## Security review — required before merge
### Auth
- [ ] Every new route has an explicit auth check (decorator, middleware, or Depends)
- [ ] BOLA/IDOR: requests for resource X by user A return 403 when user B requests X
- [ ] Session expiry and token revocation paths exist and are tested
- [ ] No new `USING (true)` or `FOR ALL TO public` RLS policies (grep `USING\s*\(\s*true`)
### Input handling
- [ ] All user input is validated at the boundary (type + shape, not just presence)
- [ ] No `innerHTML` / `dangerouslySetInnerHTML` on dynamic strings
- [ ] No `pickle.loads`, `yaml.load` (use `safe_load`), `eval`, or `exec` on input
### SQL / ORM
- [ ] Every query is parameterized — no f-strings, no `+` concatenation
- [ ] ORM raw() / text() calls use bound parameters
- [ ] Migrations reviewed for missing constraints (NOT NULL, FK, UNIQUE)
### Secrets
- [ ] No keys, tokens, or passwords in source (grep pass clean)
- [ ] `.env` and credentials files in `.gitignore`
- [ ] Secrets loaded from env/vault, not hardcoded fallbacks
### Dependencies
- [ ] `npm audit` / `pip-audit` clean for new/changed dependencies
- [ ] No packages added that aren't on the team's allowlist
- [ ] Lockfile committed
### API surface
- [ ] Error responses don't leak stack traces or DB schema
- [ ] Rate limiting on auth and resource-creation endpoints
- [ ] CORS allowlist is explicit, not `*`BOLA is the one most reviewers miss. The source code looks correct because the handler reads request.user.id somewhere — but the resource lookup uses an ID from the URL without checking ownership. The only way to catch it is an adversarial request: authenticate as user B, request user A's resource by ID, confirm 403.
LLM-generated code ships fast and looks clean — that's the hazard, not the speed itself. Run the grep triage on the next agent-generated PR you touch this week, paste the checklist into your repo's pull request template, and treat every LLM diff as untrusted input until the boxes are green.