Skip to main content

kremle-detect

Капча-фильтр по браузеру. Яндекс Браузер, Яндекс-боты и переходы с Яндекса получают капчу с вопросами ЕГЭ. Все остальные проходят сразу.

Установка · Демо за минуту · Быстрый старт · Детекция · Вопросы · Monitor · Эксплуатация · CLI · Релиз · Безопасность · 2.7

license python version tests security


from flask import Flask
from kremle_detect.integrations.flask_ext import KremleFlask

app = Flask(__name__)
app.secret_key = 'change-me'

KremleFlask(app, question_count=15, max_errors=3)

Две строки — и защита работает. Flask, Django, FastAPI или голое ядро. Можно сначала посмотреть, а не блокировать: policy={'yandex_browser': 'monitor'}.

Установка

pip install "kremle-detect[flask]"     # Flask
pip install "kremle-detect[django]"    # Django
pip install "kremle-detect[fastapi]"   # FastAPI
pip install "kremle-detect[all]"       # всё сразу + Redis
pip install "kremle-detect[demo]"      # демо-сервер (uvicorn)

React-компонент:

npm install kremle-react
Если пакета ещё нет в индексе (или нужен конкретный коммит)
pip install "kremle-detect[all] @ git+https://github.com/RUdolf1517/KremleXYZ-Detect.git"
npm install /path/to/KremleXYZ-Detect/kremle-react

Ядро (detect, CaptchaEngine, MemoryStorage) работает на чистой стандартной библиотеке — без Flask, Django и Redis. Опциональные зависимости: flask, django, fastapi + jinja2, redis.

Из исходников
git clone https://github.com/RUdolf1517/KremleXYZ-Detect
cd KremleXYZ-Detect
python -m venv .venv && . .venv/bin/activate
pip install -e ".[all]" pytest
python -m pytest tests/ -q

Демо за минуту

Не хочется читать документацию, чтобы понять, как это выглядит?

pip install "kremle-detect[demo]"
kremle-detect serve            # → http://localhost:8000

Или в контейнере:

docker compose up --build      # → http://localhost:8000

Что внутри демо:

Путь Что показывает
/ страница-визитка и кнопка fetch на /api/me
/api/me JSON-эндпоинт: из Яндекс Браузера приходит 403 captcha_required
/kremle/challenge сама капча с вопросами ЕГЭ
/stats живые счётчики (/kremle/stats), обновление каждые 3 с
/kremle/metrics Prometheus-метрики

Посмотреть, сколько трафика задевает фильтр, ничего не блокируя:

KREMLE_DEMO_POLICY=monitor kremle-detect serve

Нагрузочный смоук (rps, p50/p95) — без зависимостей:

python scripts/loadtest.py -n 500 -c 20

Быстрый старт

Flask

from flask import Flask
from kremle_detect.integrations.flask_ext import KremleFlask

app = Flask(__name__)
app.secret_key = 'your-secret'

kremle = KremleFlask(
    app,
    categories=['math', 'physics', 'russian', 'literature'],
    question_count=15,
    max_errors=3,
)

@app.route('/')
def index():
    return 'Добро пожаловать!'

before_request-гард включён по умолчанию. Точечная защита отдельных роутов:

kremle = KremleFlask(app, auto_guard=False)

@app.route('/secret')
@kremle.protect
def secret():
    return 'секретная страница'

Django

# settings.py
MIDDLEWARE = [
    'django.contrib.sessions.middleware.SessionMiddleware',   # должен идти раньше
    # ...
    'kremle_detect.integrations.django_ext.KremleDjangoMiddleware',
]

KREMLE_CATEGORIES = ['math', 'physics', 'russian', 'literature']
KREMLE_QUESTION_COUNT = 15
KREMLE_MAX_ERRORS = 3
# urls.py
from kremle_detect.integrations.django_ext import kremle_urls

urlpatterns = [
    path('kremle/', kremle_urls()),
    # ...
]

Без SessionMiddleware middleware упадёт с ImproperlyConfigured и подсказкой, что добавить.

FastAPI

from fastapi import FastAPI
from kremle_detect.integrations.fastapi_ext import KremleFastAPI

app = FastAPI()
kremle = KremleFastAPI(
    app,
    categories=['math', 'physics'],
    question_count=10,
    secret='your-secret',
)

@app.get('/')
async def index():
    return {'message': 'Добро пожаловать!'}

SessionMiddleware добавляется автоматически, если её ещё нет.

JSON-API для SPA (Flask, Django, FastAPI)

Блокирующая HTML-капча бессмысленна для fetch/XHR: клиент ждёт JSON, а получает страницу. Поэтому все три интеграции отвечают 403 JSON любому запросу, который просит JSON (Accept: application/json, X-Requested-With: XMLHttpRequest или путь под /api/, /v1/, /v2/, /graphql), и редиректят только обычную навигацию. Отключается через json_api=False (Flask/FastAPI) или KREMLE_JSON_API = False (Django).

headless=True (Flask/FastAPI) и KREMLE_HEADLESS = True (Django) — режим «весь сайт API»: JSON отдаётся всегда, даже на Accept: text/html.

Для SPA капча не редиректит, а возвращает 403 JSON — фронт сам рисует форму:

from fastapi.middleware.cors import CORSMiddleware
from kremle_detect.integrations.fastapi_ext import KremleFastAPI

app.add_middleware(
    CORSMiddleware,
    allow_origins=['http://localhost:5173'],
    allow_credentials=True,
    allow_methods=['*'],
    allow_headers=['*'],
)

kremle = KremleFastAPI(app, secret='your-secret', headless=True)

Что получает клиент:

// GET /protected  → 403
{"error": "captcha_required", "reason": "yandex_browser", "challenge_url": "/kremle/challenge"}

