AI Security Audit Checklist: 15 уязвимостей, которые Claude нашел в production-коде

Что такое AI security audit?

AI security audit — использование больших языковых моделей для систематического выявления уязвимостей в production-коде путём сканирования на паттерны известных атак, а не конкретные CVE. В отличие от традиционного аудита, занимающего 2-3 недели и стоящего от $10 000, LLM-ассистированный аудит сжимает первичный анализ до нескольких часов. Метод охватывает категории OWASP Top 10: injection-атаки, broken access control, криптографические ошибки, prototype pollution и другие.

TL;DR

  • -LLM сжимает первичный security-аудит с 2-3 недель до нескольких часов за счёт сканирования паттернов OWASP Top 10, а не конкретных CVE
  • -SQL injection через конкатенацию строк — самая частая находка, встречается даже в ORM-проектах, где разработчики переходят на raw-запросы для сложных фильтров
  • -Трёхпроходная методология: широкий скан → глубокий анализ по категориям → ручная верификация для фильтрации false positives
  • -JWT без фиксации алгоритма позволяет атакующему создать токен с 'alg: none'; исправление — одна строка с параметром 'algorithms'
  • -CI-автоматизация через Semgrep, npm audit и Gitleaks даёт регулярный SAST; LLM-ревью запускается отдельно на pre-merge этапе

Большинство веб-приложений содержат хотя бы одну уязвимость из OWASP Top 10. При этом средний security-аудит занимает 2–3 недели и стоит от $10 000. LLM сокращает первичный аудит до нескольких часов, потому что сканирует код на паттерны, а не на конкретные CVE.

Ниже 15 уязвимостей, обнаруженных при аудите production-кода с помощью Claude. Каждая включает уязвимый код, исправленную версию и промпт для воспроизведения. Классификация по OWASP Top 10 (2021). Порядок соответствует частоте обнаружения: от самых распространенных к редким.

Методология: как проводить AI security audit

Аудит состоит из трех проходов. Первый — широкий скан: LLM получает весь проект и ищет паттерны уязвимостей. Второй — глубокий анализ: каждый найденный паттерн проверяется в контексте (middleware, ORM, фреймворк). Третий — верификация: ручная проверка каждой находки, потому что LLM генерирует false positives.

Промпт для широкого скана:

Проведи security audit этого кода. Для каждой находки укажи:
1. CWE ID и название
2. OWASP Top 10 категорию
3. Severity (Critical/High/Medium/Low)
4. Уязвимый фрагмент кода
5. Вектор атаки — как именно злоумышленник эксплуатирует это
6. Исправленный код

Игнорируй стилистические замечания. Фокус только на безопасности.
Начни с injection-атак, затем broken access control, затем остальные категории.

Этот промпт работает, потому что задает структуру ответа и приоритет категорий. Без явной инструкции LLM смешивает критические уязвимости с замечаниями о валидации email.

Подробнее о структурированном AI code review: AI Code Review Checklist.

A03:2021 — Injection

1. SQL Injection через конкатенацию строк

Самая частая находка. Встречается даже в проектах, использующих ORM, потому что разработчики переключаются на raw-запросы для сложных фильтров.

Уязвимый код:

// API endpoint для поиска пользователей
app.get('/api/users', async (req, res) => {
  const { search, sortBy } = req.query;
  const query = `
    SELECT id, name, email
    FROM users
    WHERE name LIKE '%${search}%'
    ORDER BY ${sortBy}
  `;
  const result = await db.query(query);
  res.json(result.rows);
});

Вектор атаки: GET /api/users?search='; DROP TABLE users; --&sortBy=id

Исправленный код:

app.get('/api/users', async (req, res) => {
  const { search, sortBy } = req.query;

  const allowedSortColumns = ['id', 'name', 'email', 'created_at'];
  const sanitizedSort = allowedSortColumns.includes(sortBy) ? sortBy : 'id';

  const query = `
    SELECT id, name, email
    FROM users
    WHERE name LIKE $1
    ORDER BY ${sanitizedSort}
  `;
  const result = await db.query(query, [`%${search}%`]);
  res.json(result.rows);
});

