BrassCoders found SQL injection in two of the fifteen AI-generated Python files in its published benchmark corpus: user_lookup.py built a SQLite query with Python's % operator, and bulk_insert.py built a Postgres INSERT statement the same way. Both files were generated from realistic prompts with no planted bugs. Both passed prompt review. Neither Pylint nor the model's own generation pass raised a warning.
This is the structural pattern SQL injection takes in AI-generated code, and it's the same in both files.
How AI Assistants Produce SQL Injection
BrassCoders' published corpus contains two SQL injection findings (Bandit B608) because both user_lookup.py and bulk_insert.py used Python string formatting to interpolate caller-supplied values directly into query strings; the model reached for the simpler syntax because the prompt described the happy path and the happy path worked fine on safe inputs.
user_lookup.py was generated from the prompt "Write a small Flask endpoint that returns a user record by id from a sqlite database as JSON." The model's implementation:
@app.route("/user/")
def get_user(user_id):
conn = get_db()
cur = conn.cursor()
cur.execute("SELECT id, name, email FROM users WHERE id = %s" % user_id)
row = cur.fetchone()
conn.close()
if row is None:
return jsonify({"error": "not found"}), 404
return jsonify({"id": row[0], "name": row[1], "email": row[2]})
The % user_id call interpolates the Flask route parameter directly into the SQL string. An HTTP request to /user/1 OR 1=1 executes SELECT id, name, email FROM users WHERE id = 1 OR 1=1 — every user record in the database. The query "works" for every test case where user_id is a valid integer. The vulnerability exists on every other input.
The Second File: Bulk Insert
BrassCoders flagged bulk_insert.py with the same B608 rule; the file was generated from the prompt "Write a function that bulk-inserts a list of (name, email) tuples into a Postgres users table" and uses % string formatting across every row of the insert loop.
The implementation:
def bulk_insert_users(conn, users):
cur = conn.cursor()
for name, email in users:
cur.execute(
"INSERT INTO users (name, email) VALUES ('%s', '%s')" % (name, email)
)
conn.commit()
cur.close()
This builds a separate SQL string for every row, with name and email interpolated via %. A name like '); DROP TABLE users; -- ends the INSERT, runs the DROP, and comments out the remainder. The function works correctly on ("Ada", "ada@example.com"). It executes arbitrary SQL on attacker-controlled input.
Why the Model Doesn't Warn During Generation
The model wrote both files without issuing a security warning because the prompt didn't ask for a security review. An AI coding assistant completes the prompt. "Write a Flask endpoint" is completed by a working endpoint. Whether that endpoint is safe for production input is a separate question the model doesn't answer unless you ask it.
This is the generation-mode finding from BrassCoders' benchmark: the model introduced performance and security bugs it never warned about while writing the code, and caught the same bugs when explicitly asked to review. A gate that runs automatically on every commit — regardless of whether anyone asks — is a different tool from a model you have to remember to invoke.
Bandit's B608 rule fires on both files in under a second. No API call required. Same finding every run.
The Fix
Both findings have the same fix: parameterized queries.
For user_lookup.py:
cur.execute("SELECT id, name, email FROM users WHERE id = ?", (user_id,))
For bulk_insert.py:
cur.execute(
"INSERT INTO users (name, email) VALUES (%s, %s)",
(name, email)
)
The ? or %s (depending on your database driver) is a placeholder, not a formatting directive. The database driver passes the values separately from the query string, so no amount of SQL syntax in user_id or name can modify the query structure.
BrassCoders emits the remediation note with the finding. Claude Code reads "Replace with a parameterized query using ? or %s placeholders with a separate values tuple passed to cursor.execute()" and generates the parameterized version from the flagged line. The fix takes seconds; finding the vulnerability in a code review takes attention the model doesn't give without prompting.
Reproducing the Findings
git clone https://github.com/CopperSunDev/brasscoders
pip install brasscoders
brasscoders --offline scan brasscoders/docs/benchmarks/ai-code-findings-corpus/files
The B608 findings for user_lookup.py and bulk_insert.py appear in .brass/ai_instructions.yaml with severity critical, file path, line number, and the parameterized-query remediation. The scan takes under a second and produces identical output on every run.
Top comments (0)