# 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 ")` (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 (мульти-стек — будущая зависимость).