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>
114 lines
4.8 KiB
Python
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"
|