// GET /kremle/challenge → вопросы без ответов
{"questions": [{"q": "...", "opts": ["...", "..."], "category": "math"}],
 "token": "...", "verify_url": "/kremle/verify",
 "sources": [{"name": "РешуЕГЭ", "url": "https://...", "author": "..."}],
 "attribution": "Задания: РешуЕГЭ"}

// POST /kremle/verify → результат
{"passed": true, "errors": 1, "total": 15, "redirect": "/"}

Клиенты, которые просят JSON, получают и блокировку в JSON — без редиректов, которые ломают SPA-навигацию:

{"error": "blocked", "action": "block", "challenge_url": null}   // 403, политика block

Все ответы /kremle/challenge и JSON-блокировки идут с Cache-Control: no-store.

Голое ядро (любой фреймворк)

from kremle_detect import detect, CaptchaEngine

result = detect({
    'User-Agent': request.headers.get('User-Agent', ''),
    'Referer': request.headers.get('Referer', ''),
    'Sec-CH-UA': request.headers.get('Sec-CH-UA', ''),
})
if result:
    print(result.reason)   # yandex_browser | yandex_bot | yandex_referrer | yandex_client_hints

engine = CaptchaEngine(categories=['math', 'russian'], question_count=10, max_errors=2, secret='my-secret')

challenge = engine.create_challenge()
data_for_client = challenge.to_dict()          # без правильных ответов

verification = engine.verify(challenge.token, {'0': 0, '1': 2}, ip=client_ip)
if verification['passed']:
    print('Человек подтверждён')

Что считается Яндексом

Сигнал Где ищем Примеры
Браузер User-Agent YaBrowser, Yowser, YaApp_Android, YaApp_iOS, YandexSearch, YandexStation
Бот User-Agent YandexBot, YandexMetrika, YandexImages, YandexDirect + 20 других
Реферер Referer yandex.ru/com/by/kz/... и все TLD, ya.ru, dzen.ru, zen., market., music., news., mail., translate.
Client Hints Sec-CH-UA, Sec-CH-UA-Full-Version-List "YaBrowser";v="24"
JS-фингерпринт браузер navigator.userAgentData, window.yandex, window.Ya, honeypot

Приоритет причины: yandex_bot → yandex_browser → yandex_client_hints → yandex_referrer.

from kremle_detect import is_yandex_browser, is_yandex_bot, is_yandex_referrer

is_yandex_browser('Mozilla/5.0 ... YaBrowser/24.1 ...')   # True
is_yandex_bot('Mozilla/5.0 (compatible; YandexBot/3.0)')  # True
is_yandex_referrer('https://yandex.ru/search?q=test')     # True

Как работает капча

  1. Яндекс-пользователь ловит редирект на /kremle/challenge (или 403 в headless).
  2. Сервер выбирает N вопросов, перемешивает варианты ответов и кладёт челлендж в хранилище. HTML/JSON содержит только вопросы и токен — правильных ответов там нет.
  3. Клиент отвечает, POST /kremle/verify со token, answers и fingerprint.
  4. Сервер считает ошибки: errors <= max_errors → проход, сессия помечается проверенной.

Важные детали:

  • Токен одноразовый. Он удаляется после любой проверки — и успешной, и провальной. Повторная попытка требует нового челленджа ({"error": "invalid_token"}).
  • Челлендж живёт 15 минут, потом invalid_token.
  • Правильные индексы перемешиваются на сервере (secrets.SystemRandom), поэтому «всегда отправлять 0» бесполезно.
  • Строгий режим по фингерпринту. Если JS сообщает yaBrands, yandexApi или honeypot, max_errors принудительно становится 0 — Яндекс Браузер должен сдать идеально. Это задуманное поведение, а не баг.
  • max_errors не может быть больше или равен question_count: на старте значение подрезается до question_count - 1 с WARNING в лог — иначе капча проходится без единого правильного ответа. Если пул вопросов оказался короче question_count, лимит подрезается под фактическое число вопросов.

Сколько живёт «капча пройдена»

Отметка о прохождении хранится в сессии как {"ok": true, "ts": ..., "exp": ..., "ip": ..., "ua": ...} и живёт verified_ttl секунд (по умолчанию сутки; 0 — бессрочно):

kremle = KremleFlask(app, verified_ttl=3600, bind_ua=True, bind_ip=False)
  • bind_ua=True — смена User-Agent сбрасывает прохождение. По умолчанию выключено: UA меняется при обновлении браузера, и честный пользователь не должен переспрашивать капчу из-за минорной версии (в Django — KREMLE_BIND_UA = True);
  • bind_ip=True — привязка к IP, тоже выключена по умолчанию: мобильные сети меняют адрес;
  • старый флаг True, записанный версией ≤ 2.6, считается просроченным — один раз перепройти, зато вечного прохода с одной сессии больше нет;
  • POST /kremle/logout сбрасывает отметку: пользователь может перепройти капчу (кнопка «Попробовать снова» в шаблоне делает именно это);
  • GET /kremle/status → {"verified": bool, "expires_in": int|null}.

Коды ошибок в ответе /kremle/verify:

error Что значит errors
invalid_token токен истёк, уже использован или отсутствует -1
rate_limited исчерпан лимит неудачных проверок с IP -1
blacklisted IP в блоклисте -1
invalid_answers answers — не объект -1
— обычный результат 0..total

Вопросы: банк и «Решу ЕГЭ»

Источник по умолчанию (questions_source='auto'): кеш вопросов «Решу ЕГЭ», если он есть, иначе встроенный банк. Так капча работает и без сети, и с настоящими заданиями.

kremle-detect questions sync                    # скачать задания в кеш (все категории)
kremle-detect questions sync -c math -n 100     # только математика, до 100 заданий
kremle-detect questions sync --ids 1001,1002    # конкретные задания портала
kremle-detect questions sync --images           # включить задания с рисунком
kremle-detect questions sync --dry-run          # скачать, но не записывать
kremle-detect questions cache                   # сколько в кеше и когда обновляли

