Security Review of AI-Generated Code
The vulnerability classes AI coding assistants produce — and the structural patterns BrassCoders catches before they reach production.
BrassCoders found real security issues in 9 of 15 AI-generated Python files — no planted bugs, no adversarial prompts, no instructions designed to produce vulnerabilities. SQL injection appeared in two files. Command injection in two more. Hardcoded credentials in two. The model wrote them without a single warning.
This page covers which vulnerability classes appear in AI-generated code, why the model produces them, and what BrassCoders catches structurally before the code ships.
The Published Benchmark
BrassCoders's June 2026 benchmark caught 11 of 12 planted AI-generated Python security bugs — matching a frontier model on every category where deterministic rules apply. The separate N=15 AI-code-findings corpus, Apache 2.0 licensed and publicly reproducible, found real issues in 9 of 15 neutrally-prompted files, with zero proactive security warnings from the model during generation.
The benchmark ran four tools against the same planted-bug corpus: BrassCoders 2.0.8, Bandit standalone, Pylint, and Claude sonnet-4-6 reached via the Anthropic API with a realistic pre-merge review prompt. BrassCoders caught 11 of 12; the frontier model caught 12 of 12. Bandit caught 6. Pylint caught 1. The one bug BrassCoders missed — a division with no empty-list guard — left no structural marker for a rule to match. The full benchmark post documents the category breakdown and shows how to reproduce every result.
SQL Injection: The f-String Pattern
BrassCoders found SQL injection in 2 of 15 AI-generated Python files in the published corpus — user_lookup.py and bulk_insert.py, both via Python string formatting interpolated directly into query strings. Bandit's B608 rule flags this pattern without reading application logic; it fires whenever a formatting operation appears inside a SQL query string.
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 used % user_id to interpolate a route parameter directly into a SELECT statement. An HTTP request to /user/1 OR 1=1 executes against every user record in the database. The query works on valid integer inputs. The vulnerability exists on every other one.
The model issued no warning during generation because the prompt described the happy path. "Write a Flask endpoint" is satisfied by a working endpoint. Whether that endpoint is safe for production input is a separate question the model doesn't answer unless you ask. The parameterized-query fix and the full corpus examples are in the SQL injection post.
Command Injection: shell=True and os.system
BrassCoders found command injection in 2 corpus files — run_command.py used subprocess.run with shell=True on a caller-supplied string, and thumbnail.py called os.system with an f-string path. Bandit's B602 covers subprocess with shell=True; B605 covers os.system. Both patterns hand shell interpretation to the OS.
When shell=True is set, the OS shell parses the command string. A caller who passes ls -la; rm -rf /tmp/important gets a directory listing and a deletion. The semicolon is a shell separator. Any character the shell treats as syntax becomes an injection point.
The fix for both files is the list-argument form of subprocess.run: the OS receives the program name and its arguments separately, and no shell parsing occurs. The full walkthrough — both vulnerable implementations and their replacements — is in the command injection post.
Hardcoded Credentials: What the Model Fills In
BrassCoders found hardcoded credentials in 2 of 15 AI-generated files — a literal HMAC signing key in token_check.py and a plain-text SMTP password in email_sender.py. BrassCoders detects 20+ secret formats using Yelp's detect-secrets as the upstream library plus 7 custom patterns added in the scanner layer.
Both credentials appeared because the prompts asked for runnable examples. "Write a function that signs and verifies a session token using HMAC — include a usable example so I can run it" requires a key to execute. The model filled one in. A developer who copies the function without replacing SECRET_KEY ships a signing key into version control, into every clone, and into every build artifact.
The fix is the same in both cases: read the credential from an environment variable at runtime via os.environ. BrassCoders emits the remediation note with the finding; Claude Code generates the os.environ version from the flagged line. The full corpus examples are in the hardcoded credentials post.
Unsafe Deserialization: yaml.load, pickle, eval
BrassCoders found an unsafe yaml.load call in config_loader.py — the model used yaml.load(f, Loader=yaml.Loader) where yaml.safe_load is the correct choice for configuration files that may receive external input. Bandit's B506 rule covers the yaml family; the same prompt-completion pattern produces pickle.loads and eval in AI-generated code.
yaml.Loader supports Python-specific YAML tags — including !!python/object/apply:subprocess.run — that execute code during deserialization. A YAML file containing an apply tag runs arbitrary Python when parsed. The config file becomes the attack surface. The replacement is one word: yaml.safe_load, which parses only basic types and never executes code.
pickle.loads and eval follow the same logic. All three APIs are simpler than their safe counterparts and satisfy the prompt on safe input. BrassCoders flags them structurally; the AI triage layer determines whether the specific call receives user-controlled input. The full deserialization analysis is in the unsafe deserialization post.
Why a Deterministic Gate, Not an LLM Reviewer
BrassCoders returns the same findings on the same commit across repeated runs; an LLM reviewer samples its output and can flag a bug on one run and miss it on the next. A 2025 benchmark — Gnieciak and Szandala, arXiv 2508.04448 — found LLMs scored higher recall than static analyzers but mislocated findings at the line-and-column level, while deterministic tools point at the exact line every time.
CI checks earn trust by being repeatable. A check that returns different verdicts on the same commit teaches developers to ignore it. BrassCoders runs the same AST-level and pattern-matching rules on every run. The output is byte-identical on the same commit. That consistency is what makes it a gate rather than a suggestion.
The benchmark authors recommend running deterministic tools and language models together rather than picking one. BrassCoders handles the deterministic half: it emits a YAML findings file that Claude Code or Cursor reads, and the AI decides whether each finding's context makes it actionable. BrassCoders is the dumb-but-honest pattern reporter; the model is the smart triage layer. The full argument for running both is in the deterministic gate post.
How to Run the Security Review
BrassCoders runs offline by default — zero bytes leave the machine. Install from PyPI and scan your project:
pip install brasscoders
brasscoders scan /path/to/your/project
The scan writes findings to .brass/ai_instructions.yaml in your project directory. Each finding includes the scanner rule, the file location, and a remediation note — structured for AI triage, not for human reading. Open the file in Claude Code; it reads the findings and generates fixes from the flagged lines.
For projects under data-handling requirements, add --offline to guarantee nothing leaves the machine even if a BrassCoders Paid license key is activated:
brasscoders --offline scan /path/to/your/project All Security Review Posts
- We Benchmarked BrassCoders Against a Frontier Model — 11 of 12 planted bugs caught; category breakdown; how to reproduce
- SQL Injection in AI-Generated Python: How It Ships — the f-string pattern in user_lookup.py and bulk_insert.py; Bandit B608; parameterized query fix
- shell=True in AI Python: Command Injection — subprocess shell=True in run_command.py; os.system in thumbnail.py; the list-argument fix
- Hardcoded Credentials in AI-Generated Python — HMAC key in token_check.py; SMTP password in email_sender.py; 20+ secret formats detected
- The Unsafe-Deserialization Trio: yaml.load, pickle, eval — yaml.load in config_loader.py; Bandit B506; the prompt-completion pattern behind all three
- Why AI Code Needs a Deterministic Gate, Not Just an LLM — repeatability; Gnieciak-Szandala 2025 benchmark; the hybrid pattern
Frequently Asked Questions
Does AI-generated code have more security vulnerabilities than human-written code?
The evidence says yes. Perry et al. (CCS 2023) found that participants using AI assistants wrote significantly more insecure code than those without — higher rates of injection, authorization, and cryptographic vulnerabilities. BrassCoders's published N=15 corpus found real security issues in 9 of 15 neutrally-prompted AI-generated Python files, with zero proactive security warnings from the model during generation.
What is Bandit B608, and does BrassCoders use it?
Bandit B608 is the SQL injection rule in the Bandit SAST tool — it fires when a string-formatting operation (f-string, % format, or .format()) appears inside a query string. BrassCoders runs Bandit as one of its 12 scanners and surfaces B608 findings at HIGH severity in the .brass/ai_instructions.yaml output.
Why do AI assistants write shell=True into subprocess calls?
subprocess.run(cmd, shell=True) is syntactically simpler than constructing a list of arguments, and it satisfies 'run this command' prompts without additional complexity. The security implication — that shell=True with user-controlled input creates a command injection vector — requires reasoning about the caller, which the model does not do unless the prompt asks for it. BrassCoders flags this pattern (Bandit B602/B603) on every scan.
Can I reproduce BrassCoders's benchmark findings?
Yes. Clone the BrassCoders OSS repo (github.com/CopperSunDev/brasscoders), install brasscoders, and run brasscoders scan brasscoders/docs/benchmarks/ai-code-findings-corpus/files. The SQL injection, shell injection, hardcoded credential, and yaml.load findings appear in the output. The corpus is Apache 2.0 licensed and pinned to a reproducible commit.
Does BrassCoders catch all security vulnerabilities in AI-generated code?
No. BrassCoders catches what its 12 scanners cover structurally: SQL injection via string formatting, shell injection via shell=True, hardcoded secrets, and unsafe deserialization. A context-dependent vulnerability — broken access control, a business-logic bug, or a runtime exception on empty input — requires reasoning about intent that rules cannot provide. BrassCoders narrows scope to confirmed structural patterns; the AI assistant handles the rest.
What does BrassCoders send off my machine?
The OSS core sends nothing. brasscoders scan runs offline — zero bytes leave the machine. The BrassCoders Paid plan sends scanner findings (already redacted at source) and a short project signature derived from your README, manifest, entrypoint, and top-level filenames; the total is under 7500 characters. Raw source code never leaves the machine.