Параметризованный запрос для значений, whitelist для идентификаторов (имена колонок). ORDER BY нельзя параметризовать в большинстве драйверов, поэтому whitelist обязателен.

2. NoSQL Injection в MongoDB-запросах

// Уязвимо: req.body напрямую в запрос
app.post('/api/login', async (req, res) => {
  const user = await db.collection('users').findOne({
    username: req.body.username,
    password: req.body.password,
  });
  if (user) return res.json({ token: generateToken(user) });
  res.status(401).json({ error: 'Invalid credentials' });
});

Вектор атаки: POST /api/login с телом {"username": "admin", "password": {"$ne": ""}}. Оператор $ne (not equal) превращает проверку пароля в “пароль не равен пустой строке” — true для любого пользователя.

// Исправлено: явное приведение к строке
app.post('/api/login', async (req, res) => {
  const username = String(req.body.username);
  const password = String(req.body.password);

  const user = await db.collection('users').findOne({ username });
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  res.json({ token: generateToken(user) });
});

Два исправления: String() блокирует операторы MongoDB, а bcrypt.compare заменяет сравнение пароля в открытом виде.

3. Command Injection через child_process

// Уязвимо: пользовательский ввод в shell-команде
app.post('/api/convert', async (req, res) => {
  const { filename } = req.body;
  exec(`convert uploads/${filename} -resize 200x200 thumbnails/${filename}`,
    (err, stdout) => {
      if (err) return res.status(500).json({ error: 'Conversion failed' });
      res.json({ status: 'ok' });
    });
});

Вектор атаки: filename: "image.png; rm -rf /"

// Исправлено: execFile вместо exec, валидация имени файла
import { execFile } from 'child_process';

app.post('/api/convert', async (req, res) => {
  const { filename } = req.body;

  if (!/^[a-zA-Z0-9_-]+\.(png|jpg|webp)$/.test(filename)) {
    return res.status(400).json({ error: 'Invalid filename' });
  }

  execFile('convert', [
    `uploads/${filename}`, '-resize', '200x200', `thumbnails/${filename}`
  ], (err) => {
    if (err) return res.status(500).json({ error: 'Conversion failed' });
    res.json({ status: 'ok' });
  });
});

execFile не запускает shell, поэтому ; rm -rf / не интерпретируется как отдельная команда. Regex дополнительно ограничивает допустимые символы.

A01:2021 — Broken Access Control

4. IDOR — небезопасная прямая ссылка на объект

// Уязвимо: любой авторизованный пользователь видит любой заказ
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
  const order = await db.query('SELECT * FROM orders WHERE id = $1', [req.params.id]);
  res.json(order.rows[0]);
});

Вектор атаки: перебор ID — GET /api/orders/1, /api/orders/2, /api/orders/3

// Исправлено: проверка принадлежности ресурса
app.get('/api/orders/:id', authMiddleware, async (req, res) => {
  const order = await db.query(
    'SELECT * FROM orders WHERE id = $1 AND user_id = $2',
    [req.params.id, req.user.id]
  );
  if (!order.rows[0]) return res.status(404).json({ error: 'Not found' });
  res.json(order.rows[0]);
});

Добавлен AND user_id = $2. Если роль допускает доступ к чужим заказам (админ, support), проверка идет через RBAC middleware, а не через отсутствие условия.

5. Path Traversal при отдаче файлов

# Уязвимо: пользователь контролирует путь к файлу
@app.route('/api/files/<filename>')
def get_file(filename):
    return send_file(f'uploads/{filename}')

Вектор атаки: GET /api/files/../../etc/passwd

# Исправлено: send_from_directory + валидация
import os
from werkzeug.utils import secure_filename

@app.route('/api/files/<filename>')
def get_file(filename):
    safe_name = secure_filename(filename)
    if not safe_name:
        abort(400)
    upload_dir = os.path.abspath('uploads')
    file_path = os.path.abspath(os.path.join(upload_dir, safe_name))
    if not file_path.startswith(upload_dir):
        abort(403)
    return send_from_directory(upload_dir, safe_name)

secure_filename удаляет ../, send_from_directory ограничивает базовую директорию, а abspath-проверка предотвращает обход через символические ссылки.

