DEV Community

CopperSunDev
CopperSunDev

Posted on Originally published at coppersun.dev

SQL Injection in AI-Generated Code, by the Numbers

One in five. That's how many AI-generated code snippets that touch a database still ship a SQL injection hole, according to Veracode's 2025 testing of more than 100 language models. SQL was the category the models handled best. They still failed it a fifth of the time.

SQL injection is not an exotic bug. It's CWE-89, ranked third on the 2024 CWE Top 25, and the flagship weakness in Injection, the category that sits third on the OWASP Top 10. It has a fix that every major web framework ships by default. Your AI assistant writes the vulnerable form anyway, because a string-built query returns the right rows in testing and only breaks when a stranger sends a quote character.

The Numbers on AI-Generated SQL Injection

BrassCoders treats SQL injection as a measured risk in AI-generated code, not a hypothetical. Veracode's 2025 testing found AI models pass SQL-handling security checks 80% of the time, which leaves one in five database snippets vulnerable, and a 2022 IEEE study measured a 37.35% vulnerability rate across GitHub Copilot's SQL-related completions.

Veracode's 2025 GenAI Code Security Report ran more than 100 models across 80 coding tasks in four languages and four vulnerability classes. Across the whole set, 45% of the generated code failed security tests and introduced an OWASP Top 10 weakness. SQL injection was the class the models handled best. Veracode still reported an 80% security pass rate on that task, and called the remaining 20% a significant risk for database-driven applications.

The academic record runs further back. In Asleep at the Keyboard?, a 2022 IEEE Symposium on Security and Privacy paper, researchers generated 1,689 Copilot programs across 89 scenarios and found roughly 40% of them vulnerable. When they narrowed to SQL injection and varied the prompt 17 ways, 152 of 407 valid programs carried the flaw. That's 37.35%. They chose CWE-89 for the experiment because its verdict is unambiguous: a query is either open to injection or it isn't, with no grey zone to argue about.

Injection didn't earn its ranking by accident. OWASP's A03:2021 category reports that 94% of tested applications were probed for some form of injection, and MITRE's CWE-89 entry places it in the Top 25 most dangerous weaknesses. The full evidence set, with every source, sits in BrassCoders's SQL injection research index.

Why AI Assistants Reach for String-Built SQL

BrassCoders treats string-built SQL as a structural symptom of how a model generates code, not a rare slip. Asked for a function that looks up a user by email, the model aims for a query that returns the right row on the developer's test input, and dropping the email straight into the query string is the shortest path to that goal.

Consider the two ways to write the same lookup. The vulnerable form is one line:

cursor.execute(f"SELECT * FROM users WHERE email = '{email}'")
Enter fullscreen mode Exit fullscreen mode

The safe form is barely longer, and it hands the value to the driver separately from the SQL text:

cursor.execute("SELECT * FROM users WHERE email = ?", (email,))
Enter fullscreen mode Exit fullscreen mode

Both return the same row for alice@example.com. Only the first one also returns every row in the table when someone sends ' OR '1'='1. Nothing in the prompt rewarded the second version, and nothing in the developer's quick test exercised the input that separates them. This is the same gap that produces every other class of AI-generated bug: the model satisfies the stated goal, and the adversarial input was never part of the goal.

What Static Analysis Catches

BrassCoders bundles Bandit, and Bandit ships a dedicated check, B608, for exactly this shape. As one of the 12 scanners in a BrassCoders scan, it flags the pattern before the query ever runs against real input.

Bandit's B608 documentation describes the check plainly: it looks for strings that resemble SQL statements involved in some form of string-building operation. The f-string lookup above matches that description exactly. So does a query assembled with + concatenation or % formatting, or one built up across several lines before it reaches cursor.execute. A static rule catches the structural marker, the SQL keywords sitting next to a string operation, without executing anything.

That structural detection is also the boundary of what a scanner can know on its own.

What Static Analysis Can't Decide

BrassCoders reports the raw B608 pattern match and stops there. Whether the interpolated value is actually attacker-controlled, reachable from an untrusted request, or already escaped upstream is a source-context judgment BrassCoders leaves to the AI assistant reading its YAML output rather than inferring itself.

The distinction matters because B608 fires on shape, not on reachability. A query built from a hardcoded table name the developer controls is a false positive. The identical pattern built from request.args.get("email") is a live vulnerability. A deterministic scanner that tried to tell those apart would have to trace the value back through the call graph and decide whether the source is trusted, and if it guessed wrong, it would either bury a real bug or cry wolf on a safe one. BrassCoders emits the finding with its file, line, and type; Claude Code or Cursor reads the surrounding code and decides whether this specific match is exploitable. BrassCoders is the pattern reporter and the AI assistant is the triage layer, the same division of labor a BrassCoders scan applies to every finding class.

The Fix the Framework Already Ships

BrassCoders points every SQL injection finding back to the fix the language and framework vendors already document: parameterized queries. The safe form hands user input to the database driver as data, so it can never be read as SQL.

Python's own sqlite3 documentation warns against assembling queries with string operations and tells developers to always use placeholders to bind values, precisely so an attacker can't close a quote and inject their own clause.

Frameworks make the safe path the default. Django's security documentation explains that its querysets are protected from SQL injection because the query's SQL is defined separately from its parameters, and the database driver escapes anything user-provided. The risk returns the moment generated code steps outside the ORM to write raw SQL, which is exactly what an assistant does when a prompt asks for a query the ORM makes awkward. That's the line worth a second look on any AI-written data-access change: not whether a query exists, but whether it was built by hand out of strings.

Install BrassCoders and get Bandit's B608 check running as one of 12 scanners on every commit: pip install brasscoders.

Top comments (0)