Skip to content

Security: ArtVsMark/Stepik-Python-Grader

Security

SECURITY.md

Политика безопасности

Спасибо, что помогаете держать 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.

Область (scope)

В скоупе — уязвимости, дающие реальный риск в локальном использовании:

  • Веб-оболочка (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, вывод).

Вне скоупа (non-goals)

Исполнение локального недоверенного кода без 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).

Цепочка поставок (supply-chain)

Поверхность узкая осознанно: 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.

AI-подсказки (--ai-hints)

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 для редакции в логах, никогда не из файлов проекта.

Server / IDE-режим (на будущее)

Текущий --serve слушает только 127.0.0.1. Любой переход к серверному исполнению (общий доступ, удалённый прогон решений) меняет threat model и потребует настоящего проектирования sandbox — до этого недоверенный код в серверном режиме запускать нельзя. Дизайн будущего server mode (Runner-слой, API удалённого исполнения, обязательные требования sandbox — сеть off, эфемерные tmp-каталоги, квоты) и решение по нему — docs/server-mode.md и ADR-0001.

Веб-оболочка: Host/Origin guard и path-confinement

--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_iddocs/api.md § Общие правила.

--sandbox — SandboxRunner MVP (issue #266)

Опциональный флаг --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) ⚠️ только относительные пути (cwd); абсолютные пути не блокируются
Чтение файлов ⚠️ интерпретатор+stdlib + /usr ro (загрузчик/libc, см. ниже) ❌ не ограничено (allow file-read*) — см. пояснение ниже ⚠️ обычные ACL пользователя, без доп. ограничения
Переменные окружения ✅ очищается (--clearenv + только PATH/PYTHONIOENCODING/PYTHONUTF8) ✅ то же явным env (issue #627) ✅ то же явным dict
Память ✅ ядром (RLIMIT_AS) + psutil-backstop ⚠️ только psutil-поллинг (RLIMIT_AS не работает на Darwin) ✅ ядром (JOB_OBJECT_LIMIT_JOB_MEMORY, commit-charge — быстрее, чем POSIX RLIMIT_AS на практике)
CPU-время ✅ ядром (RLIMIT_CPU, SIGXCPU) ✅ ядром (RLIMIT_CPU) ⚠️ psutil-поллинг (Job Object лимит — backstop, не основной детектор)
Anti-fork-bomb ✅ абсолютный RLIMIT_NPROC (свежий user namespace, счётчик с 0) ⚠️ сэмплированный бюджет (нет namespace-аналога, слабее) 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 того же файла.

Проверка Linux-песочницы в CI (issue #420)

Обычные раннеры GitHub Actions запрещают bwrap создать непривилегированный user namespace целиком: --unshare-user завершается setting up uid map: Permission denied, а --unshare-netRTM_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 виртуального окружения по-прежнему НЕ пробрасываются.

Что означает SANDBOX_VIOLATION (и чего не означает)

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, и это корректно классифицируется как обычный RESandboxRunner не заглядывает в чужой traceback, чтобы переклассифицировать его. Это осознанное решение, не недосмотр: попытка отличить «RE от нарушения песочницы» по коду завершения/сигналу ребёнка оказалась ненадёжной на практике (напр. RLIMIT_AS-провал на Linux обычно проявляется как обычный MemoryError traceback с кодом 1, а не через сигнал).

There aren't any published security advisories