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))