Spaces:
Running
Running
File size: 16,872 Bytes
09c2269 df0f1de 09c2269 df0f1de f6f0626 09c2269 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 | """
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))
|