Don't Let the AI Find Your Bugs. Let It Judge Them. A developer building an LLM-based vulnerability scanner explains why he deliberately designed it to flag safe code, arguing that deterministic taint tracking with false positives is more trustworthy than AI agents that skim codebases and hallucinate vulnerabilities. He rejects the common approach of pointing an AI agent at a codebase because LLMs produce inconsistent results, are poor at exhaustive search, and invent findings. My vulnerability scanner flagged this Java method as SQL injection: String id = request.getParameter "id" ; // user input if id.matches " 0-9 +" { // digits only, or throw throw new IllegalArgumentException "id must be numeric" ; } String sql = "DELETE FROM products WHERE id = " + id; stmt.executeUpdate sql ; // flagged as SQL injection Look at line 2. The input must be pure digits or the method throws. You cannot inject SQL through 0-9 + . There is no attack here. The scanner flagged safe code. Here's the part that sounds wrong: I designed it to do that. The false alarm is not a bug in my scanner. It's the plan. Let me explain, because this decision is the entire foundation of the scanner — and I think it's the decision most people building "AI security tools" right now are getting backwards. I'm building an LLM-based vulnerability scanner — in public, like everything I ship. The pitch is simple: AI writes a lot of code now, fast, often for people who aren't security experts. That code ships with holes. Someone has to find them. So the obvious idea — the one I started with — is the one you've seen in a hundred launch posts this year: "Point an AI agent at your codebase. It reads everything and finds your vulnerabilities." I killed that idea before writing a line of code. Not because it doesn't sound amazing. Because I couldn't defend it with numbers — and the reasons it can't be measured are the same reasons you shouldn't trust it in production. New to this?Two words carry the whole article. Asourceis where untrusted input enters your program request.getParameter . Asinkis where it becomes dangerous executeUpdate — running a database command . A vulnerability is untrusted data reaching a sink without being cleaned on the way. That's it; everything below builds on those two words. Ask an LLM to audit a codebase and you hit three walls. Wall 1: You get a different answer every run. LLMs are probabilistic. Same repo, same prompt, run it twice — the bug lists don't match. Which run goes in the security report? Which one do you benchmark? When a tool's output changes between runs, you can't measure it, you can't compare it to Semgrep or CodeQL, and you can't do science with it. You can only do demos. Wall 2: Searching is the thing LLMs are bad at. Careful, exhaustive enumeration over a large space — visit every file, check every call, miss nothing — is exactly what LLMs don't do. They skim. They fixate. They get bored in the middle of a long file position bias is real . A scanner that "usually checks most of the code" is not a scanner. Wall 3: They invent. In my other project, an AI chatbot invented products that didn't exist in the store. An AI bug-hunter does the same thing with vulnerabilities — confident reports about code that isn't there. Now your security tool hallucinates CVEs. Great. If you've read my earlier article about the chatbot that lied to customers https://dev.to/alimafana/your-ai-is-lying-to-your-customers-and-prompt-engineering-wont-fix-it-5408 , you know where this goes: you can't prompt your way out of a structural problem. This is the same disease in a different body. Fine — so use deterministic tools. Pattern rules, taint tracking. Semgrep and CodeQL have done it for years. But rules have their own wall, and my flagged-but-safe method above IS that wall. A taint tracker follows data: user input enters at getParameter the source , travels through variables, and reaches executeUpdate the sink — the dangerous operation . Path exists → alarm. That's the whole trick, and it's a good trick. It's deterministic, fast, and it never gets bored. But look at the method again. The data DOES flow from source to sink. The taint tracker is not wrong about the flow. It's wrong about the meaning — the matches " 0-9 +" guard makes the flow harmless, and understanding that requires understanding what the code means , not just where the data goes . Rules can't read meaning. So real-world scanners over-flag, developers drown in false alarms, and — every security team knows this story — they stop reading the reports. The industry's own benchmark makes the point better than I can: the OWASP Benchmark 2,740 labeled Java test cases, the standard test set for Java scanners contains 701 cases in my scanner's categories that are deliberately designed to trick tools into false alarms. Nearly half the test set exists just So: Everyone's strength is the other one's weakness. You can see the answer coming. Police detectives don't sentence anyone. Judges don't collect evidence. The system splits the work on purpose: exhaustive, procedural search by one party; careful judgment of each individual case by another. That's the architecture: Deterministic rules do ALL the searching. The LLM only judges what the rules found. A memory of real-world bugs helps it judge better. Concretely, the pipeline looks like this: source code → Joern builds a Code Property Graph code → queryable data-flow graph → fixed taint queries find candidates same input = same output, always → slicer cuts a ~small snippet per candidate the evidence file → RAG fetches similar known CVEs past cases, from GitHub advisories → LLM judges ONE candidate at a time real bug? severity? fix? The detective is Joern https://joern.io — an open-source static analysis tool that turns code into a graph you can query. My whole detection layer is a small table of rules. Adding a vulnerability class is one row: case class VulnClass name: String, // "sql-injection" cwe: String, // "CWE-89" sinkNames: String, // which call is dangerous sinkMethodFullName: String, // narrowed to the right type sinkArg: Int, // which argument carries the taint 0 = any sinkReceiver: String = "" // fallback when the type can't be resolved val classes = List VulnClass "sql-injection", "CWE-89", "executeQuery|executeUpdate|executeLargeUpdate|execute|addBatch|prepareStatement|prepareCall|nativeSQL", ". java|javax \\.sql\\.. ", 1 , VulnClass "command-injection", "CWE-78", "exec", ". Runtime. ", 0 , VulnClass "command-injection", "CWE-78", "