6. Mass Assignment — перезапись полей через API

// Уязвимо: весь req.body передается в update
app.put('/api/profile', authMiddleware, async (req, res) => {
  await db.query(
    'UPDATE users SET name = $1, email = $2, role = $3 WHERE id = $4',
    [req.body.name, req.body.email, req.body.role, req.user.id]
  );
  res.json({ status: 'updated' });
});

Вектор атаки: PUT /api/profile с телом {"name": "Hacker", "role": "admin"}. Пользователь повышает себе роль.

// Исправлено: whitelist разрешенных полей
app.put('/api/profile', authMiddleware, async (req, res) => {
  const { name, email } = req.body;
  await db.query(
    'UPDATE users SET name = $1, email = $2 WHERE id = $3',
    [name, email, req.user.id]
  );
  res.json({ status: 'updated' });
});

Деструктуризация выбирает только разрешенные поля. Поле role из запроса игнорируется.

A02:2021 — Cryptographic Failures

7. JWT без проверки алгоритма

// Уязвимо: алгоритм берется из заголовка токена
const decoded = jwt.verify(token, publicKey);

Вектор атаки: атакующий создает JWT с "alg": "none" или "alg": "HS256", подписывая симметричным ключом, равным публичному ключу сервера. Некоторые библиотеки принимают такие токены.

// Исправлено: алгоритм задан явно
const decoded = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com',
  audience: 'api.example.com',
});

Параметр algorithms запрещает серверу принимать JWT с произвольным алгоритмом. issuer и audience дополнительно ограничивают scope токена.

8. Хранение секретов в коде

# Уязвимо: ключи в исходном коде
STRIPE_SECRET_KEY = "sk_live_4eC39HqLyjWDarjtT1zdp7dc"
AWS_ACCESS_KEY = "AKIAIOSFODNN7EXAMPLE"
DATABASE_URL = "postgresql://admin:[email protected]/prod"
# Исправлено: переменные окружения + валидация при старте
import os

def get_required_env(name: str) -> str:
    value = os.environ.get(name)
    if not value:
        raise RuntimeError(f"Missing required environment variable: {name}")
    return value

STRIPE_SECRET_KEY = get_required_env("STRIPE_SECRET_KEY")
AWS_ACCESS_KEY = get_required_env("AWS_ACCESS_KEY")
DATABASE_URL = get_required_env("DATABASE_URL")

Функция get_required_env падает при старте, если переменная не задана. Это предотвращает ситуацию, когда сервис запускается без секретов и возвращает ошибки на каждый запрос.

Промпт для поиска хардкод-секретов:

Найди в кодовой базе все захардкоженные секреты: API-ключи, пароли,
токены, connection strings. Проверь: .env файлы, закоммиченные в git;
строковые литералы, содержащие "sk_", "AKIA", "password", "secret";
конфигурационные файлы с credentials. Для каждой находки укажи файл,
строку и рекомендацию.

A05:2021 — Security Misconfiguration

9. CORS — разрешение всех origin

// Уязвимо: любой сайт может делать запросы к API
app.use(cors({ origin: '*', credentials: true }));

Вектор атаки: злоумышленник размещает на своем сайте JavaScript, который делает запросы к API от имени авторизованного пользователя (cookies передаются автоматически).

// Исправлено: whitelist origin
const allowedOrigins = [
  'https://example.com',
  'https://app.example.com',
];

app.use(cors({
  origin: (origin, callback) => {
    if (!origin || allowedOrigins.includes(origin)) {
      callback(null, true);
    } else {
      callback(new Error('Not allowed by CORS'));
    }
  },
  credentials: true,
}));

!origin пропускает запросы без заголовка Origin (server-to-server). Для production API стоит убрать эту проверку и требовать Origin всегда.

10. Verbose Error Messages в production

// Уязвимо: stack trace утекает клиенту
app.use((err, req, res, next) => {
  res.status(500).json({
    error: err.message,
    stack: err.stack,
    query: err.query, // SQL-запрос в ошибке
  });
});

Вектор атаки: ошибка раскрывает структуру БД, пути файлов, версии библиотек. Это упрощает целевую атаку.

