Спасибо, что помогаете держать Stepik-Python-Grader безопасным. Документ короткий и ссылочный — детали threat model живут в docs/configuration.md § Ограничения и безопасность.
Поддерживается только последний релиз (актуальный MINOR). Жёсткого номера
версии здесь нет намеренно: версия динамическая, из git-тегов
(setuptools-scm) — см.
CONTRIBUTING.md § Версионирование.
Обновитесь до последнего релиза перед тем, как сообщать об уязвимости.
Основной канал — GitHub Private Vulnerability Reporting (вкладка Security → Report a vulnerability в репозитории). Резервный канал — личное сообщение мейнтейнеру @ArtVsMark. Не открывайте публичный issue для уязвимостей.
In English: please report security issues via GitHub Private Vulnerability Reporting (Security → Report a vulnerability), or contact @ArtVsMark directly — do not open a public issue.
В скоупе — уязвимости, дающие реальный риск в локальном использовании:
- Веб-оболочка (
src/stepik_grader/web/,--serve): XSS в рендере stdout/stderr решения, выход за пределы localhost, path traversal при указании путей — конкретные механизмы защиты см. ниже «Веб-оболочка: Host/Origin guard и path-confinement». - Секреты: утечка
secrets.json/ OAuth-токенов (в логи, отчёты, кэш). - Downloader: обработка недоверенного ZIP-архива с тестами, распаковка вне целевой папки (zip-slip), парсинг ответов Stepik.
- Утечка токена в артефакты (отчёты,
.grader_cache, вывод).
Исполнение локального недоверенного кода без OS-sandbox — это by design, а не уязвимость сама по себе. У грейдера нет изоляции ФС/сети (есть только таймаут и best-effort лимит памяти на POSIX); он предназначен для запуска доверенных решений (своих или скачанных из Stepik as-is). Сообщения вида «решение может выполнить произвольный код» не считаются уязвимостью — это документированное ограничение (см. ниже).
Запускайте только доверенный код. Полная threat model, настройки таймаута и лимитов памяти — docs/configuration.md § Ограничения и безопасность.
Веб-оболочка (--serve) делает режим исполнения видимым (issue #565): в шапке —
бейдж статуса OS-изоляции («⚠ Без OS-изоляции» при дефолтном LocalRunner,
«🔒 OS-изоляция» под --sandbox), напоминающий не запускать недоверенный код
без --sandbox. При первом запуске с включённой историей показывается
однократное уведомление о локальном сборе аналитики (в .grader_history.db
хранится sha256 решения, не исходный код; отключается перезапуском с
--no-history).
AI-подсказки (CLI --ai-hints и web POST /api/v1/hint) — opt-in и гейтятся
явным согласием: код решения и его ввод-вывод уходят стороннему AI-провайдеру
только после однократного consent. Полная threat model (согласие, prompt-injection
#692, BYOK-ключ, «грейд не падает из-за AI») — ниже, § AI-подсказки.
secrets.jsonиstepik_config.jsonне коммитятся (в.gitignore); используйтеsecrets.json.exampleкак шаблон.- Не вставляйте реальные токены в issue, PR, отчёты или логи.
- Настройка OAuth — docs/installation.md § Работа с API Stepik (OAuth).
Поверхность узкая осознанно: 3 прямые runtime-зависимости (requests /
psutil / rich) и ноль CDN в веб-оболочке — все JS/CSS/шрифты вендорены и
коммитятся как готовые артефакты. pip-audit по runtime-замыканию против PyPI
Advisory DB — без известных уязвимостей (2026-07-20, раунд 2); в CI сигнал
держит свежим информационный не-блокирующий джоб supply-chain. Полный инвентарь
(версии, лицензии, вендоренные ассеты, как перепроверить) —
docs/supply-chain.md.
Opt-in объяснение падений WA/RE через OpenAI-совместимый провайдер (BYOK, ADR-0003). Threat model:
- Согласие обязательно. Однократный явный consent (
ai_hint_consent, issue #630/#543) проверяется ДО любого обращения к провайдеру — и в CLI, и в web (403 consent_required). Без него в сеть не уходит ничего. Контент решения попадает в запрос только ПОСЛЕ согласия, поэтому инъекцией consent не обходится. - Грейдинг никогда не падает из-за AI. Таймаут запроса ограничен
(
ai_timeout_seconds), любая ошибка канала (сеть/таймаут/HTTP/битый ответ) → подсказка пропускается, исключение не всплывает. Ретраев нет намеренно (подсказка — best-effort, не должна задерживать грейд). - Prompt-injection (issue #692, характеризовано). Подконтрольный решению
контент — его stdout, трейсбек, исходный код — кладётся в user-промпт без
структурной изоляции/экранирования (вывод может даже подделать заголовок
доверенной секции «Справка (карточка)»). Защиты только мягкие: grounding-
инструкции в system-промпте («опирайся ТОЛЬКО на контекст», «не выдавай готовый
код») + жёсткий клип по длине полей; ответ помечен как AI-generated и усечён.
Это осознанный компромисс локальной версии: злоупотребление бьёт лишь по
качеству собственной подсказки пользователя-владельца ключа, не по другим
пользователям. Позиция закреплена тестами
tests/test_ai_hints.py. - Ключ — BYOK, из env. Имя переменной —
ai_api_key_env; значение читается в момент вызова, регистрируется вdiag_logдля редакции в логах, никогда не из файлов проекта.
Текущий --serve слушает только 127.0.0.1. Любой переход к серверному
исполнению (общий доступ, удалённый прогон решений) меняет threat model и
потребует настоящего проектирования sandbox — до этого недоверенный код в
серверном режиме запускать нельзя. Дизайн будущего server mode (Runner-слой,
API удалённого исполнения, обязательные требования sandbox — сеть off,
эфемерные tmp-каталоги, квоты) и решение по нему —
docs/server-mode.md и
ADR-0001.
--serve слушает только 127.0.0.1, но добавляет две защиты поверх этого:
- Host/Origin guard (issue #242) — на каждый запрос к
/api/*: заголовокHostдолжен резолвиться в127.0.0.1/localhost(иначе 403, DNS-rebinding защита); если естьOrigin/Referer— их hostname должен быть из того же allowlist (иначе 403). Отсутствие обоих заголовков (curl, не-браузерные клиенты) — допустимо. - Path-confinement (issue #261) — пути из запросов (
path/folderв/api/grade,/api/solutions,/api/source,/api/save-solution,/api/v1/runs) резолвятся относительно рабочей директории сервера (--root, по умолчанию — cwd на момент запуска) и не могут выйти за её пределы (403 при попытке).--no-root-confinementявно снимает эту защиту (откат к прежнему поведению — доступ к любому пути на диске) — осознанное решение пользователя, не дефолт.POST /api/download(root— куда СКАЧИВАТЬ задачу) в конфайнмент не входит — отдельный concern. - Заголовки ответов и read-timeout (issue #563) — все ответы несут
X-Content-Type-Options: nosniff; HTML —Content-Security-Policy(default-src 'self', строгийscript/base-uri 'none'/object-src 'none';style-srcдержит'unsafe-inline'только ради вендоренного CodeMirror). Соединение получает 30-секундный read-timeout (slow-client не держит воркер-поток). Внешние загрузки (external_download_get) ревалидируют каждый редирект-hop против allowlist (issue #564, анти-SSRF).
Принятые остаточные риски (Sec-Fetch/CSRF, issue #565). Guard опирается на
браузерные заголовки Host/Origin/Referer/Sec-Fetch-Site, которые
страница не может подделать; полноценный CSRF-token осознанно не вводится —
сервер слушает только 127.0.0.1, а не-браузерные клиенты (curl) без этих
заголовков допускаются by design. Это принятая граница второго эшелона для
локального инструмента, не multi-tenant сервера (полноценный server-mode sandbox
— отдельный дизайн, см. «Server / IDE-режим» выше).
Полный справочник с кодами ответов и message_id —
docs/api.md § Общие правила.
Опциональный флаг --sandbox (по умолчанию выключен — дефолт остаётся
LocalRunner, без изоляции, см. «Вне скоупа» выше) исполняет решения через
ОС-уровневую изоляцию: core/sandbox/. Реализует часть требований дизайна
Фазы 2 (docs/server-mode.md), но не эквивалентен
полному server-mode sandbox — это локальный, opt-in usability/defense-in-depth
слой для CLI, а не готовый multi-tenant remote-execution sandbox (для этого
по-прежнему нужен отдельный API-слой, issue #156/#157 дизайн, не реализация).
Backend выбирается автоматически по ОС при старте (SandboxRunner.__init__);
если недоступен — CLI сразу завершается с ошибкой, без тихого отката на
LocalRunner (issue #266, явное требование).
| Linux (bwrap) | macOS (sandbox-exec) |
Windows (Job Objects) | |
|---|---|---|---|
| Сеть | ✅ ядром (--unshare-net) |
✅ ядром (deny network*) |
❌ не реализовано (см. ниже) |
| Запись вне tmp | ✅ ядром (mount namespace) | ✅ ядром (Seatbelt) | |
| Чтение файлов | /usr ro (загрузчик/libc, см. ниже) |
❌ не ограничено (allow file-read*) — см. пояснение ниже |
|
| Переменные окружения | ✅ очищается (--clearenv + только PATH/PYTHONIOENCODING/PYTHONUTF8) |
✅ то же явным env (issue #627) |
✅ то же явным dict |
| Память | ✅ ядром (RLIMIT_AS) + psutil-backstop |
RLIMIT_AS не работает на Darwin) |
✅ ядром (JOB_OBJECT_LIMIT_JOB_MEMORY, commit-charge — быстрее, чем POSIX RLIMIT_AS на практике) |
| CPU-время | ✅ ядром (RLIMIT_CPU, SIGXCPU) |
✅ ядром (RLIMIT_CPU) |
|
| Anti-fork-bomb | ✅ абсолютный RLIMIT_NPROC (свежий user namespace, счётчик с 0) |
✅ JOB_OBJECT_LIMIT_ACTIVE_PROCESS (per-job, без cross-UID гочи) |
|
| Сторонние пакеты решения | ❌ не поддерживаются (в sandbox биндится только интерпретатор+stdlib, не venv site-packages) | ❌ то же | ❌ то же |
Именованные пробелы (не забыты, осознанный вырез MVP):
- Windows: нет сетевой изоляции. Правильный примитив — AppContainer
(
CreateAppContainerProfile+UpdateProcThreadAttributeбезinternetClient), но требует ACL на всё дерево интерпретатора для SID контейнера при каждом запуске — непропорционально сложно для per-run эфемерного профиля в этом MVP. Отдельный follow-up, не блокирует MVP. - Windows: нет строгой ФС-изоляции. Изначально план предполагал
CreateRestrictedToken(Low integrity), но применение другого токена к дочернему процессу требуетCreateProcessAsUser, а значит — отказа отsubprocess.Popenи написания собственногоCreateProcessW+STARTUPINFO+ pipe-плюмбинга с нуля. Риск тихо ошибиться в непроверяемом low-level Windows API-коде признан непропорциональным для MVP — вместо этого толькоcwd-контейнмент относительных путей. - Linux: nsjail fallback не реализован — только bubblewrap (
bwrap). Еслиbwrapне установлен,--sandboxзавершается ошибкой (issue #266 план упоминал nsjail как fallback; в MVP это осталось задокументированным нереализованным пунктом, не тихим пропуском). - Все ОС: сторонние pip-пакеты в решении не работают под
--sandbox— в песочницу пробрасывается только сам интерпретатор + stdlib, не site-packages виртуального окружения грейдера. - macOS: чтение файлов сознательно не ограничено (
(allow file-read*)без пути) — перечисление конкретных системных/venv путей для чтения (dyld,/usr/lib,/System/Library, venv/stdlib) пробовалось и приводило к SIGABRT интерпретатора уже на старте (dyld/CPython падают, если им отказано в чтении чего-то по пути инициализации, а вручную составленный список органически хрупкий — не совпадает точно с тем, что реально трогает загрузчик на конкретной сборке ОС). То же решение независимо используют другие инструменты с идентичной задачей (например, актуальная Seatbelt-политика OpenAI Codex). Не потеря гарантии — изоляция чтения для macOS никогда не заявлялась в таблице выше; запись/сеть/ресурсы по-прежнему ограничены ядром.
Исполняемая фиксация Windows-пробелов (issue #648). Два Windows-пробела
выше (сеть; запись/чтение по абсолютным путям вне run_dir) не только описаны
прозой, но и закреплены характеризующими escape-тестами
tests/test_w6_windows_sandbox_gaps.py: они реально запускают код в
Job-Object-песочнице и подтверждают, что исходящее соединение и доступ к файлам
по абсолютному пути проходят (в т.ч. эксфильтрация секрета в stdout). Тесты
покраснеют, как только на Windows появится сетевая/ФС-изоляция, — это и станет
сигналом обновить строки таблицы выше и снять оговорки. Вектор чтения секрета
вне run_dir (SEC-CORE-03) теперь закреплён и для Linux/macOS в
tests/test_sandbox_runner.py: Linux (bwrap) — позитивный тест, что чтение
ЗАБЛОКИРОВАНО (файл вне bind'ов недоступен, host /tmp скрыт tmpfs); macOS
(sandbox-exec) — характеризующий тест, что чтение ПРОХОДИТ (allow file-read*,
как Windows). Symlink-write наружу из run_dir тоже закреплён
(_assert_symlink_write_outside_blocked; ожидание — заблокирован: bwrap резолвит
цель в mount-namespace, Seatbelt — по canonical-пути; красный тест = реальный
обход ограничения записи). Остальные векторы Linux/macOS
(сеть/запись/fork/ресурсы/таймаут) уже покрыты
TestLinuxSandboxRunner/TestMacSandboxRunner того же файла.
Обычные раннеры GitHub Actions запрещают bwrap создать непривилегированный
user namespace целиком: --unshare-user завершается setting up uid map: Permission denied, а --unshare-net — RTM_NEWADDR: Operation not permitted.
Поэтому Linux-бэкенд гоняется в CI внутри privileged-контейнера (job
sandbox-linux), где и userns, и netns доступны. Там исполняется весь
tests/test_sandbox_runner.py под ПОЛНОЙ изоляцией: запуск интерпретатора в
песочнице, ФС-изоляция, реальная сетевая изоляция (test_network_blocked),
RLIMIT_AS (память), output_size, timeout. Покрытие _linux.py вливается в
cross-OS combine.
anti-fork-bomb (RLIMIT_NPROC) из CI-набора исключён (--deselect): его
сдерживание зависит от сброса kernel-ucounts через --unshare-user, который во
вложенных user-namespace окружениях (в т.ч. контейнерах) ведёт себя нестабильно
— проверяется полным набором на локальной машине / self-hosted-раннере и
кросс-OS эквивалентом (test_process_count_contained) в Windows-наборе.
Бэкенд монтирует /usr read-only (issue #420): ELF-загрузчик (ld-linux) и
libc интерпретатора живут под /usr даже когда сам интерпретатор стоит вне
его (Docker-образы python, hostedtoolcache на CI, pyenv) — без этого bwrap
падает execvp: No such file на загрузчике. Это read-only доступ на чтение
системных библиотек; запись/сеть/ресурсы по-прежнему изолированы ядром, а
site-packages виртуального окружения по-прежнему НЕ пробрасываются.
Verdict SANDBOX_VIOLATION (аддитивно к AC/WA/RE/TLE/CANCELLED, см.
docs/result-contract.md) проставляется только
когда сам SandboxRunner проактивно засёк и оборвал превышение квоты:
memory (RSS/commit перешёл порог), output_size (стдаут+стдерр
превысили лимит) или cpu (SIGXCPU, POSIX). Нарушения сети/записи вне
tmp/числа процессов не попадают сюда — ядро отклоняет их ВНУТРИ
песочницы, решение падает с обычным ненулевым exit code/traceback, и это
корректно классифицируется как обычный RE — SandboxRunner не
заглядывает в чужой traceback, чтобы переклассифицировать его. Это
осознанное решение, не недосмотр: попытка отличить «RE от нарушения
песочницы» по коду завершения/сигналу ребёнка оказалась ненадёжной на
практике (напр. RLIMIT_AS-провал на Linux обычно проявляется как обычный
MemoryError traceback с кодом 1, а не через сигнал).