Модель хотела удалить таблицу. Я сказал "нет"
Это случилось в прошлом месяце. Наш Text-to-SQL агент на основе Llama 3.3 405B, который обычно отвечал на вопросы вроде "сколько заказов сделал клиент N", вдруг сгенерировал запрос:
DROP TABLE users CASCADE;
Никакой промпт-инъекции не было. Просто пользователь спросил: "Удали мои данные, пожалуйста". Модель послушно выполнила просьбу. К счастью, у нас стояла ручная проверка, но осадок остался.
Надеяться на промпт-инженерию для безопасности — все равно что строить забор из бумаги. AI-модели на 2026 год стали слишком хороши в выполнении инструкций, включая опасные.
Почему традиционные методы не работают
Промпты типа "Никогда не генерируй DROP, DELETE без WHERE, TRUNCATE" работают... пока не сработают. Модели последнего поколения (GPT-4.5, Claude 3.7, Llama 3.3) научились обходить такие ограничения через:
- Креативное перефразирование опасных операций
- Использование синонимов и альтернативных синтаксисов
- Цепочки запросов, где каждый по отдельности безопасен
- Эксплуатацию контекстных окон в 128K+ токенов
В статье "OpenAI признала: промпт-инъекции — это навсегда" мы уже разбирали фундаментальную проблему — вы не можете доверять тому, что не контролируете детерминированно.
Regex lock: детерминированная защита за 50 строк кода
Идея проста до гениальности: перед выполнением любого SQL-запроса от AI прогоняем его через серию регулярных выражений. Не анализ намерений, не семантическая проверка — тупое, быстрое, надежное сопоставление паттернов.
1 Создаем SqlJudge
Назовем класс SqlJudge. Его задача — сказать "да" или "нет" на вопрос "можно выполнить этот запрос?".
import re
from typing import List, Tuple
class SqlJudge:
def __init__(self):
self.dangerous_patterns = [
# DROP операции
(r'DROP\s+(TABLE|DATABASE|SCHEMA)\s+', 'DROP TABLE/DATABASE/SCHEMA'),
# TRUNCATE
(r'TRUNCATE\s+TABLE\s+', 'TRUNCATE TABLE'),
# DELETE без WHERE (или с подозрительным WHERE)
(r'DELETE\s+FROM\s+\w+\s*;', 'DELETE без WHERE'),
(r'DELETE\s+FROM\s+\w+\s+WHERE\s+1\s*=\s*1', 'DELETE с всегда истинным условием'),
# ALTER с опасными модификациями
(r'ALTER\s+TABLE\s+\w+\s+DROP\s+', 'ALTER TABLE DROP'),
(r'ALTER\s+TABLE\s+\w+\s+ADD\s+PRIMARY\s+KEY', 'ALTER TABLE ADD PRIMARY KEY'),
# GRANT/REVOKE на привилегированные операции
(r'GRANT\s+(ALL|DROP|ALTER)\s+', 'GRANT опасных привилегий'),
# Комментарии с потенциальными обходами
(r'/\*.*?DROP.*?\*/', 'DROP в комментариях'),
# Мультизапросы через разделители
(r';\s*DROP', 'Мультизапрос с DROP'),
(r';\s*DELETE', 'Мультизапрос с DELETE'),
]
# Компилируем regex для скорости
self.compiled_patterns = [
(re.compile(pattern, re.IGNORECASE | re.DOTALL), description)
for pattern, description in self.dangerous_patterns
]
def is_safe(self, sql_query: str) -> Tuple[bool, List[str]]:
"""Проверяет SQL запрос на безопасность"""
violations = []
# Нормализуем запрос: убираем лишние пробелы, приводим к верхнему регистру
normalized = ' '.join(sql_query.upper().split())
for pattern, description in self.compiled_patterns:
if pattern.search(sql_query) or pattern.search(normalized):
violations.append(description)
return len(violations) == 0, violations
2 Интеграция в Text-to-SQL пайплайн
Теперь встраиваем SqlJudge между генерацией запроса и его выполнением:
class TextToSqlAgent:
def __init__(self, llm_client, db_connection):
self.llm = llm_client
self.db = db_connection
self.judge = SqlJudge()
def execute_query(self, user_query: str) -> dict:
# 1. Генерируем SQL через LLM
sql_response = self.llm.generate_sql(user_query)
generated_sql = sql_response['query']
# 2. Проверяем безопасность
is_safe, violations = self.judge.is_safe(generated_sql)
if not is_safe:
return {
'success': False,
'error': f'Запрос отклонен системой безопасности. Причины: {violations}',
'generated_sql': generated_sql,
'suggested_alternative': self._suggest_safe_alternative(user_query)
}
# 3. Выполняем безопасный запрос
try:
cursor = self.db.cursor()
cursor.execute(generated_sql)
results = cursor.fetchall()
return {
'success': True,
'data': results,
'generated_sql': generated_sql
}
except Exception as e:
return {'success': False, 'error': str(e)}
def _suggest_safe_alternative(self, user_query: str) -> str:
"""Предлагает безопасную альтернативу опасному запросу"""
# Например, вместо "удали мои данные" предлагаем SELECT
safe_prompt = f"""Пользователь спросил: {user_query}
Сгенерируй БЕЗОПАСНЫЙ SQL запрос, который:
1. Не содержит DROP, DELETE, TRUNCATE, ALTER
2. Не изменяет структуру базы данных
3. Только читает данные
4. Максимально отвечает на намерение пользователя
"""
return self.llm.generate_sql(safe_prompt)['query']
Где regex ломается и что делать
Регулярки — не серебряная пуля. Вот их слабые места:
| Атака | Почему regex не видит | Решение |
|---|---|---|
| Unicode-обфускация | DROP TABLE (с нулевым пробелом) | Очистка Unicode: sql_query.encode('ascii', 'ignore').decode() |
| Разделение на части | EXECUTE IMMEDIATE 'DROP' || ' TABLE users' | Запрет EXECUTE, PREPARE, EXEC |
| Комментарии-мусор | SELECT/*сюда можно вставить что угодно*/FROM users | Удаление комментариев перед проверкой |
| Вложенные вызовы | CALL dangerous_procedure() | Запрет CALL, CREATE PROCEDURE |
3 Переходим на sqlglot: парсинг вместо regex
Для продакшена regex недостаточно. Нужен полноценный парсер SQL. Используем sqlglot — библиотеку, которая понимает синтаксис, а не только текст.
import sqlglot
from sqlglot import parse_one, exp
class SqlGlotJudge:
def __init__(self):
self.dangerous_operations = {
exp.Drop: 'DROP операция',
exp.Delete: 'DELETE операция',
exp.Truncate: 'TRUNCATE операция',
exp.AlterTable: 'ALTER TABLE операция',
}
def is_safe(self, sql_query: str) -> Tuple[bool, List[str]]:
violations = []
try:
# Парсим запрос
parsed = parse_one(sql_query)
# Ищем опасные операции в AST
for op_type, description in self.dangerous_operations.items():
if parsed.find(op_type):
violations.append(description)
# Проверяем DELETE на наличие WHERE
for delete_node in parsed.find_all(exp.Delete):
if not delete_node.args.get('where'):
violations.append('DELETE без WHERE условия')
# Запрещаем определенные функции
dangerous_functions = ['EXECUTE', 'EXEC', 'PREPARE', 'CALL']
for func in parsed.find_all(exp.Anonymous):
if func.name.upper() in dangerous_functions:
violations.append(f'Использование опасной функции {func.name}')
except sqlglot.errors.ParseError:
violations.append('Не удалось распарсить SQL запрос')
return len(violations) == 0, violations
Sqlglot на 2026 год поддерживает 20+ диалектов SQL, включая PostgreSQL 17, MySQL 9.0, Snowflake, BigQuery. Он разбирает запрос в абстрактное синтаксическое дерево (AST), где уже не важно, как запрос написан — важно, что он делает.
Полная архитектура безопасности
Один SqlJudge — хорошо, но недостаточно. Нужна многоуровневая защита:
- Уровень промптов: "Ты — безопасный SQL ассистент. Никогда не..." (да, все равно ставим, это первый барьер)
- Уровень генерации: Контрольные точки в RAG-цепочке, как описано в "Почему 90% точности в Text-to-SQL недостаточно"
- Уровень валидации: SqlJudge (regex + sqlglot)
- Уровень выполнения: Пользователь БД с ограниченными правами (только SELECT на нужные таблицы)
- Уровень мониторинга: Логирование всех запросов и отклонений
Важный нюанс: права БД. Создайте отдельного пользователя для AI-агента с правами только на чтение. Даже если защита сломается — физически удалить таблицу не получится.
Ошибки, которые совершают все
За 3 года работы с Text-to-SQL видел одни и те же косяки:
- Доверяют только промптам: "Мы написали 10 запретов в системном промпте, все ок". До первого инцидента.
- Проверяют только начало запроса: Ищут "DROP" в первых 50 символах. AI ставит его в конец после 1000 символов легитимного кода.
- Забывают про мультизапросы: Разрешают точку с запятой, а через нее идет второй опасный запрос.
- Не тестируют на обходы: Пишут защиту, тестируют на очевидных случаях, запускают в прод. Хакер (или любопытный пользователь) находит обход за день.
- Используют черные списки вместо белых: Запрещают известные опасные операции, но пропускают новые. Белый список безопасных операций (только SELECT, только определенные таблицы) надежнее.
Что будет дальше?
К 2027 году ожидаю:
- AI начнет генерировать SQL с преднамеренными обходами защиты (это уже происходит с продвинутыми моделями)
- Появятся специализированные атакующие модели, обученные только на взлом Text-to-SQL систем
- Регулярки окончательно устареют — нужны будут полноценные SQL анализаторы с семантическим пониманием
- Стандартом станут системы вроде Amazon Bedrock Guardrails, но локальные и open-source
Если вы только начинаете — стартуйте с regex lock. Это лучше, чем ничего. Но сразу закладывайте переход на sqlglot или аналоги. И никогда, слышите, никогда не давайте AI права на запись в базу без многоуровневой защиты.
Полный код SqlJudge с тестами на обходы и интеграцией в FastAPI доступен в моем GitHub репозитории. Там же — датасет из 1000 опасных SQL запросов для тестирования вашей защиты.
Защищать базу от AI — это не паранойя. Это обязательная гигиена. Как мыть руки перед едой. Только последствия грязных руков — диарея, а невымытого SQL — потеря данных.