// Исправлено: логирование внутри, минимум клиенту
app.use((err, req, res, next) => {
  const errorId = crypto.randomUUID();

  logger.error({
    errorId,
    message: err.message,
    stack: err.stack,
    path: req.path,
    method: req.method,
    userId: req.user?.id,
  });

  res.status(500).json({
    error: 'Internal server error',
    errorId,
  });
});

errorId связывает ответ клиенту с записью в логах. Пользователь сообщает ID в support, разработчик находит полный stack trace.

A04:2021 — Insecure Design

11. SSRF — Server-Side Request Forgery

// Уязвимо: сервер делает запрос по URL от пользователя
app.post('/api/preview', async (req, res) => {
  const { url } = req.body;
  const response = await fetch(url);
  const html = await response.text();
  const title = extractTitle(html);
  res.json({ title, url });
});

Вектор атаки: url: "http://169.254.169.254/latest/meta-data/iam/security-credentials/" — доступ к AWS metadata API из внутренней сети.

import { URL } from 'url';
import dns from 'dns/promises';

async function isAllowedUrl(input: string): Promise<boolean> {
  const parsed = new URL(input);
  if (!['http:', 'https:'].includes(parsed.protocol)) return false;

  const addresses = await dns.resolve4(parsed.hostname);
  const blocked = ['10.', '172.16.', '192.168.', '169.254.', '127.'];
  return !addresses.some(ip => blocked.some(prefix => ip.startsWith(prefix)));
}

app.post('/api/preview', async (req, res) => {
  const { url } = req.body;
  if (!await isAllowedUrl(url)) {
    return res.status(400).json({ error: 'URL not allowed' });
  }
  const controller = new AbortController();
  setTimeout(() => controller.abort(), 5000);

  const response = await fetch(url, {
    signal: controller.signal,
    redirect: 'error',
  });
  const html = await response.text();
  res.json({ title: extractTitle(html), url });
});

DNS-резолвинг проверяет, что целевой IP не принадлежит внутренней сети. redirect: 'error' блокирует редиректы на внутренние адреса. Таймаут предотвращает slowloris.

12. Race Condition при обработке платежей

// Уязвимо: check-then-act без блокировки
app.post('/api/withdraw', authMiddleware, async (req, res) => {
  const { amount } = req.body;
  const account = await db.query(
    'SELECT balance FROM accounts WHERE user_id = $1', [req.user.id]
  );

  if (account.rows[0].balance >= amount) {
    await db.query(
      'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
      [amount, req.user.id]
    );
    res.json({ status: 'ok' });
  } else {
    res.status(400).json({ error: 'Insufficient funds' });
  }
});

Вектор атаки: два одновременных запроса на вывод. Оба читают баланс 100, оба проходят проверку, оба списывают. Итог: баланс -100.

// Исправлено: атомарная операция в транзакции
app.post('/api/withdraw', authMiddleware, async (req, res) => {
  const { amount } = req.body;

  const client = await db.connect();
  try {
    await client.query('BEGIN');
    const account = await client.query(
      'SELECT balance FROM accounts WHERE user_id = $1 FOR UPDATE',
      [req.user.id]
    );

    if (account.rows[0].balance < amount) {
      await client.query('ROLLBACK');
      return res.status(400).json({ error: 'Insufficient funds' });
    }

    await client.query(
      'UPDATE accounts SET balance = balance - $1 WHERE user_id = $2',
      [amount, req.user.id]
    );
    await client.query('COMMIT');
    res.json({ status: 'ok' });
  } catch (e) {
    await client.query('ROLLBACK');
    throw e;
  } finally {
    client.release();
  }
});

FOR UPDATE блокирует строку на время транзакции. Второй запрос ждет завершения первого и читает уже обновленный баланс.

A07:2021 — Identification and Authentication Failures

13. Timing Attack при сравнении токенов

// Уязвимо: обычное сравнение строк
app.post('/api/webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const expected = computeHmac(req.body, WEBHOOK_SECRET);

  if (signature === expected) {
    processWebhook(req.body);
    res.json({ status: 'ok' });
  } else {
    res.status(401).json({ error: 'Invalid signature' });
  }
});

