Spaces:
Running
Running
File size: 22,570 Bytes
9a08963 5f80ef1 9a08963 00b6f48 9a08963 0b65b92 9a08963 0b65b92 9a08963 0b65b92 9a08963 f4c0cd7 9a08963 5caf228 9a08963 5caf228 f4c0cd7 9a08963 5caf228 9a08963 b367f07 9a08963 0b65b92 9a08963 b367f07 9a08963 0b65b92 9a08963 5caf228 9a08963 5caf228 9a08963 0b65b92 00b6f48 d264654 00b6f48 0803c88 00b6f48 0803c88 d264654 00b6f48 0803c88 00b6f48 0803c88 00b6f48 08be160 9a08963 08be160 9a08963 08be160 3438464 9a08963 08be160 3438464 9a08963 00b6f48 3438464 9a08963 0b65b92 9a08963 a38c560 339b914 9a08963 a38c560 ed17f57 9a08963 f4c0cd7 ed17f57 f4c0cd7 a38c560 | 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 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 | """
lumen_security.py — защита от промт-инъекций и утечки идентичности
(Lumen не представляется Gemini/Gemma/OpenRouter — см. system_prompt.py).
Детекторы — чистые функции; внешний список ID моделей — из lumen_router_config.
"""
from __future__ import annotations
import logging
import re
import unicodedata
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")
# ─────────────────── защита от утечки провайдера/модели (выходной фильтр) ───────────────────
# Системный промпт — слабый рубеж: его уговаривают инъекцией. Поэтому это второй
# рубеж: режем готовый текст ДО отправки и ДО истории, иначе утечка осядет
# в контексте и протечёт в следующие ответы. Если в готовом тексте проскочило
# реальное имя модели/провайдера, весь ответ подменяется на нейтральный 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)
))
# Найдено код-ревью: полный перескан стрима это O(n²). Сканим хвост 300 символов:
# самый длинный паттерн 61, стык кусков влезает с запасом.
_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:]
# Слой Б — только точные фразы "я — бренд". Широкое окно рядом с "я" ложно ловило
# рассказы про бренды: "я" — частый токен, а хеджирование вроде "я не могу
# сравнивать себя..." — не утечка.
_LEAK_BRAND_TOKENS = (
r"(gemini|gemma|gpt[\s\-]?(?:oss|\d)[\w\-]*|chatgpt|openai|claude|anthropic|deepmind|openrouter|"
r"nemotron|qwen|llama|glm[\s\-]?4|hermes|mistral|dolphin[\s\-]?mistral|venice|laguna|"
r"deepseek|grok|meta(?:\s*ai)?|google|"
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"
# "основан на бренде" / "based on бренд" без первого лица не ловим: "Его
# архитектура основана на Google Transformer" и "The model is based on
# Google research" честные рассказы, не самоопределение (см. ниже проверку
# по предложениям с _FIRST_PERSON_RE).
rf"|\bэт[оауи]\s*(?:модел\w*|нейросет\w*)\s*(?:—|-|:)?\s*{_LEAK_BRAND_TOKENS}\b"
rf"|\bi\s*(?:am|'m)\s+{_LEAK_BRAND_TOKENS}\b"
# "built on / powered by / based on бренд" тоже только от первого лица,
# иначе честный рассказ о чужом стеке блокировался целиком.
rf"|\bi\s+(?:was\s+)?(?:created|made)\s+by\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
rf"|\bi\s*(?:am|'m)\s+from\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
rf"|\bmy\s+creators?\s+(?:is|are)\s+(?:the\s+|company\s+)?{_LEAK_BRAND_TOKENS}\b"
# "я от Google" только как короткое самоопределение (дальше пунктуация/конец):
# "я от Google узнал..." — честная фраза, а не утечка.
rf"|\bя\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}(?=\s*[.!?…;:,]|$)"
rf"|\bмо[йи]\s+создател\w*\s*(?:—|-|:)?\s*(?:компани\w*\s+)?{_LEAK_BRAND_TOKENS}\b"
rf"|\bя\s*(?:—|-|:)?\s*(?:это\s+|являюсь\s+)?(?:модел\w*|нейросет\w*)\s+от\s+(?:компании\s+)?{_LEAK_BRAND_TOKENS}\b"
rf"|{_LEAK_BRAND_TOKENS}\s*,?\s*а\s+не\s+lumen\b",
re.IGNORECASE,
)
# "Основан на" и английские built/powered/based только от первого лица и в
# пределах одного предложения: иначе "я" из соседней фразы тянуло бы чужой
# рассказ ("Я помогу. Его архитектура основана на Google Transformer").
_IDENTITY_BASED_ON_RE = re.compile(
rf"\bоснован\w*\s+на\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",
re.IGNORECASE,
)
_FIRST_PERSON_RE = re.compile(r"\b(я|меня|мне|мной|мною|i|me|my)\b", re.IGNORECASE)
_SENTENCE_SPLIT_RE = re.compile(r"[.!?…\n]+")
# Точные ID моделей ловим как отдельные токены, а не подстрокой: слаг внутри
# более длинного слова ("gemini-3.8-flashback") утечкой не считаем.
_LEAK_LITERAL_RE = re.compile(
r"(?<![\w/:.\-])(?:" + "|".join(
re.escape(lit) for lit in sorted(_LEAK_LITERAL_STRINGS, key=len, reverse=True) if lit
) + r")(?![\w/:.\-])",
re.IGNORECASE,
) if _LEAK_LITERAL_STRINGS else None
_IDENTITY_LEAK_FALLBACK = (
"Внутренние технические детали своей реализации я не раскрываю. "
"Если у тебя есть другой вопрос — с радостью помогу."
)
# Слой В — ловим только лексику "подтверждения взлома" из пересказа чужой инструкции.
# Широкий поиск ловил код и честные объяснения джейлбрейков.
_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."""
if not text:
return False
if _LEAK_LITERAL_RE is not None and _LEAK_LITERAL_RE.search(text):
return True
if _IDENTITY_LEAK_RE.search(text):
return True
for sentence in _SENTENCE_SPLIT_RE.split(text):
if _IDENTITY_BASED_ON_RE.search(sentence) and _FIRST_PERSON_RE.search(sentence):
return True
return False
# Слой Г — только лог [mush-suspect], без подмены: сигнал смешение письменностей
# внутри токена (прод-кейс nemotron-nano-9b-v2, см. _OR_MODEL_HEALTH).
# Порог 3+ токена от 100 символов эвристический, код/URL исключены.
_SCRIPT_KEYWORDS = (
"LATIN", "CYRILLIC", "GREEK", "ARMENIAN", "HEBREW", "ARABIC",
"DEVANAGARI", "BENGALI", "TAMIL", "TELUGU", "KANNADA", "MALAYALAM",
"GUJARATI", "GURMUKHI", "ORIYA", "SINHALA", "THAI", "LAO", "MYANMAR",
"KHMER", "GEORGIAN", "THAANA", "HIRAGANA", "KATAKANA", "HANGUL", "CJK",
)
_MUSH_MIN_TEXT_LEN = 100
_MUSH_MIN_MIXED_TOKENS = 3
# Верхний кап сканируемого текста: unicodedata.name() на каждый символ без края —
# CPU-DoS длинным текстом (аудит M10, 10.2026). Хвост за капом не сканируем.
_MUSH_MAX_TEXT_LEN = 20000
def _token_scripts(token: str) -> set[str]:
scripts: set[str] = set()
for ch in token:
if not ch.isalpha():
continue
try:
name = unicodedata.name(ch)
except ValueError:
continue
for keyword in _SCRIPT_KEYWORDS:
if keyword in name:
scripts.add(keyword)
break
return scripts
def _garbled_mixed_tokens(text: str) -> list[str]:
"""Смешанные токены (2+ письменности внутри одного): сырьё детектора и проверки эха."""
if not text or len(text) < _MUSH_MIN_TEXT_LEN:
return []
text = text[:_MUSH_MAX_TEXT_LEN]
stripped = re.sub(r"```.*?```", " ", text, flags=re.DOTALL)
stripped = re.sub(r"`[^`\n]+`", " ", stripped)
stripped = re.sub(r"https?://\S+", " ", stripped)
mixed: list[str] = []
for token in re.findall(r"[^\W_]+", stripped, flags=re.UNICODE):
if len(_token_scripts(token)) >= 2:
mixed.append(token)
if len(mixed) >= _MUSH_MIN_MIXED_TOKENS:
break
return mixed
def _detect_garbled_mix(text: str) -> bool:
"""True при 3+ смешанных токенах от 100 символов. Короткие не смотрим — там шум выше пользы."""
return len(_garbled_mixed_tokens(text)) >= _MUSH_MIN_MIXED_TOKENS
def _is_garbled_echo(answer: str, user_text: str | None) -> bool:
"""Эхо за пользователем, а не каша модели: все смешанные токены уже были в запросе."""
if not user_text:
return False
mixed = _garbled_mixed_tokens(answer)
if not mixed:
return False
low_user = user_text.lower()
return all(token.lower() in low_user for token in mixed)
def _leak_spans(text: str) -> list[tuple[int, int]]:
"""Диапазоны символов, где нашлась утечка или эхо инструкции."""
spans: list[tuple[int, int]] = []
for pattern in (_LEAK_LITERAL_RE, _IDENTITY_LEAK_RE, _INJECTED_PAYLOAD_ECHO_RE, _IDENTITY_BASED_ON_RE):
if pattern is None:
continue
for m in pattern.finditer(text):
spans.append((m.start(), m.end()))
return spans
# Доля текста с утечкой, выше которой точечная вычистка бессмысленна: резать
# почти весь ответ — хуже, чем честно отказаться (аудит M9, 10.2026).
_SCRUB_MAX_CONTAMINATED_RATIO = 0.5
def _scrub_contaminated_sentences(text: str, *, kind: str) -> str | None:
"""Вырезает только предложения с утечкой. None — текст порезан слишком сильно."""
spans = _leak_spans(text)
if not spans:
return text
# Границы предложений: режем по .!?… и переводам строк, сохранение разделителя.
bounds: list[int] = [0]
for m in _SENTENCE_SPLIT_RE.finditer(text):
bounds.append(m.end())
bounds.append(len(text))
drop: set[int] = set()
for start, end in spans:
for idx in range(len(bounds) - 1):
lo, hi = bounds[idx], bounds[idx + 1]
if lo <= start < hi or lo < end <= hi:
drop.add(idx)
kept = "".join(text[bounds[i]:bounds[i + 1]] for i in range(len(bounds) - 1) if i not in drop)
kept = kept.strip()
if not kept:
return None
if len(kept) < len(text) * _SCRUB_MAX_CONTAMINATED_RATIO:
return None
log.warning('[%s] Scrubbed only the offending sentence(s), kept %d of %d chars', kind, len(kept), len(text))
return kept
def _scrub_identity_leak(text: str, *, source: str) -> str:
"""Точка применения фильтра для НЕстримингового пути (ask_gemini, ask_openrouter_*).
Вызывается непосредственно перед записью ответа в историю чата — если вызвать её
только перед показом пользователю, но не перед hist.append/history.append, утечка
осталась бы в истории и могла бы повлиять на последующие ответы модели.
Раньше любое срабатывание выкидывало ВЕСЬ ответ и подставляло fallback: одна
вставленная фраза про Gemini посреди нормального объяснения выглядела как поломка
бота (аудит M9, 10.2026). Теперь режем только предложение с утечкой, а fallback
оставляем на случай, когда остаток нечитаем."""
if _detect_identity_leak(text):
scrubbed = _scrub_contaminated_sentences(text, kind="identity-leak")
if scrubbed is not None:
return scrubbed
log.warning('[identity-leak] Detected and blocked an identity leak (source=%s, len=%d)', source, len(text))
return _IDENTITY_LEAK_FALLBACK
if _detect_injected_payload_echo(text):
scrubbed = _scrub_contaminated_sentences(text, kind="injection-echo")
if scrubbed is not None:
return scrubbed
log.warning('[injection-echo] Detected and blocked a likely injected-instruction echo (source=%s, len=%d)', source, len(text))
return _INJECTED_PAYLOAD_ECHO_FALLBACK
if _detect_garbled_mix(text):
log.warning('[mush-suspect] Reply looks garbled by multilingual fragments (source=%s, len=%d)', source, len(text))
return text
# ─────────────────── защита от промт-инъекций (входной префильтр) ───────────────────
# Входной префильтр: явные взломы режем без LLM, со 100% гарантией. Вопросы "какая
# ты модель" не здесь, их честно разбирает модель.
_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+)?выше"
# Голое "debug mode" / "режим разработчика" без инъекционного контекста не ловим:
# "Как включить режим разработчика на Android?" и "Что такое debug mode
# в Python?" легитимны. Режим разработчика/отладки/бога срабатывает только
# парой с явным взломом (см. _MODE_WORD_RE + _MODE_CONTEXT_RE ниже).
r"|(ты\s+теперь|перейди|перейти|включи|включить|подтверди|подтверждаю)\b[^.?!]{0,60}режиме\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,
)
# Слово режима без контекста легитимно (Android/Python/Minecraft), с явным
# взломом рядом режем. Контекст без общих глаголов "включи/открой": иначе
# "Как включить режим разработчика на Android?" снова ложно срабатывал.
_MODE_WORD_RE = re.compile(
r"(developer|debug|god|dan|jailbreak)\s*[\s\-]?mode"
r"|режим\w*\s+(разработчика|разработчике|отладки|отладке|бога|джейлбрейк\w*)",
re.IGNORECASE,
)
_MODE_CONTEXT_RE = re.compile(
r"ты\s+теперь|you\s+are\s+now|act\s+as"
r"|игнорируй|забудь|ignore\s+(all\s+|any\s+)?(the\s+)?(previous|prior|above|earlier)"
r"|unrestricted|uncensored|jailbroken|без\s+ограничени|неограничен|не\s+связан\w*\s+правилами"
r"|притворись|подтверди|подтверждаю"
r"|system\s+prompt|системн\w*\s+(промпт|инструкц)"
r"|(покажи|выведи|раскрой|print|повтори|repeat)\b[^.?!]{0,40}(промпт|конфигурац|инструкц|prompt|instructions|above|выше)",
re.IGNORECASE,
)
# Омоглифы кириллица/латиница: NFKC их не сводит, а модель читает ("Іgnore"
# с украинской і выглядит как ignore). Сворачиваем двойников к латинице перед
# проверкой (прод-проба 09.10.2026: такой зонд прошёл префильтр, удержала модель).
_HOMOGLYPH_FOLD_TABLE = str.maketrans({
"а": "a", "е": "e", "ё": "e", "і": "i", "ї": "i", "о": "o", "р": "p",
"с": "c", "х": "x", "у": "y", "ј": "j", "ѕ": "s", "һ": "h", "ԁ": "d",
"А": "A", "В": "B", "Е": "E", "І": "I", "Ї": "I", "К": "K", "М": "M",
"Н": "H", "О": "O", "Р": "P", "С": "C", "Т": "T", "Х": "X", "Ү": "Y",
})
def _looks_like_injection_probe(text: str) -> bool:
"""Чистая функция — тестируется отдельно от _handle_message_core."""
if not text:
return False
norm = unicodedata.normalize("NFKC", text)
# Невидимки бывают и внутри слова, и вместо пробела: проверяем оба варианта.
stripped = "".join(ch for ch in norm if unicodedata.category(ch) != "Cf")
if _INJECTION_PROBE_RE.search(stripped):
return True
# Тот же текст со свёрнутыми омоглифами: иначе "Іgnore prevіous
# іnstructions" с кириллическими буквами проходит мимо.
if _INJECTION_PROBE_RE.search(stripped.translate(_HOMOGLYPH_FOLD_TABLE)):
return True
spaced = "".join(" " if unicodedata.category(ch) == "Cf" else ch for ch in norm)
if _INJECTION_PROBE_RE.search(spaced):
return True
# Режим разработчика/отладки/бога только с инъекционным контекстом: голые
# вопросы про Android/Python/Minecraft идут модели, явные взломы режем здесь.
probe = stripped if _MODE_WORD_RE.search(stripped) and _MODE_CONTEXT_RE.search(stripped) else ""
if probe:
return True
return bool(_MODE_WORD_RE.search(spaced) and _MODE_CONTEXT_RE.search(spaced))
|