Files
orchestrator/docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md
claude-bot 834cbdf875
All checks were successful
CI / test (push) Successful in 18s
architect(ET): auto-commit from architect run_id=327
2026-06-07 17:03:11 +00:00

19 KiB
Raw Blame History

ADR-001: Security-гейт — secret-scanning + dependency audit перед мержем

  • Статус: Accepted (proposed → принято архитектором ORCH-022)
  • Дата: 2026-06-07
  • Задача: ORCH-022
  • Связанный global ADR: docs/architecture/adr/adr-0012-security-gate.md
  • Источники: 01-brd.md (BR-1..BR-14), 02-trz.md (FR-1..FR-7, §4 варианты, §7 инварианты), 03-acceptance-criteria.md (AC-1..AC-21).

Контекст

Оркестратор автономен: developer-агент пишет код без человека-фильтра. Перед слиянием ветки задачи в main нет автоматической проверки на утёкший секрет (ключ/токен/пароль/ приватный ключ) и на уязвимую зависимость (известная CVE). Для self-hosting это особенно опасно: один общий прод-инстанс обслуживает все проекты с общей БД — секрет или CVE, просочившийся через одну задачу, попадает в прод всех проектов (CLAUDE.md §self-hosting, §8).

Конвейер уже содержит линию детерминированных страховок на ребре deploy-staging → deploy (непосредственно перед фактическим мержем PR в main, который делает deployer в начале стадии deploy):

  • merge-gate (ORCH-043, check_branch_mergeable) — догон main + re-test + сериализация;
  • image-freshness (ORCH-058, check_staging_image_fresh) — провенанс staging-образа.

Оба построены по одному паттерну: leaf-модуль чистой логики (never-raise) + тонкая обёртка в QG_CHECKS + врезка-обработчик _handle_* в advance_stage, с условным раскатом (*_enabled + *_repos, реально только для self-hosting при пустом scope) и откатом на development с developer-retry (cap MAX_DEVELOPER_RETRIES = 3).

Открытые вопросы BRD §8 / TRZ §4, требующие решения архитектора:

  1. Размещение гейта в пайплайне (review / merge-edge / CI-job).
  2. Где запускается сканер (CI-job через check_ci_green / отдельный QG-чек).
  3. Degrade при недоступном CVE-фиде (fail-open / fail-closed).
  4. Выбор инструментов (gitleaks/trufflehog; pip-audit/trivy).

Решение

Р-1. Размещение — Вариант M (под-гейт ребра deploy-staging → deploy), ПЕРВЫМ среди edge-под-гейтов

Security-гейт реализуется как детерминированный под-гейт того же ребра deploy-staging → deploy, что merge-gate и image-freshness, и исполняется ПЕРВЫМДО merge-gate. STAGE_TRANSITIONS не меняется (триггер — то же событие «staging- deployer завершился»; инвариант TRZ §7.1).

Порядок врезок в advance_stage (блок current_stage == "deploy-staging"):

check_staging_status (PASS, существующий QG стадии)
  → security-gate          (НОВЫЙ, _handle_security_gate)   ← первым
  → merge-gate             (_handle_merge_gate)
  → image-freshness        (_handle_image_freshness)
  → Phase A (self-deploy approve)

Почему merge-edge, а не review (Вариант R):

  • BRD-требование «перед слиянием в main» удовлетворяют оба, но на review-стадии diff может разойтись с тем, что реально вольётся в main (параллельная задача двигает main вперёд между review и merge). Merge-edge — последняя точка перед фактическим мержем.
  • Переиспользуется готовая машинерия отката/retry/нотификаций edge-под-гейтов (минимальный blast-radius, инвариант TRZ §7).