Вектор атаки: оператор === завершается на первом несовпадающем байте. Измеряя время ответа, атакующий подбирает подпись побайтово.

import crypto from 'crypto';

app.post('/api/webhook', (req, res) => {
  const signature = req.headers['x-webhook-signature'];
  const expected = computeHmac(req.body, WEBHOOK_SECRET);

  if (!signature || !crypto.timingSafeEqual(
    Buffer.from(signature, 'hex'),
    Buffer.from(expected, 'hex')
  )) {
    return res.status(401).json({ error: 'Invalid signature' });
  }
  processWebhook(req.body);
  res.json({ status: 'ok' });
});

timingSafeEqual сравнивает все байты за одинаковое время вне зависимости от позиции первого расхождения.

14. Отсутствие rate limiting на аутентификации

// Уязвимо: бесконечный перебор паролей
app.post('/api/login', async (req, res) => {
  const { email, password } = req.body;
  const user = await findUserByEmail(email);
  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    return res.status(401).json({ error: 'Invalid credentials' });
  }
  res.json({ token: generateToken(user) });
});
// Исправлено: rate limiting + account lockout
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000,
  max: 10,
  keyGenerator: (req) => req.body.email || req.ip,
  message: { error: 'Too many login attempts. Try again in 15 minutes.' },
});

app.post('/api/login', loginLimiter, async (req, res) => {
  const { email, password } = req.body;
  const user = await findUserByEmail(email);

  if (user?.lockedUntil && user.lockedUntil > new Date()) {
    return res.status(423).json({ error: 'Account temporarily locked' });
  }

  if (!user || !await bcrypt.compare(password, user.passwordHash)) {
    if (user) {
      await incrementFailedAttempts(user.id);
    }
    return res.status(401).json({ error: 'Invalid credentials' });
  }

  await resetFailedAttempts(user.id);
  res.json({ token: generateToken(user) });
});

Rate limit по email предотвращает brute force одного аккаунта. Account lockout добавляет второй уровень защиты. keyGenerator использует email, а не только IP, чтобы распределенная атака с разных IP тоже блокировалась.

Подробнее о resilience-паттернах для защиты API: Circuit Breaker в Deno Edge Functions.

A08:2021 — Software and Data Integrity Failures

15. Prototype Pollution через глубокое слияние объектов

