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.
Чеклист для быстрого аудита
Перед каждым релизом:
- Все пользовательские входы параметризованы (SQL, NoSQL, shell)
- Каждый endpoint проверяет ownership ресурса, а не только аутентификацию
- Файловые операции ограничены базовой директорией (path traversal)
- Update-операции принимают whitelist полей (mass assignment)
- JWT алгоритм зафиксирован в коде, не берется из токена
- Секретов нет в исходном коде или переменных сборки
- CORS origin ограничен whitelist, а не
* - Error messages не раскрывают внутренности (stack trace, SQL)
- Внешние URL проходят DNS-валидацию (SSRF)
- Финансовые операции в транзакциях с
FOR UPDATE - Сравнение токенов через
timingSafeEqual, не через=== - Rate limiting на auth-эндпоинтах + account lockout
- Глубокое слияние объектов защищено от prototype pollution
- CI pipeline включает SAST (semgrep) и dependency audit
- 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 для предотвращения повторных инцидентов. Любой секрет, попавший в репозиторий, считается скомпрометированным — вне зависимости от того, как долго он там находился.