AI agents wrote the code. AI agents did the audit. What did they find?
· · Not by Prompt Alone · episode 5.5 · Czytaj po polsku
100% of the code in Zakupomat was written by AI agents. A Python backend, a Kotlin Multiplatform mobile app, a web version, an MCP server for Claude and ChatGPT. I decided what got built, read the changes and kept an eye on the tests. But I won't pretend I read every single line with security in mind.
So who should review code written by agents? Agents, of course! 😉 Just definitely not with one prompt like "find the holes in my code". A prompt like that gives you 50 alerts, 45 of which are "missing header X" and "consider rate limiting", while the two real problems get lost somewhere in the middle.
The occasion came on its own. PHP on the server needed patching for a completely different site, and moving everything to a new server got underway (that one gets its own big article), so since we were cleaning up anyway - let's check our own code too.
A skill, not a prompt
I used security-audit from Cloudflare - an open source skill for agents (in my case Claude Code). Before the agent installed it, it read its code. A skill is a set of instructions the agent will execute with your permissions on your computer, so I treat it like any other dependency from the internet.
The skill runs the audit in 6 phases:
- Reconnaissance - a map of the system: what's public, where the data lives, where the trust boundaries are.
- Hunting by a "coverage book" - the system is split into units (33 in my case), and each one is combed through by separate agents.
- Independent validation of every vulnerability candidate by a fresh agent that never saw how the lead came about.
- Structured findings - JSON plus a validator, not free text.
- A second, independent verification of every record.
- The report.
The most important rule of the whole process - every lead has to show which trust boundary it crosses. Who can do what they shouldn't be able to. If it can't show that, it's "hardening", a good practice worth considering, not a vulnerability. That one rule makes more difference than everything else combined.
The numbers
- About 60 agent calls: 4 for reconnaissance, 35 "hunters" in 4 waves, 5 coverage critics, 9 candidate verifiers and 7 record verifiers.
- 33 coverage units. The coverage critics added 13 units on top of the initial plan and sent two "closed" ones back for another look (the MCP server and the mobile app). The last critic said "stop" - no gaps.
- Result: 0 confirmed vulnerabilities, 6 leads to validate, 3 leads rejected by the verifiers.
The rejected leads are more interesting than they seem. "No request size limit" was dropped as general hardening with no demonstrated harm. "A deleted account survives in backups" was dropped because backup retention is a deliberate decision recorded in the project's brain, and the privacy policy does mention backups. The verifier agent found that decision and took it into account. That's project memory at work!
There was an argument, too. On one lead the second verifier wanted to reject it, while two independent ones kept it as "to validate". Following the skill's rule, a third, fresh agent settled it. A bit like a good architecture board, except nobody gets offended 😂
The core came out clean: data isolation between families, keys, OAuth for AI assistants, escaping, no secrets in the repo or in the git history. The agents looked for a boundary being crossed and didn't find one.
To be fair: an audit without running the code
To run code, the skill needs a system sandbox. This Mac didn't have one, so the agent deliberately ran nothing - the whole audit was reading source code. Anything that couldn't be established from the code alone went into "to validate" instead of pretending to be certain. That's why "0 confirmed" doesn't mean "0 holes". It means "0 holes you can prove from the code". And one lead from the "to validate" pile was confirmed a few hours later...
Bug 1: the comment that promised security
Attempt limits (login, sign-up, OAuth client registration, OAuth consent) were counted per client IP address. The address came from a function that trusted the first entry in the X-Forwarded-For header. Above it sat a comment along the lines of "the reverse proxy sets this header, so the first entry is safe".
Except Apache in front of the backend doesn't set that header. It appends the real address to the end of whatever the client sent. So the first entry was chosen by the client. Sending a different "address" with each request was enough to get a fresh budget of attempts every time. The limits only worked on people who politely changed nothing.
The comment sounded reasonable and passed reviews for months - human and agent ones alike. Because it described the intention, not the behaviour. Nobody checked whether the proxy really did what the comment promised.
The fix: we trust the header only when it comes from the local proxy, and take the last entry - the one our server appended. Plus a test that sends a forged header and checks that it doesn't buy a new budget. On the old code, 3 of the 4 new tests fail, so they really do catch the problem.
This became lesson L-014 in Zakupomat's brain. The key sentence:
An infrastructure assumption that security depends on has to be enforced in configuration or in code - a comment doesn't enforce it.
And just as important - what this lesson does not teach. Not "don't trust comments". It teaches: when a comment says something is safe thanks to something outside this file, that something has to be checked and pinned down with a test or with configuration in the repo.
Bug 2: the pinned version that froze a vulnerability
The backend runs on FastAPI, and forms are parsed by the python-multipart library. The agent pinned it at version 0.0.6 right at the start of the project, for stable deployments. And nobody touched it since.
That version parses the Content-Type header with an exponentially backtracking regex - a classic ReDoS, known as CVE-2024-24762 and fixed back in version 0.0.7. The audit only had it as a hypothesis, since it wasn't running anything.
While patching, the agent measured it locally. 25 characters in the header - 7 ms. 50 characters... over a minute! BOOM! Through a public endpoint that needs no login, 32 characters took 0.18 s on the old version and 0.00 s after the upgrade. And because the backend is asynchronous, the regex grinds away in the event loop - one crafted request blocks the whole API, not just its own thread.
The fix: bumping fastapi to 0.115.14, starlette to 0.46.2 and python-multipart to 0.0.20. Why not the latest? Because the old server had Python 3.9, and these are the highest versions that don't require bumping pydantic and work on both 3.9 and 3.12 on the new server. The full upgrade comes after the move.
The lesson: a pinned version is a decision with an expiry date. A pin protects you from surprises on deployment, but without a regular dependency review (pip-audit, Dependabot) it freezes known vulnerabilities. That review is now in the queue.
Bonus: the redirect nobody checked
A third fix from the same PR, for those of you building OAuth for AI assistants. The server fetches the client metadata document (CIMD) from an address the client provides, and checks that address against a list of allowed hosts. Except the fetch followed redirects, and the list only checked the first address. Now a redirect simply ends in a fetch error.
What's left
Three leads and a batch of hardening are waiting in the queue. I'm not describing them until they're fixed - that should be obvious 😉 All the tickets went into the backlog with the usual inbox label and will go through normal refinement with acceptance criteria. The agent didn't skip the process just because "it's security".
What it cost
The audit took less than an hour... and ate my entire 5-hour usage limit 😮 The agent finished the tickets on what was left, and the patching only happened after the limit reset. Good thing there's the Mac mini, where this kind of work now runs in the background all day. From the decision "we're patching" to production took one day: 173 tests green, CI green on two databases.
Who did what
The agents: read and installed the skill, ran the whole process, wrote 7 tickets, the patches, the tests, the lesson in the brain and the PR.
Me: I commissioned the audit and set its scale, assessed the tickets, decided what to patch and in what order. I also corrected the agent's plan - it wanted to check the proxy's behaviour on the old server, and I decided that only made sense on the new one, since the move was underway. And I gave the go-ahead to merge and deploy.
What this means for your team
Code written by AI can be audited by AI - but the value comes from a process with independent verification, not from "detection". Look in your code for comments that promise security thanks to something outside the file, and pin them down with a test. And treat pinned versions as decisions with an expiry date, not as "done".
At ANSLAN we help teams adopt AI agents so that things move faster, but safely too - with a process, tests and a human who decides.
Zakupomat is free and ad-free - a shared shopping list for the whole family.