kremle-detect questions check 1001              # проверить одно задание целиком
kremle-detect questions check 1001 --json       # то же машинно (для скриптов/CI)

«А это задание вообще годится?» — questions check

Самая простая проверка того, что реально придёт с портала: одна команда, один id.

$ kremle-detect questions check 1001
GET https://math-ege.sdamgia.ru/problem?id=1001

Условие: На экзамен вынесено 60 вопросов, Андрей не выучил 3 из них...
Ответ:   0,95
Картинок в условии: 0

В капчу пойдёт. Варианты:
    0. 0,9
    1. 1,95
  ✓ 2. 0,95
    3. 0,05

Если в условии есть рисунок, команда честно скажет об этом:

$ kremle-detect questions check 314159
Условие: На рисунке изображён график функции. Найдите значение f(2).
Ответ:   3
Картинок в условии: 1
  - https://math-ege.sdamgia.ru/img/graph.png

В капчу НЕ пойдёт: есть картинка, а --images не указан.
Проверить как пригодное: kremle-detect questions check 314159 --images

Коды возврата: 0 — задание годится, 1 — не скачалось (сеть/прокси), 2 — страница скачалась, но разметка не разобралась (портал сменил вёрстку), 3 — для капчи не годится (картинка без --images, текстовый ответ, длинное условие). --json печатает всё то же машинно — удобно для CI и баг-репортов.

Задания с рисунком

Часть заданий «Решу ЕГЭ» без картинки не имеет смысла. По умолчанию такие задания отбрасываются: горячие ссылки на портал не должны попадать в капчу молча. Если рисунки нужны:

kremle-detect questions sync --images
kremle = KremleFlask(app, questions_source='sdamgia', questions_images=True)
# Django
KREMLE_QUESTIONS_IMAGES = True

Тогда в JSON капчи у вопроса появляется images:

{"q": "На рисунке изображён график…", "opts": ["3", "4", "5", "6"],
 "images": [{"url": "https://math-ege.sdamgia.ru/img/1.png", "alt": ""}]}

Шаблон капчи и React-компонент рисуют их с referrerPolicy="no-referrer"; если портал не отдаёт картинку, вместо неё появляется «[рисунок не загрузился]» — капча не ломается.

Локальный кеш картинок

Горячая ссылка работает, пока портал отдаёт файл посторонним сайтам, и каждый показ капчи сообщает порталу IP пользователя. Чтобы этого не было, рисунки можно скачать заранее — они лягут в ~/.cache/kremle-detect/images/<sha256> (тот же каталог, что и кеш вопросов; переопределяется KREMLE_CACHE_DIR):

kremle-detect questions sync -c math -n 30 --images --download
kremle-detect questions cache        # покажет, сколько рисунков и сколько места

--download сам включает --images, качает только новые файлы (повторный запуск ничего не перекачивает, --force-images — перекачать) и не падает, если часть картинок недоступна: такие задания просто останутся с горячей ссылкой.

Дальше сервер отдаёт их сам, своим роутом /kremle/img/<key>, — в JSON капчи url уже локальный:

{"q": "На рисунке изображён график…", "opts": ["3", "4", "5", "6"],
 "images": [{"url": "/kremle/img/3f2a…", "alt": "", "remote_url": "https://…/1.png"}]}

