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 <noreply@anthropic.com>
This commit is contained in:
21
.env.example
21
.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
|
||||
|
||||
38
.gitleaks.toml
Normal file
38
.gitleaks.toml
Normal file
@@ -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_-]+>''',
|
||||
]
|
||||
File diff suppressed because one or more lines are too long
@@ -44,10 +44,10 @@ created → analysis → architecture → development → review → testing →
|
||||
- ADR per work-item: `docs/work-items/<plane-id>/06-adr/ADR-NNN-slug.md`
|
||||
- Global ADR (сквозные решения): `docs/architecture/adr/adr-NNNN-slug.md`
|
||||
- Work items: `docs/work-items/<plane-id>/`
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`), никогда проза
|
||||
- Машинные вердикты Quality Gate — строго YAML-frontmatter (`verdict:`, `deploy_status:`, `staging_status:`, `security_status:`), никогда проза
|
||||
|
||||
## Артефакты задачи (`docs/work-items/<plane-id>/`)
|
||||
`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`.
|
||||
|
||||
21
Dockerfile
21
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
|
||||
|
||||
@@ -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; обновлять также при изменении этих мест).*
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 <repo>")`` 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,
|
||||
}
|
||||
|
||||
689
src/security_gate.py
Normal file
689
src/security_gate.py
Normal file
@@ -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, "<reason>")``; 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, <reason>)`` (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 <repo>")``.
|
||||
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, "<reason>")``; 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}"
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -235,6 +235,7 @@ def test_tc19_qg_checks_registry_unchanged():
|
||||
"check_staging_status",
|
||||
"check_branch_mergeable",
|
||||
"check_staging_image_fresh",
|
||||
"check_security_gate",
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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},
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
113
tests/test_qg_security.py
Normal file
113
tests/test_qg_security.py
Normal file
@@ -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 <repo>') 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"
|
||||
324
tests/test_security_gate.py
Normal file
324
tests/test_security_gate.py
Normal file
@@ -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"
|
||||
@@ -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},
|
||||
|
||||
264
tests/test_stage_engine_security_gate.py
Normal file
264
tests/test_stage_engine_security_gate.py
Normal file
@@ -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)
|
||||
Reference in New Issue
Block a user