Mistakes that ship vulnerabilities
A catalogue of the mistakes AI-assisted engineering ships most often: broken authorisation, insecure defaults, weak crypto, injection, ReDoS, stale APIs, licence contamination, over-broad IAM, swallowed errors and tests that assert the bug. How each looks, why models produce it and how to catch it.
01Authorisation that only looks right
- Recognise the recurring vulnerable patterns in generated code
- Explain why models produce them, so you know where to look
- Pair each with a review question and an automated control
- Read vulnerable and fixed versions side by side
Models learn from public code, tutorials and Stack Overflow answers, which optimise for "it works" and are frequently insecure. They also optimise for the request in front of them: ask for an endpoint that returns an alert and you get one that returns any alert. Two patterns dominate.
Trusting generated authorisation checks
The check exists and reads well, but checks the wrong thing: a role from the request body, a isAdmin claim from an unverified token, a middleware that is registered after the route it should protect.
Missing object-level authorisation (IDOR)
// Generated: authenticated, not authorised. Any user can read any tenant's alert.
app.get("/api/alerts/:id", requireLogin, async (req, res) => {
const alert = await db.alert.findUnique({ where: { id: req.params.id } });
res.json(alert);
});
// Fixed: the query is scoped to the caller; missing and forbidden look the same.
app.get("/api/alerts/:id", requireLogin, async (req, res) => {
const alert = await db.alert.findFirst({
where: { id: req.params.id, tenantId: req.session.tenantId },
});
if (!alert) return res.status(404).end();
res.json(alert);
});Catch it: the review question is "whose object is this, and where is that checked?" for every handler that takes an ID. Automate with an authorisation test matrix (user A requests user B's object, expect 404) in CI, and a lint rule or repository wrapper that refuses unscoped queries on tenant tables.
02Insecure defaults and weak crypto
When a model hits an error it often suggests the change that makes the error go away. Those changes are usually security controls being switched off.
| Pattern | How it looks | Why AI suggests it | Catch it |
|---|---|---|---|
| CORS wildcard | origin: "*" with credentials | Fixes the browser error instantly | Config lint; allow-list per environment |
| Debug on | DEBUG=True, stack traces in responses | Tutorials ship with it on | Env-specific config checks in CI |
| TLS verify off | verify=False, rejectUnauthorized: false | Fixes self-signed cert errors | SAST rule; fix the trust store instead |
| ECB or static IV | aes-128-ecb, a hardcoded IV | Shortest working example | SAST crypto rules; use AEAD helpers |
| MD5 / SHA-1 | Hashing passwords or signatures | Common in older training data | SAST; banned-API list |
Math.random tokens | Reset or session tokens from Math.random() | It is the obvious random function | Lint rule; require crypto.randomBytes |
// Generated
const resetToken = Math.random().toString(36).slice(2);
const cipher = createCipheriv("aes-256-cbc", key, Buffer.alloc(16, 0)); // static IV, no MAC
// Fixed
const resetToken = randomBytes(32).toString("base64url"); // CSPRNG, 256 bits
const nonce = randomBytes(12);
const cipher = createCipheriv("aes-256-gcm", key, nonce); // AEAD, unique nonceA certificate error, a CORS rejection or a failing permission check is often the security control doing its job. Fix the cause (trust store, origin allow-list, missing grant) rather than accepting the suggestion that disables the check.
03Injection, ReDoS, stale APIs and licences
SQL built with strings. Models concatenate or template user input into queries because so much example code does. Parameterise always, and let a query builder or ORM do it.
// Generated
const rows = await db.query("SELECT * FROM alerts WHERE host = '" + host + "'");
// Fixed
const rows = await db.query("SELECT * FROM alerts WHERE host = $1", [host]);Regex DoS. Nested quantifiers like ^(\w+\s?)*$ backtrack exponentially on a crafted string, and a single request can pin a CPU. Log parsers and input validators generated by a model are a frequent source. Catch it with ReDoS linters, length limits before matching, and linear-time engines (RE2) for untrusted input.
Outdated APIs. Training data lags. Expect deprecated functions (crypto.createCipher), old library versions with known CVEs, and auth flows that are no longer recommended (OAuth implicit flow). Dependency scanning and a pinned, current lockfile catch most of it.
Licence contamination. A model can reproduce substantial chunks of copyleft code. Shipping it inside a commercial product can create obligations you did not intend. Use tools with code-reference filtering, run licence scanning, and treat large verbatim-looking blocks with suspicion.
04Over-broad IAM, swallowed errors and tests that assert the bug
Over-broad IAM generated for convenience. Ask for a policy that lets a Lambda read a bucket and you may get s3:* on *. It works, so it ships.
// Generated
{ "Effect": "Allow", "Action": "s3:*", "Resource": "*" }
// Fixed: one action, one bucket prefix
{ "Effect": "Allow", "Action": "s3:GetObject",
"Resource": "arn:aws:s3:::cyart-evidence/tenant-*/reports/*" }Swallowed errors. try { ... } catch { } or catch (e) { return true } hides failures and, in a security check, fails open. Lint for empty catches and review every catch in an auth path.
Tests that assert the bug. Generate tests from the code and they faithfully lock in its behaviour. If the handler returns another tenant's alert, the generated test asserts status 200. Tests must come from the spec, and must include the negative and cross-tenant cases.
| Mistake | Review question | Automated control |
|---|---|---|
| Trusted authz check | Where does each authz input come from? | Authz test matrix, DAST |
| IDOR | Whose object is this and where is that checked? | Cross-tenant tests, scoped repository |
| Insecure defaults | What did this change switch off? | Config lint, IaC scanning |
| Weak crypto / randomness | Is this a vetted high-level API? | SAST crypto rules, banned APIs |
| String-built SQL | Is any input concatenated into a query? | SAST injection rules |
| ReDoS | Can untrusted input reach this regex? | ReDoS lint, RE2, input length caps |
| Outdated API | Is this the current recommended API? | Dependency and deprecation scanning |
| Licence contamination | Does this look copied verbatim? | Licence scanning, reference filters |
| Over-broad IAM | What is the smallest action and resource? | IaC policy checks, access analyser |
| Swallowed errors | What happens on failure? | Lint for empty or permissive catch |
| Tests assert the bug | Was this test written from the spec? | Mutation testing, negative-case coverage |
"Generated code fails in predictable ways: it authenticates but does not authorise, it switches off the control that raised an error, it reaches for the shortest crypto example, and its tests assert whatever the code does. I review with a fixed set of questions for those patterns and back each one with a CI control, so catching them does not depend on a tired reviewer."
A generated test for `GET /api/alerts/:id` logs in as tenant A, requests an alert owned by tenant B and asserts status 200 with the alert body. What does this tell you?
Q1Why do models suggest `verify=False` so often?+
Because it is the most common answer to certificate errors in public code, and it makes the error disappear. The right fix is to add the internal CA to the trust store.
Q2How do you find generated code with a known-bad pattern after the fact?+
Provenance tags in PRs narrow the search, and SAST rules written for the pattern find it across the codebase regardless of who wrote it.
Q3What is mutation testing and why does it help here?+
It changes the code in small ways (flip a comparison, delete a check) and expects tests to fail. Tests that still pass after the authz check is deleted are not testing authorisation.