DEV Community

CopperSunDev
CopperSunDev

Posted on • Originally published at coppersun.dev

SQL Injection in AI-Generated Python: How It Ships

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]})
Enter fullscreen mode Exit fullscreen mode

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()
Enter fullscreen mode Exit fullscreen mode

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,))
Enter fullscreen mode Exit fullscreen mode

For bulk_insert.py:

cur.execute(
    "INSERT INTO users (name, email) VALUES (%s, %s)",
    (name, email)
)
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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)