5 Bug Classes LLMs Introduce That Static Linters Miss
LLMs generate code that passes linters but introduces subtle security flaws. Learn the 5 bug classes Copilot and Cursor introduce that static analysis misses.
Why Linters Give a False Sense of Safety
Most developers treating AI-generated code the same way they treat their own have adopted a workflow that feels reasonable: accept the suggestion, run the linter, ship if green. The problem is that the bug classes LLMs most reliably produce are semantic, not syntactic. A linter checks structure. It cannot reason about intent, trust boundaries, or the difference between a safe default and a dangerous one.
This post documents the five vulnerability categories we see most often in projects scanned through Vouch, all of which sail through ESLint, Ruff, and similar tools without a single warning.
1. Insecure Deserialization via Trusted-Looking Wrappers
LLMs frequently generate code that deserializes user-controlled data without validation, wrapped in a try/except block that makes it look handled:
# What Copilot generated
def load_config(raw: str) -> dict:
try:
return pickle.loads(base64.b64decode(raw))
except Exception:
return {}
The try/except does not make pickle.loads safe. Arbitrary code execution is still possible before the exception stage is even reached. The correct fix is to never deserialize untrusted data with pickle at all; use JSON or a schema-validated format instead.
# Safe replacement
import json, jsonschema
CONFIG_SCHEMA = {"type": "object", "properties": {"theme": {"type": "string"}}}
def load_config(raw: str) -> dict:
data = json.loads(raw)
jsonschema.validate(data, CONFIG_SCHEMA)
return data
2. Path Traversal Hidden Behind f-Strings
LLMs are optimistic about input. When asked to read a file by name, the generated code almost always concatenates user input directly into a path:
# Dangerous pattern
def get_report(report_name: str) -> str:
path = f"/app/reports/{report_name}.pdf"
return open(path).read()
Passing ../../etc/passwd as report_name works exactly as the attacker intends. The safe version resolves the path and verifies it stays inside the allowed directory:
import pathlib
REPORTS_DIR = pathlib.Path("/app/reports").resolve()
def get_report(report_name: str) -> str:
target = (REPORTS_DIR / report_name).resolve()
if not str(target).startswith(str(REPORTS_DIR)):
raise PermissionError("Access denied")
return target.with_suffix(".pdf").read_text()
3. JWT Signature Bypass via Algorithm Confusion
When generating JWT verification code, models frequently produce implementations that accept the none algorithm or fail to pin the expected algorithm:
// What the model wrote
const payload = jwt.verify(token, process.env.JWT_SECRET);
Without { algorithms: ['HS256'] }, some library versions will accept tokens signed with none. Always pin the algorithm:
const payload = jwt.verify(token, process.env.JWT_SECRET, {
algorithms: ['HS256']
});
4. Overly Broad CORS Policies
LLMs defaulting to permissive CORS headers is one of the most consistent patterns we observe. In FastAPI, Express, and Flask generated code, the default is almost always allow_origins=["*"] with credentials enabled, a combination that browsers and specs explicitly prohibit but that only fails at runtime in specific conditions:
# Generated by LLM
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True, # this silently breaks with wildcard origin
)
The correct approach is to enumerate allowed origins explicitly and derive them from environment configuration, not hardcode them.
5. Race Conditions in Async File and State Writes
Async-first frameworks have become the default, and LLMs write async code confidently. What they frequently miss is that concurrent coroutines sharing state need explicit locks:
# Race condition
active_sessions = {}
async def register(user_id: str):
if user_id not in active_sessions:
active_sessions[user_id] = await create_session()
Two concurrent requests can both pass the if check before either has written, creating duplicate sessions or overwriting a valid one. Wrapping the critical section in asyncio.Lock() eliminates the race.
Key Takeaways
- Static linters catch syntax and style issues but cannot detect semantic bugs like deserialization of untrusted data or missing algorithm pinning in JWT verification.
- The five most common LLM-introduced vulnerability classes are insecure deserialization, path traversal, JWT algorithm confusion, overly broad CORS, and async race conditions.
- Reviewing AI-generated code specifically for these patterns, or scanning with a tool that understands trust boundaries, closes the gap that linting alone leaves open.