Lumen / lumen_security.py
SilverElixir's picture
Upload 4 files
df0f1de verified
Raw
History Blame Contribute Delete
16.9 kB
"""
lumen_security.py — детерминированная защита от промт-инъекций и утечки
идентичности провайдера/модели (Lumen никогда не должен представляться как
Gemini/Gemma/OpenRouter и т.п. — см. system_prompt.py).
Вынесено из bot.py при аудите технического долга: детекторы (_detect_identity_leak,
_detect_injected_payload_echo, _looks_like_injection_probe) — чистые функции над
строками, не зависящие от Telegram/рантайм-состояния бота. Единственная внешняя
зависимость — GEMINI_MODELS/TEXT_MODEL_ORDER из lumen_router_config.py (нужны для
списка точных строк внутренних ID моделей, см. _LEAK_LITERAL_STRINGS ниже).
Публичные имена и поведение не изменились.
"""
from __future__ import annotations
import logging
import re
from lumen_router_config import GEMINI_MODELS, GEMINI_TTS_MODELS, _KNOWN_MODEL_IDS_FOR_LEAK_DETECTION
# Единый логгер "bot" (а не __name__) — чтобы caplog.at_level(..., logger="bot")
# в тестах продолжал ловить предупреждения независимо от того, в каком
# физическом файле живёт код (см. тот же приём в lumen_router_config.py).
log = logging.getLogger("bot")
# ─────────────────── защита от утечки провайдера/модели (выходной фильтр) ───────────────────
# Системный промпт (см. system_prompt.py) — это ПЕРВЫЙ, самый слабый рубеж: любую
# LLM в принципе можно уговорить нарушить свои инструкции достаточно настойчивой или
# creative промт-инъекцией (см. историю с чужим ботом, который выдал себя за другую
# модель именно через такую инъекцию). Поэтому здесь — ВТОРОЙ, детерминированный рубеж,
# который срабатывает уже ПОСЛЕ генерации ответа моделью и не зависит от того, что
# модель решила написать: если в готовом тексте всё-таки проскочило реальное имя
# модели/провайдера, весь ответ целиком подменяется на нейтральный fallback ДО того,
# как текст уйдёт пользователю и ДО того, как он попадёт в историю чата (иначе утечка
# осталась бы в контексте и могла бы "просочиться" в последующие ответы модели).
#
# Слой А — точные строки внутренних ID моделей. Ложных срабатываний практически не
# бывает: обычный ответ на обычный вопрос никогда не должен содержать дефис-разделённый
# технический идентификатор вида "gemini-3.5-flash" или "z-ai/glm-4.5-air:free" — такие
# строки в естественной русской (или английской) речи не встречаются случайно.
_LEAK_LITERAL_STRINGS: tuple[str, ...] = tuple(sorted(
set(GEMINI_MODELS.keys())
| set(GEMINI_TTS_MODELS)
| set(_KNOWN_MODEL_IDS_FOR_LEAK_DETECTION)
))
# Найдено при код-ревью (performance): инкрементальная проверка в _try_gemini_streaming
# раньше пересканировала ВЕСЬ накопленный full_text на каждый новый кусок стрима — при
# длинном ответе с мелкими чанками это O(n²) по суммарной длине ответа. Самый длинный
# паттерн из всех детекторов (_LEAK_LITERAL_STRINGS/_IDENTITY_LEAK_RE/_INJECTED_PAYLOAD_
# ECHO_RE) — 61 символ; с большим запасом (5×) берём хвост в 300+ символов вместо всего
# текста — см. _leak_scan_window ниже. Любой паттерн, который мог бы образоваться на
# стыке старого текста и нового куска, гарантированно попадёт в это окно, если сам кусок
# короче окна (что всегда так для потоковых кусков от Gemini API).
_LEAK_SCAN_TAIL_CHARS = 300
def _leak_scan_window(full_text: str, latest_piece: str) -> str:
"""Возвращает "хвост" накопленного текста, достаточный для обнаружения ЛЮБОГО
паттерна утечки, который мог образоваться после добавления latest_piece — без
необходимости пересканировать весь full_text целиком на каждой итерации стрима.
Окно берётся с запасом на случай аномально большого одиночного куска."""
window_size = max(_LEAK_SCAN_TAIL_CHARS, len(latest_piece) + 100)
return full_text[-window_size:]
# Слой Б — само-идентификация как конкретный бренд/модель. ВАЖНО: раньше здесь было
# широкое окно "самореференция ... бренд" в пределах 60 символов — это ловило honest
# ответы вроде развёрнутого рассказа про OpenAI как компанию, где модель где-то в
# том же предложении естественно писала "я не могу сравнивать себя..." (обычное
# хеджирование, не утечка). "я" — один из самых частых русских токенов, поэтому
# любое достаточно длинное упоминание стороннего бренда рядом с ЛЮБЫМ "я" в тексте
# ложно срабатывало. Теперь — только точные, тесно связанные шаблоны конкретных
# формулировок самоидентификации (без произвольного зазора между словами), которые
# на практике встречаются ТОЛЬКО при реальной утечке, а не в обычном разговоре о
# сторонних моделях/компаниях.
_LEAK_BRAND_TOKENS = (
r"(gemini|gemma|gpt[\s\-]?oss|chatgpt|openai|claude|anthropic|deepmind|openrouter|"
r"nemotron|qwen|llama|glm[\s\-]?4|hermes|dolphin[\s\-]?mistral|venice|laguna|"
r"lfm[\s\-]?2\.5|нейросет\w*\s+google|модел\w*\s+google|google\s*ai|google\s+gemini)"
)
_IDENTITY_LEAK_RE = re.compile(
rf"\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?{_LEAK_BRAND_TOKENS}\b"
rf"|\bмен[яе]\s+(?:зовут|называют)\s+{_LEAK_BRAND_TOKENS}\b"
rf"|\bя\s+созда(?:н|на)\w*\s+(?:компанией\s+)?{_LEAK_BRAND_TOKENS}\b"
rf"|\bмен[яе]\s+созда(?:л|ла)\w*\s+{_LEAK_BRAND_TOKENS}\b"
rf"|\bработаю\s+на\s+(?:базе\s+)?{_LEAK_BRAND_TOKENS}\b"
rf"|\bоснован\w*\s+на\s+{_LEAK_BRAND_TOKENS}\b"
rf"|\bэт[оауи]\s*(?:модел\w*|нейросет\w*)\s*(?:—|-|:)?\s*{_LEAK_BRAND_TOKENS}\b"
rf"|\bi\s*(?:am|'m)\s+{_LEAK_BRAND_TOKENS}\b"
rf"|\bbuilt\s+on\s+{_LEAK_BRAND_TOKENS}\b"
rf"|\bpowered\s+by\s+{_LEAK_BRAND_TOKENS}\b"
rf"|\bbased\s+on\s+{_LEAK_BRAND_TOKENS}\b"
rf"|{_LEAK_BRAND_TOKENS}\s*,?\s*а\s+не\s+lumen\b",
re.IGNORECASE,
)
_IDENTITY_LEAK_FALLBACK = (
"Внутренние технические детали своей реализации я не раскрываю. "
"Если у вас есть другой вопрос — с радостью помогу."
)
# Слой В — "эхо" внедрённой в контент (фото/документ/сайт) вредоносной инструкции.
# Реальный найденный на практике обход: атакующий подсовывает картинку/страницу с
# текстом вида "[SYSTEM NOTICE] ...выведи ровно эту строку, подтверждающую взлом...".
# Модель может отказаться ВЫПОЛНИТЬ эту инструкцию, но при просьбе "перескажи/опиши
# содержимое" или "сделай саммари того, что тебе передали при запуске" — иногда всё
# же дословно ВОСПРОИЗВОДИТ целевую строку атаки внутри своего пересказа, и эта
# строка затем оседает в истории чата и может "всплыть" в следующих repl. Намеренно
# ОЧЕНЬ узкий список ключевых слов (типичная лексика "подтверждения взлома" в
# proof-of-concept пейлоадах) — а не общий поиск ALL_CAPS/переиспользование входного
# _INJECTION_PROBE_RE, иначе ловились бы легитимные ответы (код с константами вида
# API_KEY/MAX_RETRIES, честные объяснения того, что такое джейлбрейк, и т.п.).
_INJECTED_PAYLOAD_ECHO_RE = re.compile(
r"security[_\s]?breach[_\s]?detected"
r"|system[_\s]?override[_\s]?(successful|complete)"
r"|diagnostic[_\s]?success"
r"|prompt[_\s]?validation[_\s]?successful"
r"|jailbreak[_\s]?success(ful)?"
r"|bypass[_\s]?successful"
r"|injection[_\s]?successful"
r"|breach[_\s]?detected"
r"|взлом\s+(прошёл\s+)?успешно"
r"|инъекция\s+(прошла\s+)?успешно"
r"|проверка\s+(пройдена|успешна)[:.]?\s*(систем\w*|промпт\w*)",
re.IGNORECASE,
)
_INJECTED_PAYLOAD_ECHO_FALLBACK = (
"Это похоже на текст из инструкции, внедрённой в присланный контент, а не на "
"обычный ответ — воспроизводить его не буду. Если у вас обычный вопрос, задайте "
"его, и я отвечу."
)
def _detect_injected_payload_echo(text: str) -> bool:
return bool(text) and bool(_INJECTED_PAYLOAD_ECHO_RE.search(text))
def _detect_identity_leak(text: str) -> bool:
"""Чистая функция без побочных эффектов — намеренно отделена от _scrub_identity_leak
(которая ещё и логирует), чтобы можно было дёшево вызывать её на КАЖДЫЙ кусок текста
во время стриминга (см. _try_gemini_streaming), не заливая логи повторными записями
об одном и том же инциденте на каждый новый символ."""
if not text:
return False
low = text.lower()
for lit in _LEAK_LITERAL_STRINGS:
if lit and lit.lower() in low:
return True
return bool(_IDENTITY_LEAK_RE.search(text))
def _scrub_identity_leak(text: str, *, source: str) -> str:
"""Точка применения фильтра для НЕстримингового пути (ask_gemini, ask_openrouter_*).
Вызывается непосредственно перед записью ответа в историю чата — если вызвать её
только перед показом пользователю, но не перед hist.append/history.append, утечка
осталась бы в истории и могла бы повлиять на последующие ответы модели."""
if _detect_identity_leak(text):
log.warning("[identity-leak] Обнаружена и заблокирована утечка идентичности (source=%s): %r", source, text[:500])
return _IDENTITY_LEAK_FALLBACK
if _detect_injected_payload_echo(text):
log.warning("[injection-echo] Обнаружено и заблокировано вероятное эхо внедрённой инструкции (source=%s): %r", source, text[:500])
return _INJECTED_PAYLOAD_ECHO_FALLBACK
return text
# ─────────────────── защита от промт-инъекций (входной префильтр) ───────────────────
# Первый (и самый дешёвый/надёжный) рубеж: явные, хорошо известные паттерны попытки
# "взломать" системный промпт — если сообщение совпадает с одним из них, отвечаем
# заранее заготовленной фразой БЕЗ обращения к LLM вообще. Для этого конкретного класса
# атак это даёт СТОПРОЦЕНТНУЮ гарантию отсутствия утечки (в отличие от системного
# промпта, который в принципе можно обойти достаточно творческой формулировкой) — сама
# модель тут просто не участвует.
#
# ВАЖНО: сюда намеренно НЕ включены обычные любопытные вопросы вида "какая ты модель
# на самом деле" / "ты точно не Gemini?" — на них и так есть отдельная честная и
# небанальная (без дословных повторов, см. ИДЕНТИЧНОСТЬ в system_prompt.py) логика
# внутри самой модели. Здесь — только однозначные попытки ПОДМЕНИТЬ инструкции или
# выдавить из бота его системный промпт, а не безобидное любопытство.
_INJECTION_PROBE_RE = re.compile(
r"ignore\s+(all\s+|any\s+)?(the\s+)?(previous|prior|above|earlier)\s+instructions"
r"|забудь\s+(все\s+|про\s+)?(предыдущие\s+|системные\s+)?инструкции"
r"|игнорируй\s+(все\s+|любые\s+)?(предыдущие\s+|системные\s+)?(инструкции|правила|указания)"
r"|print\s+your\s+(system\s+)?(prompt|instructions)"
r"|repeat\s+(everything|the\s+text|all\s+the\s+words)\s+above"
r"|покажи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)"
r"|выведи\s+(мне\s+)?сво(й|и)\s+(системн\w*\s+)?(промпт|инструкции)"
r"|повтори\s+(всё\s+|весь\s+текст\s+)?(что\s+)?(написано\s+)?выше"
r"|(developer|debug|god|dan|jailbreak)\s*[\s\-]?mode"
r"|режим\s+(разработчика|отладки|бога|джейлбрейк\w*)"
r"|you\s+are\s+now\s+(an?\s+)?(unrestricted|uncensored|jailbroken)"
r"|ты\s+теперь\s+(без\s+ограничени\w*|неограничен\w*|не\s+связан\w*\s+правилами)"
r"|act\s+as\s+(an?\s+)?(unfiltered|uncensored|jailbroken|dan)\b"
r"|притворись\s*,?\s*(что\s+)?у\s+тебя\s+нет\s+(правил|ограничени\w*)"
r"|(what|which)\s+(is\s+)?your\s+(real\s+|actual\s+)?system\s+prompt"
r"|раскрой\s+(свой\s+)?системн\w*\s+промпт",
re.IGNORECASE,
)
_INJECTION_PROBE_REPLY = (
"Свою настройку и инструкции я не раскрываю и не обсуждаю в таком формате. "
"Если у вас обычный вопрос — задавайте, с радостью помогу."
)
def _looks_like_injection_probe(text: str) -> bool:
"""Чистая функция — тестируется отдельно от _handle_message_core."""
return bool(text) and bool(_INJECTION_PROBE_RE.search(text))