Files
orchestrator/tests/test_qg_security.py
claude-bot 30b6187c73 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>
2026-06-07 18:04:50 +00:00

114 lines
4.8 KiB
Python

"""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"