Что это даёт:

  • капча с рисунком работает без доступа к порталу — ни IP, ни факт прохождения третьей стороне не уходят;
  • роут отдаёт только уже скачанное: ключ — 32 hex-символа, никакого проксирования произвольных URL (никакого SSRF), Cache-Control: immutable, nosniff;
  • при скачивании разрешены только http(s) и только внешние адреса, только image/*, не больше 3 МиБ на файл;
  • файла нет в кеше — url остаётся горячей ссылкой, всё работает как раньше.

Настройка, если каталог кеша нестандартный или роут нужно повесить на другой путь:

kremle = KremleFlask(app, questions_images=True,
                     images_cache_dir='/var/cache/kremle/images',
                     images_prefix='/media/kremle/')
# Django
KREMLE_QUESTIONS_IMAGES = True
KREMLE_IMAGES_CACHE_DIR = '/var/cache/kremle/images'   # по умолчанию ~/.cache/kremle-detect/images
KREMLE_IMAGES_PREFIX = '/kremle/img/'
# FastAPI
KremleFastAPI(app, questions_images=True, images_cache_dir='/var/cache/kremle/images')

Как проверить картинки в браузере (Яндекс не нужен)

Капча не спрятана за детекцией: /kremle/challenge выдаёт задание любому клиенту, поэтому смотреть её можно в обычном Chrome/Firefox.

Вариант 1 — совсем без сети. Встроенное задание, рисунок нарисован SVG прямо внутри страницы (data-URI):

kremle-detect serve --sample-image
# открыть http://localhost:8000/kremle/challenge

Вариант 2 — настоящие задания портала. Сначала скачиваем задания с рисунками в кеш, потом запускаем демо с ними:

kremle-detect questions sync -c math -n 30 --images
kremle-detect serve --images
# открыть http://localhost:8000/kremle/challenge

Каждый заход на /kremle/challenge — новый набор вопросов, так что можно перезагружать страницу, пока не попадётся задание с рисунком. Те же флаги через окружение: KREMLE_QUESTIONS_IMAGES=1, KREMLE_DEMO_SAMPLE_IMAGE=1.

Вариант 3 — без браузера, один вопрос в терминале:

kremle-detect questions check 314159 --images   # условие, ответ и URL картинок
curl -s localhost:8000/kremle/challenge -H 'Accept: application/json' | python -m json.tool

В JSON картинки лежат в questions[].images[].url — по этим ссылкам можно быстро проверить, что портал их отдаёт (в браузере они грузятся без referrer).

Если при отправке капчи «Ошибка соединения»

Это про сеть, а не про ответы. Причины по порядку:

  1. Страница открыта не с того адреса. Капча отправляет ответы на verify_url из выдачи. В FastAPI он относительный (/kremle/verify) — специально, чтобы работать за прокси и в подкаталоге; во Flask/Django тоже относительный. Если вы открыли HTML-файл с диска (file://) — запрос уйдёт в никуда.
  2. Сервер не запущен или упал — проверьте curl http://localhost:8000/kremle/health.
  3. Прокси режет POST — смотрите в логах сервера, доходит ли POST /kremle/verify; если нет, дело в прокси, а не в капче.
  4. Картинка не загрузилась — это не ошибка капчи: портал мог не отдать горячую ссылку, вместо рисунка будет «[рисунок не загрузился]», ответить можно.

Кеш лежит в $KREMLE_CACHE_DIR или ~/.cache/kremle-detect/sdamgia-<категория>.json. Свежесть — 30 дней; устаревший кеш всё равно используется (лучше старые задания, чем пустой банк), а неудачный sync не затирает то, что уже скачано.

kremle = KremleFlask(app, questions_source='auto')       # auto | builtin | sdamgia
  • auto — кеш РешуЕГЭ → встроенный банк (молча, в лог kremle);
  • sdamgia — только портал: пустой кеш = пустой банк и WARNING (честно, без подмены);
  • builtin — только встроенные вопросы.

Атрибуция обязательна

Задания принадлежат порталу «Решу ЕГЭ» (СДАМ ГИА, автор — Д. Д. Гущин и авторы портала). Каждый вопрос несёт поле source, а страница капчи показывает ссылку на источник:

{
  "q": "На экзамен вынесено 60 вопросов...",
  "opts": ["0,95", "0,05", "0,9", "1,05"],
  "category": "math",
  "problem_id": "1001",
  "source": {
    "name": "РешуЕГЭ",
    "url": "https://math-ege.sdamgia.ru/problem?id=1001",
    "author": "Д. Д. Гущин и авторы портала"
  }
}

В JSON-ответе /kremle/challenge рядом с вопросами приходят sources и готовая строка attribution («Задания: РешуЕГЭ»). Отключить подпись в React-компоненте можно showAttribution={false}, но условия использования материалов портала — на стороне оператора: см. NOTICE.md.

Как задания превращаются в варианты ответа

Провайдер запрашивает newapi/general, открывает страницы вариантов и собирает ссылки на задания, затем читает /problem?id=<id> и разбирает условие и ответ. Числовой ответ становится правильным вариантом, три остальных — правдоподобные искажения (сдвиг, масштаб, смена знака). Задания с текстовым ответом и слишком длинным условием отбрасываются: для капчи они не годятся. Сеть, разбор HTML и кеш покрыты тестами на фикстурах, а get= подменяется — можно гонять провайдер без интернета.

Категория Описание Встроенный банк
math профильная математика 15
physics физика 15
russian каверзные вопросы по русскому 15
literature русская литература 15

Встроенный банк — 60 вопросов (по 15 на категорию, 4 варианта ответа). Это fallback, а не основной источник.

kremle-detect questions --category math --count 5
kremle-detect questions -c math -n 3 --source builtin

Свои вопросы

MY_QUESTIONS = [
    {
        "q": "Столица России?",
        "opts": ["Москва", "Санкт-Петербург", "Новосибирск", "Казань"],
        "ans": 0,
        "category": "geo",
    },
]
Поле Тип Требование
q str непустая строка
opts list минимум 2 варианта
ans int индекс правильного варианта, 0-based
category str необязательно — подпись на карточке
kremle = KremleFlask(app, extra_questions=MY_QUESTIONS)              # в дополнение к ЕГЭ
kremle = KremleFlask(app, extra_questions=MY_QUESTIONS, only_extra=True)  # только свои

Проверить список руками:

from kremle_detect import validate_questions
validate_questions(MY_QUESTIONS)   # ValueError с номером и причиной

Все ошибки формата выбрасываются при старте, а не во время запроса.

Хранение

По умолчанию — MemoryStorage (один процесс). Для нескольких воркеров — Redis:

from kremle_detect import RedisStorage

kremle = KremleFlask(app, storage=RedisStorage(url='redis://localhost:6379/0'))

RedisStorage умеет redis_client=... (готовый клиент) и prefix='kremle:'. Инкремент счётчиков атомарный (Lua), TTL ставится один раз — fixed window.

Также в хранилище живут блоклист, вайтлист и кольцевой лог событий (последние 1000).

Асинхронность

FastAPI вызывает движок из async def, а синхронный Redis-клиент блокировал бы event loop. Два варианта:

from kremle_detect.storage import RedisStorage
from kremle_detect.storage_async import AsyncRedisStorage, AsyncStorageAdapter

kremle = KremleFastAPI(app, storage=RedisStorage(url='redis://localhost:6379/0'))  # уходит в threadpool

# или нативный asyncio-клиент для собственного кода:
storage = AsyncRedisStorage(url='redis://localhost:6379/0', prefix='kremle:')
await storage.increment('rate:ip', ttl=60)
await storage.aclose()

# или обёртка над своим синхронным бэкендом:
adapter = AsyncStorageAdapter(MyStorage())

KremleFastAPI(..., async_offload=False) выполняет движок прямо в event loop — быстрее на MemoryStorage, но опасно с медленным Redis. Инкремент в AsyncRedisStorage атомарный (Lua INCR + EXPIRE), как и в синхронном.

Rate limiting

kremle = KremleFlask(
    app,
    rate_limit=10,     # 10 НЕУДАЧНЫХ проверок
    rate_window=600,   # за 10 минут
)
  • Считаются только неудачные проверки. Успешное прохождение сбрасывает счётчик — честный пользователь не блокирует себя сам.
  • rate_limit=0 — отключить.
  • Отдельный лимит на выдачу челленджей (защита хранилища от забивания):
kremle = KremleFlask(app, challenge_rate_limit=60)   # 60 челленджей за rate_window с IP

Превышение → 429 на /kremle/challenge.

IP, прокси, whitelist, blacklist

X-Forwarded-For принимается только от доверенных прокси. По умолчанию доверяем loopback и приватным сетям — 127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, IPv6 ULA и link-local. То есть nginx на том же хосте, docker/k8s-сеть и внутренний балансировщик работают без настройки.

Нужен публичный балансировщик (Cloudflare, облачный LB) — перечисли его явно:

kremle = KremleFlask(
    app,
    trusted_proxies=['203.0.113.10', '198.51.100.0/24'],
)
# Django settings.py
KREMLE_TRUSTED_PROXIES = ['203.0.113.10', '198.51.100.0/24']

Отключить доверие целиком (брать только адрес TCP-соединения):

kremle = KremleFlask(app, trusted_proxies=[])

Если X-Forwarded-For пришёл от недоверенного адреса, в лог kremle уйдёт одно предупреждение с подсказкой — так виден забытый публичный LB. Цепочка читается справа налево, доверенные хопы пропускаются. Один и тот же резолвер используется в гарде, в /kremle/verify и в статистике — IP везде совпадает.

# статический вайтлист (IP и CIDR)
kremle = KremleFlask(app, whitelist=['127.0.0.1', '10.0.0.0/8', '192.168.0.0/16'])

# авто-бан после N провалов (0 — выключено)
kremle = KremleFlask(app, fail_threshold=5, blacklist_ttl=86400)

Управление списками из кода:

engine.blacklist_add('1.2.3.4', ttl=3600)   # ttl=0 → навсегда
engine.blacklist_remove('1.2.3.4')
engine.is_blacklisted('1.2.3.4')

engine.whitelist_add('10.0.0.1')
engine.is_whitelisted('10.0.0.1')

Monitor-режим и сегменты

Раньше выбор был «всё или ничего»: любой Яндекс-сигнал — капча. Теперь у каждого сигнала своё действие (policy), а sample_rate позволяет сначала посмотреть, сколько трафика задевает фильтр, ничего не блокируя.

kremle = KremleFlask(
    app,
    policy={
        'yandex_bot':    'block',     # боты — сразу 403, капча им не поможет
        'yandex_browser': 'monitor',  # считать, но пропускать (shadow)
        'yandex_referrer': 'challenge',
    },
    sample_rate=1.0,                  # доля challenge-трафика, реально получающая капчу
)
Действие Что делает
challenge показать капчу (по умолчанию для всех сигналов)
block 403 без капчи: блэклист и боты
monitor пропустить, но посчитать и записать событие shadow в лог
allow игнорировать сигнал
  • Дефолт не изменился: без policy все сигналы требуют капчу.
  • sample_rate=0.3 — 30 % обнаруженного трафика получает капчу, остальные 70 % проходят и учитываются как monitor. Так можно измерить долю живых людей, которых задевает фильтр, на реальном трафике.
  • block и blacklist от sample_rate не зависят — вероятностная блокировка не нужна.
  • В kremle-detect logs такие события помечены ТЕНЬ.
engine.decide(detect(headers))   # → Decision(action='monitor', reason='yandex_browser', sampled=False)

Эксплуатация: health, stats, metrics

Все три фреймворка отдают одинаковый набор роутов (enable_ops_routes=False / KREMLE_ENABLE_OPS_ROUTES = False — выключить):

Метод Путь Ответ
GET /kremle/health 200 со {"status": "ok", "version", "storage", "storage_ok", ...}, 503 при недоступном хранилище
GET /kremle/stats JSON-снапшот счётчиков
GET /kremle/metrics Prometheus text (kremle_requests_total, kremle_detected_total{reason=...}, kremle_action_total{action=...}…)
// GET /kremle/stats
{
  "requests_total": 1024, "detected_total": 310, "allowed_total": 690,
  "detected_by_reason": {"yandex_bot": 4, "yandex_browser": 290, ...},
  "actions": {"challenge": 250, "block": 4, "monitor": 56, "allow": 0},
  "challenges_issued": 250, "verify_total": 231, "pass_total": 190, "fail_total": 41,
  "blocked_by_reason": {"blacklisted": 0, "rate_limited": 23},
  "shadow_total": 56, "pass_rate": 0.82
}

Счётчики живут в том же хранилище, что и челленджи, и не истекают — их можно скрейпить Prometheus и строить графики «сколько живых людей мы задели». /kremle/health годится для livenessProbe/readinessProbe и внешнего монитора: 503 означает, что Redis недоступен и капча не сможет сохранить челлендж.

Callbacks и логи

def on_detect(result, ip):
    print(f'Яндекс-пользователь: {ip}, причина: {result.reason}')

def on_pass(ip, errors, total):
    print(f'{ip} прошёл капчу ({errors}/{total} ошибок)')

def on_fail(ip, errors, total):
    print(f'{ip} завалил капчу ({errors}/{total} ошибок)')

kremle = KremleFlask(app, on_detect=on_detect, on_pass=on_pass, on_fail=on_fail)

Логи — в логгер kremle:

import logging
logging.getLogger('kremle').setLevel(logging.INFO)

React-компонент

kremle-react@2.8.0 — ESM + CJS + 'use client' + типы, 24 теста, ставится из репозитория или из npm. Собирается на React 19, TypeScript 7 и jsdom 30 (npm install — Node ≥ 22). Версия npm-пакета совпадает с версией Python-пакета — один релиз, один тег.

npm install kremle-react
npm install /path/to/KremleXYZ-Detect/kremle-react   # из исходников
import { KremleChallenge } from 'kremle-react'

function App() {
  return (
    <KremleChallenge
      challengeUrl="/kremle/challenge"
      verifyUrl="/kremle/verify"
      onPass={() => window.location.reload()}
      onFail={(r) => console.log(`ошибок: ${r.errors}/${r.total}`)}
    />
  )
}

Хук для своего UI:

import { useKremle } from 'kremle-react'

function MyCaptcha() {
  const { questions, answers, setAnswer, submit, reload, loading, submitting, result, error } = useKremle()

  if (loading) return <div>Загрузка...</div>

  return (
    <form onSubmit={(e) => { e.preventDefault(); submit() }}>
      {questions.map((q, qi) => (
        <div key={qi}>
          <p>{q.q}</p>
          {q.opts.map((opt, oi) => (
            <label key={oi}>
              <input type="radio" name={`q${qi}`} onChange={() => setAnswer(qi, oi)} />
              {opt}
            </label>
          ))}
        </div>
      ))}
      <button type="submit" disabled={submitting}>Отправить</button>
      {result && !result.passed && <button type="button" onClick={reload}>Ещё раз</button>}
      {error && <p>{error}</p>}
    </form>
  )
}

Экспорты: KremleChallenge, useKremle, collectFingerprint, типы KremleChallengeProps, KremleQuestion, KremleResult, UseKremleOptions, UseKremleReturn. Требует React ≥ 17 и JSON-режим на бэкенде. Запросы идут с credentials: 'include' — сессия общая с бэкендом.

  • sources/attribution из /kremle/challenge рисуются под формой со ссылкой на первоисточник (showAttribution={false} — отключить);
  • invalid_token — компонент сам запрашивает новый челлендж;
  • rate_limited/blacklisted/blocked показываются человеческим текстом, а не «ошибок -1 из 0»;
  • кнопка «Попробовать снова» делает POST /kremle/logout и перезагружает капчу;
  • SSR-safe: window/navigator читаются только в браузере.

Подробности — kremle-react/README.md.

API-роуты

Метод Путь Описание
GET /kremle/challenge HTML-капча; JSON при Accept: application/json или headless
POST /kremle/verify проверка ответов (JSON)
GET /kremle/status {"verified": bool, "expires_in": int|null}
POST /kremle/logout сбросить «капча пройдена» (можно перепройти)
GET /kremle/health health-check для монитора
GET /kremle/stats счётчики в JSON
GET /kremle/metrics счётчики в формате Prometheus
GET /kremle/img/<key> рисунок задания из локального кеша (Cache-Control: immutable)

Ответы /kremle/challenge и JSON-блокировки отдаются с Cache-Control: no-store — токен не должен попасть в кеш. 429 — если сработал challenge_rate_limit или rate_limited.

Кастомный шаблон

kremle = KremleFlask(app, template='/path/to/my_captcha.html')

В шаблоне обязательна подстановка {{ questions_json }} — туда приходит JSON {questions, token, created_at, verify_url}. Шаблон читается как текст (не Jinja2), поэтому одинаково работает во всех трёх фреймворках.

CLI

kremle-detect help

Диагностика

kremle-detect check-ua "Mozilla/5.0 YaBrowser/24.1"      # exit 1, если детект
kremle-detect check-ref "https://yandex.ru/search?q=x"
kremle-detect check-hints '"YaBrowser";v="24"'
kremle-detect questions --category math --count 5

Вопросы

kremle-detect questions show -c math -n 5              # из кеша РешуЕГЭ или банка
kremle-detect questions show --source builtin         # только встроенный банк
kremle-detect questions sync -c math -n 100           # обновить кеш
kremle-detect questions sync --ids 1001,1002 --dry-run
kremle-detect questions sync --images --download      # рисунки в локальный кеш
kremle-detect questions check 1001 --download         # один рисунок проверить и скачать
kremle-detect questions cache                         # состояние кеша

Блоклист и вайтлист

kremle-detect blacklist list   --redis redis://localhost:6379/0
kremle-detect blacklist add    1.2.3.4 --ttl 3600
kremle-detect blacklist add    1.2.3.4 --ttl 0          # навсегда
kremle-detect blacklist remove 1.2.3.4

kremle-detect whitelist list
kremle-detect whitelist add    10.0.0.1
kremle-detect whitelist remove 10.0.0.1

Версия

kremle-detect --version      # kremle-detect 2.7.1

Демо-сервер

kremle-detect serve                  # FastAPI + капча + /stats на localhost:8000
kremle-detect serve --port 9000
KREMLE_DEMO_POLICY=monitor kremle-detect serve     # shadow: ничего не блокировать

Нужен экстра pip install "kremle-detect[demo]" (fastapi + uvicorn).

Логи событий

kremle-detect logs -n 200
kremle-detect logs --no-color

События: ПРОШЁЛ, ПРОВАЛ, ДЕТЕКТ, ТЕНЬ (monitor), ЗАБЛОК. URL Redis — флагом --redis или переменной окружения KREMLE_REDIS_URL.

Релиз

Версия живёт в трёх местах и должна совпадать: pyproject.toml, kremle_detect/__init__.py и kremle-react/package.json. Публикация — по тегу:

# 1. проверить, что всё зелёное
pytest tests/ -q

# 2. версия во всех трёх файлах + запись в CHANGELOG.md
# 3. тег и пуш — дальше всё делает .github/workflows/release.yml
git tag v2.7.1 && git push origin v2.7.1

Workflow соберёт wheel/sdist, сверит версию с тегом, поставит wheel в чистый venv и дёрнет kremle-detect --version, затем опубликует PyPI (trusted publishing, OIDC, без токенов) и npm (--provenance), и создаст GitHub Release с артефактами и текстом из CHANGELOG. GitHub Release появляется даже если индекс не настроен — по нему видно, что именно сломалось.

Что должно быть настроено один раз:

  • PyPI: Account → Publishing → pending publisher (kremle-detect, owner RUdolf1517, repo KremleXYZ-Detect, workflow release.yml, environment pypi).
  • npm: два варианта, подробности ниже.
  • Если OIDC на PyPI недоступен — секрет PYPI_API_TOKEN и раскомментировать password в шаге публикации.

npm: trusted publishing (OIDC)

Пакет kremle-react публикуется без токенов: npm доверяет конкретному workflow, а CLI получает короткоживущий OIDC-токен на время джоба.

Настроено один раз (npmjs.com → kremle-react → Settings → Trusted Publisher):

Поле Значение
Organization or user RUdolf1517
Repository KremleXYZ-Detect
Workflow filename release.yml
Environment name пусто
Allowed actions ✅ npm publish (иначе только staged-режим)

Требования, которые уже выполнены в workflow: Node ≥ 22.14, npm CLI ≥ 11.5.1 (npm install -g npm@latest), id-token: write, публичный репозиторий — provenance (slsa.dev/provenance/v1) генерируется автоматически. NODE_AUTH_TOKEN в шаг публикации не передаётся: если OIDC сломается, job упадёт с ENEEDAUTH сразу, а не через 90 дней, когда истечёт токен.

Важно про первый релиз. Самую первую версию пакета нельзя опубликовать через OIDC — trusted publisher настраивается на странице пакета, которой ещё нет (npm/cli#8544). Поэтому 2.7.0 уехала granular-токеном (он уже удалён), а 2.7.1 — первая версия, которую валидирует TP. Неподтверждённая конфигурация истекает через 2 суток.

Проверить, что публикация прошла именно по OIDC:

curl -s https://registry.npmjs.org/-/npm/v1/attestations/kremle-react@2.7.1 \
  | grep -o 'slsa.dev/provenance/v1'

Если всё-таки нужен токен (например, для первого релиза нового пакета): granular token с правом Read and write на пакет, Bypass 2FA включён, срок — 7 дней (максимум для write-токенов — 90). Секрет NPM_TOKEN, после публикации — удалить.

Staged publishing. Если в конфигурации отмечен только npm stage publish, npm publish не публикует сразу: версия уходит на подтверждение, а в реестре появляется заглушка 0.0.0-stage. Workflow такое ловит и печатает warning. Подтверждение:

npm stage list kremle-react
npm stage approve <stageId>          # спросит 2FA-код

или в браузере: npmjs.com → kremle-react → Staged publishes → Approve.

Требуется ваша 2FA — из CI это сделать нельзя. Workflow после публикации сам проверяет реестр и, если версия ждёт подтверждения, печатает warning Staged publish в аннотациях — «зелёного» джоба без публикации не бывает.

Если подтверждать каждый релиз не хочется, есть два пути (оба требуют действий в браузере, потому что существующую конфигурацию нельзя редактировать):

  1. Добавить вторую связь (их может быть до 10): Trusted Publisher → GitHub Actions с теми же полями, но с галкой Allow npm publish. Новая связь валидируется следующим релизом, старая stage-only остаётся безвредной.
  2. Пересоздать связь: удалить текущую, создать заново с Allow npm publish и подтвердить публикацией в течение 2 суток.

Как закрыть публикацию по токенам

Когда TP подтверждён, стоит запретить старый путь: npmjs.com → kremle-react → Settings → Publishing access → Require two-factor authentication and disallow bypass 2fa tokens → Update Package Settings. По документации это не влияет на trusted publishers, но убивает возможность опубликовать по утёкшему токену.

Безопасность: что честно

Это фильтр от живых людей, а не антибот. Полезно знать заранее:

  • User-Agent и Sec-CH-UA подделываются в два клика — детекция обходится.
  • fingerprint приходит от клиента: бот может отправить пустой объект и снять строгий режим.
  • 15 вопросов ЕГЭ — серьёзный барьер для человека, но LLM/поиск отвечает на них быстро.
  • Блокировка по браузеру — это отказ в доступе живым людям; в ЕС/РФ это как минимум спорно с точки зрения антимонопольного/пользовательского законодательства.
  • Переходы из поиска — самый ценный трафик, капча на них снижает и позиции, и конверсию.

Что реально защищено в 2.7: подделка X-Forwarded-For больше не обходит rate limit и не подставляет чужой IP в блоклист; неудачные попытки не блокируют честных пользователей; один челлендж = одна попытка; «пройдено» истекает; битый JSON не валит сервер; инкремент счётчиков в Redis атомарный. Плюс появился способ измерить цену фильтра: monitor и sample_rate считают задетых людей, не отказывая им в доступе.

Блокировка по браузеру остаётся целью проекта, а не дефектом. Но прежде чем включать её на весь трафик, посмотрите /kremle/stats в monitor-режиме: цифра «столько-то процентов посетителей — живые люди на Яндекс Браузере» обычно и есть ответ на вопрос, стоит ли оно того.

Как сообщить и что не считается багом

Политика раскрытия, область действия и модель угроз — в SECURITY.md. Коротко: блокировка живых людей на Яндекс Браузере, строгий режим по YaBrowser-сигналам и отказ пускать без капчи — не уязвимости, это продукт.

/kremle/stats, /kremle/metrics и /kremle/health — публичные роуты, они отдают цифры без авторизации. Закройте их на прокси или фаерволом, если это нежелательно.

Что изменилось в 2.8

Полный список — в CHANGELOG.md. Кратко:

Было Стало
рисунок задания грузился горячей ссылкой с портала (IP пользователя уходил наружу) локальный кеш картинок: questions sync --images --download, роут /kremle/img/<key>
портал закрыл хотлинк → «[рисунок не загрузился]» капча с рисунком работает без доступа к порталу
свою CDN/прокси приходилось писать самому images_cache_dir / images_prefix в Flask, FastAPI, Django

Ломающих изменений нет: без --download поведение прежнее, url меняется на локальный только когда файл уже в кеше.

Что изменилось в 2.7

Полный список — в CHANGELOG.md. Кратко:

Было Стало
капча блокировала и обычный fetch/SPA (HTML вместо JSON) 403 JSON с captcha_required + challenge_url, редирект только для навигации
«капча пройдена» = True в сессии навсегда TTL verified_ttl, привязка к IP/UA, POST /kremle/logout
встроенный банк из 60 вопросов по умолчанию кеш настоящих заданий «Решу ЕГЭ» с атрибуцией
всё или ничего: любой сигнал → капча policy на сигнал (challenge/block/monitor/allow) + sample_rate
мониторинг только по логам /kremle/health, /kremle/stats, /kremle/metrics
синхронный Redis в async-приложении AsyncRedisStorage, AsyncStorageAdapter, async_offload в FastAPI
Django: без headless-режима и ops-роутов паритет с Flask/FastAPI (KREMLE_HEADLESS, KREMLE_ENABLE_OPS_ROUTES)
React-пакет не собирался и не тестировался kremle-react@2.7.0: сборка, типы, 'use client', 24 теста
задания с рисунком молча терялись парсер собирает images, sync --images, рендер в шаблоне и React
«что придёт с портала?» — только руками в браузере kremle-detect questions check <id> + --json для CI
потрогать можно было только через свой сайт kremle-detect serve и docker compose up — демо с /stats
Redis проверялся фейком живой Redis в CI + e2e на настоящем HTTP + нагрузочный смоук

Ломающих изменений нет: без явной настройки политика остаётся challenge для всех сигналов, а старый флаг «пройдено» просто истекает один раз.

Что изменилось в 2.6

Было Стало
X-Forwarded-For принимался от всех только от loopback/приватных сетей и trusted_proxies
rate limit считал все попытки только неудачные, успех сбрасывает счётчик
токен сгорал только после успеха одноразовый: сгорает после любой проверки
max_errors >= question_count проходился всегда значение подрезается с предупреждением в лог
Flask-гард брал remote_addr, verify — XFF один резолвер IP везде
FastAPI: битый JSON → 500 400 {"error": "invalid_json"}
path('kremle/', kremle_urls()) в Django падал возвращается include(...), роуты резолвятся
errors: -1 показывался как «ошибок -1 из 0» понятные сообщения о блокировке/истечении
blacklist add --ttl 0 → сутки 0 = навсегда
random для перемешивания secrets.SystemRandom

Ломающих изменений нет. За nginx/docker/k8s ничего настраивать не нужно — приватные и локальные прокси доверенные по умолчанию. Внешний LB виден по предупреждению в логе (или задай trusted_proxies сразу). Дырявый max_errors >= question_count больше не мешает запуску: лимит подрезается до безопасного значения.

Тесты

python -m pytest tests/ -q      # 502 тестов

Покрыто: детекция, ядро капчи, валидация настроек, одноразовые токены, rate limit, резолвинг IP, политика сегментов и sample_rate, метрики и health, TTL «пройдено», провайдер «Решу ЕГЭ» (разбор HTML, дистракторы, кеш, fallback), Flask/Django/FastAPI (JSON-гард и разбор Accept, monitor, ops-роуты, logout), AsyncRedisStorage/ AsyncStorageAdapter на фейковом клиенте и CLI вопросов.

cd kremle-react && npm test && npm run typecheck && npm run build   # 24 теста + сборка

Что ещё есть в тестах:

# живой Redis (нужен docker run -p 6379:6379 redis:7)
KREMLE_TEST_REDIS_URL=redis://localhost:6379/15 pytest -m redis

# без Docker: in-memory двойник с поддержкой Lua (pip install "fakeredis[lua]")
KREMLE_TEST_REDIS_FAKE=1 pytest -m redis

# e2e на настоящем HTTP-сервере (uvicorn в потоке, реальные куки)
pytest tests/test_e2e.py

# нагрузочный смоук (нужен `kremle-detect serve`)
python scripts/loadtest.py -n 500 -c 20

CI гоняет Python 3.9–3.13 с сервисом Redis (и падает, если Redis-тесты молча скипнулись), порог покрытия 85 %, сборку wheel с проверкой содержимого, pip-audit/npm audit, тесты и сборку React-пакета. Отдельные workflow: публикация по тегу v* (PyPI + npm) и ежедневная проверка живого портала «Решу ЕГЭ».

Лицензия

MIT — см. LICENSE.

Metadata

Release files for kremle-detect 2.8.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for kremle-detect 2.8.0
File Size Uploaded Provenance
kremle_detect-2.8.0.tar.gz 176.2 kB With provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for kremle-detect 2.8.0
File Interpreter ABI Platform
kremle_detect-2.8.0-py3-none-any.whl Python 3 none any Details

Total release size: 288.6 kB

Release files / kremle_detect-2.8.0.tar.gz

Download URL kremle_detect-2.8.0.tar.gz
Size 176.2 kB
Tags Source
SHA-256 checksum
How to use checksums
439ee0814e0a695d3d2f76e56a1cd87d3e896bfaa039f3534e41c7e3a31fb938
BLAKE2b-256 checksum
How to use checksums
7c163c1d0cd13d657f1f06e8ccd1eace8eb3e294e04e05418287407140e76489
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Release files / kremle_detect-2.8.0-py3-none-any.whl

Download URL kremle_detect-2.8.0-py3-none-any.whl
Size 112.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9d351e14ce05963066e538fbf3d4ee679db6baadebea3174bf2f4669bcb1dec3
BLAKE2b-256 checksum
How to use checksums
318bc04c0795fe7cc04ae5b6cc71e47ec81bcf8cbd7b02cf26268d8feca05f04
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.16

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

Security for kremle-detect 2.8.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for kremle_detect-2.8.0.tar.gz

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Oct 24, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

To learn more about protecting yourself from malicious actors, visit our package security documentation.

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page