{"slug": "ai-security-audit-checklist-15-vulnerabilities-claude-found-in-production-code", "title": "AI Security Audit Checklist: 15 Vulnerabilities Claude Found in Production Code", "summary": "An engineer used Claude to audit production code and identified 15 vulnerabilities from the OWASP Top 10, compressing a typical 2-3 week audit into hours. The most common findings include SQL injection and NoSQL injection, with detailed examples of vulnerable code, attack vectors, and fixes provided. The audit process involves three passes: broad scan, deep analysis, and manual verification.", "body_md": "Most web applications contain at least one vulnerability from the OWASP Top 10. A typical security audit takes 2-3 weeks and costs upward of $10,000. An LLM can compress the initial audit down to a few hours because it scans code for patterns rather than specific CVEs.\n\nBelow are 15 vulnerabilities found while auditing production code with Claude. Each includes the vulnerable code, the fixed version, and a prompt to reproduce the finding. Classification follows OWASP Top 10 (2021). Order reflects frequency of occurrence: most common first.\n\nThe audit consists of three passes. First, a broad scan: the LLM receives the entire project and looks for vulnerability patterns. Second, deep analysis: each identified pattern is verified in context (middleware, ORM, framework). Third, verification: manual review of every finding, because LLMs produce false positives.\n\nPrompt for the broad scan:\n\n```\nPerform a security audit of this code. For each finding, include:\n1. CWE ID and name\n2. OWASP Top 10 category\n3. Severity (Critical/High/Medium/Low)\n4. The vulnerable code snippet\n5. Attack vector -- exactly how an attacker would exploit this\n6. Fixed code\n\nIgnore stylistic comments. Focus on security only.\nStart with injection attacks, then broken access control, then the rest.\n```\n\nThis prompt works because it defines the output structure and prioritizes categories. Without explicit instructions, the LLM mixes critical vulnerabilities with remarks about email validation.\n\nMore on structured AI code review: [AI Code Review Checklist](https://dev.to/blog/claude-concilium-multi-agent-code-review/).\n\nThe most common finding. Shows up even in projects using an ORM, because developers switch to raw queries for complex filters.\n\n**Vulnerable code:**\n\n``` js\n// API endpoint for user search\napp.get('/api/users', async (req, res) => {\n  const { search, sortBy } = req.query;\n  const query = `\n    SELECT id, name, email\n    FROM users\n    WHERE name LIKE '%${search}%'\n    ORDER BY ${sortBy}\n  `;\n  const result = await db.query(query);\n  res.json(result.rows);\n});\n```\n\n**Attack vector:** `GET /api/users?search='; DROP TABLE users; --&sortBy=id`\n\n**Fixed code:**\n\n``` js\napp.get('/api/users', async (req, res) => {\n  const { search, sortBy } = req.query;\n\n  const allowedSortColumns = ['id', 'name', 'email', 'created_at'];\n  const sanitizedSort = allowedSortColumns.includes(sortBy) ? sortBy : 'id';\n\n  const query = `\n    SELECT id, name, email\n    FROM users\n    WHERE name LIKE $1\n    ORDER BY ${sanitizedSort}\n  `;\n  const result = await db.query(query, [`%${search}%`]);\n  res.json(result.rows);\n});\n```\n\nParameterized query for values, whitelist for identifiers (column names). `ORDER BY`\n\ncannot be parameterized in most drivers, so the whitelist is mandatory.\n\n```\n// Vulnerable: req.body passed directly into the query\napp.post('/api/login', async (req, res) => {\n  const user = await db.collection('users').findOne({\n    username: req.body.username,\n    password: req.body.password,\n  });\n  if (user) return res.json({ token: generateToken(user) });\n  res.status(401).json({ error: 'Invalid credentials' });\n});\n```\n\n**Attack vector:** `POST /api/login`\n\nwith body `{\"username\": \"admin\", \"password\": {\"$ne\": \"\"}}`\n\n. The `$ne`\n\n(not equal) operator turns the password check into \"password is not equal to empty string\" -- true for any user.\n\n``` js\n// Fixed: explicit string casting\napp.post('/api/login', async (req, res) => {\n  const username = String(req.body.username);\n  const password = String(req.body.password);\n\n  const user = await db.collection('users').findOne({ username });\n  if (!user || !await bcrypt.compare(password, user.passwordHash)) {\n    return res.status(401).json({ error: 'Invalid credentials' });\n  }\n  res.json({ token: generateToken(user) });\n});\n```\n\nTwo fixes: `String()`\n\nblocks MongoDB operators, and `bcrypt.compare`\n\nreplaces plaintext password comparison.\n\n```\n// Vulnerable: user input in a shell command\napp.post('/api/convert', async (req, res) => {\n  const { filename } = req.body;\n  exec(`convert uploads/${filename} -resize 200x200 thumbnails/${filename}`,\n    (err, stdout) => {\n      if (err) return res.status(500).json({ error: 'Conversion failed' });\n      res.json({ status: 'ok' });\n    });\n});\n```\n\n**Attack vector:** `filename: \"image.png; rm -rf /\"`\n\n``` js\n// Fixed: execFile instead of exec, filename validation\nimport { execFile } from 'child_process';\n\napp.post('/api/convert', async (req, res) => {\n  const { filename } = req.body;\n\n  if (!/^[a-zA-Z0-9_-]+\\.(png|jpg|webp)$/.test(filename)) {\n    return res.status(400).json({ error: 'Invalid filename' });\n  }\n\n  execFile('convert', [\n    `uploads/${filename}`, '-resize', '200x200', `thumbnails/${filename}`\n  ], (err) => {\n    if (err) return res.status(500).json({ error: 'Conversion failed' });\n    res.json({ status: 'ok' });\n  });\n});\n```\n\n`execFile`\n\ndoes not spawn a shell, so `; rm -rf /`\n\nis not interpreted as a separate command. The regex restricts allowable characters.\n\n```\n// Vulnerable: any authenticated user can view any order\napp.get('/api/orders/:id', authMiddleware, async (req, res) => {\n  const order = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);\n  res.json(order.rows[0]);\n});\n```\n\n**Attack vector:** ID enumeration -- `GET /api/orders/1`\n\n, `/api/orders/2`\n\n, `/api/orders/3`\n\n...\n\n```\n// Fixed: ownership check\napp.get('/api/orders/:id', authMiddleware, async (req, res) => {\n  const order = await db.query(\n    'SELECT * FROM orders WHERE id = $1 AND user_id = $2',\n    [req.params.id, req.user.id]\n  );\n  if (!order.rows[0]) return res.status(404).json({ error: 'Not found' });\n  res.json(order.rows[0]);\n});\n```\n\n`AND user_id = $2`\n\nadded. If a role legitimately allows access to others' orders (admin, support), the check happens through RBAC middleware rather than through the absence of a condition.\n\n```\n# Vulnerable: user controls the file path\n@app.route('/api/files/<filename>')\ndef get_file(filename):\n    return send_file(f'uploads/{filename}')\n```\n\n**Attack vector:** `GET /api/files/../../etc/passwd`\n\n``` python\n# Fixed: send_from_directory + validation\nimport os\nfrom werkzeug.utils import secure_filename\n\n@app.route('/api/files/<filename>')\ndef get_file(filename):\n    safe_name = secure_filename(filename)\n    if not safe_name:\n        abort(400)\n    upload_dir = os.path.abspath('uploads')\n    file_path = os.path.abspath(os.path.join(upload_dir, safe_name))\n    if not file_path.startswith(upload_dir):\n        abort(403)\n    return send_from_directory(upload_dir, safe_name)\n```\n\n`secure_filename`\n\nstrips `../`\n\n, `send_from_directory`\n\nconfines the base directory, and the `abspath`\n\ncheck prevents bypass via symlinks.\n\n```\n// Vulnerable: entire req.body passed to update\napp.put('/api/profile', authMiddleware, async (req, res) => {\n  await db.query(\n    'UPDATE users SET name = $1, email = $2, role = $3 WHERE id = $4',\n    [req.body.name, req.body.email, req.body.role, req.user.id]\n  );\n  res.json({ status: 'updated' });\n});\n```\n\n**Attack vector:** `PUT /api/profile`\n\nwith body `{\"name\": \"Hacker\", \"role\": \"admin\"}`\n\n. User elevates their own role.\n\n```\n// Fixed: whitelist of allowed fields\napp.put('/api/profile', authMiddleware, async (req, res) => {\n  const { name, email } = req.body;\n  await db.query(\n    'UPDATE users SET name = $1, email = $2 WHERE id = $3',\n    [name, email, req.user.id]\n  );\n  res.json({ status: 'updated' });\n});\n```\n\nDestructuring picks only allowed fields. The `role`\n\nfield from the request is silently ignored.\n\n``` js\n// Vulnerable: algorithm taken from the token header\nconst decoded = jwt.verify(token, publicKey);\n```\n\n**Attack vector:** the attacker crafts a JWT with `\"alg\": \"none\"`\n\nor `\"alg\": \"HS256\"`\n\n, signing it with a symmetric key equal to the server's public key. Some libraries accept such tokens.\n\n``` js\n// Fixed: algorithm pinned explicitly\nconst decoded = jwt.verify(token, publicKey, {\n  algorithms: ['RS256'],\n  issuer: 'https://auth.example.com',\n  audience: 'api.example.com',\n});\n```\n\nThe `algorithms`\n\nparameter prevents the server from accepting JWTs with an arbitrary algorithm. `issuer`\n\nand `audience`\n\nrestrict the token's scope.\n\n```\n# Vulnerable: keys in source code\nSTRIPE_SECRET_KEY = \"sk_live_4eC39HqLyjWDarjtT1zdp7dc\"\nAWS_ACCESS_KEY = \"AKIAIOSFODNN7EXAMPLE\"\nDATABASE_URL = \"postgresql://admin:password123@db.example.com/prod\"\npython\n# Fixed: environment variables + startup validation\nimport os\n\ndef get_required_env(name: str) -> str:\n    value = os.environ.get(name)\n    if not value:\n        raise RuntimeError(f\"Missing required environment variable: {name}\")\n    return value\n\nSTRIPE_SECRET_KEY = get_required_env(\"STRIPE_SECRET_KEY\")\nAWS_ACCESS_KEY = get_required_env(\"AWS_ACCESS_KEY\")\nDATABASE_URL = get_required_env(\"DATABASE_URL\")\n```\n\n`get_required_env`\n\nfails at startup if the variable is not set, preventing the service from silently starting without credentials and returning errors on every request.\n\nPrompt for finding hardcoded secrets:\n\n```\nFind all hardcoded secrets in the codebase: API keys, passwords,\ntokens, connection strings. Check: .env files committed to git;\nstring literals containing \"sk_\", \"AKIA\", \"password\", \"secret\";\nconfig files with credentials. For each finding, include the file,\nline number, and recommendation.\n// Vulnerable: any site can make requests to the API\napp.use(cors({ origin: '*', credentials: true }));\n```\n\n**Attack vector:** an attacker hosts JavaScript on their site that makes requests to the API on behalf of an authenticated user (cookies are sent automatically).\n\n``` js\n// Fixed: origin whitelist\nconst allowedOrigins = [\n  'https://example.com',\n  'https://app.example.com',\n];\n\napp.use(cors({\n  origin: (origin, callback) => {\n    if (!origin || allowedOrigins.includes(origin)) {\n      callback(null, true);\n    } else {\n      callback(new Error('Not allowed by CORS'));\n    }\n  },\n  credentials: true,\n}));\n```\n\n`!origin`\n\npasses requests without an Origin header (server-to-server). For production APIs, consider removing this check and always requiring Origin.\n\n``` js\n// Vulnerable: stack trace leaks to the client\napp.use((err, req, res, next) => {\n  res.status(500).json({\n    error: err.message,\n    stack: err.stack,\n    query: err.query, // SQL query in the error\n  });\n});\n```\n\n**Attack vector:** the error exposes database structure, file paths, and library versions, making targeted attacks easier.\n\n```\n// Fixed: log internally, send minimal info to client\napp.use((err, req, res, next) => {\n  const errorId = crypto.randomUUID();\n\n  logger.error({\n    errorId,\n    message: err.message,\n    stack: err.stack,\n    path: req.path,\n    method: req.method,\n    userId: req.user?.id,\n  });\n\n  res.status(500).json({\n    error: 'Internal server error',\n    errorId,\n  });\n});\n```\n\n`errorId`\n\nlinks the client response to the log entry. The user reports the ID to support; the developer finds the full stack trace.\n\n```\n// Vulnerable: server makes a request to a user-supplied URL\napp.post('/api/preview', async (req, res) => {\n  const { url } = req.body;\n  const response = await fetch(url);\n  const html = await response.text();\n  const title = extractTitle(html);\n  res.json({ title, url });\n});\n```\n\n**Attack vector:** `url: \"http://169.254.169.254/latest/meta-data/iam/security-credentials/\"`\n\n-- access to the AWS metadata API from the internal network.\n\n``` python\nimport { URL } from 'url';\nimport dns from 'dns/promises';\n\nasync function isAllowedUrl(input: string): Promise<boolean> {\n  const parsed = new URL(input);\n  if (!['http:', 'https:'].includes(parsed.protocol)) return false;\n\n  const addresses = await dns.resolve4(parsed.hostname);\n  const blocked = ['10.', '172.16.', '192.168.', '169.254.', '127.'];\n  return !addresses.some(ip => blocked.some(prefix => ip.startsWith(prefix)));\n}\n\napp.post('/api/preview', async (req, res) => {\n  const { url } = req.body;\n  if (!await isAllowedUrl(url)) {\n    return res.status(400).json({ error: 'URL not allowed' });\n  }\n  const controller = new AbortController();\n  setTimeout(() => controller.abort(), 5000);\n\n  const response = await fetch(url, {\n    signal: controller.signal,\n    redirect: 'error',\n  });\n  const html = await response.text();\n  res.json({ title: extractTitle(html), url });\n});\n```\n\nDNS resolution verifies that the target IP does not belong to an internal network. `redirect: 'error'`\n\nblocks redirects to internal addresses. The timeout prevents slowloris attacks.\n\n```\n// Vulnerable: check-then-act without locking\napp.post('/api/withdraw', authMiddleware, async (req, res) => {\n  const { amount } = req.body;\n  const account = await db.query(\n    'SELECT balance FROM accounts WHERE user_id = $1', [req.user.id]\n  );\n\n  if (account.rows[0].balance >= amount) {\n    await db.query(\n      'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',\n      [amount, req.user.id]\n    );\n    res.json({ status: 'ok' });\n  } else {\n    res.status(400).json({ error: 'Insufficient funds' });\n  }\n});\n```\n\n**Attack vector:** two concurrent withdrawal requests. Both read balance 100, both pass the check, both deduct. Result: balance -100.\n\n```\n// Fixed: atomic operation in a transaction\napp.post('/api/withdraw', authMiddleware, async (req, res) => {\n  const { amount } = req.body;\n\n  const client = await db.connect();\n  try {\n    await client.query('BEGIN');\n    const account = await client.query(\n      'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',\n      [req.user.id]\n    );\n\n    if (account.rows[0].balance < amount) {\n      await client.query('ROLLBACK');\n      return res.status(400).json({ error: 'Insufficient funds' });\n    }\n\n    await client.query(\n      'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',\n      [amount, req.user.id]\n    );\n    await client.query('COMMIT');\n    res.json({ status: 'ok' });\n  } catch (e) {\n    await client.query('ROLLBACK');\n    throw e;\n  } finally {\n    client.release();\n  }\n});\n```\n\n`FOR UPDATE`\n\nlocks the row for the duration of the transaction. The second request waits for the first to finish and reads the updated balance.\n\n``` js\n// Vulnerable: regular string comparison\napp.post('/api/webhook', (req, res) => {\n  const signature = req.headers['x-webhook-signature'];\n  const expected = computeHmac(req.body, WEBHOOK_SECRET);\n\n  if (signature === expected) {\n    processWebhook(req.body);\n    res.json({ status: 'ok' });\n  } else {\n    res.status(401).json({ error: 'Invalid signature' });\n  }\n});\n```\n\n**Attack vector:** the `===`\n\noperator exits at the first mismatched byte. By measuring response time, an attacker can recover the signature byte by byte.\n\n``` python\nimport crypto from 'crypto';\n\napp.post('/api/webhook', (req, res) => {\n  const signature = req.headers['x-webhook-signature'];\n  const expected = computeHmac(req.body, WEBHOOK_SECRET);\n\n  if (!signature || !crypto.timingSafeEqual(\n    Buffer.from(signature, 'hex'),\n    Buffer.from(expected, 'hex')\n  )) {\n    return res.status(401).json({ error: 'Invalid signature' });\n  }\n  processWebhook(req.body);\n  res.json({ status: 'ok' });\n});\n```\n\n`timingSafeEqual`\n\ncompares all bytes in constant time, regardless of where the first mismatch occurs.\n\n```\n// Vulnerable: unlimited password brute force\napp.post('/api/login', async (req, res) => {\n  const { email, password } = req.body;\n  const user = await findUserByEmail(email);\n  if (!user || !await bcrypt.compare(password, user.passwordHash)) {\n    return res.status(401).json({ error: 'Invalid credentials' });\n  }\n  res.json({ token: generateToken(user) });\n});\npython\n// Fixed: rate limiting + account lockout\nimport rateLimit from 'express-rate-limit';\n\nconst loginLimiter = rateLimit({\n  windowMs: 15 * 60 * 1000,\n  max: 10,\n  keyGenerator: (req) => req.body.email || req.ip,\n  message: { error: 'Too many login attempts. Try again in 15 minutes.' },\n});\n\napp.post('/api/login', loginLimiter, async (req, res) => {\n  const { email, password } = req.body;\n  const user = await findUserByEmail(email);\n\n  if (user?.lockedUntil && user.lockedUntil > new Date()) {\n    return res.status(423).json({ error: 'Account temporarily locked' });\n  }\n\n  if (!user || !await bcrypt.compare(password, user.passwordHash)) {\n    if (user) {\n      await incrementFailedAttempts(user.id);\n    }\n    return res.status(401).json({ error: 'Invalid credentials' });\n  }\n\n  await resetFailedAttempts(user.id);\n  res.json({ token: generateToken(user) });\n});\n```\n\nRate limiting by email prevents brute-forcing a single account. Account lockout adds a second layer of defense. `keyGenerator`\n\nuses email rather than just IP, so a distributed attack from multiple IPs is also blocked.\n\nMore on resilience patterns for API protection: [Circuit Breaker in Deno Edge Functions](https://dev.to/blog/circuit-breaker-deno-edge-functions/).\n\n```\n// Vulnerable: recursive merge without protection\nfunction deepMerge(target: any, source: any): any {\n  for (const key of Object.keys(source)) {\n    if (typeof source[key] === 'object' && source[key] !== null) {\n      target[key] = deepMerge(target[key] || {}, source[key]);\n    } else {\n      target[key] = source[key];\n    }\n  }\n  return target;\n}\n\napp.put('/api/settings', authMiddleware, async (req, res) => {\n  const currentSettings = await getSettings(req.user.id);\n  const merged = deepMerge(currentSettings, req.body);\n  await saveSettings(req.user.id, merged);\n  res.json(merged);\n});\n```\n\n**Attack vector:** `PUT /api/settings`\n\nwith body `{\"__proto__\": {\"isAdmin\": true}}`\n\n. After the merge, every object in the application inherits `isAdmin: true`\n\n.\n\n``` js\nfunction deepMerge(target: any, source: any): any {\n  for (const key of Object.keys(source)) {\n    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {\n      continue;\n    }\n    if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {\n      target[key] = deepMerge(target[key] || {}, source[key]);\n    } else {\n      target[key] = source[key];\n    }\n  }\n  return target;\n}\n```\n\nThree keys are blocked: `__proto__`\n\n, `constructor`\n\n, `prototype`\n\n. In production, prefer battle-tested libraries: lodash `merge`\n\nstarting from 4.17.21 is protected, or use `structuredClone`\n\nfor copying.\n\nA broad scan catches obvious vulnerabilities. Deep analysis requires specialized prompts by category.\n\n**Injection (A03):**\n\n```\nFind all places where user input reaches SQL, NoSQL,\nLDAP, OS commands, or ORM raw queries without parameterization.\nConsider: query params, request body, headers, cookies, file uploads.\nCheck ORM methods that use raw SQL.\n```\n\n**Access Control (A01):**\n\n```\nReview every API endpoint: is there a check that the current user\nowns the requested resource? Find endpoints that verify authentication\nbut not authorization. Pay attention to admin endpoints, bulk\noperations, export/download.\n```\n\n**SSRF and Insecure Design (A04):**\n\n```\nFind all places where the server makes HTTP requests to a URL from\nuser input. Check: is it possible to reach internal services\n(metadata API, localhost, private networks)? Is there URL validation,\nDNS rebinding protection, redirect restrictions?\n```\n\n**Authentication (A07):**\n\n```\nReview the authentication mechanism: password storage (bcrypt/argon2?),\nJWT (algorithm pinned? refresh tokens present?), sessions (httpOnly?\nsecure? sameSite?), rate limiting on login/register/reset-password.\nFind endpoints without authentication that should be protected.\n```\n\nManual audit provides depth. Automated audit in CI provides consistency. Combining both closes most vulnerabilities before production.\n\n```\n# .github/workflows/security-audit.yml\nname: AI Security Audit\non:\n  pull_request:\n    paths:\n      - 'src/**'\n      - 'api/**'\n\njobs:\n  security-scan:\n    runs-on: ubuntu-latest\n    steps:\n      - uses: actions/checkout@v4\n\n      - name: Run SAST\n        run: |\n          npx semgrep --config=p/owasp-top-ten src/\n\n      - name: Check dependencies\n        run: npm audit --audit-level=high\n\n      - name: Check secrets\n        run: npx gitleaks detect --source=. --no-git\n```\n\nSemgrep covers OWASP Top 10 with minimal false positives. `npm audit`\n\ncatches vulnerable dependencies. Gitleaks finds committed secrets. LLM audit runs separately, at pre-merge review.\n\nBefore each release:\n\n`*`\n\n`FOR UPDATE`\n\n`timingSafeEqual`\n\n`===`\n\nEach item corresponds to one of the 15 vulnerabilities above. If any item fails, it maps to a concrete vulnerability with a known attack vector.\n\n*Need help with AI-powered security audits? I help startups build AI products and automate processes — belov.works.*\n\nNo. LLMs excel at pattern recognition across large codebases and surface the majority of OWASP Top 10 issues in hours, but they produce false positives and miss logic-level flaws that require understanding business context. A manual security review by a specialist is still necessary for critical systems — AI compresses the preparation phase and handles the repeatable patterns, freeing the human reviewer for the nuanced findings.\n\nIn practice, Claude and GPT-4o produce comparable results for security audits when given a structured prompt. The model matters less than the prompt quality and the completeness of the code submitted for review. What consistently degrades results: sending partial snippets instead of full files, omitting framework and ORM context, and skipping the verification pass against false positives.\n\nFinding and removing the secret from code is not enough — it remains in Git history. Rotate the exposed credential immediately, then use `git filter-repo`\n\n(not the deprecated `git filter-branch`\n\n) to purge it from all commits. After that, set up pre-commit hooks with Gitleaks or detect-secrets to prevent future commits. Treat any secret that touched a repository as compromised, regardless of how briefly.", "url": "https://wpnews.pro/news/ai-security-audit-checklist-15-vulnerabilities-claude-found-in-production-code", "canonical_source": "https://dev.to/spyrae/ai-security-audit-checklist-15-vulnerabilities-claude-found-in-production-code-3ajd", "published_at": "2026-07-08 03:10:45+00:00", "updated_at": "2026-07-08 03:58:31.545231+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-tools", "developer-tools", "ai-safety"], "entities": ["Claude", "OWASP", "SQL", "NoSQL", "MongoDB", "bcrypt"], "alternates": {"html": "https://wpnews.pro/news/ai-security-audit-checklist-15-vulnerabilities-claude-found-in-production-code", "markdown": "https://wpnews.pro/news/ai-security-audit-checklist-15-vulnerabilities-claude-found-in-production-code.md", "text": "https://wpnews.pro/news/ai-security-audit-checklist-15-vulnerabilities-claude-found-in-production-code.txt", "jsonld": "https://wpnews.pro/news/ai-security-audit-checklist-15-vulnerabilities-claude-found-in-production-code.jsonld"}}