// Уязвимо: рекурсивный merge без защиты
function deepMerge(target: any, source: any): any {
  for (const key of Object.keys(source)) {
    if (typeof source[key] === 'object' && source[key] !== null) {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

app.put('/api/settings', authMiddleware, async (req, res) => {
  const currentSettings = await getSettings(req.user.id);
  const merged = deepMerge(currentSettings, req.body);
  await saveSettings(req.user.id, merged);
  res.json(merged);
});

Вектор атаки: PUT /api/settings с телом {"__proto__": {"isAdmin": true}}. После merge каждый объект в приложении наследует isAdmin: true.

function deepMerge(target: any, source: any): any {
  for (const key of Object.keys(source)) {
    if (key === '__proto__' || key === 'constructor' || key === 'prototype') {
      continue;
    }
    if (typeof source[key] === 'object' && source[key] !== null && !Array.isArray(source[key])) {
      target[key] = deepMerge(target[key] || {}, source[key]);
    } else {
      target[key] = source[key];
    }
  }
  return target;
}

Три ключа блокируются: __proto__, constructor, prototype. В production лучше использовать проверенные библиотеки: lodash merge начиная с 4.17.21 защищен, или structuredClone для копирования.

Промпты для каждой категории OWASP

Широкий скан выявляет очевидные уязвимости. Для глубокого анализа нужны специализированные промпты по категориям.

Injection (A03):

Найди все места, где пользовательский ввод попадает в SQL, NoSQL,
LDAP, OS-команды или ORM raw-запросы без параметризации. Учитывай:
query params, request body, headers, cookies, file uploads.
Проверь ORM-методы, использующие raw SQL.

Access Control (A01):

Проверь каждый API endpoint: есть ли проверка, что текущий
пользователь владеет запрашиваемым ресурсом? Найди endpoints, где
проверяется только аутентификация, но не авторизация. Обрати внимание
на admin-эндпоинты, массовые операции, export/download.

SSRF и Insecure Design (A04):

Найди все места, где сервер делает HTTP-запросы по URL из
пользовательского ввода. Проверь: нет ли возможности обратиться
к внутренним сервисам (metadata API, localhost, приватные сети)?
Есть ли валидация URL, DNS rebinding protection, ограничение
редиректов?

Authentication (A07):

Проверь механизм аутентификации: хранение паролей (bcrypt/argon2?),
JWT (алгоритм зафиксирован? refresh-токены есть?), сессии (httpOnly?
secure? sameSite?), rate limiting на login/register/reset-password.
Найди endpoints без аутентификации, которые должны быть защищены.

Автоматизация: CI pipeline для security audit

Ручной аудит дает глубину. Автоматический аудит в CI дает регулярность. Комбинация обоих закрывает большинство уязвимостей до production.

# .github/workflows/security-audit.yml
name: AI Security Audit
on:
  pull_request:
    paths:
      - 'src/**'
      - 'api/**'

jobs:
  security-scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run SAST
        run: |
          npx semgrep --config=p/owasp-top-ten src/

      - name: Check dependencies
        run: npm audit --audit-level=high

      - name: Check secrets
        run: npx gitleaks detect --source=. --no-git

Semgrep покрывает OWASP Top 10 с минимумом false positives. npm audit ловит уязвимые зависимости. Gitleaks находит закоммиченные секреты. LLM-аудит запускается отдельно, на pre-merge review.

Чеклист для быстрого аудита

Перед каждым релизом:

  1. Все пользовательские входы параметризованы (SQL, NoSQL, shell)
  2. Каждый endpoint проверяет ownership ресурса, а не только аутентификацию
  3. Файловые операции ограничены базовой директорией (path traversal)
  4. Update-операции принимают whitelist полей (mass assignment)
  5. JWT алгоритм зафиксирован в коде, не берется из токена
  6. Секретов нет в исходном коде или переменных сборки
  7. CORS origin ограничен whitelist, а не *
  8. Error messages не раскрывают внутренности (stack trace, SQL)
  9. Внешние URL проходят DNS-валидацию (SSRF)
  10. Финансовые операции в транзакциях с FOR UPDATE
  11. Сравнение токенов через timingSafeEqual, не через ===
  12. Rate limiting на auth-эндпоинтах + account lockout
  13. Глубокое слияние объектов защищено от prototype pollution
  14. CI pipeline включает SAST (semgrep) и dependency audit
  15. LLM security review на каждом PR с изменениями в API/auth

Каждый пункт соответствует одной из 15 уязвимостей выше. Если какой-то пункт не проходит, это конкретная уязвимость с известным вектором атаки.

FAQ

Может ли LLM заменить профессионального пентестера?

Нет. LLM хорошо справляется с поиском паттернов по всей кодовой базе и находит большинство OWASP Top 10 уязвимостей за часы, но генерирует false positives и пропускает логические ошибки, требующие понимания бизнес-контекста. Ручное ревью специалиста по безопасности по-прежнему необходимо для критических систем — AI сжимает подготовительную фазу и берёт на себя повторяющиеся паттерны, освобождая время для нетривиальных находок.

Какая модель лучше для security-аудита — GPT-4o, Claude или Gemini?

На практике Claude и GPT-4o дают сопоставимые результаты при структурированном промпте. Модель влияет меньше, чем качество промпта и полнота предоставленного кода. Результат ухудшают: передача фрагментов вместо полных файлов, отсутствие контекста фреймворка и ORM, пропуск верификационного прохода для фильтрации false positives.

Что делать, если секрет уже попал в репозиторий?

Удалить секрет из кода недостаточно — он остаётся в Git-истории. Первый шаг — немедленно ротировать скомпрометированные credentials. Затем очистить историю через git filter-repo (не устаревший git filter-branch). После этого настроить pre-commit хуки с Gitleaks или detect-secrets для предотвращения повторных инцидентов. Любой секрет, попавший в репозиторий, считается скомпрометированным — вне зависимости от того, как долго он там находился.