From 2d20da295e1ba3691c1729c2d763673d86955a3b Mon Sep 17 00:00:00 2001 From: Slava Date: Sun, 7 Jun 2026 19:49:34 +0300 Subject: [PATCH 01/10] docs: init ORCH-022 business request --- docs/work-items/ORCH-022/00-business-request.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 docs/work-items/ORCH-022/00-business-request.md diff --git a/docs/work-items/ORCH-022/00-business-request.md b/docs/work-items/ORCH-022/00-business-request.md new file mode 100644 index 0000000..eb3d55e --- /dev/null +++ b/docs/work-items/ORCH-022/00-business-request.md @@ -0,0 +1,7 @@ +# Business Request: [★ высокий] Security-гейт: secret-scanning + аудит зависимостей перед мержем + +Work Item ID: ORCH-022 + +## Description + +TBD From 4f24f96169c4140bfe8ae1cb6f6fde9db6228c3c Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 16:54:54 +0000 Subject: [PATCH 02/10] analyst(ET): auto-commit from analyst run_id=326 --- docs/work-items/ORCH-022/01-brd.md | 150 +++++++++++++++ docs/work-items/ORCH-022/02-trz.md | 175 ++++++++++++++++++ .../ORCH-022/03-acceptance-criteria.md | 140 ++++++++++++++ docs/work-items/ORCH-022/04-test-plan.yaml | 126 +++++++++++++ 4 files changed, 591 insertions(+) create mode 100644 docs/work-items/ORCH-022/01-brd.md create mode 100644 docs/work-items/ORCH-022/02-trz.md create mode 100644 docs/work-items/ORCH-022/03-acceptance-criteria.md create mode 100644 docs/work-items/ORCH-022/04-test-plan.yaml diff --git a/docs/work-items/ORCH-022/01-brd.md b/docs/work-items/ORCH-022/01-brd.md new file mode 100644 index 0000000..62b4d54 --- /dev/null +++ b/docs/work-items/ORCH-022/01-brd.md @@ -0,0 +1,150 @@ +# 01 — BRD: Security-гейт (secret-scanning + аудит зависимостей перед мержем) + +Work Item: **ORCH-022** +Приоритет: **★ высокий** +Источник: предложение Стрим, одобрено Славой (2026-06-04). +Стадия: analysis. + +--- + +## 1. Бизнес-проблема + +Оркестратор — автономная мульти-агентная система: агенты (`developer`) пишут код +**без человека-фильтра по умолчанию**. Перед мержем в `main` сейчас нет проверки на: + +- **утёкший секрет** — закоммиченный API-ключ / токен / пароль / приватный ключ; +- **дырявую зависимость** — пакет с известной CVE; +- (опционально) **базовую уязвимость кода** — типовой SAST-паттерн. + +Для автономной системы это критично: ошибку, которую в обычной команде «выловили бы +глазами на ревью», здесь поймать некому. Утёкший в `git`-историю ключ или уязвимая +зависимость может уехать в прод и обслуживать **все** проекты (общий инстанс, +self-hosting). + +### Прецеденты / связки +- **PR #18** (`check_ci_green`: красный CI → возврат на `development`) — задаёт целевой + паттерн поведения красного гейта. Security-гейт должен вести себя так же. +- **Управление секретами** (CLAUDE.md §8): секреты живут только в `.env`/`.env.staging` + на хосте, канон — `.env.example`. Гейт — это автоматический страж этого правила. + +--- + +## 2. Цель + +Ввести **security-гейт перед слиянием ветки задачи в `main`**, который детерминированно +(без LLM) проверяет diff/ветку на секреты и уязвимые зависимости и **блокирует +продвижение** при нарушении порогов: красный security-гейт → **возврат на `development`** +(developer-retry, как красный CI / merge-gate), задача **не уезжает в прод**. + +### Бизнес-ценность +- Структурно невозможно «тихо» влить секрет или известную CVE в прод автономной системы. +- Самоприменение правила CLAUDE.md §8 (секреты не в гит) без участия человека. +- Расширяет уже выстроенную линию автономных страховок (CI-гейт, merge-gate ORCH-043, + staging-провенанс ORCH-058, post-deploy ORCH-021). + +--- + +## 3. Объём (Scope) + +### 3.1 В объёме (v1) — **предположение по умолчанию (A1)** +1. **Secret-scanning** — обязательный минимум гейта. Поиск закоммиченных секретов + в ветке задачи / её diff относительно `main`. +2. **Dependency audit** — аудит зависимостей проекта на известные CVE. +3. **Машиночитаемый артефакт-вердикт** security-гейта (YAML-frontmatter — канон гейтов). +4. **Поведение красного гейта** = откат на `development` + developer-retry (cap + `MAX_DEVELOPER_RETRIES = 3`), наблюдаемость (Telegram + Plane-коммент). +5. **Условный раскат** (kill-switch + scope репозиториев), **never-raise**, + self-hosting (`orchestrator`) — первым. + +### 3.2 Вне объёма (v1) — **предположение (A2), отдельные WI** +- **SAST (semgrep)** — вынесен в follow-up WI: шумнее, требует policy-тюнинга правил; + гейт проектируется с точкой расширения под него, но в v1 не включается. +- **Полноценный мульти-стек** (JS/npm, Android) — см. A3 ниже; в v1 целевой стек — + Python (сам оркестратор). Связь с ORCH-9/15 фиксируется как зависимость на будущее. +- Ретроспективное сканирование уже существующей истории `main` (гейт смотрит вперёд — + ветку перед мержем, не чистит прошлое). +- Управление аллоулистом ложных срабатываний через UI/Plane (в v1 — файл в репозитории). + +### 3.3 Зафиксированные предположения по умолчанию +> ⚠️ Интерактивный опрос Owner на стадии анализа не дал ответа; ниже — +> **дефолты по конвенциям проекта**. Любой из них Owner/архитектор может переопределить +> (для A4 предусмотрены конфиг-флаги порогов). + +- **A1 (объём сканеров v1):** secret-scanning + dependency-audit. SAST отложен. +- **A2 (SAST):** отложен в отдельный WI; гейт оставляет точку расширения. +- **A3 (стек):** **Python-only сначала**, реально только для self-hosting + (`is_self_hosting_repo` / scope-CSV), как ORCH-35/43/58. Прочие репо — no-op pass. + Мульти-стек (детект стека по репо) — отдельный WI. +- **A4 (пороги):** **секреты — всегда блок**; **зависимости — блок на HIGH/CRITICAL, + warning на MEDIUM/LOW**. Пороги вынесены в конфиг (переопределяемы без редеплоя кода). + +--- + +## 4. Заинтересованные стороны +| Роль | Интерес | +|------|---------| +| Owner (Слава) | Прод-безопасность автономного конвейера; контроль порогов и раската. | +| Стрим | Инициатор; снижение риска утечки/уязвимости в автономном режиме. | +| Агент `developer` | Получает понятную причину красного гейта → быстрый фикс. | +| Агент `reviewer` | Гейт снимает с него непосильную задачу «глазами ловить ключи». | +| Все проекты на инстансе | Общий прод не должен получить секрет/CVE через одну задачу. | + +--- + +## 5. Бизнес-требования + +| ID | Требование | Приоритет | +|----|-----------|-----------| +| BR-1 | Перед слиянием ветки задачи в `main` обязателен security-гейт (секреты + аудит зависимостей). | MUST | +| BR-2 | Найден секрет (порог A4) → гейт **красный** → откат на `development`, в прод не уходит. | MUST | +| BR-3 | Уязвимость зависимости уровня блокировки (порог A4) → гейт **красный** → откат на `development`. | MUST | +| BR-4 | Уязвимость ниже порога блокировки → **warning**, продвижение не блокируется, но фиксируется в артефакте. | MUST | +| BR-5 | Красный гейт ведёт себя как красный CI / merge-gate: откат на `development` + developer-retry (cap 3), затем эскалация (Telegram + Plane Blocked). | MUST | +| BR-6 | Вердикт гейта — **машиночитаемый** (YAML-frontmatter артефакта), читается гейтом ТОЛЬКО из frontmatter (канон проекта), не из прозы. | MUST | +| BR-7 | Гейт **детерминированный, без LLM** в критическом пути (как merge-gate / image-freshness). | MUST | +| BR-8 | Гейт **never-raise**: внутренняя ошибка не роняет `advance_stage` и не вешает конвейер всех проектов. | MUST | +| BR-9 | Условный раскат: глобальный kill-switch + scope-CSV репозиториев; пусто → реально только self-hosting (`orchestrator`), прочие репо — no-op pass. | MUST | +| BR-10 | Пороги блокировки конфигурируемы (env-флаги, без редеплоя кода). | SHOULD | +| BR-11 | Наблюдаемость: причина блокировки видна (Telegram + Plane-коммент + артефакт); проход — без шума. | MUST | +| BR-12 | Документация (CLAUDE.md «Артефакты задачи», `docs/architecture/README.md` таблица гейтов, CHANGELOG, ADR) обновлена в том же PR. | MUST | +| BR-13 | Аллоулист ложных срабатываний (заведомо-безопасные совпадения, напр. в `.env.example`, фикстуры тестов) поддерживается версионируемым файлом в репозитории. | SHOULD | +| BR-14 | Точка расширения под SAST и мульти-стек заложена, но в v1 не активна (A2/A3). | SHOULD | + +--- + +## 6. Ограничения и риски (бизнес-уровень) +- **Self-hosting:** гейт исполняется внутри инстанса, который правит сам себя. Запрет на + рестарт/падение прод-контейнера в рамках задачи (CLAUDE.md §self-hosting) сохраняется — + гейт ничего не деплоит и не рестартит, только читает/сканирует. +- **Ложные срабатывания** (false positives) могут зациклить откат `→ development` + (прецедент ORCH-061 со staging-петлёй). Митигировано: cap retry=3 + аллоулист (BR-13) + + конфигурируемые пороги (BR-10) + kill-switch (BR-9). +- **Внешние БД уязвимостей** (CVE-фиды) — сетевая зависимость; недоступность фида не + должна давать ложный красный (см. AC: degrade-поведение при недоступности фида — + решение порога «fail-open vs fail-closed для аудита» закрепляется в acceptance + ADR). +- **Стоимость/время** сканирования добавляется к каждому прогону задачи — должно быть + ограничено таймаутом (как merge-retest). + +--- + +## 7. Критерий успеха (бизнес) +Ветка с подсаженным тестовым секретом и/или зависимостью с известной CRITICAL-CVE +**не может** дойти до `main`/прода: гейт краснеет, задача откатывается на `development` +с понятной причиной. Чистая ветка проходит гейт без задержек и без шума. Для не-self +репозиториев конвейер не меняется (no-op). Прод-контейнер не рестартится гейтом. + +--- + +## 8. Открытые вопросы (для архитектора / Owner) +1. **Размещение гейта** (решение архитектора): (а) на стадии `review`, либо (б) отдельный + под-гейт перед мержем на ребре `deploy-staging → deploy` (где уже живёт merge-gate + ORCH-043 / image-freshness ORCH-058). Требование BRD — «перед слиянием в `main`»; + обе опции его удовлетворяют. См. 02-trz §4. +2. **Где запускается сканер**: новый job в `.gitea/workflows/ci.yml` (тогда вердикт может + течь через существующий `check_ci_green`) **или** отдельный QG-чек/под-гейт в `src/qg`. + Решение — архитектор (02-trz фиксирует требования к обоим путям). +3. **Аудит зависимостей при недоступном CVE-фиде:** fail-open (warning) или fail-closed + (блок)? Дефолт-предложение — **fail-open с громким warning** (не плодить ложные + завороты), закрепить в ADR. +4. **Выбор конкретных инструментов** (gitleaks vs trufflehog; pip-audit vs trivy) — + технологическое решение архитектора; BRD фиксирует только функцию. diff --git a/docs/work-items/ORCH-022/02-trz.md b/docs/work-items/ORCH-022/02-trz.md new file mode 100644 index 0000000..0d95796 --- /dev/null +++ b/docs/work-items/ORCH-022/02-trz.md @@ -0,0 +1,175 @@ +# 02 — ТЗ: Security-гейт (secret-scanning + dependency audit) + +Work Item: **ORCH-022** · Стадия: analysis · См. `01-brd.md`, `03-acceptance-criteria.md`. + +> **Граница ответственности аналитика.** Ниже — *функциональные требования и точки +> касания* кода. Выбор размещения гейта в пайплайне, конкретных инструментов и схемы +> модулей — **решение архитектора** (см. §4 и `01-brd.md` §8). ТЗ фиксирует требования к +> любому из допустимых вариантов и инварианты, которые нельзя нарушать. + +--- + +## 1. Контекст кода (как есть) + +- **Стадии:** `src/stages.py::STAGE_TRANSITIONS` — линейный конвейер + `… review → testing → deploy-staging → deploy → done`. Фактический merge ветки в + `main` делает агент `deployer` **в начале стадии `deploy`** (CLAUDE/README). +- **Quality Gates:** `src/qg/checks.py` — реестр `QG_CHECKS` (имя → функция), сигнатуры + диспетчеризуются в `src/stage_engine.py::_run_qg`. +- **Существующий паттерн «красный гейт → возврат developer»:** + `check_ci_green` (PR #18) и rollback-ветки в + `stage_engine._handle_qg_failure_rollbacks` (откат на `development`, developer-retry, + cap `MAX_DEVELOPER_RETRIES = 3`, затем `set_issue_blocked` + Telegram). +- **Эталонный паттерн детерминированного под-гейта на ребре** (без LLM, never-raise, + условный раскат, откат на `development`): + - merge-gate **ORCH-043** — `src/merge_gate.py` + `check_branch_mergeable` + + `stage_engine._handle_merge_gate` (ребро `deploy-staging → deploy`); + - image-freshness **ORCH-058** — `src/image_freshness.py` + `_check_staging_image_fresh` + + `stage_engine._handle_image_freshness` (то же ребро). + Оба: leaf-модуль с чистой логикой (never-raise) + тонкая обёртка в `QG_CHECKS` + + врезка-обработчик в `advance_stage`, kill-switch `*_enabled` + scope `*_repos`, + реально только для self-hosting при пустом scope. +- **CI:** `.gitea/workflows/ci.yml` — один job `test` (pytest) на `self-hosted` раннере, + push в `feature/**` и PR в `main`. `check_ci_green` читает комбинированный статус + коммита из Gitea API. +- **Артефакты задачи** нумерованы до `16-post-deploy-log.md`. +- **Зависимости Python:** `requirements.txt` (корень репо). + +--- + +## 2. Функциональные требования к реализации + +### FR-1. Secret-scanning ветки перед мержем +- Сканировать ветку задачи / её diff относительно `origin/main` на секреты + (ключи, токены, пароли, приватные ключи). +- **Любой** подтверждённый секрет (не из аллоулиста) → вердикт **FAIL** (порог A4: секреты + всегда блокируют). +- Инструмент (gitleaks / trufflehog) — выбор архитектора. Должен запускаться offline-/ + детерминированно (без LLM) и иметь конфиг правил/аллоулиста в репозитории. + +### FR-2. Dependency audit +- Аудит зависимостей целевого стека на известные CVE. Для Python — манифест + `requirements.txt` (инструмент pip-audit / trivy — выбор архитектора). +- Классификация по severity. **Порог блокировки (A4, конфигурируемо BR-10):** + - `CRITICAL`, `HIGH` → вклад в **FAIL**; + - `MEDIUM`, `LOW` → **warning** (фиксируется в артефакте, не блокирует). +- Недоступность CVE-фида: degrade-поведение по решению ADR (дефолт-предложение — + fail-open + громкий warning, чтобы не плодить ложные завороты). Поведение должно быть + детерминированным и протестированным. + +### FR-3. Машиночитаемый артефакт-вердикт +- Гейт порождает артефакт security-отчёта с **YAML-frontmatter**, напр.: + ``` + --- + security_status: PASS # PASS | FAIL + secrets_found: 0 + deps_blocking: 0 # число уязвимостей уровня блокировки + deps_warning: 2 + --- + ``` + Имя артефакта — предложение: **`17-security-report.md`** (следующий свободный номер; + финализирует архитектор). Тело — человекочитаемый список находок. +- Вердикт читается гейтом **ТОЛЬКО из frontmatter** (канон проекта: «машинные вердикты — + строго YAML-frontmatter, никогда проза»), по образцу `_parse_deploy_status` / + `_parse_staging_status` / `check_reviewer_verdict`. Negative-токен (FAIL) авторитетен. +- Отсутствие/битый frontmatter → `(False, reason)` (fail-closed на чтении вердикта, + как у существующих парсеров). + +### FR-4. Поведение красного гейта (откат) +- `security_status: FAIL` → откат на `development` + enqueue `developer`, по образцу + `_handle_qg_failure_rollbacks` (merge-gate-ветка — точный шаблон): + - cap `MAX_DEVELOPER_RETRIES` (3); при исчерпании — `set_issue_blocked` + Telegram-алерт; + - `task_desc` для developer несёт **дословную причину** (какие секреты/CVE), по образцу + ORCH-046 (встраивание must-fix в `task_desc`), а не только ссылку на артефакт; + - Plane-коммент + `notify_qg_failure` (наблюдаемость BR-11). + +### FR-5. Условный раскат (как ORCH-35/43/58) +- Глобальный kill-switch `security_gate_enabled` (env `ORCH_SECURITY_GATE_ENABLED`, + дефолт по согласованию; рекомендуется `true` с safety-net, как у соседних фич). +- Scope `security_gate_repos` (CSV); пусто → реально только `is_self_hosting_repo(repo)` + (`orchestrator`). Прочие репо → `(True, "security-gate N/A for ")` (мгновенный pass). +- Отдельные пороги-флаги (A4/BR-10): напр. `security_dep_block_severity` + (`HIGH` по умолчанию), при желании `security_secrets_block` (`true`). + +### FR-6. never-raise +- Любая внутренняя ошибка гейта (сбой сканера, отсутствие бинаря, таймаут) → + `(False, "")` **без** проброса исключения в `advance_stage`. Контракт — + как у `check_branch_mergeable` (внешний + внутренний guard). +- Таймаут сканирования ограничен (по образцу `merge_retest_timeout_s`). + +### FR-7. Наблюдаемость +- Блокировка → Telegram + Plane-коммент (BR-11). Проход → лог-строка, без шумных + нотификаций (по образцу merge-gate pass). +- Желательно: краткий снимок в `GET /queue` (опционально, по образцу блоков `reconcile`/ + `reaper`/`post_deploy`) — на усмотрение архитектора. + +--- + +## 3. Задействованные модули `src/` (точки касания) + +| Модуль | Изменение | +|--------|-----------| +| `src/security_gate.py` (**новый leaf-модуль**) | Чистая логика гейта: запуск сканеров, классификация по severity, применение порогов/аллоулиста, формирование вердикта + парсер frontmatter. **never-raise.** По образцу `src/merge_gate.py` / `src/image_freshness.py` / `src/post_deploy.py`. | +| `src/qg/checks.py` | Новый чек `check_security_gate` (тонкая обёртка над `security_gate`, ленивый импорт во избежание циклов) + регистрация в `QG_CHECKS`. Условность (kill-switch/scope/self-hosting) — как `check_branch_mergeable` / `_check_staging_image_fresh`. | +| `src/stage_engine.py` | Врезка-обработчик `_handle_security_gate(...)` по образцу `_handle_merge_gate` / `_handle_image_freshness`: вызов в `advance_stage` на выбранном архитектором ребре; FAIL → откат на `development` (FR-4); never-raise. **`STAGE_TRANSITIONS` НЕ меняется**, если выбран вариант «под-гейт ребра». | +| `src/config.py` | Новые настройки: `security_gate_enabled`, `security_gate_repos`, `security_dep_block_severity`, `security_scan_timeout_s` (+ при необходимости пути к бинарям/конфигам сканеров). С docstring-комментариями по образцу ORCH-043/058. | +| `.gitea/workflows/ci.yml` | **Если** архитектор выберет CI-путь: новый job `security` (secret-scan + dep-audit), влияющий на комбинированный статус коммита (тогда срабатывает `check_ci_green`-паттерн PR #18). Иначе — не трогается. | +| `requirements.txt` / Dockerfile | Установка выбранных сканеров (если они Python-пакеты — в `requirements.txt`; если бинари — в Dockerfile/раннер). | +| Конфиг сканера + аллоулист | Версионируемые файлы в репозитории (напр. `.gitleaks.toml` / аллоулист) — BR-13. | +| `.openclaw/agents/developer.md` | (Если нужно) краткая инструкция developer'у про устранение security-находок при заворотах. | + +> Если выбран вариант «гейт на стадии `review`» — врезка делается в соответствующую +> ветку `advance_stage`/обработчик ревью вместо ребра `deploy-staging → deploy`. + +--- + +## 4. Размещение в пайплайне — варианты для архитектора + +Требование BRD: **«перед слиянием ветки в `main`»**. Допустимы (выбор + обоснование — в ADR): + +- **Вариант R (review):** security-проверка на стадии `review` (раньше отлов, дешевле + откат — задача ещё близко к development). Минус: дальше по конвейеру `main` может уйти + вперёд (но это закрывает merge-gate). +- **Вариант M (merge-edge, рекомендуемый к рассмотрению):** под-гейт на ребре + `deploy-staging → deploy`, рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058) — + непосредственно перед фактическим мержем `deployer`'ом. Плюс: единое место «последней + страховки перед main», переиспользование готового паттерна врезки/отката/lease. +- **Вариант C (CI-job):** добавить job в `ci.yml`; вердикт течёт через `check_ci_green`. + Плюс: меньше нового кода в движке. Минус: пороги/severity-логика и артефакт-вердикт + сложнее выразить только статусом коммита. + +ТЗ не предписывает вариант; реализация обязана сохранить инварианты §6. + +--- + +## 5. Изменения API +- Новых HTTP-endpoint'ов **не требуется**. +- Допустимо (опционально, FR-7): расширить ответ `GET /queue` блоком `security` + (counts/last_run) — по образцу блоков `reconcile`/`reaper`/`post_deploy`. Не обязательно. + +## 6. Изменения схемы БД +- **Не требуется.** Состояние гейта — артефакт-файл + (при необходимости) sentinel-файлы, + по образцу merge-lease / deploy-state / post-deploy-state. Миграций БД нет. +- Если архитектор сочтёт нужным считать security-retry отдельно от developer-retry — + предпочесть подсчёт по `jobs`/`agent_runs` (как `_developer_retry_count` / + `_merge_defer_count`), без новых колонок. + +## 7. Инварианты (НЕ нарушать) +1. `STAGE_TRANSITIONS` и реестр `QG_CHECKS` остаются консистентными; при варианте + «под-гейт ребра» — `STAGE_TRANSITIONS` не меняется (триггер — то же событие стадии). +2. Машинный вердикт — только из YAML-frontmatter, не из прозы. +3. never-raise: гейт никогда не пробрасывает исключение в `advance_stage`. +4. Условность как ORCH-35/43/58: не-self репо при пустом scope не затрагиваются (no-op). +5. Гейт **не деплоит и не рестартит** прод-контейнер (self-hosting safety). +6. Откат и retry-счётчик developer не ломаются (cap=3, затем эскалация). +7. Документация (CLAUDE.md, README, CHANGELOG, ADR) обновлена в том же PR (BR-12). + +## 8. Артефакты pipeline, создаваемые/обновляемые +- **Новый:** `docs/work-items/ORCH-022/17-security-report.md` (имя финализирует архитектор) + с `security_status:`-frontmatter (FR-3) — порождается гейтом per-task. +- **ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-.md` (решение: размещение, + инструменты, degrade-поведение фида, пороги). При сквозном влиянии — global ADR в + `docs/architecture/adr/`. +- **Обновить:** `CLAUDE.md` (раздел «Артефакты задачи» — добавить 17-…), + `docs/architecture/README.md` (таблица гейтов + реестр `QG_CHECKS` + новый раздел), + `CHANGELOG.md`, `.env.example` (новые `ORCH_SECURITY_*`). diff --git a/docs/work-items/ORCH-022/03-acceptance-criteria.md b/docs/work-items/ORCH-022/03-acceptance-criteria.md new file mode 100644 index 0000000..65695ea --- /dev/null +++ b/docs/work-items/ORCH-022/03-acceptance-criteria.md @@ -0,0 +1,140 @@ +# 03 — Критерии приёмки: Security-гейт (ORCH-022) + +Формат: каждый критерий имеет чёткое условие **PASS/FAIL**. Привязка к +`01-brd.md` (BR-*) и `02-trz.md` (FR-*). + +--- + +## A. Secret-scanning (FR-1, BR-1/BR-2) + +### AC-1 — Подсаженный секрет блокирует гейт +- **PASS:** ветка с тестовым секретом (напр. фиктивный AWS-ключ формата `AKIA…` вне + аллоулиста) → `security_status: FAIL`; гейт возвращает `(False, reason)`, причина + называет секрет/файл. +- **FAIL:** секрет не обнаружен ИЛИ гейт зелёный при наличии секрета. + +### AC-2 — Чистая ветка проходит +- **PASS:** ветка без секретов → `security_status: PASS`; `secrets_found: 0`; + гейт возвращает `(True, …)`. +- **FAIL:** ложное срабатывание (FAIL на чистой ветке). + +### AC-3 — Аллоулист подавляет заведомо-безопасное (BR-13) +- **PASS:** совпадение, явно занесённое в версионируемый аллоулист (напр. плейсхолдер в + `.env.example` / фикстура теста), **не** даёт FAIL. +- **FAIL:** аллоулист игнорируется и даёт ложный FAIL. + +--- + +## B. Dependency audit (FR-2, BR-3/BR-4) + +### AC-4 — CVE уровня блокировки краснит гейт +- **PASS:** зависимость с известной `CRITICAL`/`HIGH` CVE (при пороге по умолчанию + `HIGH`) → вклад в `security_status: FAIL`; `deps_blocking >= 1`. +- **FAIL:** блокирующая уязвимость не приводит к FAIL. + +### AC-5 — Низкая severity = warning, не блок +- **PASS:** только `MEDIUM`/`LOW` уязвимости → `security_status: PASS`, при этом + `deps_warning >= 1` и находки перечислены в теле артефакта. +- **FAIL:** `MEDIUM`/`LOW` блокирует продвижение. + +### AC-6 — Порог блокировки конфигурируем (BR-10) +- **PASS:** при `ORCH_SECURITY_DEP_BLOCK_SEVERITY=CRITICAL` та же `HIGH`-уязвимость + становится warning (не блок); при `=HIGH` — блок. Поведение детерминированно + определяется флагом. +- **FAIL:** флаг не влияет на классификацию. + +### AC-7 — Degrade при недоступном CVE-фиде +- **PASS:** недоступность фида обрабатывается по решению ADR детерминированно и + протестированно (дефолт: fail-open + громкий warning, гейт не краснеет ложно). +- **FAIL:** недоступность фида даёт неконтролируемый красный/исключение. + +--- + +## C. Вердикт и артефакт (FR-3, BR-6) + +### AC-8 — Машинный вердикт только из frontmatter +- **PASS:** вердикт читается ТОЛЬКО из YAML-frontmatter `17-security-report.md`; проза с + «PASS»/«FAIL» в теле не влияет на решение. Negative-токен (FAIL) авторитетен. +- **FAIL:** вердикт извлекается из тела/прозы. + +### AC-9 — Битый/отсутствующий frontmatter → fail-closed на чтении +- **PASS:** нет frontmatter / битый YAML / нет поля `security_status` → `(False, reason)` + (как `_parse_deploy_status`/`check_reviewer_verdict`). +- **FAIL:** битый артефакт трактуется как PASS. + +### AC-10 — Артефакт создаётся с корректными полями +- **PASS:** после прогона существует `17-security-report.md` с валидным frontmatter + (`security_status`, `secrets_found`, `deps_blocking`, `deps_warning`) и телом-списком. +- **FAIL:** артефакт не создан/без машинных полей. + +--- + +## D. Откат и retry (FR-4, BR-5) + +### AC-11 — Красный гейт → откат на development + developer-retry +- **PASS:** `FAIL` → стадия задачи становится `development`, enqueue `developer`, + Plane-коммент + `notify_qg_failure`; счётчик developer-retry растёт. +- **FAIL:** при FAIL задача продвигается дальше / не откатывается. + +### AC-12 — task_desc несёт дословную причину (ORCH-046-паттерн) +- **PASS:** `task_desc` для перезапущенного developer содержит конкретику находок + (какие секреты/CVE), а не только ссылку на артефакт. +- **FAIL:** developer получает только ссылку без сути. + +### AC-13 — Cap retry и эскалация +- **PASS:** после `MAX_DEVELOPER_RETRIES` (3) безуспешных фиксов — `set_issue_blocked` + + Telegram-алерт; бесконечного отскока нет. +- **FAIL:** откат зацикливается без cap/эскалации. + +--- + +## E. Условный раскат и устойчивость (FR-5/FR-6, BR-8/BR-9) + +### AC-14 — Не-self репозиторий = no-op pass +- **PASS:** для repo, не входящего в scope и не self-hosting → гейт возвращает + `(True, "security-gate N/A for ")` мгновенно, конвейер такого репо не меняется. +- **FAIL:** гейт реально запускается/блокирует чужой репо при пустом scope. + +### AC-15 — Kill-switch отключает гейт +- **PASS:** `ORCH_SECURITY_GATE_ENABLED=false` → гейт — no-op pass (`(True, …)`), + поведение конвейера 1:1 как до ORCH-022. +- **FAIL:** при выключенном флаге гейт всё ещё блокирует. + +### AC-16 — never-raise +- **PASS:** искусственный сбой (нет бинаря сканера / таймаут / исключение внутри) → + `(False, reason)` без проброса исключения; `advance_stage` не падает, конвейер других + задач/проектов не встаёт. +- **FAIL:** внутренняя ошибка пробрасывается/вешает движок. + +### AC-17 — Таймаут ограничен +- **PASS:** сканирование, превысившее `ORCH_SECURITY_SCAN_TIMEOUT_S`, корректно + прерывается → детерминированный вердикт (по политике degrade), без зависания. +- **FAIL:** сканер висит без таймаута. + +--- + +## F. Инварианты и интеграция (BR-7/BR-12, TRZ §7) + +### AC-18 — STAGE_TRANSITIONS/QG_CHECKS консистентны +- **PASS:** при варианте «под-гейт ребра» `STAGE_TRANSITIONS` не изменён; новый чек + зарегистрирован в `QG_CHECKS`; `_run_qg` корректно его диспетчеризует. Все + существующие тесты гейтов/стадий зелёные. +- **FAIL:** сломан реестр/переходы/существующие тесты. + +### AC-19 — Гейт не деплоит/не рестартит прод +- **PASS:** код гейта не вызывает деплой-хук/рестарт прод-контейнера; только + чтение/сканирование. +- **FAIL:** гейт инициирует рестарт/деплой. + +### AC-20 — Документация обновлена в том же PR (BR-12) +- **PASS:** обновлены `CLAUDE.md` (артефакт 17-…), `docs/architecture/README.md` + (таблица гейтов + реестр QG + раздел ORCH-022), `CHANGELOG.md`, `.env.example` + (`ORCH_SECURITY_*`); заведён ADR `06-adr/ADR-001-*`. +- **FAIL:** функционал есть, документация/ADR не обновлены → reviewer обязан + REQUEST_CHANGES (CLAUDE.md §6). + +### AC-21 — End-to-end на тестовой задаче +- **PASS:** прогон на self-hosting-репо: грязная ветка (секрет/CVE) → откат на + `development`; после фикса чистая ветка → гейт зелёный → конвейер идёт дальше; прод не + затронут в процессе. +- **FAIL:** любой шаг E2E не воспроизводится. diff --git a/docs/work-items/ORCH-022/04-test-plan.yaml b/docs/work-items/ORCH-022/04-test-plan.yaml new file mode 100644 index 0000000..694ebce --- /dev/null +++ b/docs/work-items/ORCH-022/04-test-plan.yaml @@ -0,0 +1,126 @@ +work_item: ORCH-022 +title: "Security-гейт: secret-scanning + dependency audit перед мержем" +notes: > + План тестов для security-гейта. Чистая логика выносится в leaf-модуль + src/security_gate.py (never-raise) — основной предмет unit-тестов (по образцу + tests для merge_gate / image_freshness / post_deploy / staging_verdict). + Интеграция врезки в advance_stage и условный раскат — integration-тесты. + Имена модулей тестов финализирует разработчик/архитектор по факту реализации. + +tests: + # --- Secret-scanning (FR-1 / AC-1..AC-3) --- + - id: TC-01 + type: unit + description: "Подсаженный тестовый секрет в diff -> вердикт FAIL, secrets_found>=1, причина называет находку." + module: tests/test_security_gate.py + expected: PASS + - id: TC-02 + type: unit + description: "Чистая ветка без секретов -> вердикт PASS, secrets_found=0." + module: tests/test_security_gate.py + expected: PASS + - id: TC-03 + type: unit + description: "Совпадение из аллоулиста (плейсхолдер .env.example / фикстура) НЕ даёт FAIL." + module: tests/test_security_gate.py + expected: PASS + + # --- Dependency audit + пороги (FR-2 / AC-4..AC-7) --- + - id: TC-04 + type: unit + description: "CVE уровня HIGH/CRITICAL при пороге HIGH -> вклад в FAIL, deps_blocking>=1." + module: tests/test_security_gate.py + expected: PASS + - id: TC-05 + type: unit + description: "Только MEDIUM/LOW уязвимости -> PASS, deps_warning>=1, находки в теле артефакта." + module: tests/test_security_gate.py + expected: PASS + - id: TC-06 + type: unit + description: "Конфиг порога: severity=CRITICAL делает HIGH-CVE warning; severity=HIGH делает её блоком." + module: tests/test_security_gate.py + expected: PASS + - id: TC-07 + type: unit + description: "Недоступный CVE-фид -> детерминированный degrade по политике ADR (дефолт fail-open + warning), без исключения и без ложного FAIL." + module: tests/test_security_gate.py + expected: PASS + + # --- Вердикт / парсер frontmatter (FR-3 / AC-8..AC-10) --- + - id: TC-08 + type: unit + description: "Вердикт читается ТОЛЬКО из YAML-frontmatter; проза PASS/FAIL в теле не влияет; negative-токен авторитетен." + module: tests/test_security_gate.py + expected: PASS + - id: TC-09 + type: unit + description: "Нет frontmatter / битый YAML / нет поля security_status -> (False, reason) (fail-closed на чтении)." + module: tests/test_security_gate.py + expected: PASS + - id: TC-10 + type: unit + description: "Артефакт 17-security-report.md создаётся с валидным frontmatter (security_status, secrets_found, deps_blocking, deps_warning) и телом-списком." + module: tests/test_security_gate.py + expected: PASS + + # --- never-raise / таймаут / условность (FR-5/FR-6 / AC-14..AC-17) --- + - id: TC-11 + type: unit + description: "Отсутствие бинаря сканера / внутреннее исключение -> (False, reason), исключение не пробрасывается (never-raise)." + module: tests/test_security_gate.py + expected: PASS + - id: TC-12 + type: unit + description: "Превышение ORCH_SECURITY_SCAN_TIMEOUT_S -> корректное прерывание и детерминированный вердикт, без зависания." + module: tests/test_security_gate.py + expected: PASS + - id: TC-13 + type: unit + description: "check_security_gate: не-self репо при пустом scope -> (True, 'security-gate N/A for ') мгновенно." + module: tests/test_qg_security.py + expected: PASS + - id: TC-14 + type: unit + description: "check_security_gate: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True)." + module: tests/test_qg_security.py + expected: PASS + - id: TC-15 + type: unit + description: "Новый чек зарегистрирован в QG_CHECKS и корректно диспетчеризуется _run_qg." + module: tests/test_qg_security.py + expected: PASS + + # --- Откат / retry в stage_engine (FR-4 / AC-11..AC-13) --- + - id: TC-16 + type: integration + description: "security_status FAIL -> advance_stage откатывает на development, enqueue developer, Plane-коммент + notify_qg_failure." + module: tests/test_stage_engine_security_gate.py + expected: PASS + - id: TC-17 + type: integration + description: "task_desc перезапущенного developer содержит дословную причину находок (ORCH-046-паттерн), не только ссылку." + module: tests/test_stage_engine_security_gate.py + expected: PASS + - id: TC-18 + type: integration + description: "После MAX_DEVELOPER_RETRIES (3) -> set_issue_blocked + Telegram-алерт; бесконечного отскока нет." + module: tests/test_stage_engine_security_gate.py + expected: PASS + - id: TC-19 + type: integration + description: "security_status PASS -> advance_stage продвигает конвейер штатно (без отката, без шумных нотификаций)." + module: tests/test_stage_engine_security_gate.py + expected: PASS + + # --- Инварианты / интеграция (BR-7/BR-12 / AC-18..AC-19) --- + - id: TC-20 + type: integration + description: "При варианте 'под-гейт ребра' STAGE_TRANSITIONS не изменён; существующие тесты стадий/гейтов остаются зелёными." + module: tests/test_stages.py + expected: PASS + - id: TC-21 + type: integration + description: "Гейт не вызывает деплой-хук/рестарт прод-контейнера (self-hosting safety)." + module: tests/test_stage_engine_security_gate.py + expected: PASS From 44db94e462f7ec97542750cbe0453548a918ba42 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 17:03:11 +0000 Subject: [PATCH 03/10] architect(ET): auto-commit from architect run_id=327 --- docs/architecture/README.md | 36 ++- .../adr/adr-0012-security-gate.md | 63 +++++ .../ORCH-022/06-adr/ADR-001-security-gate.md | 235 ++++++++++++++++++ .../ORCH-022/07-infra-requirements.md | 56 +++++ .../ORCH-022/08-data-requirements.md | 26 ++ docs/work-items/ORCH-022/10-tech-risks.md | 16 ++ 6 files changed, 430 insertions(+), 2 deletions(-) create mode 100644 docs/architecture/adr/adr-0012-security-gate.md create mode 100644 docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md create mode 100644 docs/work-items/ORCH-022/07-infra-requirements.md create mode 100644 docs/work-items/ORCH-022/08-data-requirements.md create mode 100644 docs/work-items/ORCH-022/10-tech-risks.md diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 0b1d743..6ec3974 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -36,7 +36,7 @@ created → analysis → architecture → development → review → testing → | deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) | | done | — | — | — | -**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058). +**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022 — design). **Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`. @@ -155,6 +155,38 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md), детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`. +### Security-гейт: secret-scanning + dependency audit перед мержем (ORCH-022 — design) +Автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/ +приватный ключ) и уязвимую зависимость (CVE); для self-hosting один секрет/CVE через одну +задачу уезжал в общий прод всех проектов (CLAUDE.md §8). ORCH-022 вводит детерминированный +(без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, рядом с merge-gate +(ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди edge-под-гейтов +(ДО merge-gate). Паттерн соседей: leaf `src/security_gate.py` (never-raise) + тонкая обёртка +`check_security_gate` в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`. +`STAGE_TRANSITIONS` и схема БД — **без изменений**. +- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне + аллоулиста `.gitleaks.toml` → вклад в FAIL. Offline → гарантия «секрет всегда блокирует» + не зависит от сети (безусловна). +- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity` + (дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open + + громкий warning** (анти-петля ORCH-061; флаг `security_dep_audit_fail_closed` для строгого + режима). best-effort при доступности фида. +- **ПЕРВЫМ, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки ДО rebase + не «обвиняет» задачу в CVE из обновившегося `main`; до захвата merge-lease → при FAIL lease + освобождать не нужно. +- **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/ + `deps_blocking`/`deps_warning`/`deps_audit_degraded`); вердикт читается ТОЛЬКО из + frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает: единый + источник истины), negative-токен авторитетен, битый/нет → fail-closed. +- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3, + затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046). +- **Условность как ORCH-35/43/58:** `security_gate_enabled` + `security_gate_repos` (пусто → + только self-hosting); never-raise; таймаут `security_scan_timeout_s`; гейт не деплоит/не + рестартит прод. v1 — Python-only; SAST/мульти-стек — follow-up (BR-14). + +Подробнее: [adr-0012](adr/adr-0012-security-gate.md), детально — +`docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`. + ### Reconciler: реконсиляция потерянных webhook (ORCH-053 — реализовано) Конвейер продвигается только входящими webhook; потерянное событие (502 на ребилде, нет ретраев у Plane/Gitea, неразрезолвленный `sha→branch`) → задача застревает молча @@ -306,4 +338,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — **design**, ветка feature/ORCH-022-security-secret-scanning (при реализации: новый leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.md; обновлять также при изменении этих мест).* diff --git a/docs/architecture/adr/adr-0012-security-gate.md b/docs/architecture/adr/adr-0012-security-gate.md new file mode 100644 index 0000000..048ad32 --- /dev/null +++ b/docs/architecture/adr/adr-0012-security-gate.md @@ -0,0 +1,63 @@ +# adr-0012: Security-гейт — secret-scanning + dependency audit перед мержем + +- **Статус:** proposed +- **Дата:** 2026-06-07 +- **Задача:** ORCH-022 +- **Детальный ADR:** `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md` + +## Контекст +Оркестратор автономен: `developer` пишет код без человека-фильтра. Перед слиянием ветки в +`main` нет проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую +зависимость (CVE). Для self-hosting один общий прод-инстанс обслуживает все проекты с общей +БД — секрет/CVE через одну задачу попадает в прод всех (CLAUDE.md §self-hosting, §8). Фактический +мерж PR в `main` делает `deployer` в начале стадии `deploy`. + +## Решение +Детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, +рядом с merge-gate (ORCH-043) и image-freshness (ORCH-058), исполняемый **ПЕРВЫМ** среди +edge-под-гейтов (ДО merge-gate). `STAGE_TRANSITIONS` не меняется; в `QG_CHECKS` добавлен +`check_security_gate`. Паттерн — как у соседей: leaf-модуль `src/security_gate.py` +(never-raise) + тонкая обёртка в `QG_CHECKS` + врезка `_handle_security_gate` в `advance_stage`. + +- **Secret-scanning (`gitleaks`, offline):** скан `origin/main..HEAD`; любой секрет вне + аллоулиста (`.gitleaks.toml`) → вклад в FAIL. Offline → гарантия «секрет всегда блокирует» + не зависит от сети. +- **Dependency audit (`pip-audit`, OSV/PyPI):** severity ≥ `security_dep_block_severity` + (дефолт `HIGH`) → FAIL; ниже / UNKNOWN → warning. Недоступность фида → **fail-open + + громкий warning** (анти-петля; флаг `security_dep_audit_fail_closed` для строгого режима). +- **ПЕРВЫМ на ребре, ДО merge-gate:** дёшево фейлить до дорогих rebase/rebuild; скан ветки + ДО rebase не «обвиняет» задачу в CVE, притащенной обновившимся `main` (анти-петля + ORCH-061); до захвата merge-lease → при FAIL lease освобождать не нужно. +- **Артефакт `17-security-report.md`** с YAML-frontmatter (`security_status`, + `secrets_found`, `deps_blocking`, `deps_warning`, `deps_audit_degraded`); вердикт читается + ТОЛЬКО из frontmatter (канон), negative-токен авторитетен; битый/нет → fail-closed. +- **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap 3, + затем `set_issue_blocked` + Telegram); `task_desc` несёт дословные находки (ORCH-046). +- **Условность (как ORCH-35/43/58):** `security_gate_enabled` + `security_gate_repos`; пусто + → реально только self-hosting (`orchestrator`), прочие репо — no-op pass. +- **never-raise**, таймаут `security_scan_timeout_s`, гейт не деплоит/не рестартит прод. + +## Альтернативы +- **Вариант R (review-стадия):** diff может разойтись с мержем в `main`; merge-edge — последняя + страховка. Отклонено. +- **Вариант C (CI-job через `check_ci_green`):** пороги/severity/аллоулист/артефакт плохо + выражаются статусом коммита; коуплинг с раннером. Отклонено для v1 (точка расширения). +- **Новая стадия `security`:** «пустая» стадия без агента не имеет триггера (как в ORCH-043). + Отклонено. +- **fail-closed dep-audit / аудит после rebase:** ложные откаты → петля. Отклонено. +- **Новая колонка retry в БД:** не нужна (переиспользуем `_developer_retry_count`). + +## Последствия +- Класс «тихо влитый секрет/CVE» закрыт: секреты — безусловно (offline), CVE — best-effort при + доступности фида. Самоприменение CLAUDE.md §8 без человека. +- Плата: ещё один «скрытый» под-гейт ребра (нет в `STAGE_TRANSITIONS`); внешние инструменты + (gitleaks в образе, pip-audit в зависимостях); время скана на каждом прогоне (ограничено + таймаутом); v1 — Python-only (SAST/мульти-стек — follow-up WI). +- Сквозное изменение (новый QG + edge-под-гейт) → `arch:major-change`; прод-деплой ORCH-022 — + строго через staging-гейт (8501), без рестарта прод-контейнера. + +## Связи +adr-0006 (merge-gate — паттерн edge-под-гейта/отката), adr-0008 (image-freshness — +условность/never-raise/fail-closed), adr-0003 (условный гейт / `is_self_hosting_repo`), +adr-0009 (анти-петля ложных FAIL, ORCH-061), ORCH-046 (дословный reason в `task_desc`), +ORCH-9/15 (мульти-стек — будущая зависимость), ORCH-2 (worktree-изоляция). diff --git a/docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md b/docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md new file mode 100644 index 0000000..d38dfff --- /dev/null +++ b/docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md @@ -0,0 +1,235 @@ +# 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 (мульти-стек — будущая зависимость). diff --git a/docs/work-items/ORCH-022/07-infra-requirements.md b/docs/work-items/ORCH-022/07-infra-requirements.md new file mode 100644 index 0000000..d48f588 --- /dev/null +++ b/docs/work-items/ORCH-022/07-infra-requirements.md @@ -0,0 +1,56 @@ +# 07 — Инфраструктурные требования: Security-гейт (ORCH-022) + +См. `06-adr/ADR-001-security-gate.md` (Р-2, Р-3, Р-8). Топология не меняется (один сервер +mva154, Docker Compose). Новые требования — только инструменты сканирования и сетевой доступ +к CVE-фиду. + +## I-1. Бинарь `gitleaks` в образе +- **Что:** статический Go-бинарь `gitleaks` (secret-scanning), устанавливается в `Dockerfile` + (НЕ pip-пакет). Зафиксировать версию (pinned release) для детерминизма. +- **Почему в образе, а не на хосте:** гейт исполняется внутри контейнера оркестратора + (`advance_stage`); сканируется per-task worktree, смонтированный в контейнер. +- **Оффлайн:** gitleaks не требует сети (правила локальны) → гарантия «секрет всегда + блокирует» (BR-2) не зависит от доступности интернета. +- **Контракт exit-кодов:** 0 = чисто, 1 = найдены секреты, ≥2 = ошибка инструмента + (≥2 → never-raise degrade-вердикт гейта). + +## I-2. `pip-audit` в зависимостях +- **Что:** Python-пакет `pip-audit` (dependency audit), добавляется в `requirements.txt` + (pinned-версия). +- **Источник advisory:** OSV / PyPI advisory DB — **требует сетевого доступа** (исходящий + HTTPS к OSV/PyPI). +- **Цель v1:** аудит `requirements.txt` корня репо (Python-стек, A3). Мульти-стек — follow-up. + +## I-3. Сетевой доступ к CVE-фиду (degrade-политика) +- **Требование:** исходящий HTTPS из прод-контейнера к OSV/PyPI advisory. +- **При недоступности (Р-3):** **fail-open + громкий warning** по умолчанию — dep-audit не + краснит гейт из-за сетевого сбоя (анти-петля ORCH-061); фиксируется + `deps_audit_degraded: true` + Telegram + лог. Флаг `security_dep_audit_fail_closed` + (дефолт `false`) — для перевода в строгий режим без редеплоя кода. +- **Секреты не зависят от сети** (I-1) — критическая гарантия безусловна. + +## I-4. Конфиг-файлы в репозитории (версионируемые, BR-13) +- `.gitleaks.toml` (корень репо): правила + аллоулист заведомо-безопасных совпадений + (плейсхолдеры `.env.example`, тест-фикстуры). Версионируется, ревьюится как код. + +## I-5. Env-флаги (`.env.example` + хост `.env`/`.env.staging`) +| Переменная | Дефолт | Назначение | +|------------|--------|-----------| +| `ORCH_SECURITY_GATE_ENABLED` | `true` | глобальный kill-switch | +| `ORCH_SECURITY_GATE_REPOS` | `` (пусто) | CSV scope; пусто → только self-hosting | +| `ORCH_SECURITY_DEP_BLOCK_SEVERITY` | `HIGH` | порог блокировки зависимостей | +| `ORCH_SECURITY_SCAN_TIMEOUT_S` | `300` | таймаут каждого внешнего вызова сканера | +| `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` | `false` | строгий режим при недоступном фиде | +| `ORCH_SECURITY_SECRETS_BLOCK` | `true` | секреты блокируют (всегда по дефолту) | + +Секреты-значения в гит НЕ коммитятся (CLAUDE.md §8) — только дефолты в `.env.example`. + +## I-6. Ресурсы и тайминги +- Время сканирования добавляется к каждому прогону задачи на ребре `deploy-staging → deploy`, + ограничено `ORCH_SECURITY_SCAN_TIMEOUT_S` (по образцу `merge_retest_timeout_s`). +- Гейт исполняется ДО merge-gate/image-freshness (дёшево фейлить до дорогих rebase/rebuild). + +## I-7. Self-hosting safety (инвариант) +Гейт **только читает/сканирует** (git, gitleaks, pip-audit, запись артефакта). Не вызывает +деплой-хук, не рестартит/не трогает прод-контейнер (8500/8501). Прод-деплой ORCH-022 — строго +через staging-гейт (8501). diff --git a/docs/work-items/ORCH-022/08-data-requirements.md b/docs/work-items/ORCH-022/08-data-requirements.md new file mode 100644 index 0000000..9445746 --- /dev/null +++ b/docs/work-items/ORCH-022/08-data-requirements.md @@ -0,0 +1,26 @@ +# 08 — Требования к схеме БД: Security-гейт (ORCH-022) + +## Решение: схема БД НЕ меняется + +Миграций нет. Обоснование (соответствует TRZ §6 и паттерну edge-под-гейтов ORCH-043/058): + +1. **Вердикт гейта — артефакт-файл** `17-security-report.md` (YAML-frontmatter), как + `14-deploy-log.md` / `15-staging-log.md`. Не хранится в БД. +2. **Состояние/идемпотентность** — детерминированная пересборка вердикта при каждом тике + (гейт чистый, без долгоживущего состояния между прогонами); sentinel-файлы НЕ требуются + (в отличие от deploy-state/post-deploy-state — там асинхронный self-restart). +3. **Retry-счётчик** — переиспользуется существующий `_developer_retry_count(task_id)` + (подсчёт по `jobs`/`agent_runs`), общий с merge-gate/image-freshness. **Новой колонки + `security_retry` НЕ вводим** (TRZ §6: предпочесть подсчёт по `jobs`/`agent_runs`). Это + корректно: security-FAIL, как merge/freshness-FAIL, откатывает на `development` и + запускает developer — он и есть единица retry; общий cap=3 защищает от петли. + +## Используемые существующие таблицы (без изменений) +- `tasks` — стадия задачи (`update_task_stage` при откате на `development`). +- `jobs` — enqueue `developer` при FAIL; основа `_developer_retry_count`. +- `agent_runs` — usage/duration; основа подсчёта retry. + +## Что НЕ делаем +- Не добавляем таблицу findings/CVE-журнала (история находок — в артефактах per-task; петля + уроков ORCH-8 читает артефакт). +- Не добавляем колонок в `tasks`/`jobs`. diff --git a/docs/work-items/ORCH-022/10-tech-risks.md b/docs/work-items/ORCH-022/10-tech-risks.md new file mode 100644 index 0000000..47a76c3 --- /dev/null +++ b/docs/work-items/ORCH-022/10-tech-risks.md @@ -0,0 +1,16 @@ +# 10 — Технические риски: Security-гейт (ORCH-022) + +| ID | Риск | Вероятность / Влияние | Митигация (заложена в ADR-001) | +|----|------|----------------------|-------------------------------| +| R-1 | **Ложные срабатывания → петля отката** `→ development` (прецедент ORCH-061 staging-loop). | Средн. / Выс. | Аллоулист `.gitleaks.toml` (BR-13); cap `MAX_DEVELOPER_RETRIES=3` → эскалация (`set_issue_blocked`+Telegram); конфигурируемый порог severity; kill-switch; UNKNOWN-severity → warning, не блок. | +| R-2 | **Недоступность CVE-фида** даёт ложный красный/исключение. | Средн. / Выс. | fail-open + громкий warning по умолчанию (Р-3); `deps_audit_degraded:true`; флаг `security_dep_audit_fail_closed` для строгого режима. Секреты offline → не затронуты. | +| R-3 | **Скан вешает worker-слот** (зависший gitleaks/pip-audit) → стоит конвейер всех проектов (общий инстанс, `max_concurrency`). | Низк. / Выс. | `security_scan_timeout_s` (300) на каждый внешний вызов; never-raise degrade-вердикт; гейт ПЕРВЫМ на ребре (фейлит до дорогих rebase/rebuild). | +| R-4 | **Исключение гейта роняет `advance_stage`** → встаёт движок. | Низк. / Выс. | Двойной never-raise guard (внешний+внутренний) как `check_branch_mergeable`; AC-16/TC-11. | +| R-5 | **Скан после rebase обвиняет задачу в CVE из `main`** → петля. | — (устранён дизайном) | Гейт исполняется ДО merge-gate (скан ветки до rebase); Р-1. | +| R-6 | **Отсутствие бинаря `gitleaks` в образе** (забыт в Dockerfile) → гейт всегда degrade. | Низк. / Средн. | Установка в Dockerfile (I-1), pinned-версия; TC-11 (нет бинаря → `(False,reason)`, never-raise); проверяется на staging (8501) до прода. | +| R-7 | **pip-audit без severity (UNKNOWN)** → либо ложный блок, либо пропуск. | Средн. / Средн. | UNKNOWN → warning (не блок), логируется; осознанный анти-петля компромисс; ужесточение — follow-up. | +| R-8 | **Self-hosting: гейт трогает прод** (рестарт/деплой). | — (запрещено дизайном) | Гейт только читает/сканирует; AC-19/TC-21; прод-деплой ORCH-022 — через staging-гейт. | +| R-9 | **Drift вердикта vs артефакта** (возврат ≠ frontmatter). | Низк. / Средн. | Единый источник: гейт пишет артефакт → читает обратно через `parse_security_status` → возвращает (Р-5); AC-8. | +| R-10 | **Регресс существующих гейтов/стадий** (сломан `QG_CHECKS`/`STAGE_TRANSITIONS`). | Низк. / Выс. | `STAGE_TRANSITIONS` не меняется; новый чек — аддитивно в реестр; полный прогон `tests/` (TC-20); staging-гейт перед прод. | +| R-11 | **v1 Python-only** — секреты/CVE в не-Python стеке (JS/Android) не ловятся. | — (вне scope v1, A3) | Условность scope; точка расширения мульти-стек/SAST (BR-14); зависимость ORCH-9/15 зафиксирована. | +| R-12 | **Стоимость времени** на каждом прогоне задачи. | Низк. / Низк. | Таймаут; гейт первым (ранний выход); только self-hosting по умолчанию. | From 30b6187c73fc2cdc40b45e861c3c572c22ec7ac3 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 17:23:13 +0000 Subject: [PATCH 04/10] feat(security): security-gate (gitleaks secret-scan + pip-audit) before merge Add a deterministic (no-LLM) security sub-gate on the deploy-staging -> deploy edge, run FIRST (before merge-gate ORCH-043 and image-freshness ORCH-058) so it fails cheaply before any expensive rebase/rebuild, and scans origin/main..HEAD before rebase so a task is never blamed for a CVE introduced by an updated main. Why: the autonomous pipeline merged branches into main with no check for a leaked secret or a vulnerable dependency. For the self-hosting orchestrator (one shared prod instance serving every project from a shared DB) a single leak/CVE landed in the prod of all projects (CLAUDE.md self-hosting, section 8). - New leaf src/security_gate.py (never-raise): gitleaks (offline, fail-closed on tool error => secrets guarantee is unconditional) + pip-audit (best-effort; unreachable CVE feed degrades fail-open + loud warning by default, strict via security_dep_audit_fail_closed). Verdict lives ONLY in 17-security-report.md YAML frontmatter (write -> read-back single source of truth); FAIL is authoritative; missing/broken frontmatter => fail-closed. - check_security_gate thin wrapper registered in QG_CHECKS (lazy import, no cycle). - _handle_security_gate wired FIRST in advance_stage deploy-staging block: FAIL -> rollback to development + developer-retry (cap MAX_DEVELOPER_RETRIES); task_desc carries verbatim findings (ORCH-046 pattern). No merge-lease release (runs before lease acquire). Self-hosting safe: only reads/scans/writes, never deploys. - Conditional rollout (security_gate_enabled + security_gate_repos; empty scope -> self-hosting only). 6 new ORCH_SECURITY_* settings. - Infra: pinned gitleaks Go binary in Dockerfile (+curl/ca-certificates), pip-audit in requirements.txt, versioned .gitleaks.toml at repo root. - STAGE_TRANSITIONS and DB schema unchanged. Docs: docs/architecture/README.md (marked realized), CLAUDE.md (artifact 17), CHANGELOG.md. Tests: test_security_gate.py, test_qg_security.py, test_stage_engine_security_gate.py + updated registry/edge snapshots. Refs: ORCH-022 Co-Authored-By: Claude Opus 4.7 --- .env.example | 21 + .gitleaks.toml | 38 ++ CHANGELOG.md | 1 + CLAUDE.md | 4 +- Dockerfile | 21 +- docs/architecture/README.md | 6 +- requirements.txt | 5 + src/config.py | 30 + src/qg/checks.py | 18 + src/security_gate.py | 689 +++++++++++++++++++++++ src/stage_engine.py | 100 ++++ tests/test_config.py | 1 + tests/test_deploy_approve.py | 1 + tests/test_qg_registry_snapshot.py | 1 + tests/test_qg_security.py | 113 ++++ tests/test_security_gate.py | 324 +++++++++++ tests/test_stage_engine.py | 12 + tests/test_stage_engine_security_gate.py | 264 +++++++++ 18 files changed, 1643 insertions(+), 6 deletions(-) create mode 100644 .gitleaks.toml create mode 100644 src/security_gate.py create mode 100644 tests/test_qg_security.py create mode 100644 tests/test_security_gate.py create mode 100644 tests/test_stage_engine_security_gate.py diff --git a/.env.example b/.env.example index a7ef50c..75e0ed6 100644 --- a/.env.example +++ b/.env.example @@ -146,6 +146,27 @@ ORCH_REAPER_MAX_RUNNING_S=3600 ORCH_REAPER_FINALIZE_GRACE_S=300 ORCH_LEASE_RECLAIM_ENABLED=true +# ORCH-022: security-gate (secret-scanning + dependency audit) on the +# deploy-staging -> deploy edge, run FIRST among the edge sub-gates. Deterministic +# (no LLM): gitleaks (offline secret-scan, pinned Go binary in the image) + pip-audit +# (OSV/PyPI CVE audit). Verdict in the versioned 17-security-report.md frontmatter; +# FAIL -> rollback to development + developer-retry (cap 3). See ADR-001. +# GATE_ENABLED -> global kill-switch; false -> pipeline 1:1 as before ORCH-022. +# GATE_REPOS -> CSV of repos where the gate is REAL; empty -> only self-hosting. +# DEP_BLOCK_SEVERITY -> CVE severity that BLOCKS (CRITICAL>HIGH>MEDIUM>LOW); below / +# UNKNOWN -> warning only (anti-loop). +# SCAN_TIMEOUT_S -> per external scanner call timeout. +# DEP_AUDIT_FAIL_CLOSED -> strict mode: unreachable CVE feed -> FAIL instead of the +# default fail-open + warning (anti-loop). Default false. +# SECRETS_BLOCK -> a found secret blocks (always true by default; the offline +# secrets guarantee is unconditional). +ORCH_SECURITY_GATE_ENABLED=true +ORCH_SECURITY_GATE_REPOS= +ORCH_SECURITY_DEP_BLOCK_SEVERITY=HIGH +ORCH_SECURITY_SCAN_TIMEOUT_S=300 +ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED=false +ORCH_SECURITY_SECRETS_BLOCK=true + # ORCH-021: post-deploy production monitoring + degradation reaction. After the # terminal deploy->done transition for an applicable repo, a reserved-agent job # `post-deploy-monitor` (no LLM, modelled on deploy-finalizer) probes prod over a diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 0000000..e14a5d7 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,38 @@ +# gitleaks config — ORCH-022 security-gate (secret-scanning). +# +# Versioned in the repo root (07-infra I-4 / BR-13): rules + an allowlist of +# known-safe matches are reviewed as code. The security-gate (src/security_gate.py) +# passes this file via `--config` when present. gitleaks runs OFFLINE (local rules) +# so the "a secret always blocks" guarantee (BR-2) never depends on the network. +# +# Strategy: extend the built-in ruleset (broad coverage, maintained upstream) and +# only ADD a narrow allowlist for placeholders / fixtures that are intentionally +# fake (e.g. .env.example dummy values, test fixtures). Keep the allowlist tight — +# an over-broad allowlist silently re-opens the leak it was meant to bless. + +title = "orchestrator gitleaks config" + +[extend] +# Start from gitleaks' maintained default ruleset. +useDefault = true + +[allowlist] +description = "Known-safe, intentionally non-secret matches (placeholders + fixtures)." + +# Files that legitimately contain placeholder/dummy secret-shaped values: +# * .env.example — the committed canon of env vars with DUMMY values (CLAUDE.md §8; +# real secrets live only in the host .env / .env.staging, never in git). +# * tests/ — fixtures may embed fake tokens to exercise the scanner itself (TC-03). +# * .gitleaks.toml — this file (avoid self-matching example patterns below). +paths = [ + '''(^|/)\.env\.example$''', + '''(^|/)tests/''', + '''(^|/)\.gitleaks\.toml$''', +] + +# Generic placeholder tokens used in docs / examples that are NOT real secrets. +regexes = [ + '''(?i)(your[-_]?(token|key|secret|password)[-_]?here)''', + '''(?i)(changeme|dummy|example|placeholder|xxxxx+)''', + '''(?i)<[a-z0-9_-]+>''', +] diff --git a/CHANGELOG.md b/CHANGELOG.md index 09dfba8..4a3778b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,7 @@ ## [Unreleased] ### Added +- **Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) перед мержем** (ORCH-022): автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/приватный ключ) и уязвимую зависимость (известный CVE) — для self-hosting `orchestrator` это особенно остро: один общий прод-инстанс обслуживает все проекты из общей БД, поэтому секрет/CVE, проскочивший через одну задачу, уезжает в прод всех проектов (CLAUDE.md §self-hosting, §8). ORCH-022 вводит детерминированный (без LLM) **security-гейт как под-гейт ребра `deploy-staging → deploy`**, исполняемый **ПЕРВЫМ** среди edge-под-гейтов (ДО merge-gate ORCH-043 и image-freshness ORCH-058) — дёшево фейлить до дорогих rebase/rebuild, а скан ветки ДО rebase не «обвиняет» задачу в CVE из обновившегося `main`. Паттерн соседей: новый leaf-модуль `src/security_gate.py` (контракт «never-raise», по образцу `merge_gate`/`image_freshness`/`staging_verdict`) + тонкая обёртка `check_security_gate` в реестре `QG_CHECKS` (`src/qg/checks.py`, lazy-import → нет цикла) + врезка `_handle_security_gate` в `src/stage_engine.py` в блок `current_stage == "deploy-staging"` ПЕРВОЙ. `STAGE_TRANSITIONS` и схема БД — **без изменений**. **Secret-scanning (`gitleaks`, offline):** скан диапазона `origin/main..HEAD` (ровно коммиты задачи); любой секрет вне аллоулиста версионируемого `.gitleaks.toml` → вклад в FAIL. Полностью оффлайн (локальные правила) → гарантия «секрет всегда блокирует» (BR-2) безусловна, не зависит от сети; **fail-closed** при ошибке инструмента/отсутствии бинаря/таймауте (нельзя доказать «секретов нет» → FAIL). Контракт exit-кодов: 0=чисто, 1=найдено, ≥2=ошибка. **Dependency audit (`pip-audit`, OSV/PyPI):** аудит `requirements.txt`; severity ≥ `security_dep_block_severity` (дефолт `HIGH`, порядок CRITICAL>HIGH>MEDIUM>LOW) → вклад в FAIL (`deps_blocking`); ниже порога / UNKNOWN → warning (`deps_warning`, анти-петля Р-4, не авто-блок). Источник advisory требует сети → недоступность фида **fail-open + громкий warning** по умолчанию (`deps_audit_degraded: true` + Telegram + лог; прецедент анти-петли ORCH-061), флаг `security_dep_audit_fail_closed` переводит в строгий режим без редеплоя кода. **Артефакт `17-security-report.md`** (YAML-frontmatter `security_status`/`secrets_found`/`deps_blocking`/`deps_warning`/`deps_audit_degraded` + тело-списки находок); машинный вердикт читается ТОЛЬКО из frontmatter (гейт пишет → читает обратно через `parse_security_status` → возвращает ровно то, что записал: единый источник истины, AC-8), negative-токен (FAIL) авторитетен, нет frontmatter/битый YAML/нет поля → **fail-closed** на чтении; значения секретов в артефакте маскируются (не ре-лик). **FAIL → откат на `development`** + developer-retry (общий `_developer_retry_count`, cap `MAX_DEVELOPER_RETRIES`=3, затем `set_issue_blocked` + Telegram, без бесконечного баунса); `task_desc` перезапущенного developer'а несёт дословные находки (`extract_security_findings`, паттерн ORCH-046) + ссылку на артефакт. **Self-hosting safety:** гейт только читает/сканирует/пишет артефакт — не вызывает деплой-хук, не рестартит прод-контейнер (под-гейт исполняется ДО захвата merge-lease → при FAIL lease освобождать не нужно). **Условность как ORCH-35/43/58:** `security_gate_enabled` (kill-switch) + `security_gate_repos` (CSV; пусто → только self-hosting `orchestrator`); таймаут `security_scan_timeout_s`; never-raise. v1 — Python-only стек; SAST/мульти-стек — follow-up (BR-14). Инфраструктура: pinned `gitleaks` (статический Go-бинарь) в `Dockerfile` (+ `curl`/`ca-certificates`), `pip-audit` (pinned) в `requirements.txt`, `.gitleaks.toml` в корне репо. Новые настройки: `ORCH_SECURITY_GATE_ENABLED` (true), `ORCH_SECURITY_GATE_REPOS` (""), `ORCH_SECURITY_DEP_BLOCK_SEVERITY` (HIGH), `ORCH_SECURITY_SCAN_TIMEOUT_S` (300), `ORCH_SECURITY_DEP_AUDIT_FAIL_CLOSED` (false), `ORCH_SECURITY_SECRETS_BLOCK` (true). Инварианты НЕ менялись: `STAGE_TRANSITIONS` (9 стадий), `check_branch_mergeable`/`check_staging_image_fresh` и их под-гейты, БАГ-8 откат, terminal-sync, схема БД (без миграций). ADR `docs/work-items/ORCH-022/06-adr/ADR-001-security-gate.md`, глобальный `docs/architecture/adr/adr-0012-security-gate.md`. Документация: `docs/architecture/README.md`, `CLAUDE.md`, `.env.example`. Тесты: `tests/test_security_gate.py`, `tests/test_qg_security.py`, `tests/test_stage_engine_security_gate.py`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. - **Job-reaper + проактивный реклейм протухшего merge-lease + идемпотентная финализация merge** (ORCH-065): закрыт класс инцидентов «zombie jobs» — статус job выставлялся ТОЛЬКО в живом процессе launcher'а, поэтому гибель процесса (OOM/рестарт инстанса/segfault Claude-CLI) оставляла строку `jobs.status='running'` навсегда; при `max_concurrency=1` один такой зомби намертво блокировал очередь ВСЕХ проектов (self-hosting: enduro-trails встаёт из-за зомби ORCH-задачи). Плюс два смежных дефекта: застрявший merge-lease (`.merge-lease-.json` реклеймился лишь лениво по TTL при чужом acquire, живость pid-holder'а не проверялась) и неидемпотентная финализация merge (rebase+re-test зелёные, но процесс умер до самого merge → нет повторного проигрывания). Решение — новый фоновый daemon-поток **`src/job_reaper.py`** (контракт «never-raise на единицу работы», паттерн `reconciler`/`queue_worker`): периодический тик (`reaper_interval_s`) сканирует `running`-jobs трёхуровневой проверкой живости (ADR Р-1): **Tier-1** мёртвый pid (`os.kill(pid, 0)` → `ProcessLookupError`) с анти-false-positive порогом `reaper_dead_ticks` подряд-мёртвых тиков (стрик в памяти); **Tier-2** `agent_runs.exit_code` записан, но job всё ещё `running` — но только после finalization-grace `reaper_finalize_grace_s` (окно неоднозначно: живой monitor пишет exit_code ПЕРВЫМ, затем git push/PR/Plane-комментарии и лишь потом `_finalize_job`, а pid агента к этому моменту мёртв в обоих случаях — живой финализирующий monitor НЕ реапится); **Tier-3** backstop-потолок `reaper_max_running_s`. Единственная мутирующая запись reaper'а — атомарный терминальный флип через `db.reap_running_job(... WHERE status='running')` (rowcount==1 у победителя, проигравший в гонке с `requeue_running_jobs`/launcher видит rowcount==0 — без двойной обработки, TC-06). Для Tier-2 exit0 действие построено по принципу **claim-before-act** (ADR Р-1): источник истины — канонический QG (не «exit0»), он оценивается read-only (`_gate_is_green` → `stage_engine._run_qg`, как у reconciler) ПЕРЕД claim, затем атомарный claim `done` ПЕРВЫМ и только победитель claim делает gate-driven advance (`_gate_driven_advance` → штатный `launcher._try_advance_stage`, кандидат-стадии агента из `STAGE_TRANSITIONS`) — проигравший claim не выполняет НИКАКИХ побочных эффектов (нет дубль-advance / дубль-enqueue следующей стадии); зелёный гейт → `done`+advance, красный → путь неуспеха (requeue в пределах `attempts post_deploy_5xx_threshold`; иначе `HEALTHY` — одиночный глюк не откатывает), `decide_action` (self-hosting → ВСЕГДА `ALERT_ONLY`; не-self + `post_deploy_auto_rollback=true` → `ROLLBACK`; иначе `ALERT_ONLY`), `map_rollback_exit_code` (`0→ROLLBACK_OK`, иначе `ROLLBACK_FAILED`), sentinel-state хелперы (`armed`/`series`/`done` под `/.post-deploy-state-//`, restart-safe счётчики), `build_rollback_command`/`run_rollback` (ssh-хук `--rollback` с прод-env, синхронно — только для не-self), `build/write_post_deploy_log` (артефакт `16-post-deploy-log.md`), `arm_monitor` (идемпотентный арм + первый отложенный job), `status` (снимок для `/queue`). **Механизм наблюдения — reserved-agent job `post-deploy-monitor`** (детерминированный, no-LLM, калька `deploy-finalizer`, НЕ стадия и НЕ daemon): арм в `stage_engine.advance_stage` в блоке `next_stage == "done"` ПОСЛЕ terminal-sync/release-lease (`post_deploy.arm_monitor`, sentinel `armed` = идемпотентность при двойном webhook/reconciler/finalizer); один тик = один job — перехват в `agents/launcher.launch_job` ДО `_spawn` → `stage_engine.run_post_deploy_monitor` (один опрос → append в `series` → `classify` → перепостановка с задержкой `available_at_delay_s` ИЛИ реакция+артефакт+`mark_done`); бюджет тиков `window_s/interval_s` (анти-livelock). **Self-hosting safety (BR-5):** для `orchestrator` тик НИКОГДА не откатывает/рестартит прод-контейнер — реакция всегда `ALERT_ONLY` (громкий Telegram + Plane-коммент с запросом ручного approve); авто-rollback хуком `--rollback` — только для не-self репо при `post_deploy_auto_rollback=true` (целевой контейнер ≠ orchestrator). Наблюдаемость — блок `post_deploy` в `GET /queue` (enabled/window/interval/активные наблюдения). Артефакт `16-post-deploy-log.md` (YAML-frontmatter `post_deploy_status`/`action_taken`/`window_s`/`checks_total`/`checks_failed`) — машиночитаемо для петли уроков ORCH-8; best-effort. Новые настройки: `ORCH_POST_DEPLOY_MONITOR_ENABLED` (true, kill-switch), `ORCH_POST_DEPLOY_REPOS` (CSV; пусто → только self-hosting), `ORCH_POST_DEPLOY_WINDOW_S` (900), `ORCH_POST_DEPLOY_INTERVAL_S` (30), `ORCH_POST_DEPLOY_FAIL_THRESHOLD` (3), `ORCH_POST_DEPLOY_5XX_THRESHOLD` (0.5), `ORCH_POST_DEPLOY_AUTO_ROLLBACK` (false), `ORCH_POST_DEPLOY_BASE_URL` (http://localhost:8500); параметры отката переиспользуют `deploy_prod_*`. Инварианты НЕ менялись: `STAGE_TRANSITIONS`, реестр `QG_CHECKS`, `check_deploy_status`/`_parse_deploy_status`, terminal-sync `deploy→done`, merge-gate, exit-код-контракт хука (0/1/2), схема БД (без миграций; состояние — sentinel-файлы). Условность как ORCH-35/36/43/58. ADR `docs/work-items/ORCH-021/06-adr/ADR-001-post-deploy-monitor.md`, глобальный `docs/architecture/adr/adr-0010-post-deploy-monitor.md`. Тесты: `tests/test_post_deploy.py`, `tests/test_post_deploy_integration.py`. - **Провенанс staging-образа перед BUILD-ONCE retag в прод (свежесть артефакта, INV-FRESH)** (ORCH-058): BUILD-ONCE retag (ORCH-036) промоутит staging-образ (`orchestrator-orchestrator-staging`) в прод **без rebuild**, полагаясь на «образ свеж и провалидирован» — гарантии не было: конвейер нигде не пересобирал staging-образ из провалидированного коммита, поэтому retag мог тихо промоутнуть УСТАРЕВШИЙ образ (инцидент LESSONS_ORCH-036 п.4 — зелёный деплой молча откатывал прод). Закрыто **двумя слоями (defense in depth), только для self-hosting**. Новый модуль `src/image_freshness.py` (контракт «never raise», по образцу `merge_gate`): `provenance_verdict` (чистая функция вердикта match/mismatch/fail-closed), `validated_revision` (`git rev-parse HEAD` в worktree валидированного коммита — единый якорь и для штампа A, и для `EXPECTED_REVISION` B), `image_revision` (OCI-лейбл `org.opencontainers.image.revision` через `docker image inspect`, ``/ошибка → пусто), `rebuild_staging_image` (ssh-хук `--build-staging`), `image_freshness_applies` (условность), `check_staging_image_fresh` (композитный QG). **Strategy A (liveness):** новый детерминированный QG-под-чек `check_staging_image_fresh` (зарегистрирован в `QG_CHECKS`, `src/qg/checks.py`) на ребре `deploy-staging → deploy` ПОСЛЕ merge-gate и ДО Phase A — пересобирает staging-образ из worktree валидированного коммита (хук `--build-staging`, `--build-arg GIT_SHA=`), пересоздаёт 8501 и прогоняет `staging_check.py --mode stub` против свежего 8501 (health + e2e, внутри staging-контейнера через `docker exec` — канон ORCH-048) → валидируем РОВНО тот артефакт (build + e2e), что промоутится в прод (AC-4); FAIL/не-ноль staging_check → откат на `development` (как merge-gate, кап `MAX_DEVELOPER_RETRIES`). `rebuild_staging_image` пробрасывает в хук **явный** staging-таргет (service/port/profile/container), исключая дрейф на прод 8500. Сборки/recreate/validate — **только staging (8501)**, прод (8500) не трогается. **Strategy B (safety):** `Dockerfile` штампует `LABEL org.opencontainers.image.revision=$GIT_SHA` (`ARG GIT_SHA`); `build_deploy_command` (`src/self_deploy.py`) пробрасывает `EXPECTED_REVISION`; хост-хук шагом 2b ПЕРЕД `docker tag` fail-closed сверяет лейбл `revision` у `SOURCE_IMAGE` с `EXPECTED_REVISION` — несовпадение / пустой лейбл / ошибка inspect → `exit 1` (FAILED → БАГ-8 откат), делает тихий промоут устаревшего образа структурно невозможным даже при проигравшей гонку/отключённой A. Хост-хук `scripts/orchestrator-deploy-hook.sh` расширен **обратно-совместимым** режимом `--build-staging` (пересборка+recreate staging, exit 0/1) и fail-closed guard'ом (активен только при заданном `EXPECTED_REVISION`). Единый kill-switch `ORCH_IMAGE_FRESHNESS_ENABLED` (true) включает A+B **как целое** (нет «B без A» = вечного fail-fast); область — `ORCH_IMAGE_FRESHNESS_REPOS` (CSV; пусто → только self-hosting `orchestrator`). Контракты НЕ менялись: `STAGE_TRANSITIONS` (под-гейт ребра, не стадия), exit-code-контракт хука (0/1/2), `map_exit_code_to_status`, `check_deploy_status`/`_parse_deploy_status`, БАГ-8, terminal-sync, merge-gate; схема БД — без миграций. ADR `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`, глобальный `docs/architecture/adr/adr-0008-staging-image-provenance.md`. Документация: `docs/architecture/README.md`, `docs/operations/DEPLOY_HOOK.md`, `docs/operations/STAGING.md`, `docs/operations/INFRA.md`, `.env.example`. Тесты: `tests/test_image_freshness.py`, `tests/test_deploy_hook_provenance.py`, `tests/test_deploy_build_once.py` (TC-06), `tests/test_deploy_hook_mapping.py` (TC-09), `tests/test_stage_engine.py::TestImageFreshnessGate`, `tests/test_qg_registry_snapshot.py`, `tests/test_config.py`. diff --git a/CLAUDE.md b/CLAUDE.md index 63cf19e..a407582 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -44,10 +44,10 @@ created → analysis → architecture → development → review → testing → - ADR per work-item: `docs/work-items//06-adr/ADR-NNN-slug.md` - Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md` - Work items: `docs/work-items//` -- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза +- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`, `security_status:`), никогда проза ## Артефакты задачи (`docs/work-items//`) -`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021). +`00-business-request.md`, `01-brd.md`, `02-trz.md`, `03-acceptance-criteria.md`, `04-test-plan.yaml`, `06-adr/ADR-NNN-slug.md`, `07-infra-requirements.md`, `08-data-requirements.md`, `10-tech-risks.md`, `12-review.md`, `13-test-report.md`, `14-deploy-log.md`, `15-staging-log.md`, `16-post-deploy-log.md` (post-deploy наблюдение, ORCH-021), `17-security-report.md` (security-гейт: `security_status:`/secrets/deps, ORCH-022). ## Правила для агентов 1. Перед любым действием прочесть этот файл и `docs/architecture/README.md`. diff --git a/Dockerfile b/Dockerfile index 890aef5..8ed2471 100644 --- a/Dockerfile +++ b/Dockerfile @@ -8,9 +8,28 @@ FROM python:3.12-slim ARG GIT_SHA="" LABEL org.opencontainers.image.revision=$GIT_SHA WORKDIR /app -RUN apt-get update -qq && apt-get install -y -qq openssh-client git && rm -rf /var/lib/apt/lists/* +RUN apt-get update -qq && apt-get install -y -qq openssh-client git curl ca-certificates && rm -rf /var/lib/apt/lists/* # git operations run as root over bind-mounted /repos (may be owned by host uid) -> trust it. RUN git config --system --add safe.directory '*' +# ORCH-022: pinned gitleaks static Go binary for the offline secret-scan sub-gate +# (07-infra I-1). Baked into the image (NOT a pip package): the gate runs INSIDE the +# orchestrator container over a per-task worktree. Pinned release => deterministic +# rules; gitleaks needs no network so the "a secret always blocks" guarantee (BR-2) +# is independent of internet access. Multi-arch aware (amd64/arm64). +ARG GITLEAKS_VERSION=8.18.4 +RUN set -eux; \ + arch="$(dpkg --print-architecture)"; \ + case "$arch" in \ + amd64) gl_arch="x64" ;; \ + arm64) gl_arch="arm64" ;; \ + *) echo "unsupported arch: $arch" >&2; exit 1 ;; \ + esac; \ + curl -fsSL -o /tmp/gitleaks.tar.gz \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_${gl_arch}.tar.gz"; \ + tar -xzf /tmp/gitleaks.tar.gz -C /usr/local/bin gitleaks; \ + chmod +x /usr/local/bin/gitleaks; \ + rm -f /tmp/gitleaks.tar.gz; \ + gitleaks version # ORCH-58: compose runs the container as uid:gid 1000:1000 (ORCH-40), but the base # image has no passwd entry for uid 1000 -> ssh/whoami fail with # "No user exists for uid 1000" (rc=255), breaking the detached self-deploy ssh diff --git a/docs/architecture/README.md b/docs/architecture/README.md index 6ec3974..140485f 100644 --- a/docs/architecture/README.md +++ b/docs/architecture/README.md @@ -36,7 +36,7 @@ created → analysis → architecture → development → review → testing → | deploy | — | `check_deploy_status` | 14-deploy-log.md (`deploy_status:`) | | done | — | — | — | -**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022 — design). +**Реестр QG** (`QG_CHECKS`): check_analysis_approved, check_analysis_complete, check_architecture_done, check_ci_green, check_review_approved, check_tests_passed, check_reviewer_verdict, check_tests_local, check_deploy_status, check_staging_status, check_branch_mergeable (ORCH-043), check_staging_image_fresh (ORCH-058), check_security_gate (ORCH-022). **Канон гейтов:** машинные вердикты читаются ТОЛЬКО из YAML-frontmatter, никогда из прозы. Лог-файлы мержатся в `origin/main` отдельным PR; гейт читает из `origin/main`. @@ -155,7 +155,7 @@ helper `validated_revision` питает и штамп A, и `EXPECTED_REVISION` образа, без миграций). Подробнее: [adr-0008](adr/adr-0008-staging-image-provenance.md), детально — `docs/work-items/ORCH-058/06-adr/ADR-001-staging-image-provenance.md`. -### Security-гейт: secret-scanning + dependency audit перед мержем (ORCH-022 — design) +### Security-гейт: secret-scanning + dependency audit перед мержем (ORCH-022 — реализовано) Автономный конвейер вливал ветку в `main` без проверки на утёкший секрет (ключ/токен/пароль/ приватный ключ) и уязвимую зависимость (CVE); для self-hosting один секрет/CVE через одну задачу уезжал в общий прод всех проектов (CLAUDE.md §8). ORCH-022 вводит детерминированный @@ -338,4 +338,4 @@ ORCH-065 вводит фоновый watchdog, чтобы смерть проц Схема БД, потоки данных, resilience-слой, детали Dockerfile — [internals.md](internals.md). --- -*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — **design**, ветка feature/ORCH-022-security-secret-scanning (при реализации: новый leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.md; обновлять также при изменении этих мест).* +*Актуально на 2026-06-07. Обновлять при изменении src/stages.py, src/qg/checks.py, src/main.py. Статусы доработок: ORCH-036 (исполняемый самодеплой `deploy`, adr-0007) — реализовано; ORCH-043 (merge-gate, adr-0006) — design, ветка feature/ORCH-043; ORCH-053 (reconciler, adr-0007, src/reconciler.py) — реализовано; ORCH-060 (F-1 skip escalated/Blocked/Needs-Input, `docs/work-items/ORCH-060/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-060 (Guard 1 `developer_retry_count>=MAX_DEVELOPER_RETRIES` + Guard 2 `plane_sync.fetch_issue_state` Blocked/Needs-Input, флаг `ORCH_RECONCILE_SKIP_BLOCKED_ENABLED`); ORCH-058 (провенанс staging-образа: check_staging_image_fresh + staging_check свежего образа + хук-guard, adr-0008) — реализовано в ветке feature/ORCH-058 (обновлять также при изменении src/image_freshness.py, scripts/orchestrator-deploy-hook.sh, Dockerfile); ORCH-061 (толерантность staging-вердикта к инфра-FAIL C9a/C9b, adr-0009, `docs/work-items/ORCH-061/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-061 (обновлять также при изменении src/staging_verdict.py, scripts/staging_check.py, флаг staging_infra_tolerance_enabled); ORCH-021 (post-deploy наблюдение прода + реакция на деградацию, adr-0010, `docs/work-items/ORCH-021/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-021-post-deploy-rollback (reserved-agent job `post-deploy-monitor`: арм в src/stage_engine.py блок `next_stage == "done"`, тик `run_post_deploy_monitor` + перехват в src/agents/launcher.py ДО _spawn; чистая логика src/post_deploy.py never-raise; флаги `post_deploy_*` в src/config.py; блок `post_deploy` в `/queue`; артефакт 16-post-deploy-log.md; self-hosting всегда ALERT_ONLY — тик не рестартит прод; обновлять также при изменении src/post_deploy.py / арм-блока / launcher-перехвата); ORCH-065 (job-reaper + проактивный реклейм merge-lease + идемпотентная финализация merge, adr-0011, `docs/work-items/ORCH-065/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-065 (новый daemon-поток src/job_reaper.py + старт/стоп в src/main.py lifespan; колонка `jobs.pid` через _ensure_column + проставление в src/agents/launcher.py `_spawn`; функции реклейма lease `pid_alive`/`reclaim_stale_lease` + guard `pr_already_merged` в src/merge_gate.py (консультируется merge-актором — промпт `.openclaw/agents/deployer.md`); флаги `reaper_*`/`lease_reclaim_*` в src/config.py; блок `reaper` в `/queue`; обновлять также при изменении этих мест); ORCH-022 (security-гейт: secret-scanning gitleaks + dependency audit pip-audit как под-гейт ребра `deploy-staging → deploy` ПЕРВЫМ, adr-0012, `docs/work-items/ORCH-022/06-adr/ADR-001`) — реализовано в ветке feature/ORCH-022-security-secret-scanning (leaf src/security_gate.py never-raise + check_security_gate в src/qg/checks.py `QG_CHECKS` + врезка _handle_security_gate в src/stage_engine.py блок `current_stage == "deploy-staging"` ПЕРВОЙ; флаги `security_*` в src/config.py; gitleaks (pinned) в Dockerfile, pip-audit в requirements.txt, `.gitleaks.toml` в корне; артефакт 17-security-report.md; обновлять также при изменении этих мест).* diff --git a/requirements.txt b/requirements.txt index 55490e7..9aed60e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,8 @@ pydantic-settings==2.5.0 httpx==0.27.0 pytest==8.3.3 pytest-asyncio==0.23.8 +# ORCH-022: dependency audit (OSV/PyPI advisory) for the security-gate. Needs the +# network at scan time -> an unreachable feed degrades fail-open + warning by +# default (ADR-001 Р-3 / 07-infra I-2). gitleaks (secret-scan) is a pinned Go +# binary baked into the Dockerfile, NOT a pip package. +pip-audit==2.7.3 diff --git a/src/config.py b/src/config.py index a35aafa..bba6bc3 100644 --- a/src/config.py +++ b/src/config.py @@ -219,6 +219,36 @@ class Settings(BaseSettings): image_freshness_enabled: bool = True image_freshness_repos: str = "" + # ORCH-022: security-gate (secret-scanning + dependency audit) on the + # deploy-staging -> deploy edge, run FIRST among the edge sub-gates (cheap to + # fail before the expensive rebase/rebuild). Deterministic (no LLM): gitleaks + # (offline secret-scan) + pip-audit (OSV/PyPI dependency audit), verdict in the + # versioned 17-security-report.md frontmatter; FAIL -> rollback to development + + # developer-retry (cap MAX_DEVELOPER_RETRIES). See ADR-001-security-gate.md. + # security_gate_enabled -> SINGLE kill-switch; False -> pipeline 1:1 as + # before ORCH-022 for everyone. Env + # ORCH_SECURITY_GATE_ENABLED. + # security_gate_repos -> CSV of repos where the gate is REAL; empty -> + # only the self-hosting repo (orchestrator). + # Mirrors merge_gate_repos / image_freshness_repos. + # security_dep_block_severity -> CVE severity threshold that BLOCKS (CRITICAL > + # HIGH > MEDIUM > LOW); below it / UNKNOWN -> a + # warning only (anti-loop ADR-001 Р-4). + # security_scan_timeout_s -> per external scanner call timeout (mirrors + # merge_retest_timeout_s). + # security_dep_audit_fail_closed -> strict mode: an unreachable CVE feed -> FAIL + # instead of the default fail-open + warning + # (Р-3). Default False (anti-loop ORCH-061). + # security_secrets_block -> a found secret blocks (always True by default; + # the offline secrets guarantee is unconditional, + # BR-2). + security_gate_enabled: bool = True + security_gate_repos: str = "" + security_dep_block_severity: str = "HIGH" + security_scan_timeout_s: int = 300 + security_dep_audit_fail_closed: bool = False + security_secrets_block: bool = True + # ORCH-061: tolerate KNOWN sandbox-infra FAILs (C9a/C9b) in the staging suite. # The self-hosting deploy-staging stage looped because scripts/staging_check.py # exited non-zero on ANY failed check, so two infra-only failures (sandbox bot diff --git a/src/qg/checks.py b/src/qg/checks.py index ead2b95..2c95d84 100644 --- a/src/qg/checks.py +++ b/src/qg/checks.py @@ -716,6 +716,23 @@ def _check_staging_image_fresh(repo: str, work_item_id: str, branch: str) -> tup return check_staging_image_fresh(repo, work_item_id, branch) +def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-022 security sub-gate (secret-scan + dependency audit) on the + deploy-staging -> deploy edge, run FIRST (before merge-gate / image-freshness). + + Thin registry wrapper that delegates to ``security_gate.check_security_gate`` + (gitleaks offline + pip-audit, write/read-back ``17-security-report.md``). The + real logic lives in ``src/security_gate.py`` (leaf module, never-raise, + fail-closed on secrets, fail-open degrade for the dep-audit feed); importing it + lazily here avoids an import cycle (security_gate imports is_self_hosting_repo + from this module). For non-self repos with an empty scope it returns + ``(True, "security-gate N/A for ")`` so the deploy edge is unchanged for + them (AC-13/TC-13). + """ + from ..security_gate import check_security_gate as _impl + return _impl(repo, work_item_id, branch) + + # Registry for dynamic lookup by name QG_CHECKS = { "check_analysis_approved": check_analysis_approved, @@ -730,4 +747,5 @@ QG_CHECKS = { "check_staging_status": check_staging_status, "check_branch_mergeable": check_branch_mergeable, "check_staging_image_fresh": _check_staging_image_fresh, + "check_security_gate": check_security_gate, } diff --git a/src/security_gate.py b/src/security_gate.py new file mode 100644 index 0000000..05a33dc --- /dev/null +++ b/src/security_gate.py @@ -0,0 +1,689 @@ +"""Security-gate core (ORCH-022): secret-scanning + dependency audit before merge. + +Background +---------- +The orchestrator is autonomous: the ``developer`` agent writes code with no human +filter. Before a task branch merges into ``main`` there was no automatic check for a +leaked secret (key / token / password / private key) or a vulnerable dependency +(known CVE). For the self-hosting ``orchestrator`` repo this is acute: one shared +prod instance serves every project from a shared DB, so a secret or CVE that slips +through one task lands in the prod of all projects (CLAUDE.md §self-hosting, §8). + +This module provides the deterministic (no-LLM) primitives that the quality-gate +``check_security_gate`` (src/qg/checks.py) composes on the ``deploy-staging -> +deploy`` edge, **FIRST** among the edge sub-gates (BEFORE the merge-gate and +image-freshness), immediately before the deployer merges the PR (ADR-001 Р-1): + + * ``scan_secrets`` -> run ``gitleaks`` over ``origin/main..HEAD`` (offline). + * ``audit_dependencies`` -> run ``pip-audit`` over ``requirements.txt`` (OSV/PyPI). + * ``classify_severity`` -> pure: map a CVE severity to block / warning. + * ``compute_verdict`` -> pure: combine findings + thresholds -> the artefact + frontmatter fields + a human-readable reason. + * ``write_security_report`` / ``parse_security_status`` -> write the + ``17-security-report.md`` artefact and read its machine verdict back (single + source of truth: the gate returns exactly the frontmatter it wrote, AC-8). + * ``check_security_gate`` -> the orchestrating entry the QG wrapper delegates to. + +Invariants (ADR-001 §7, never broken): + * **Secrets are unconditional** (BR-2): gitleaks is fully offline, so the "a + secret always blocks" guarantee does not depend on the network. A secret-scan + TOOL error is **fail-closed** (we cannot prove "no secret" -> FAIL). + * **Dependency audit is best-effort** (Р-3): an unreachable CVE feed degrades + **fail-open + a loud warning** by default (anti-loop, precedent ORCH-061); + ``security_dep_audit_fail_closed`` flips it to strict. + * **never-raise**: any internal error -> ``(False, "")``; an exception + never escapes into ``advance_stage`` (AC-16). + * **Self-hosting safety** (AC-19): the gate only reads / scans / writes the + artefact. It never calls the deploy hook and never restarts the prod container. + +This module is a **leaf**: it imports only ``config`` / ``git_worktree`` and lazily +``qg.checks.is_self_hosting_repo`` / ``notifications``; it never imports +``stage_engine``. +""" + +import json +import logging +import os +import subprocess +from dataclasses import dataclass, field + +from .config import settings +from .git_worktree import ensure_worktree, get_worktree_path + +logger = logging.getLogger("orchestrator.security_gate") + +# Bounded git timeout so a hung fetch never wedges the monitor-thread running the +# gate (the scan timeout itself comes from settings.security_scan_timeout_s). +_GIT_TIMEOUT = 60 + +# Severity ranking for the dependency block threshold. UNKNOWN / unrecognised is +# intentionally absent -> classified as "warning" (anti-loop, ADR-001 Р-4). +_SEVERITY_ORDER = {"LOW": 1, "MEDIUM": 2, "HIGH": 3, "CRITICAL": 4} + + +# --------------------------------------------------------------------------- +# Result containers (plain dataclasses, easy to build in tests) +# --------------------------------------------------------------------------- +@dataclass +class SecretScanResult: + """Outcome of :func:`scan_secrets`. + + status: + * ``"clean"`` -> no secret found. + * ``"found"`` -> ``findings`` lists the confirmed (non-allowlisted) secrets. + * ``"error"`` -> the scanner could not run (missing binary / timeout / rc>=2); + treated as **fail-closed** by :func:`compute_verdict` (BR-2). + """ + + status: str = "clean" + findings: list = field(default_factory=list) + detail: str = "" + + +@dataclass +class DepAuditResult: + """Outcome of :func:`audit_dependencies`. + + status: + * ``"ok"`` -> the audit ran; ``findings`` may be empty or non-empty. + * ``"degraded"`` -> the CVE feed was unreachable / the tool failed; **fail-open** + by default (ADR-001 Р-3), surfaced as ``deps_audit_degraded: true``. + """ + + status: str = "ok" + findings: list = field(default_factory=list) + detail: str = "" + + +# --------------------------------------------------------------------------- +# Conditionality (mirrors _merge_gate_applies / image_freshness_applies) +# --------------------------------------------------------------------------- +def security_gate_applies(repo: str) -> bool: + """Whether the security-gate is REAL for this repo (conditional rollout). + + Mirrors the ORCH-35 / ORCH-43 / ORCH-58 pattern: + * ``security_gate_enabled=False`` -> always False (kill-switch; pipeline is + 1:1 as before ORCH-022 for everyone). + * ``security_gate_repos`` (CSV) non-empty -> real only for the listed repos. + * empty CSV -> real ONLY for the self-hosting repo (``orchestrator``). + Never raises (AC-16): any error -> False (the safe no-op default). + """ + try: + if not settings.security_gate_enabled: + return False + raw = (settings.security_gate_repos or "").strip() + if raw: + allowed = {r.strip().lower() for r in raw.split(",") if r.strip()} + return (repo or "").strip().lower() in allowed + # Lazy import keeps this module a leaf (no qg import at module load). + from .qg.checks import is_self_hosting_repo + return is_self_hosting_repo(repo) + except Exception as e: # noqa: BLE001 - never-raise contract + logger.warning("security_gate_applies error for %s: %s", repo, e) + return False + + +# --------------------------------------------------------------------------- +# Secret-scanning (gitleaks, offline) — FR-1 / AC-1..AC-3 +# --------------------------------------------------------------------------- +def _gitleaks_config_path(worktree: str) -> str | None: + """Versioned ``.gitleaks.toml`` at the repo root (BR-13), or None if absent.""" + cfg = os.path.join(worktree, ".gitleaks.toml") + return cfg if os.path.isfile(cfg) else None + + +def _mask(secret: str) -> str: + """Mask a matched secret so the artefact never re-leaks it verbatim.""" + s = (secret or "").strip() + if len(s) <= 8: + return "****" + return f"{s[:4]}…{s[-2:]}" + + +def parse_gitleaks_report(text: str) -> list: + """Pure parser for the gitleaks JSON report -> a list of finding dicts. + + Each finding: ``{"file", "rule", "line", "match"}`` (the match is MASKED). + Tolerates an empty / non-JSON / non-list body (returns ``[]``); never raises. + """ + try: + data = json.loads(text or "[]") + except (ValueError, TypeError): + return [] + if not isinstance(data, list): + return [] + out = [] + for item in data: + if not isinstance(item, dict): + continue + out.append( + { + "file": item.get("File") or item.get("file") or "?", + "rule": item.get("RuleID") or item.get("Description") or "secret", + "line": item.get("StartLine") or item.get("startLine") or 0, + "match": _mask(item.get("Secret") or item.get("Match") or ""), + } + ) + return out + + +def scan_secrets(repo: str, branch: str) -> SecretScanResult: + """Scan ``origin/main..HEAD`` of the task branch for secrets with ``gitleaks``. + + Offline (BR-2): gitleaks rules are local, so the "a secret always blocks" + guarantee never depends on the network. Scanning the ``origin/main..HEAD`` + range covers exactly the commits this task adds (and that will land in + ``main``), and — because it runs BEFORE the merge-gate rebase — does not blame + the task for a secret introduced by a parallel update of ``main`` (ADR-001 Р-1). + + Exit-code contract (07-infra-requirements.md I-1): 0 = clean, 1 = secrets + found, >=2 = tool error. A tool error / missing binary / timeout -> ``"error"`` + (fail-closed downstream). Never raises (AC-16). + """ + try: + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + return SecretScanResult(status="error", detail=f"worktree error: {e}") + + # Refresh origin/main so the origin/main..HEAD range is meaningful. Best-effort: + # a fetch failure does not abort the scan (gitleaks still scans whatever range + # it can resolve); the scan itself is the security-critical step. + try: + subprocess.run( + ["git", "-C", wt, "fetch", "origin", "main"], + capture_output=True, timeout=_GIT_TIMEOUT, + ) + except (subprocess.SubprocessError, OSError) as e: + logger.warning("scan_secrets: fetch origin/main failed for %s/%s: %s", repo, branch, e) + + report_path = os.path.join(wt, ".gitleaks-report.json") + cmd = [ + "gitleaks", "detect", + "--source", wt, + "--log-opts", "origin/main..HEAD", + "--report-format", "json", + "--report-path", report_path, + "--exit-code", "1", + "--no-banner", + ] + cfg = _gitleaks_config_path(wt) + if cfg: + cmd += ["--config", cfg] + + timeout = settings.security_scan_timeout_s + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return SecretScanResult(status="error", detail=f"gitleaks timeout after {timeout}s") + except FileNotFoundError: + # Missing binary -> fail-closed (we cannot prove the branch is secret-free). + return SecretScanResult(status="error", detail="gitleaks binary not found") + except (subprocess.SubprocessError, OSError) as e: + return SecretScanResult(status="error", detail=f"gitleaks error: {e}") + finally: + # The report file is transient scratch inside the worktree; remove it after + # reading so it is never committed/scanned on a later pass. + report_text = "" + try: + if os.path.isfile(report_path): + with open(report_path, "r", encoding="utf-8") as f: + report_text = f.read() + os.remove(report_path) + except OSError: + report_text = "" + + if r.returncode == 0: + return SecretScanResult(status="clean", detail="no secrets found") + if r.returncode == 1: + findings = parse_gitleaks_report(report_text) or parse_gitleaks_report(r.stdout) + if not findings: + # rc=1 with no parseable findings -> still treat as found (fail-closed). + findings = [{"file": "?", "rule": "secret", "line": 0, "match": "****"}] + return SecretScanResult( + status="found", findings=findings, detail=f"{len(findings)} secret(s) found" + ) + # rc >= 2 (or any other) -> tool error -> fail-closed. + tail = ((r.stderr or "") + (r.stdout or "")).strip()[-200:] + return SecretScanResult(status="error", detail=f"gitleaks rc={r.returncode}: {tail}") + + +# --------------------------------------------------------------------------- +# Dependency audit (pip-audit, OSV/PyPI) — FR-2 / AC-4..AC-7 +# --------------------------------------------------------------------------- +def parse_pip_audit_report(text: str) -> list: + """Pure parser for the ``pip-audit -f json`` report -> a list of finding dicts. + + Each finding: ``{"package", "version", "id", "severity", "fix"}``. pip-audit's + default JSON rarely carries a CVSS severity (OSV advisories often omit it), so a + missing severity is reported as ``"UNKNOWN"`` (classified as a warning, never an + auto-block — ADR-001 Р-4 anti-loop). Tolerates both the modern + ``{"dependencies": [...]}`` shape and a bare list; never raises. + """ + try: + data = json.loads(text or "{}") + except (ValueError, TypeError): + return [] + if isinstance(data, dict): + deps = data.get("dependencies", data.get("vulnerabilities", [])) + elif isinstance(data, list): + deps = data + else: + return [] + out = [] + for dep in deps or []: + if not isinstance(dep, dict): + continue + name = dep.get("name") or dep.get("package") or "?" + version = dep.get("version") or "?" + for v in dep.get("vulns", dep.get("vulnerabilities", [])) or []: + if not isinstance(v, dict): + continue + sev = _extract_severity(v) + fix = v.get("fix_versions") or v.get("fixed_in") or [] + aliases = v.get("aliases") or [] + vuln_id = v.get("id") or (aliases[0] if aliases else "?") + out.append( + { + "package": name, + "version": version, + "id": vuln_id, + "severity": sev, + "fix": ", ".join(fix) if isinstance(fix, list) else str(fix), + } + ) + return out + + +def _extract_severity(vuln: dict) -> str: + """Best-effort severity extraction from a pip-audit vuln record -> UPPER token. + + pip-audit JSON may carry severity in different shapes depending on the advisory + source; when none is present we return ``"UNKNOWN"`` (warning, never a block). + """ + raw = vuln.get("severity") + if isinstance(raw, str) and raw.strip(): + return raw.strip().upper() + if isinstance(raw, list) and raw: + first = raw[0] + if isinstance(first, dict): + val = first.get("severity") or first.get("score") or first.get("type") + if val: + return str(val).strip().upper() + elif first: + return str(first).strip().upper() + return "UNKNOWN" + + +def audit_dependencies(repo: str, branch: str) -> DepAuditResult: + """Audit the branch's ``requirements.txt`` for known CVEs with ``pip-audit``. + + The advisory source is OSV/PyPI -> it needs the network. Per ADR-001 Р-3 an + unreachable feed / tool failure degrades **fail-open** by default (status + ``"degraded"``), so a transient network problem on the prod instance never + produces a false rollback loop (precedent ORCH-061). The ``"degraded"`` state + is surfaced loudly (``deps_audit_degraded: true`` + warning log + Telegram). + + Returns a :class:`DepAuditResult`. Never raises (AC-16). + """ + try: + wt = get_worktree_path(repo, branch) + if not os.path.isdir(wt): + wt = ensure_worktree(repo, branch) + except Exception as e: # noqa: BLE001 - never-raise contract + return DepAuditResult(status="degraded", detail=f"worktree error: {e}") + + req = os.path.join(wt, "requirements.txt") + if not os.path.isfile(req): + # Python-only v1 (A3): no manifest -> nothing to audit (not a degrade). + return DepAuditResult(status="ok", detail="no requirements.txt to audit") + + cmd = ["pip-audit", "-r", req, "-f", "json", "--progress-spinner", "off"] + timeout = settings.security_scan_timeout_s + try: + r = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout) + except subprocess.TimeoutExpired: + return DepAuditResult(status="degraded", detail=f"pip-audit timeout after {timeout}s") + except FileNotFoundError: + # Missing binary -> degrade (dep-audit is best-effort, not unconditional). + return DepAuditResult(status="degraded", detail="pip-audit binary not found") + except (subprocess.SubprocessError, OSError) as e: + return DepAuditResult(status="degraded", detail=f"pip-audit error: {e}") + + # pip-audit exits 0 (no vulns) or 1 (vulns found) with valid JSON on stdout. A + # network/feed error produces non-JSON output (and often a non-zero rc) -> if + # we cannot parse the JSON we degrade fail-open rather than block falsely. + out = (r.stdout or "").strip() + if not out: + if r.returncode == 0: + return DepAuditResult(status="ok", detail="no vulnerabilities") + tail = (r.stderr or "").strip()[-200:] + return DepAuditResult(status="degraded", detail=f"pip-audit no output (rc={r.returncode}): {tail}") + try: + json.loads(out) + except ValueError: + tail = (r.stderr or "").strip()[-200:] + return DepAuditResult(status="degraded", detail=f"pip-audit feed unavailable: {tail}") + + findings = parse_pip_audit_report(out) + return DepAuditResult(status="ok", findings=findings, detail=f"{len(findings)} vuln(s)") + + +# --------------------------------------------------------------------------- +# Pure classification + verdict (FR-2/FR-3/Р-4) — the core of the unit tests +# --------------------------------------------------------------------------- +def classify_severity(severity: str, block_threshold: str) -> str: + """Pure: classify a CVE severity against the block threshold -> token. + + Returns ``"block"`` when ``severity >= block_threshold`` in CRITICAL > HIGH > + MEDIUM > LOW order, else ``"warning"``. An UNKNOWN / unrecognised severity is + ALWAYS ``"warning"`` (never an auto-block — anti-loop, ADR-001 Р-4). Never + raises. + """ + sev = (severity or "").upper().strip() + thr = (block_threshold or "HIGH").upper().strip() + sev_rank = _SEVERITY_ORDER.get(sev) + thr_rank = _SEVERITY_ORDER.get(thr, _SEVERITY_ORDER["HIGH"]) + if sev_rank is None: + return "warning" + return "block" if sev_rank >= thr_rank else "warning" + + +def compute_verdict( + secret_result: SecretScanResult, + dep_result: DepAuditResult, + *, + secrets_block: bool, + dep_block_severity: str, + dep_fail_closed: bool, +) -> dict: + """Pure: combine scan results + thresholds into the artefact's machine fields. + + Returns a dict with the frontmatter fields (``security_status``, + ``secrets_found``, ``deps_blocking``, ``deps_warning``, ``deps_audit_degraded``), + a one-line ``reason`` summary, and the categorised finding lists for the body. + + Decision (ADR-001 Р-4): + * secret-scan ERROR -> FAIL (fail-closed; BR-2 secrets guarantee is unconditional). + * any secret found AND ``secrets_block`` -> FAIL. + * any dependency at/over ``dep_block_severity`` -> FAIL (``deps_blocking``). + * MEDIUM/LOW/UNKNOWN deps -> warning only (``deps_warning``), never block. + * feed degraded -> warning by default; FAIL only when ``dep_fail_closed``. + Never raises. + """ + secret_scan_error = secret_result.status == "error" + secret_findings = list(secret_result.findings) if secret_result.status == "found" else [] + secrets_found = len(secret_findings) + + deps_audit_degraded = dep_result.status == "degraded" + blocking_findings = [] + warning_findings = [] + for f in dep_result.findings or []: + if classify_severity(f.get("severity", "UNKNOWN"), dep_block_severity) == "block": + blocking_findings.append(f) + else: + warning_findings.append(f) + + reasons = [] + fail = False + if secret_scan_error: + fail = True + reasons.append(f"secret scan error (fail-closed): {secret_result.detail}") + if secrets_block and secrets_found > 0: + fail = True + names = ", ".join( + f"{x.get('rule')} in {x.get('file')}:{x.get('line')}" for x in secret_findings + ) + reasons.append(f"{secrets_found} secret(s): {names}") + if blocking_findings: + fail = True + names = ", ".join( + f"{x.get('package')} {x.get('version')} {x.get('id')} ({x.get('severity')})" + for x in blocking_findings + ) + reasons.append(f"{len(blocking_findings)} blocking CVE(s): {names}") + if deps_audit_degraded and dep_fail_closed: + fail = True + reasons.append(f"dep-audit feed unavailable (fail-closed): {dep_result.detail}") + + status = "FAIL" if fail else "PASS" + if reasons: + reason = "; ".join(reasons) + else: + extra = " (dep-audit degraded — warning only)" if deps_audit_degraded else "" + reason = f"clean: {secrets_found} secrets, {len(blocking_findings)} blocking CVE(s){extra}" + + return { + "security_status": status, + "secrets_found": secrets_found, + "secret_scan_error": secret_scan_error, + "deps_blocking": len(blocking_findings), + "deps_warning": len(warning_findings), + "deps_audit_degraded": deps_audit_degraded, + "reason": reason, + "secret_findings": secret_findings, + "blocking_findings": blocking_findings, + "warning_findings": warning_findings, + } + + +# --------------------------------------------------------------------------- +# Artefact: write the report, read the machine verdict back (FR-3 / AC-8..AC-10) +# --------------------------------------------------------------------------- +def _report_rel(work_item_id: str) -> str: + return f"docs/work-items/{work_item_id}/17-security-report.md" + + +def _report_path(repo: str, work_item_id: str, branch: str) -> str: + """Absolute path of 17-security-report.md inside the task worktree.""" + try: + wt = get_worktree_path(repo, branch) + if not os.path.isdir(wt): + wt = ensure_worktree(repo, branch) + except Exception: # noqa: BLE001 - never-raise; fall back to shared clone + wt = os.path.join(settings.repos_dir, repo) + return os.path.join(wt, _report_rel(work_item_id)) + + +def _bool_yaml(v: bool) -> str: + return "true" if v else "false" + + +def render_security_report(work_item_id: str, fields: dict) -> str: + """Pure: render the 17-security-report.md content (frontmatter + body) from the + fields produced by :func:`compute_verdict`. Never raises.""" + def _secret_lines(): + items = fields.get("secret_findings") or [] + if not items: + return "- None" + return "\n".join( + f"- `{x.get('file')}:{x.get('line')}` — {x.get('rule')} (match `{x.get('match')}`)" + for x in items + ) + + def _dep_lines(key): + items = fields.get(key) or [] + if not items: + return "- None" + return "\n".join( + f"- `{x.get('package')}=={x.get('version')}` — {x.get('id')} " + f"severity={x.get('severity')} fix={x.get('fix') or 'n/a'}" + for x in items + ) + + return ( + "---\n" + f"security_status: {fields.get('security_status', 'FAIL')}\n" + f"secrets_found: {int(fields.get('secrets_found', 0))}\n" + f"deps_blocking: {int(fields.get('deps_blocking', 0))}\n" + f"deps_warning: {int(fields.get('deps_warning', 0))}\n" + f"deps_audit_degraded: {_bool_yaml(bool(fields.get('deps_audit_degraded', False)))}\n" + "---\n" + f"# Security Report — {work_item_id}\n\n" + "Детерминированный security-гейт (ORCH-022): secret-scanning (gitleaks, offline) + " + "dependency audit (pip-audit). Машинный вердикт читается ТОЛЬКО из frontmatter выше.\n\n" + "## Verdict\n" + f"{fields.get('reason', '')}\n\n" + "## Secrets\n" + f"{_secret_lines()}\n\n" + "## Dependencies (blocking)\n" + f"{_dep_lines('blocking_findings')}\n\n" + "## Dependencies (warning)\n" + f"{_dep_lines('warning_findings')}\n" + ) + + +def write_security_report(repo: str, work_item_id: str, branch: str, fields: dict) -> str: + """Write 17-security-report.md into the task worktree; return its path. + + Best-effort/never-raise: a write error is logged and the path is still returned + (the caller's read-back then fails closed). The artefact body is human-readable; + the machine verdict lives ONLY in the YAML frontmatter (canon).""" + path = _report_path(repo, work_item_id, branch) + try: + os.makedirs(os.path.dirname(path), exist_ok=True) + with open(path, "w", encoding="utf-8") as f: + f.write(render_security_report(work_item_id, fields)) + except OSError as e: + logger.error("write_security_report error for %s/%s: %s", repo, work_item_id, e) + return path + + +def parse_security_status(content: str) -> tuple[bool, str]: + """Map a 17-security-report.md body to a quality-gate verdict by reading ONLY + the machine-readable ``security_status:`` YAML frontmatter — never the prose. + + Mirrors ``_parse_deploy_status`` / ``_parse_staging_status`` (canon: machine + verdict only from frontmatter, AC-8). The negative token (FAIL) is authoritative + (checked first). Returns: + * ``security_status: PASS`` -> ``(True, "Security status: PASS")`` + * ``security_status: FAIL`` -> ``(False, "Security status: FAIL")`` + * missing field / no frontmatter / bad YAML -> ``(False, )`` (fail-closed + on the verdict read, AC-9). + """ + import yaml + + status = None + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + try: + fm = yaml.safe_load(parts[1]) or {} + except yaml.YAMLError as e: + return False, f"Invalid YAML frontmatter in security report: {e}" + if isinstance(fm, dict): + status = str(fm.get("security_status", "")).upper().strip() + if status == "FAIL": + return False, "Security status: FAIL" + if status == "PASS": + return True, "Security status: PASS" + return False, f"No machine-readable security_status in frontmatter (got: {status!r})" + + +def extract_security_findings(report_path: str) -> str: + """ORCH-046: best-effort verbatim excerpt of the report's finding sections for + embedding into the developer's ``task_desc`` on a rollback. + + Pulls the ``## Verdict`` + ``## Secrets`` + ``## Dependencies (blocking)`` + sections so the developer sees the must-fix substance directly (not just a + link). Contract «never raise»: any error / missing file -> ``""`` (the caller + then falls back to the reason + link). Mirrors ``review_parse`` defensiveness. + """ + try: + if not os.path.isfile(report_path): + return "" + with open(report_path, "r", encoding="utf-8") as f: + content = f.read() + # Drop the frontmatter; keep the human body. + if content.startswith("---"): + parts = content.split("---", 2) + if len(parts) >= 3: + content = parts[2] + wanted = ("## Verdict", "## Secrets", "## Dependencies (blocking)") + lines = content.splitlines() + out = [] + keep = False + for ln in lines: + if ln.startswith("## "): + keep = any(ln.startswith(w) for w in wanted) + if keep: + out.append(ln) + excerpt = "\n".join(out).strip() + return excerpt[:1500] + except Exception as e: # noqa: BLE001 - never-raise (ORCH-046 defensive) + logger.warning("extract_security_findings error for %s: %s", report_path, e) + return "" + + +# --------------------------------------------------------------------------- +# Orchestrating entry — delegated to by qg.checks.check_security_gate +# --------------------------------------------------------------------------- +def check_security_gate(repo: str, work_item_id: str, branch: str) -> tuple[bool, str]: + """ORCH-022 security-gate on the deploy-staging -> deploy edge, run FIRST. + + Deterministic, no LLM. Algorithm (ADR-001 Р-1/Р-5): + 1. Conditionality: ``security_gate_enabled=False`` -> ``(True, "...disabled")``; + a repo the gate is not real for -> ``(True, "security-gate N/A for ")``. + 2. ``scan_secrets`` (offline) + ``audit_dependencies`` (best-effort). + 3. ``compute_verdict`` -> write ``17-security-report.md`` -> read the verdict + BACK via ``parse_security_status`` (single source of truth: the returned + verdict == the artefact frontmatter, AC-8). + 4. FAIL -> ``(False, reason)`` (engine rolls back to ``development``); PASS -> + ``(True, reason)`` (engine proceeds to the merge-gate). + + A degraded dep-audit on a PASS is surfaced loudly (Telegram + log) without + failing the gate (ADR-001 Р-3). Never-raise (AC-16): any internal error -> + ``(False, "")``; an exception never escapes into ``advance_stage``. + """ + try: + if not settings.security_gate_enabled: + return True, "security-gate disabled" + if not security_gate_applies(repo): + return True, f"security-gate N/A for {repo}" + + secret_result = scan_secrets(repo, branch) + dep_result = audit_dependencies(repo, branch) + fields = compute_verdict( + secret_result, + dep_result, + secrets_block=settings.security_secrets_block, + dep_block_severity=settings.security_dep_block_severity, + dep_fail_closed=settings.security_dep_audit_fail_closed, + ) + + path = write_security_report(repo, work_item_id, branch, fields) + + # Read the machine verdict back from the artefact we just wrote — so the + # returned (bool, reason) is guaranteed == the YAML frontmatter (AC-8). + try: + with open(path, "r", encoding="utf-8") as f: + content = f.read() + except OSError as e: + return False, f"cannot read security report (fail-closed): {e}" + ok, _verdict = parse_security_status(content) + + # Surface a degraded dep-audit loudly even when the gate passes (Р-3 / BR-11). + if fields.get("deps_audit_degraded"): + logger.warning( + "security-gate %s/%s: dep-audit DEGRADED (fail-%s): %s", + repo, work_item_id, + "closed" if settings.security_dep_audit_fail_closed else "open", + dep_result.detail, + ) + try: + from .notifications import send_telegram + send_telegram( + f"⚠️ {work_item_id}: dep-audit недоступен фид CVE " + f"({dep_result.detail}). " + + ("Гейт fail-closed → FAIL." if settings.security_dep_audit_fail_closed + else "Гейт fail-open → warning (секреты проверены оффлайн).") + ) + except Exception as e: # noqa: BLE001 - telegram best-effort + logger.warning("security-gate degraded telegram failed: %s", e) + + if ok: + logger.info("security-gate passed for %s/%s: %s", repo, work_item_id, fields["reason"]) + return True, f"security clean ({fields['reason']})" + return False, fields["reason"] + except Exception as e: # noqa: BLE001 - never-raise contract (AC-16) + logger.error("check_security_gate error for %s/%s: %s", repo, branch, e) + return False, f"security-gate error: {e}" diff --git a/src/stage_engine.py b/src/stage_engine.py index df84ca5..f4797fc 100644 --- a/src/stage_engine.py +++ b/src/stage_engine.py @@ -34,6 +34,7 @@ from .db import get_db, update_task_stage, enqueue_job from .stages import get_next_stage, get_qg_for_stage, get_agent_for_stage from .git_worktree import get_worktree_path from .review_parse import extract_review_findings, extract_test_failures +from .security_gate import extract_security_findings from .qg.checks import QG_CHECKS from . import merge_gate from . import self_deploy @@ -277,6 +278,18 @@ def advance_stage( # event. If it intervenes (defer on busy-lock, or rollback on conflict / # red re-test) it owns the outcome and we return without advancing. if current_stage == "deploy-staging": + # --- ORCH-022 security sub-gate (deploy-staging -> deploy edge) ----- + # Run FIRST among the edge sub-gates (BEFORE the merge-gate and the + # image-freshness rebuild): it is cheap (read-only scan) and we want to + # fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic: + # gitleaks (offline secret-scan) + pip-audit (CVE audit). FAIL -> rollback + # to development + developer-retry (cap MAX_DEVELOPER_RETRIES). It owns + # the outcome on intervention (mirrors the merge-gate / image-freshness). + if _handle_security_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result + ): + return result + if _handle_merge_gate( task_id, current_stage, repo, work_item_id, branch, agent, result ): @@ -911,6 +924,93 @@ def _handle_merge_gate_rollback( ) +# --------------------------------------------------------------------------- +# ORCH-022: security sub-gate (secret-scan + dependency audit) on the +# deploy-staging -> deploy edge +# --------------------------------------------------------------------------- +def _handle_security_gate( + task_id, current_stage, repo, work_item_id, branch, agent, result: AdvanceResult +) -> bool: + """Run check_security_gate on the deploy-staging -> deploy edge (ORCH-022). + + Runs FIRST among the edge sub-gates — BEFORE the merge-gate and the + image-freshness rebuild — because it is a cheap read-only scan and we want to + fail BEFORE the expensive rebase/rebuild (07-infra I-6). Deterministic (no LLM): + gitleaks (offline secret-scan, fail-closed) + pip-audit (CVE audit, fail-open + degrade). The machine verdict lives in 17-security-report.md frontmatter. + + Returns True if the gate INTERVENED (the caller must return without advancing): + * FAIL (secret found / blocking CVE / fail-closed) -> ROLLBACK to development + (+ developer retry, capped by MAX_DEVELOPER_RETRIES). No merge-lease release + here: the security-gate runs BEFORE the merge-gate, so the lease is not held + yet (distinct from the image-freshness rollback). The verbatim findings are + embedded into the developer's task_desc (ORCH-046 pattern, TC-17). + Returns False when the gate PASSED (clean, or N/A for a non-self repo with an + empty scope) so advance_stage proceeds to the merge-gate. + """ + passed, reason = _run_qg("check_security_gate", repo, work_item_id, branch) + if passed: + logger.info(f"Task {task_id}: security-gate passed ({reason})") + return False + + result.qg_name = "check_security_gate" + result.qg_passed = False + result.qg_reason = reason + + update_task_stage(task_id, "development") + notify_stage_change(task_id, current_stage, "development") + plane_notify_stage(work_item_id, current_stage, "development") + result.rolled_back_to = "development" + set_issue_in_progress(work_item_id) + notify_qg_failure(task_id, current_stage, "check_security_gate", reason) + plane_add_comment( + work_item_id, + f"❌ Security-гейт провален ({reason}). Откат на development. " + f"Developer нужен для фикса (секреты/уязвимые зависимости).", + author="deployer", + ) + retry_count = _developer_retry_count(task_id) + if retry_count < MAX_DEVELOPER_RETRIES: + # ORCH-046: embed the verbatim findings into task_desc so the developer + # agent sees the must-fix substance directly (not just a link). + # extract_security_findings never raises; "" -> graceful link-only fallback. + report_ref = f"docs/work-items/{work_item_id}/17-security-report.md" + report_path = os.path.join(get_worktree_path(repo, branch), report_ref) + findings = extract_security_findings(report_path) + head = ( + f"Work item: {work_item_id}\nRepo: {repo}\nBranch: {branch}\n" + f"Stage: development\nNote: Security-гейт провален " + f"(attempt {retry_count + 1}/{MAX_DEVELOPER_RETRIES}). " + f"Причина: {reason}." + ) + if findings: + task_desc = ( + f"{head}\nFindings:\n{findings}\n" + f"Полный контекст: {report_ref}" + ) + else: + task_desc = f"{head} Fix findings in {report_ref}" + new_job = enqueue_job("developer", repo, task_desc, task_id=task_id) + result.enqueued_agent = "developer" + result.enqueued_job_id = new_job + logger.info( + f"Task {task_id}: security-gate FAILED, enqueued developer (job_id={new_job})" + ) + else: + set_issue_blocked(work_item_id) + send_telegram( + f"\U0001f6a8 {work_item_id}: Security-гейт still failing after " + f"{MAX_DEVELOPER_RETRIES} developer retries ({reason}). " + f"Manual intervention needed." + ) + result.alerted = True + logger.error( + f"Task {task_id}: security-gate FAILED, rolled back deploy-staging -> " + f"development ({reason})" + ) + return True + + # --------------------------------------------------------------------------- # ORCH-058: staging-image freshness sub-gate on the deploy-staging -> deploy edge # --------------------------------------------------------------------------- diff --git a/tests/test_config.py b/tests/test_config.py index 6957461..092395b 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -235,6 +235,7 @@ def test_tc19_qg_checks_registry_unchanged(): "check_staging_status", "check_branch_mergeable", "check_staging_image_fresh", + "check_security_gate", } diff --git a/tests/test_deploy_approve.py b/tests/test_deploy_approve.py index 146a8e4..e5d5182 100644 --- a/tests/test_deploy_approve.py +++ b/tests/test_deploy_approve.py @@ -101,6 +101,7 @@ def test_tc05_no_approve_does_not_call_prod_hook(monkeypatch): stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) diff --git a/tests/test_qg_registry_snapshot.py b/tests/test_qg_registry_snapshot.py index 5270bbc..0067f7b 100644 --- a/tests/test_qg_registry_snapshot.py +++ b/tests/test_qg_registry_snapshot.py @@ -30,6 +30,7 @@ _EXPECTED_QGS = { "check_staging_status", "check_branch_mergeable", # ORCH-043 merge-gate (deploy-staging -> deploy edge) "check_staging_image_fresh", # ORCH-058 image-freshness sub-gate (same edge) + "check_security_gate", # ORCH-022 security sub-gate (same edge, run FIRST) } diff --git a/tests/test_qg_security.py b/tests/test_qg_security.py new file mode 100644 index 0000000..408c6e5 --- /dev/null +++ b/tests/test_qg_security.py @@ -0,0 +1,113 @@ +"""ORCH-022 / TC-13..TC-15: the security-gate QG wrapper + registry wiring. + +Covers the thin ``check_security_gate`` registry wrapper in src/qg/checks.py (its +conditionality fast-paths) and that the new check is registered + dispatched by +``_run_qg``. The deterministic core (scan / verdict / frontmatter) is covered in +tests/test_security_gate.py. +""" + +import os + +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from src import security_gate as sg # noqa: E402 +from src.qg import checks as qg # noqa: E402 +from src.qg.checks import QG_CHECKS, check_security_gate # noqa: E402 + +_WI = "ORCH-022" +_BRANCH = "feature/ORCH-022-x" + + +# --------------------------------------------------------------------------- +# TC-13 — non-self repo with empty scope -> N/A fast pass (no scanner run). +# --------------------------------------------------------------------------- +def test_tc13_non_self_repo_empty_scope_is_na(monkeypatch): + """TC-13: a non-self repo with an empty scope -> (True, 'security-gate N/A + for ') immediately, WITHOUT invoking the scanners.""" + monkeypatch.setattr(sg.settings, "security_gate_enabled", True) + monkeypatch.setattr(sg.settings, "security_gate_repos", "") + + called = {"scan": False} + + def _should_not_run(*a, **k): + called["scan"] = True + raise AssertionError("scanner must not run for an N/A repo") + + monkeypatch.setattr(sg, "scan_secrets", _should_not_run) + monkeypatch.setattr(sg, "audit_dependencies", _should_not_run) + + ok, reason = check_security_gate("enduro-trails", _WI, _BRANCH) + assert ok is True + assert "N/A" in reason + assert "enduro-trails" in reason + assert called["scan"] is False + + +# --------------------------------------------------------------------------- +# TC-14 — kill-switch disabled -> no-op pass. +# --------------------------------------------------------------------------- +def test_tc14_disabled_is_noop_pass(monkeypatch): + """TC-14: ORCH_SECURITY_GATE_ENABLED=false -> no-op pass (True), scanners untouched.""" + monkeypatch.setattr(sg.settings, "security_gate_enabled", False) + + def _should_not_run(*a, **k): + raise AssertionError("scanner must not run when the gate is disabled") + + monkeypatch.setattr(sg, "scan_secrets", _should_not_run) + monkeypatch.setattr(sg, "audit_dependencies", _should_not_run) + + ok, reason = check_security_gate("orchestrator", _WI, _BRANCH) + assert ok is True + assert "disabled" in reason.lower() + + +# --------------------------------------------------------------------------- +# TC-15 — registered in QG_CHECKS + dispatched by _run_qg. +# --------------------------------------------------------------------------- +def test_tc15_registered_in_qg_checks(): + """TC-15a: the new check is registered and callable.""" + assert "check_security_gate" in QG_CHECKS + assert QG_CHECKS["check_security_gate"] is check_security_gate + assert callable(QG_CHECKS["check_security_gate"]) + + +def test_tc15_dispatched_by_run_qg(monkeypatch): + """TC-15b: _run_qg routes 'check_security_gate' with the (repo, work_item_id, + branch) signature to the registered wrapper.""" + from src import stage_engine + + captured = {} + + def _fake(repo, work_item_id, branch): + captured["args"] = (repo, work_item_id, branch) + return True, "ok" + + monkeypatch.setitem(stage_engine.QG_CHECKS, "check_security_gate", _fake) + passed, reason = stage_engine._run_qg("check_security_gate", "orchestrator", _WI, _BRANCH) + assert passed is True + assert captured["args"] == ("orchestrator", _WI, _BRANCH) + + +def test_security_gate_applies_scope(monkeypatch): + """Conditionality matrix mirrors merge_gate_applies / image_freshness_applies.""" + monkeypatch.setattr(sg.settings, "security_gate_enabled", True) + # Empty scope -> only the self-hosting repo. + monkeypatch.setattr(sg.settings, "security_gate_repos", "") + assert sg.security_gate_applies("orchestrator") is True + assert sg.security_gate_applies("enduro-trails") is False + # Explicit CSV scope -> only the listed repos (case-insensitive). + monkeypatch.setattr(sg.settings, "security_gate_repos", "enduro-trails, foo") + assert sg.security_gate_applies("enduro-trails") is True + assert sg.security_gate_applies("orchestrator") is False + # Kill-switch wins over everything. + monkeypatch.setattr(sg.settings, "security_gate_enabled", False) + assert sg.security_gate_applies("orchestrator") is False + + +def test_qg_wrapper_delegates(monkeypatch): + """The QG wrapper delegates to security_gate.check_security_gate verbatim.""" + monkeypatch.setattr(sg, "check_security_gate", lambda r, w, b: (False, "delegated FAIL")) + ok, reason = check_security_gate("orchestrator", _WI, _BRANCH) + assert ok is False + assert reason == "delegated FAIL" diff --git a/tests/test_security_gate.py b/tests/test_security_gate.py new file mode 100644 index 0000000..499ca3b --- /dev/null +++ b/tests/test_security_gate.py @@ -0,0 +1,324 @@ +"""ORCH-022 / TC-01..TC-12: the security-gate leaf module (src/security_gate.py). + +These exercise the DETERMINISTIC core: the pure classifier / verdict / frontmatter +helpers (no binaries needed) plus scan_secrets / audit_dependencies with the +external scanners (gitleaks / pip-audit) mocked at subprocess.run. The integration +of the gate into advance_stage is covered in tests/test_stage_engine_security_gate.py; +the QG registry wiring in tests/test_qg_security.py. + +Contract under test (ADR-001 §7): + * secrets are UNCONDITIONAL + offline -> a found secret blocks; a tool error is + fail-closed (FAIL); + * dependency audit is best-effort -> blocking only at/over the severity threshold; + UNKNOWN / below-threshold -> warning; an unreachable feed degrades fail-open + + warning by default, fail-closed only when configured; + * the machine verdict lives ONLY in the YAML frontmatter (read-back == written); + * never-raise: any internal error -> (False, reason), no exception escapes. +""" + +import os +import subprocess + +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +import pytest # noqa: E402 + +from src import security_gate as sg # noqa: E402 + +_REPO = "orchestrator" +_BRANCH = "feature/ORCH-022-x" +_WI = "ORCH-022" + + +# --------------------------------------------------------------------------- +# Builders for the result containers (no binaries needed). +# --------------------------------------------------------------------------- +def _clean_secret(): + return sg.SecretScanResult(status="clean", detail="no secrets found") + + +def _found_secret(n=1): + findings = [ + {"file": "src/config.py", "rule": "generic-api-key", "line": 12 + i, "match": "abcd…yz"} + for i in range(n) + ] + return sg.SecretScanResult(status="found", findings=findings, detail=f"{n} secret(s)") + + +def _ok_deps(findings=None): + return sg.DepAuditResult(status="ok", findings=findings or [], detail="ok") + + +def _degraded_deps(): + return sg.DepAuditResult(status="degraded", detail="pip-audit feed unavailable") + + +def _verdict(secret, dep, *, secrets_block=True, dep_block_severity="HIGH", dep_fail_closed=False): + return sg.compute_verdict( + secret, dep, + secrets_block=secrets_block, + dep_block_severity=dep_block_severity, + dep_fail_closed=dep_fail_closed, + ) + + +# --------------------------------------------------------------------------- +# TC-01 / TC-02 / TC-03 — secret-scanning (FR-1 / AC-1..AC-3) +# --------------------------------------------------------------------------- +def test_tc01_secret_in_diff_fails(): + """TC-01: a planted secret -> FAIL, secrets_found>=1, reason names the finding.""" + fields = _verdict(_found_secret(1), _ok_deps()) + assert fields["security_status"] == "FAIL" + assert fields["secrets_found"] >= 1 + # The reason must name the finding substance (rule + file), not just "FAIL". + assert "generic-api-key" in fields["reason"] + assert "src/config.py" in fields["reason"] + + +def test_tc02_clean_branch_passes(): + """TC-02: a clean branch -> PASS, secrets_found=0.""" + fields = _verdict(_clean_secret(), _ok_deps()) + assert fields["security_status"] == "PASS" + assert fields["secrets_found"] == 0 + assert fields["deps_blocking"] == 0 + + +def test_tc03_allowlisted_match_does_not_fail(monkeypatch, tmp_path): + """TC-03: an allowlisted match (placeholder / fixture) is filtered by gitleaks + (rc=0) -> scan_secrets reports clean -> PASS. The allowlist lives in the + versioned .gitleaks.toml; here we assert the gate honours gitleaks' rc=0.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + def _fake_run(cmd, **kwargs): + # `git fetch` and `gitleaks detect` both routed here; both "succeed clean". + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + + monkeypatch.setattr(sg.subprocess, "run", _fake_run) + res = sg.scan_secrets(_REPO, _BRANCH) + assert res.status == "clean" + fields = _verdict(res, _ok_deps()) + assert fields["security_status"] == "PASS" + + +# --------------------------------------------------------------------------- +# TC-04..TC-07 — dependency audit + thresholds (FR-2 / AC-4..AC-7) +# --------------------------------------------------------------------------- +def test_tc04_high_cve_at_high_threshold_blocks(): + """TC-04: a HIGH/CRITICAL CVE at threshold HIGH -> FAIL, deps_blocking>=1.""" + deps = _ok_deps([ + {"package": "requests", "version": "2.0.0", "id": "CVE-1", "severity": "HIGH", "fix": "2.1"}, + {"package": "urllib3", "version": "1.0.0", "id": "CVE-2", "severity": "CRITICAL", "fix": "1.1"}, + ]) + fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH") + assert fields["security_status"] == "FAIL" + assert fields["deps_blocking"] >= 1 + assert "CVE-1" in fields["reason"] or "CVE-2" in fields["reason"] + + +def test_tc05_only_medium_low_warns_passes(): + """TC-05: only MEDIUM/LOW vulns -> PASS, deps_warning>=1, findings in the body.""" + deps = _ok_deps([ + {"package": "jinja2", "version": "2.0", "id": "CVE-M", "severity": "MEDIUM", "fix": "2.1"}, + {"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""}, + ]) + fields = _verdict(_clean_secret(), deps, dep_block_severity="HIGH") + assert fields["security_status"] == "PASS" + assert fields["deps_warning"] >= 1 + assert fields["deps_blocking"] == 0 + body = sg.render_security_report(_WI, fields) + assert "CVE-M" in body and "CVE-L" in body + + +def test_tc06_threshold_config_changes_classification(): + """TC-06: severity=CRITICAL makes a HIGH CVE a warning; severity=HIGH blocks it.""" + assert sg.classify_severity("HIGH", "CRITICAL") == "warning" + assert sg.classify_severity("HIGH", "HIGH") == "block" + assert sg.classify_severity("CRITICAL", "CRITICAL") == "block" + # UNKNOWN is ALWAYS a warning, never an auto-block (anti-loop, Р-4). + assert sg.classify_severity("UNKNOWN", "LOW") == "warning" + assert sg.classify_severity("", "HIGH") == "warning" + + deps = _ok_deps([ + {"package": "x", "version": "1", "id": "CVE-H", "severity": "HIGH", "fix": ""}, + ]) + at_critical = _verdict(_clean_secret(), deps, dep_block_severity="CRITICAL") + at_high = _verdict(_clean_secret(), deps, dep_block_severity="HIGH") + assert at_critical["security_status"] == "PASS" + assert at_critical["deps_warning"] == 1 + assert at_high["security_status"] == "FAIL" + assert at_high["deps_blocking"] == 1 + + +def test_tc07_degraded_feed_failopen_default_failclosed_strict(): + """TC-07: an unreachable CVE feed degrades fail-open + warning by default (no + exception, no false FAIL); fail-closed -> FAIL only when configured.""" + default = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=False) + assert default["security_status"] == "PASS" + assert default["deps_audit_degraded"] is True + + strict = _verdict(_clean_secret(), _degraded_deps(), dep_fail_closed=True) + assert strict["security_status"] == "FAIL" + assert strict["deps_audit_degraded"] is True + assert "fail-closed" in strict["reason"] + + +# --------------------------------------------------------------------------- +# TC-08..TC-10 — verdict / frontmatter parser + artefact (FR-3 / AC-8..AC-10) +# --------------------------------------------------------------------------- +def test_tc08_verdict_only_from_frontmatter(): + """TC-08: the verdict is read ONLY from the YAML frontmatter; prose in the body + does not influence it; the negative (FAIL) token is authoritative.""" + # Frontmatter PASS but body screams FAIL -> still PASS (prose ignored). + pass_fm = ( + "---\nsecurity_status: PASS\nsecrets_found: 0\n---\n" + "# Report\nThis build totally FAILED everything, FAIL FAIL.\n" + ) + ok, reason = sg.parse_security_status(pass_fm) + assert ok is True + assert "PASS" in reason + + # Frontmatter FAIL but body says PASS -> FAIL (negative token authoritative). + fail_fm = "---\nsecurity_status: FAIL\n---\nEverything PASS, looks great!\n" + ok, reason = sg.parse_security_status(fail_fm) + assert ok is False + assert "FAIL" in reason + + +def test_tc09_missing_or_broken_frontmatter_failclosed(): + """TC-09: no frontmatter / broken YAML / missing field -> (False, reason).""" + # No frontmatter at all. + ok, reason = sg.parse_security_status("# Just a body, no frontmatter\nPASS\n") + assert ok is False and reason + + # Frontmatter present but no security_status field. + ok, reason = sg.parse_security_status("---\nother: 1\n---\nbody\n") + assert ok is False + + # Broken YAML in the frontmatter. + ok, reason = sg.parse_security_status("---\nsecurity_status: : : [bad\n---\nbody\n") + assert ok is False + + +def test_tc10_artifact_has_valid_frontmatter_and_body(tmp_path, monkeypatch): + """TC-10: 17-security-report.md is written with valid frontmatter (all machine + fields) and a body listing the findings; read-back == the written verdict.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt)) + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + deps = _ok_deps([ + {"package": "requests", "version": "2.0", "id": "CVE-X", "severity": "HIGH", "fix": "2.1"}, + {"package": "click", "version": "7.0", "id": "CVE-L", "severity": "LOW", "fix": ""}, + ]) + fields = _verdict(_found_secret(1), deps, dep_block_severity="HIGH") + path = sg.write_security_report(_REPO, _WI, _BRANCH, fields) + assert os.path.isfile(path) + with open(path, encoding="utf-8") as f: + content = f.read() + + # Frontmatter carries every machine field. + for key in ("security_status", "secrets_found", "deps_blocking", "deps_warning", + "deps_audit_degraded"): + assert f"{key}:" in content + # Body lists findings. + assert "CVE-X" in content and "CVE-L" in content + # Read-back agrees with the computed status (single source of truth, AC-8). + ok, _ = sg.parse_security_status(content) + assert ok is (fields["security_status"] == "PASS") + assert ok is False # this fixture is a FAIL (secret + HIGH CVE) + + +# --------------------------------------------------------------------------- +# TC-11 / TC-12 — never-raise / timeout (FR-5/FR-6 / AC-14..AC-17) +# --------------------------------------------------------------------------- +def test_tc11_missing_binary_failclosed_never_raises(monkeypatch, tmp_path): + """TC-11: a missing scanner binary / internal exception -> error -> FAIL + (fail-closed for secrets), and the exception never propagates.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + def _raise_fnf(cmd, **kwargs): + # git fetch ok, gitleaks missing. + if cmd[:1] == ["git"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + raise FileNotFoundError("gitleaks") + + monkeypatch.setattr(sg.subprocess, "run", _raise_fnf) + res = sg.scan_secrets(_REPO, _BRANCH) + assert res.status == "error" + fields = _verdict(res, _ok_deps()) + assert fields["security_status"] == "FAIL" # fail-closed, BR-2 + assert "fail-closed" in fields["reason"] + + # check_security_gate as a whole never raises even if everything explodes. + monkeypatch.setattr(sg, "security_gate_applies", lambda r: True) + + def _boom(*a, **k): + raise RuntimeError("kaboom") + + monkeypatch.setattr(sg, "scan_secrets", _boom) + ok, reason = sg.check_security_gate(_REPO, _WI, _BRANCH) + assert ok is False + assert "error" in reason.lower() + + +def test_tc12_timeout_is_deterministic_failclosed(monkeypatch, tmp_path): + """TC-12: exceeding the scan timeout -> a deterministic error verdict, no hang.""" + wt = tmp_path / "wt" + wt.mkdir() + monkeypatch.setattr(sg, "ensure_worktree", lambda r, b: str(wt)) + + def _timeout(cmd, **kwargs): + if cmd[:1] == ["git"]: + return subprocess.CompletedProcess(cmd, 0, stdout="", stderr="") + raise subprocess.TimeoutExpired(cmd, kwargs.get("timeout", 1)) + + monkeypatch.setattr(sg.subprocess, "run", _timeout) + res = sg.scan_secrets(_REPO, _BRANCH) + assert res.status == "error" + assert "timeout" in res.detail.lower() + fields = _verdict(res, _ok_deps()) + assert fields["security_status"] == "FAIL" + + # pip-audit timeout -> degrade (best-effort), not a hard error. + monkeypatch.setattr(sg, "get_worktree_path", lambda r, b: str(wt)) + (wt / "requirements.txt").write_text("requests==2.0\n") + dep = sg.audit_dependencies(_REPO, _BRANCH) + assert dep.status == "degraded" + assert "timeout" in dep.detail.lower() + + +# --------------------------------------------------------------------------- +# Parser robustness (supports the above; pure, never raises) +# --------------------------------------------------------------------------- +def test_parse_gitleaks_report_tolerant(): + assert sg.parse_gitleaks_report("") == [] + assert sg.parse_gitleaks_report("not json") == [] + assert sg.parse_gitleaks_report("{}") == [] + parsed = sg.parse_gitleaks_report( + '[{"File":"a.py","RuleID":"key","StartLine":3,"Secret":"supersecretvalue"}]' + ) + assert parsed[0]["file"] == "a.py" + assert parsed[0]["rule"] == "key" + # The secret value is masked, never re-leaked verbatim. + assert "supersecretvalue" not in parsed[0]["match"] + + +def test_parse_pip_audit_report_tolerant(): + assert sg.parse_pip_audit_report("") == [] + assert sg.parse_pip_audit_report("garbage") == [] + doc = ( + '{"dependencies":[{"name":"requests","version":"2.0",' + '"vulns":[{"id":"CVE-1","severity":"HIGH","fix_versions":["2.1"]}]}]}' + ) + parsed = sg.parse_pip_audit_report(doc) + assert parsed[0]["package"] == "requests" + assert parsed[0]["severity"] == "HIGH" + # Missing severity -> UNKNOWN. + doc2 = '{"dependencies":[{"name":"x","version":"1","vulns":[{"id":"CVE-2"}]}]}' + assert sg.parse_pip_audit_report(doc2)[0]["severity"] == "UNKNOWN" diff --git a/tests/test_stage_engine.py b/tests/test_stage_engine.py index ca3dab6..66ced68 100644 --- a/tests/test_stage_engine.py +++ b/tests/test_stage_engine.py @@ -832,6 +832,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -856,6 +857,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("merge-lock busy")}, ) monkeypatch.setattr(stage_engine.settings, "merge_defer_delay_s", 30) @@ -883,6 +885,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("merge-lock busy")}, ) monkeypatch.setattr(stage_engine.settings, "merge_defer_max_attempts", 3) @@ -916,6 +919,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -939,6 +943,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("re-test failed after rebase: 1 failed")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -962,6 +967,7 @@ class TestMergeGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _fail("rebase conflict: src/db.py")}, ) task_id = _make_task("deploy-staging", repo="orchestrator", wi="ORCH-043", @@ -1014,6 +1020,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _fail( "staging rebuild failed: health FAILED")}, @@ -1041,6 +1048,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _fail("provenance mismatch")}, ) @@ -1064,6 +1072,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -1089,6 +1098,7 @@ class TestImageFreshnessGate: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass}, ) # check_staging_image_fresh left REAL -> N/A for enduro-trails task_id = _make_task("deploy-staging", repo="enduro-trails", wi="ET-099", @@ -1160,6 +1170,7 @@ class TestStagingInfraTolerance: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass}, ) @@ -1232,6 +1243,7 @@ class TestStagingInfraTolerance: stage_engine, "QG_CHECKS", {**stage_engine.QG_CHECKS, "check_staging_status": _pass, + "check_security_gate": _pass, "check_branch_mergeable": _pass, "check_staging_image_fresh": _pass, "check_deploy_status": _pass}, diff --git a/tests/test_stage_engine_security_gate.py b/tests/test_stage_engine_security_gate.py new file mode 100644 index 0000000..8b82f7f --- /dev/null +++ b/tests/test_stage_engine_security_gate.py @@ -0,0 +1,264 @@ +"""ORCH-022 / TC-16..TC-19, TC-21: the security sub-gate wired into advance_stage. + +These are integration tests over src.stage_engine.advance_stage on the +deploy-staging -> deploy edge. The security verdict is injected by patching the +QG_CHECKS registry entry (the leaf scanner logic is unit-tested in +tests/test_security_gate.py), so we exercise the ENGINE behaviour: + * FAIL -> rollback to development + enqueue developer + Plane comment + notify; + * the rollback task_desc carries the verbatim findings (ORCH-046 pattern); + * after MAX_DEVELOPER_RETRIES -> set_issue_blocked + Telegram, no bounce; + * PASS -> the pipeline advances normally (no rollback, no noisy notify); + * self-hosting safety: a FAIL never calls the deploy hook / restarts prod. + +Network/Plane/Telegram side effects are mocked at the src.stage_engine level. +""" + +import os +import tempfile + +import pytest + +_test_db = os.path.join(tempfile.gettempdir(), "test_orchestrator_security_gate.db") +os.environ["ORCH_DB_PATH"] = _test_db +os.environ["ORCH_REPOS_DIR"] = tempfile.gettempdir() +os.environ.setdefault("ORCH_GITEA_TOKEN", "test-token") +os.environ.setdefault("ORCH_PLANE_API_TOKEN", "test-token") + +from unittest.mock import MagicMock # noqa: E402 + +import src.db as _db # noqa: E402 +from src.db import init_db, get_db # noqa: E402 +from src import stage_engine # noqa: E402 +from src.stage_engine import advance_stage # noqa: E402 + +_BRANCH = "feature/ORCH-022-x" + + +# --------------------------------------------------------------------------- +# Fixtures (mirror tests/test_stage_engine.py) +# --------------------------------------------------------------------------- +@pytest.fixture(autouse=True) +def fresh_db(monkeypatch): + monkeypatch.setattr(_db.settings, "db_path", _test_db) + if os.path.exists(_test_db): + os.unlink(_test_db) + init_db() + yield + + +@pytest.fixture(autouse=True) +def silence_side_effects(monkeypatch): + for name in ( + "notify_stage_change", + "notify_qg_failure", + "notify_approve_requested", + "send_telegram", + "plane_notify_stage", + "plane_notify_qg", + "plane_add_comment", + "set_issue_in_review", + "set_issue_needs_input", + "set_issue_in_progress", + "set_issue_blocked", + "set_issue_done", + ): + monkeypatch.setattr(stage_engine, name, MagicMock()) + + +def _make_task(stage, repo, branch=_BRANCH, wi="ORCH-022"): + conn = get_db() + cur = conn.execute( + "INSERT INTO tasks (plane_id, work_item_id, repo, branch, stage) " + "VALUES (?, ?, ?, ?, ?)", + (f"plane-{wi}", wi, repo, branch, stage), + ) + task_id = cur.lastrowid + conn.commit() + conn.close() + return task_id + + +def _stage(task_id): + conn = get_db() + row = conn.execute("SELECT stage FROM tasks WHERE id=?", (task_id,)).fetchone() + conn.close() + return row[0] + + +def _jobs(): + conn = get_db() + rows = conn.execute("SELECT agent, repo, task_id FROM jobs ORDER BY id").fetchall() + conn.close() + return [dict(r) for r in rows] + + +def _job_contents(): + conn = get_db() + rows = conn.execute("SELECT task_content FROM jobs ORDER BY id").fetchall() + conn.close() + return [r[0] for r in rows] + + +def _add_developer_runs(task_id, n): + conn = get_db() + for _ in range(n): + conn.execute( + "INSERT INTO agent_runs (task_id, agent) VALUES (?, 'developer')", + (task_id,), + ) + conn.commit() + conn.close() + + +def _pass(*a, **k): + return (True, "ok") + + +def _fail(reason): + def _f(*a, **k): + return (False, reason) + return _f + + +def _qg_with_security(monkeypatch, security_result): + """Patch QG_CHECKS so every gate passes EXCEPT the security gate, which returns + ``security_result``. Keeps the deploy-staging edge reachable (check_staging_status + passes) and isolates the security verdict under test.""" + patched = {k: _pass for k in stage_engine.QG_CHECKS} + patched["check_security_gate"] = security_result + monkeypatch.setattr(stage_engine, "QG_CHECKS", patched) + + +# --------------------------------------------------------------------------- +# TC-16 — FAIL -> rollback to development + enqueue developer + notify. +# --------------------------------------------------------------------------- +def test_tc16_fail_rolls_back_and_enqueues_developer(monkeypatch): + """TC-16: security_status FAIL -> rollback deploy-staging -> development, + enqueue developer, Plane comment + notify_qg_failure.""" + _qg_with_security(monkeypatch, _fail("2 secret(s): aws-key in src/x.py:3")) + task_id = _make_task("deploy-staging", repo="enduro-trails") + + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.advanced is False + assert res.rolled_back_to == "development" + assert _stage(task_id) == "development" + jobs = _jobs() + assert len(jobs) == 1 + assert jobs[0]["agent"] == "developer" + assert res.qg_name == "check_security_gate" + # The deployer-authored Plane comment + the QG-failure notification fired. + assert stage_engine.plane_add_comment.called + assert stage_engine.notify_qg_failure.called + + +# --------------------------------------------------------------------------- +# TC-17 — the rollback task_desc carries the verbatim findings (ORCH-046). +# --------------------------------------------------------------------------- +def test_tc17_task_desc_has_verbatim_findings(monkeypatch): + """TC-17: the re-launched developer's task_desc embeds the verbatim finding + substance (not just a link), following the ORCH-046 pattern.""" + reason = "2 secret(s): aws-access-key in src/config.py:12" + _qg_with_security(monkeypatch, _fail(reason)) + task_id = _make_task("deploy-staging", repo="enduro-trails") + + # Seed a real 17-security-report.md in the worktree so extract_security_findings + # has a verbatim body to excerpt. + wt = stage_engine.get_worktree_path("enduro-trails", _BRANCH) + report_dir = os.path.join(wt, "docs", "work-items", "ORCH-022") + os.makedirs(report_dir, exist_ok=True) + with open(os.path.join(report_dir, "17-security-report.md"), "w", encoding="utf-8") as f: + f.write( + "---\nsecurity_status: FAIL\nsecrets_found: 1\n---\n" + "# Security Report — ORCH-022\n\n" + "## Verdict\n1 secret(s): aws-access-key in src/config.py:12\n\n" + "## Secrets\n- `src/config.py:12` — aws-access-key (match `AKIA…YZ`)\n\n" + "## Dependencies (blocking)\n- None\n" + ) + + advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + contents = _job_contents() + assert len(contents) == 1 + desc = contents[0] + # The verbatim reason AND the excerpted finding line are present. + assert "aws-access-key in src/config.py:12" in desc + assert "src/config.py:12" in desc + # Plus the link to the full artefact. + assert "17-security-report.md" in desc + + +# --------------------------------------------------------------------------- +# TC-18 — after MAX_DEVELOPER_RETRIES -> block + Telegram, no bounce. +# --------------------------------------------------------------------------- +def test_tc18_retry_cap_blocks_and_alerts(monkeypatch): + """TC-18: after MAX_DEVELOPER_RETRIES developer attempts -> set_issue_blocked + + Telegram alert; no infinite bounce (no new developer job).""" + _qg_with_security(monkeypatch, _fail("blocking CVE")) + task_id = _make_task("deploy-staging", repo="enduro-trails") + _add_developer_runs(task_id, stage_engine.MAX_DEVELOPER_RETRIES) + + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.rolled_back_to == "development" + assert res.alerted is True + assert stage_engine.set_issue_blocked.called + assert stage_engine.send_telegram.called + # No further developer job past the cap. + assert _jobs() == [] + + +# --------------------------------------------------------------------------- +# TC-19 — PASS -> the pipeline advances normally. +# --------------------------------------------------------------------------- +def test_tc19_pass_advances_normally(monkeypatch): + """TC-19: security_status PASS -> advance deploy-staging -> deploy with the + deployer launched, no rollback, no QG-failure notification.""" + _qg_with_security(monkeypatch, lambda *a, **k: (True, "security clean")) + task_id = _make_task("deploy-staging", repo="enduro-trails") + + res = advance_stage( + task_id, "deploy-staging", "enduro-trails", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.advanced is True + assert res.to_stage == "deploy" + assert _stage(task_id) == "deploy" + assert res.rolled_back_to is None + # No noisy QG-failure notification on the happy path. + assert not stage_engine.notify_qg_failure.called + + +# --------------------------------------------------------------------------- +# TC-21 — self-hosting safety: a FAIL never deploys / restarts prod. +# --------------------------------------------------------------------------- +def test_tc21_fail_never_triggers_deploy(monkeypatch): + """TC-21: on a security FAIL the gate only rolls back + enqueues developer; it + never calls the deploy hook / restarts the prod container (self-hosting safety).""" + _qg_with_security(monkeypatch, _fail("secret found")) + # Spy on the self-deploy entrypoints — none must be invoked on a FAIL. + monkeypatch.setattr(stage_engine.self_deploy, "initiate_deploy", MagicMock()) + monkeypatch.setattr(stage_engine.self_deploy, "self_deploy_applies", MagicMock(return_value=True)) + task_id = _make_task("deploy-staging", repo="orchestrator") + + res = advance_stage( + task_id, "deploy-staging", "orchestrator", "ORCH-022", _BRANCH, + finished_agent="deployer", + ) + + assert res.rolled_back_to == "development" + # The security FAIL returns BEFORE the self-deploy block -> no deploy initiated. + assert not stage_engine.self_deploy.initiate_deploy.called + # Only the developer is re-enqueued; no deployer job. + jobs = _jobs() + assert all(j["agent"] == "developer" for j in jobs) From 85ecf509262941ddd52408c35a3da960b3b35b8d Mon Sep 17 00:00:00 2001 From: stream Date: Sun, 7 Jun 2026 17:37:10 +0000 Subject: [PATCH 05/10] ci: re-run after gitea restart (ORCH-022 flaky CI) From 04233cb3c87564480836f8ba3e42e4cfde971b67 Mon Sep 17 00:00:00 2001 From: Dev Date: Sun, 7 Jun 2026 20:51:35 +0300 Subject: [PATCH 06/10] test(ORCH-022): isolate TC-17 worktree under tmp_path (fix CI PermissionError on /repos/_wt) TC-17 seeded 17-security-report.md via get_worktree_path() which resolves to settings.worktrees_dir (default /repos/_wt) -> the test wrote into the real shared host worktree path. In CI that dir is owned by another user -> PermissionError. Monkeypatch git_worktree.settings.worktrees_dir to tmp_path/_wt (same pattern as test_git_worktree.py / test_merge_gate.py). Prod logic untouched. --- tests/test_stage_engine_security_gate.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/test_stage_engine_security_gate.py b/tests/test_stage_engine_security_gate.py index 8b82f7f..72fd8d7 100644 --- a/tests/test_stage_engine_security_gate.py +++ b/tests/test_stage_engine_security_gate.py @@ -158,13 +158,19 @@ def test_tc16_fail_rolls_back_and_enqueues_developer(monkeypatch): # --------------------------------------------------------------------------- # TC-17 — the rollback task_desc carries the verbatim findings (ORCH-046). # --------------------------------------------------------------------------- -def test_tc17_task_desc_has_verbatim_findings(monkeypatch): +def test_tc17_task_desc_has_verbatim_findings(monkeypatch, tmp_path): """TC-17: the re-launched developer's task_desc embeds the verbatim finding substance (not just a link), following the ORCH-046 pattern.""" reason = "2 secret(s): aws-access-key in src/config.py:12" _qg_with_security(monkeypatch, _fail(reason)) task_id = _make_task("deploy-staging", repo="enduro-trails") + # Isolate the worktree base under tmp_path so this test never touches the real + # shared /repos/_wt host path (PermissionError in CI). Mirrors the pattern in + # tests/test_git_worktree.py / test_merge_gate.py. + from src import git_worktree + monkeypatch.setattr(git_worktree.settings, "worktrees_dir", str(tmp_path / "_wt")) + # Seed a real 17-security-report.md in the worktree so extract_security_findings # has a verbatim body to excerpt. wt = stage_engine.get_worktree_path("enduro-trails", _BRANCH) From cb3bdd9c7a9a2bee5f3e8858050a39872a6c2038 Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 18:00:23 +0000 Subject: [PATCH 07/10] reviewer(ET): auto-commit from reviewer run_id=330 --- docs/work-items/ORCH-022/12-review.md | 74 +++++++++++++++++++++++++++ 1 file changed, 74 insertions(+) create mode 100644 docs/work-items/ORCH-022/12-review.md diff --git a/docs/work-items/ORCH-022/12-review.md b/docs/work-items/ORCH-022/12-review.md new file mode 100644 index 0000000..b8875df --- /dev/null +++ b/docs/work-items/ORCH-022/12-review.md @@ -0,0 +1,74 @@ +--- +type: review +work_item_id: ORCH-022 +verdict: APPROVED +version: 1 +--- + +# Review ORCH-022 + +## Summary +Security-гейт (secret-scanning `gitleaks` + dependency audit `pip-audit`) реализован как +детерминированный под-гейт ребра `deploy-staging → deploy`, исполняемый ПЕРВЫМ среди +edge-под-гейтов — в точности по ADR-001 (Вариант M) и эталонному паттерну соседей +(merge-gate ORCH-043 / image-freshness ORCH-058): leaf-модуль `src/security_gate.py` +(never-raise) + тонкая обёртка `check_security_gate` в `QG_CHECKS` (lazy-import, нет цикла) ++ врезка `_handle_security_gate` ПЕРВОЙ в блоке `current_stage == "deploy-staging"`. +`STAGE_TRANSITIONS` и схема БД не тронуты. Все 772 теста зелёные (25 из них — +security-специфичные: `test_security_gate.py`, `test_qg_security.py`, +`test_stage_engine_security_gate.py`). Документация обновлена полностью и в этом же PR. + +### Соответствие ТЗ (02-trz) +- FR-1 secret-scan offline `origin/main..HEAD`, любой секрет вне аллоулиста → FAIL ✓ +- FR-2 dep-audit по severity (`HIGH` дефолт), MEDIUM/LOW/UNKNOWN → warning ✓ +- FR-3 машинный вердикт ТОЛЬКО из frontmatter `17-security-report.md`, negative-токен + авторитетен, write→read-back (единый источник истины) ✓ +- FR-4 FAIL → откат на `development` + developer-retry (cap 3) + `task_desc` с дословными + находками (ORCH-046) ✓ +- FR-5 условность `security_gate_enabled` / `security_gate_repos` (пусто → self-hosting) ✓ +- FR-6 never-raise + таймаут `security_scan_timeout_s` ✓ +- FR-7 наблюдаемость (Telegram при degraded/FAIL, лог при PASS) ✓ +- §6 без миграций БД, §7 инварианты соблюдены (STAGE_TRANSITIONS/QG_CHECKS консистентны, + gate не деплоит/не рестартит прод) ✓ + +### Соответствие ADR (06-adr/ADR-001 + global adr-0012) +Р-1 (размещение ПЕРВЫМ, до merge-gate, до захвата merge-lease → lease не освобождается), +Р-2 (gitleaks pinned Go-бинарь в Dockerfile, pip-audit в requirements), Р-3 (fail-open +degrade + флаг `security_dep_audit_fail_closed`), Р-4 (пороги, UNKNOWN→warning), Р-5 +(артефакт + read-back), Р-6 (откат/cap/эскалация), Р-7 (lazy-import, double-guard +never-raise), Р-8 (self-hosting safety) — все реализованы как описано. + +### Критерии приёмки (03) +AC-1..AC-21 покрыты тестами TC-01..TC-21 (incl. rollback TC-16, verbatim task_desc TC-17, +cap+blocked TC-18, PASS-advance TC-19, no-deploy-on-FAIL TC-21). AC-20 (документация) — +подтверждён ниже. + +## Findings + +### P0 — Blocker +- нет + +### P1 — Must fix +- нет + +### P2 — Should fix +- нет + +### P3 — Nice-to-have +- Глобальный `docs/architecture/adr/adr-0012-security-gate.md` помечен `Статус: proposed`, + тогда как per-WI `06-adr/ADR-001` — `Accepted`. Косметическая рассинхронизация статуса, + на функциональность/гейты не влияет. + +## Документация +Обновлена в том же PR (AC-20, CLAUDE.md §6 соблюдён): +- `CLAUDE.md` — раздел «Артефакты задачи» (добавлен `17-security-report.md`) + строка о + машинных вердиктах (`security_status:`). +- `docs/architecture/README.md` — реестр `QG_CHECKS` (`check_security_gate (ORCH-022)`), + новый раздел «Security-гейт …», статусная сноска внизу. +- `docs/architecture/adr/adr-0012-security-gate.md` — новый global ADR (+ per-WI ADR-001). +- `CHANGELOG.md` — подробная запись в `[Unreleased] / Added`. +- `.env.example` — все шесть `ORCH_SECURITY_*` с комментариями. +- `Dockerfile` (pinned gitleaks), `requirements.txt` (pip-audit), `.gitleaks.toml` (корень, + правила + аллоулист) — инфраструктура версионирована. + +Статус: документация = golden source — синхронна с кодом. Замечаний нет. From 8cdb9f194a2b76c51bd16cba0acfffe75642a2fc Mon Sep 17 00:00:00 2001 From: claude-bot Date: Sun, 7 Jun 2026 18:01:52 +0000 Subject: [PATCH 08/10] tester(ET): auto-commit from tester run_id=331 --- docs/work-items/ORCH-022/13-test-report.md | 76 ++++++++++++++++++++++ 1 file changed, 76 insertions(+) create mode 100644 docs/work-items/ORCH-022/13-test-report.md diff --git a/docs/work-items/ORCH-022/13-test-report.md b/docs/work-items/ORCH-022/13-test-report.md new file mode 100644 index 0000000..8977d5c --- /dev/null +++ b/docs/work-items/ORCH-022/13-test-report.md @@ -0,0 +1,76 @@ +--- +type: test-report +work_item_id: ORCH-022 +result: PASS +--- + +# Test Report — ORCH-022 + +Security-гейт: secret-scanning (gitleaks) + dependency audit (pip-audit) как под-гейт +ребра `deploy-staging → deploy`. + +## Окружение +- Python: 3.12.13 +- pytest: 8.3.3 +- Дата: 2026-06-07 +- Ветка: `feature/ORCH-022-security-secret-scanning` +- Review verdict: APPROVED (`12-review.md`) + +## Smoke test API (prod 8500, self-hosting — не трогаем контейнер) +| Endpoint | Результат | +|----------|-----------| +| `GET /health` | `{"status":"ok","service":"orchestrator"}` — OK | +| `GET /status` | OK (active task ORCH-022 в stage=testing виден) | +| `GET /queue` | OK (counts/resilience/reconcile/reaper/post_deploy присутствуют) | + +## Результаты (привязка к 04-test-plan.yaml) + +| TC ID | Описание | Тест | Результат | +|-------|----------|------|-----------| +| TC-01 | Секрет в diff → FAIL, secrets_found>=1, причина называет находку | test_security_gate.py::test_tc01_secret_in_diff_fails | PASS | +| TC-02 | Чистая ветка → PASS, secrets_found=0 | test_tc02_clean_branch_passes | PASS | +| TC-03 | Аллоулист подавляет заведомо-безопасное | test_tc03_allowlisted_match_does_not_fail | PASS | +| TC-04 | HIGH/CRITICAL CVE при пороге HIGH → FAIL, deps_blocking>=1 | test_tc04_high_cve_at_high_threshold_blocks | PASS | +| TC-05 | Только MEDIUM/LOW → PASS, deps_warning>=1 | test_tc05_only_medium_low_warns_passes | PASS | +| TC-06 | Конфиг порога severity влияет на классификацию | test_tc06_threshold_config_changes_classification | PASS | +| TC-07 | Недоступный фид → детерминированный degrade (fail-open default / fail-closed strict) | test_tc07_degraded_feed_failopen_default_failclosed_strict | PASS | +| TC-08 | Вердикт ТОЛЬКО из frontmatter; negative-токен авторитетен | test_tc08_verdict_only_from_frontmatter | PASS | +| TC-09 | Нет/битый frontmatter → (False, reason) fail-closed | test_tc09_missing_or_broken_frontmatter_failclosed | PASS | +| TC-10 | Артефакт 17-security-report.md с валидным frontmatter + телом | test_tc10_artifact_has_valid_frontmatter_and_body | PASS | +| TC-11 | Нет бинаря / исключение → (False, reason), never-raise | test_tc11_missing_binary_failclosed_never_raises | PASS | +| TC-12 | Таймаут → детерминированный fail-closed, без зависания | test_tc12_timeout_is_deterministic_failclosed | PASS | +| TC-13 | Не-self репо при пустом scope → (True, N/A) мгновенно | test_qg_security.py::test_tc13_non_self_repo_empty_scope_is_na | PASS | +| TC-14 | ORCH_SECURITY_GATE_ENABLED=false → no-op pass | test_tc14_disabled_is_noop_pass | PASS | +| TC-15 | Зарегистрирован в QG_CHECKS и диспетчеризуется _run_qg | test_tc15_registered_in_qg_checks / test_tc15_dispatched_by_run_qg | PASS | +| TC-16 | FAIL → откат на development, enqueue developer, notify_qg_failure | test_stage_engine_security_gate.py::test_tc16_fail_rolls_back_and_enqueues_developer | PASS | +| TC-17 | task_desc несёт дословную причину (ORCH-046) | test_tc17_task_desc_has_verbatim_findings | PASS | +| TC-18 | После MAX_DEVELOPER_RETRIES (3) → set_issue_blocked + Telegram | test_tc18_retry_cap_blocks_and_alerts | PASS | +| TC-19 | PASS → штатное продвижение конвейера | test_tc19_pass_advances_normally | PASS | +| TC-20 | STAGE_TRANSITIONS не изменён; тесты стадий зелёные | tests/test_stages.py (полный прогон) | PASS | +| TC-21 | Гейт не вызывает деплой-хук/рестарт прод (self-hosting safety) | test_tc21_fail_never_triggers_deploy | PASS | + +Все 21 TC покрыты и зелёные. Соответствие критериям приёмки (03-acceptance-criteria): +AC-1..AC-21 закрыты соответствующими TC (AC-N ↔ TC-N для N=1..21; AC-20 «документация» +подтверждён в review 12-review.md). + +## Вывод pytest + +### Security-специфичные тесты (25 шт.) +``` +tests/test_security_gate.py ............... (15) +tests/test_qg_security.py ...... (6) +tests/test_stage_engine_security_gate.py ..... (5) +======================== 25 passed, 1 warning in 0.49s ========================= +``` + +### Полный регресс +``` +======================= 772 passed, 1 warning in 14.70s ======================== +``` +(1 warning — PydanticDeprecatedSince20 в src/config.py, не связан с ORCH-022, +существовал до задачи.) + +## Итог +**PASS** — полный регресс 772/772 зелёный, 25 security-тестов покрывают все 21 TC +плана и AC-1..AC-21, smoke-тесты API прод-инстанса OK. Прод-контейнер в процессе +тестирования не затронут (тесты офлайн/изолированы). Задача готова к стадии deploy-staging. From e07ee9e574af7892de096b8f9f9931365ad0e4c8 Mon Sep 17 00:00:00 2001 From: deploy-finalizer Date: Sun, 7 Jun 2026 18:42:29 +0000 Subject: [PATCH 09/10] deploy(ORCH-036): finalize SUCCESS for ORCH-022 --- docs/work-items/ORCH-022/14-deploy-log.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 docs/work-items/ORCH-022/14-deploy-log.md diff --git a/docs/work-items/ORCH-022/14-deploy-log.md b/docs/work-items/ORCH-022/14-deploy-log.md new file mode 100644 index 0000000..6edaa73 --- /dev/null +++ b/docs/work-items/ORCH-022/14-deploy-log.md @@ -0,0 +1,12 @@ +--- +deploy_status: SUCCESS +work_item: ORCH-022 +hook_exit_code: 0 +deployed_by: deploy-finalizer +--- + +# Deploy log — ORCH-036 executable self-deploy + +Прод-деплой завершён хост-хуком с exit-code `0` -> `deploy_status: SUCCESS`. + +Вердикт зафиксирован детерминированным finalizer'ом (Фаза C), не LLM. From 0cbb7ef0bbb8110ae8f22195fd061c1655d08636 Mon Sep 17 00:00:00 2001 From: post-deploy-monitor Date: Sun, 7 Jun 2026 19:24:29 +0000 Subject: [PATCH 10/10] docs(ORCH-021): post-deploy HEALTHY/NONE for ORCH-022 --- docs/work-items/ORCH-022/16-post-deploy-log.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 docs/work-items/ORCH-022/16-post-deploy-log.md diff --git a/docs/work-items/ORCH-022/16-post-deploy-log.md b/docs/work-items/ORCH-022/16-post-deploy-log.md new file mode 100644 index 0000000..1fe4449 --- /dev/null +++ b/docs/work-items/ORCH-022/16-post-deploy-log.md @@ -0,0 +1,14 @@ +--- +post_deploy_status: HEALTHY +action_taken: NONE +work_item: ORCH-022 +window_s: 900 +checks_total: 30 +checks_failed: 0 +--- + +# Post-deploy log — ORCH-021 post-deploy monitor + +Наблюдение прода завершено: `post_deploy_status: HEALTHY`, `action_taken: NONE`. + +Окно наблюдения: 900s; опросов всего: 30, из них с провалом: 0.