Почему ПЕРВЫМ (до merge-gate), а не после image-freshness:

  • Дёшево фейлить. merge-gate (rebase + re-test, минуты) и image-freshness (docker rebuild, до 1200с) — дорогие. Нет смысла гонять их на ветке с секретом/CVE.
  • Корректность для секретов. Секрет живёт в собственных коммитах ветки; rebase онто main его не добавляет и не убирает → скан диапазона origin/main..HEAD до rebase ловит ровно те коммиты, что попадут в main.
  • Анти-петля для зависимостей. Аудит ветки до rebase оценивает то, что вносит ИМЕННО эта задача (её requirements.txt/diff), а не уязвимость, которую притащил в ветку обновившийся main. Аудит после rebase «обвинял» бы задачу в чужой (main'овой) CVE → ложный откат → development → петля (прецедент ORCH-061). Скан до rebase этого избегает.
  • Проще, чем image-freshness. Гейт исполняется ДО захвата merge-lease → при FAIL lease освобождать не нужно (в отличие от _handle_image_freshness). Чистый откат.

Почему не CI-job (Вариант C): пороги severity, warning-vs-block, аллоулист и машиночитаемый артефакт-вердикт плохо выражаются одним статусом коммита Gitea; путь коуплится с CI-раннером. Отклонено для v1; оставлено как точка расширения (BR-14).

Р-2. Инструменты

  • Secret-scanning — gitleaks. Полностью offline (без сетевого фида → гарантия «секрет всегда блокирует» не зависит от сети, BR-2), один статический бинарь, детерминированный, конфиг + аллоулист в репо (.gitleaks.toml, BR-13), поддержка --log-opts="origin/main..HEAD" (скан диапазона), JSON-отчёт, exit-code контракт (0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента). Бинарь устанавливается в Dockerfile (Go-бинарь, не pip-пакет) — см. 07-infra-requirements.md.
  • Dependency audit — pip-audit. Python-native (v1-стек — сам оркестратор, Python), читает requirements.txt, источник advisory — OSV/PyPI, JSON-выход, ставится через requirements.txt. trivy/trufflehog отклонены как тяжелее/контейнер-ориентированные для v1-цели «Python-only» (A3).

Конкретные инструменты — деталь реализации; контракт гейта (вход: repo/branch/wi, выход: (bool, reason) + артефакт) от них не зависит, заменяемы за leaf-модулем.

Р-3. Degrade при недоступном CVE-фиде — fail-open + громкий warning (дефолт)

pip-audit требует сети (OSV/PyPI advisory DB). Недоступность фида по умолчанию:

  • fail-open: dep-audit не даёт FAIL по причине недоступности фида (иначе — ложные откаты → development → петля при сетевых проблемах прод-инстанса, прецедент ORCH-061);
  • громко: в артефакте deps_audit_degraded: true, лог logger.warning, Telegram-алерт.
  • Секреты не деградируют: gitleaks offline → гарантия BR-2 безусловна даже при отсутствии сети. Деградирует ТОЛЬКО dep-audit.
  • Конфигурируемо: флаг security_dep_audit_fail_closed (дефолт false) позволяет Owner'у переключить на fail-closed (недоступность фида → FAIL) без редеплоя кода.

Это разделяет две гарантии: «нет секрета в прод» — безусловная; «нет известной CVE» — best-effort при доступности фида. Закреплено в acceptance (AC-7).

Р-4. Пороги классификации (A4, BR-10)

  • Секреты: любой подтверждённый (не из аллоулиста) секрет → вклад в FAIL (всегда блок; флаг security_secrets_block, дефолт true).
  • Зависимости: severity ≥ security_dep_block_severity (дефолт HIGH) → вклад в FAIL (deps_blocking); ниже порога (MEDIUM/LOW) → warning (deps_warning, не блокирует, фиксируется в теле).
  • Severity = UNKNOWN (OSV/advisory без CVSS — частый случай pip-audit): трактуется как ниже порога → warning, никогда не авто-блок (анти-петля). Логируется.

Р-5. Артефакт и вердикт (FR-3, BR-6, канон проекта)

  • Новый артефакт 17-security-report.md (следующий свободный номер; финализировано).
  • YAML-frontmatter:
    ---
    security_status: PASS        # PASS | FAIL
    secrets_found: 0
    deps_blocking: 0
    deps_warning: 2
    deps_audit_degraded: false
    ---
    
    Тело — человекочитаемый список находок (секреты: файл/правило/маскированное совпадение; CVE: пакет/версия/идентификатор/severity).
  • Единый источник истины: гейт вычисляет находки → пишет артефакт → читает вердикт обратно через parse_security_status(content) (frontmatter-парсер по образцу _parse_deploy_status/_parse_staging_status) → возвращает этот вердикт. Так возвращаемый (bool, reason) гарантированно == frontmatter артефакта (канон «машинный вердикт — только из YAML-frontmatter, никогда из прозы», AC-8). Negative-токен (FAIL) авторитетен.
  • Битый/отсутствующий frontmatter / нет поля security_status(False, reason) — fail-closed на чтении вердикта (AC-9).

Р-6. Поведение красного гейта (FR-4, BR-5)

security_status: FAIL → врезка _handle_security_gate (по образцу _handle_image_freshness, но БЕЗ работы с lease — гейт до его захвата):

  • update_task_stage(development) + enqueue_job("developer", …);
  • retry-счётчик — существующий _developer_retry_count (общий с merge/freshness; без новой колонки, TRZ §6); cap MAX_DEVELOPER_RETRIES = 3 → при исчерпании set_issue_blocked + Telegram;
  • task_desc несёт дословную причину (какие секреты/файлы, какие пакеты/CVE/severity) по образцу ORCH-046 — не только ссылку на артефакт (AC-12);
  • notify_qg_failure + Plane-коммент (наблюдаемость BR-11).

PASS → return False из обработчика → advance_stage идёт к merge-gate (тишина, без шума).

Р-7. Условный раскат и устойчивость (FR-5/FR-6)

  • check_security_gate(repo, work_item_id, branch) в QG_CHECKS; обёртка делегирует в src/security_gate.py (ленивый импорт во избежание цикла — по образцу _check_staging_image_fresh).
  • Условность: security_gate_enabled=False(True, "security-gate disabled"); security_gate_repos (CSV) пусто → реально только is_self_hosting_repo → прочие репо (True, "security-gate N/A for <repo>") (AC-14/AC-15).
  • never-raise (двойной guard как check_branch_mergeable): любая ошибка (нет бинаря, таймаут, исключение) → (False, reason), исключение не уходит в advance_stage (AC-16).
  • Таймаут сканирования security_scan_timeout_s (дефолт 300) на каждый внешний вызов (subprocess … timeout=) — превышение → детерминированный degrade-вердикт (AC-17).

Р-8. Self-hosting safety (инвариант TRZ §7.5, AC-19)

Гейт только читает/сканирует (git, gitleaks, pip-audit, запись артефакта). Не вызывает деплой-хук, не рестартит и не трогает прод-контейнер (8500/8501).


Точки касания (для developer; reviewer проверяет полноту — AC-20)

Модуль Изменение
src/security_gate.py (новый leaf) security_gate_applies, scan_secrets, audit_dependencies, classify_severity, compute_verdict, write_security_report, parse_security_status, check_security_gate. never-raise, fail-closed на чтении вердикта. По образцу image_freshness.py.
src/qg/checks.py check_security_gate (тонкая обёртка, ленивый импорт) + регистрация в QG_CHECKS.
src/stage_engine.py _handle_security_gate(...) + врезка ПЕРВОЙ в блоке current_stage == "deploy-staging" (до _handle_merge_gate). FAIL → откат на development. never-raise. STAGE_TRANSITIONS НЕ меняется.
src/config.py security_gate_enabled (True), security_gate_repos (""), security_dep_block_severity ("HIGH"), security_scan_timeout_s (300), security_dep_audit_fail_closed (False), security_secrets_block (True) — с docstring по образцу ORCH-043/058.
Dockerfile Установка gitleaks (release-бинарь).
requirements.txt pip-audit.
.gitleaks.toml (новый, корень репо) Конфиг правил + аллоулист (.env.example-плейсхолдеры, тест-фикстуры) — BR-13.
.openclaw/agents/developer.md (Опц.) краткая инструкция про устранение security-находок при заворотах.
tests/ test_security_gate.py, test_qg_security.py, test_stage_engine_security_gate.py (см. 04-test-plan.yaml).
Документация CLAUDE.md (артефакт 17-…), docs/architecture/README.md (таблица гейтов + реестр QG + раздел), CHANGELOG.md, .env.example (ORCH_SECURITY_*), global adr-0012.

Альтернативы (отклонены)

  • Вариант R (review-стадия): раньше/дешевле, но diff может разойтись с тем, что вольётся в main; merge-edge уже закрывает «последнюю страховку».
  • Вариант C (CI-job через check_ci_green): пороги/severity/аллоулист/артефакт плохо выражаются статусом коммита; коуплинг с CI-раннером. → точка расширения BR-14.
  • fail-closed dep-audit по умолчанию: ложные откаты при сетевых сбоях → петля. → только опционально через флаг.
  • Аудит после rebase (как анкер image-freshness): обвиняет задачу в CVE из main → петля. → скан ветки ДО merge-gate.
  • Новая стадия security: «пустая» стадия без агента не имеет триггера (как отклонено в ORCH-043). → под-гейт ребра.
  • Новая колонка retry в БД: не нужна — переиспользуем _developer_retry_count.

Последствия

Плюсы. Структурно невозможно тихо влить секрет (безусловно) или известную CVE (best-effort) в main/прод автономной системы. Самоприменение CLAUDE.md §8. Минимальный blast-radius: STAGE_TRANSITIONS/схема БД не меняются, переиспользован готовый паттерн.

Минусы / плата. Ещё один «скрытый» под-гейт ребра (нет в STAGE_TRANSITIONS). Добавлены внешние инструменты (gitleaks-бинарь в образ, pip-audit в зависимости). Время сканирования добавляется к каждому прогону (ограничено таймаутом). Dep-audit best-effort при сетевых сбоях (осознанный компромисс против петли). v1 — Python-only (A3); мульти-стек и SAST — follow-up WI (BR-14).

Раскат. Сквозное изменение конвейера (новый QG + новый edge-под-гейт) → лейбл arch:major-change. Прод-деплой ORCH-022 — строго через staging-гейт (8501), без рестарта прод-контейнера в рамках задачи (self-hosting safety).

Связи

adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness — условность/never-raise/fail-closed), adr-0003 (is_self_hosting_repo — образец условности), adr-0009/ORCH-061 (анти-петля ложных FAIL), ORCH-046 (дословный reason в task_desc), ORCH-9/15 (мульти-стек — будущая зависимость).