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