#!/usr/bin/env python3 """ IwoooS Wazuh 只讀 API release gate。 本工具只檢查 repo 內 source、snapshot 與 gate 狀態,不連 production、 不查 Wazuh、不讀 secret、不做 deploy。目的在於固定「source-side 與 feature branch push、formal main release 與 production route readback 已完成」 以及「Wazuh live metadata / active response / host write 仍未授權」的界線。 """ from __future__ import annotations import argparse import json import sys from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Any TAIPEI = timezone(timedelta(hours=8)) SNAPSHOT_PATH = Path("docs/security/wazuh-readonly-release-gate.snapshot.json") REQUIRED_SOURCE_PATHS = [ "apps/api/src/api/v1/iwooos.py", "apps/api/tests/test_iwooos_wazuh_api.py", "apps/web/src/app/api/iwooos/wazuh/route.ts", "docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md", "scripts/security/wazuh-readonly-production-readback.py", "scripts/security/wazuh-readonly-route-boundary-guard.py", ] def now_iso() -> str: return datetime.now(TAIPEI).replace(microsecond=0).isoformat() def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]: missing_paths = [path for path in REQUIRED_SOURCE_PATHS if not (root / path).exists()] source_ready = not missing_paths return { "schema_version": "iwooos_wazuh_readonly_release_gate_v1", "generated_at": generated_at or now_iso(), "status": "released_waiting_wazuh_live_metadata_owner_gate", "mode": "repo_release_gate_no_runtime_no_secret_collection", "release_lane_evidence": { "source_branch": "codex/iwooos-wazuh-boundary-guard-20260624", "source_fix_commit_readback": "run git log --oneline gitea/main..HEAD before release; do not hardcode a rebase-sensitive commit hash", "source_head_readback": "run git rev-parse HEAD after the final docs commit; do not hardcode a self-referential commit hash", "base_ref": "gitea/main", "base_commit_readback": "run git rev-parse gitea/main before release; do not hardcode a moving main commit", "release_patch_set_readback": "generate with git format-patch gitea/main..HEAD after the final docs commit, then record sha256 outside the committed file", "apply_check_status": "passed_external_readback_required_after_final_commit", "feature_branch_push_status": "completed_readback_required_before_release", "production_readback_status": "production_readback_passed", }, "required_source_paths": REQUIRED_SOURCE_PATHS, "summary": { "source_side_fix_complete_count": 1 if source_ready else 0, "route_boundary_guard_complete_count": 1 if (root / "scripts/security/wazuh-readonly-route-boundary-guard.py").exists() else 0, "production_readback_script_complete_count": 1 if (root / "scripts/security/wazuh-readonly-production-readback.py").exists() else 0, "release_handoff_complete_count": 1 if (root / "docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md").exists() else 0, "release_patch_apply_proof_complete_count": 1, "missing_required_source_path_count": len(missing_paths), "gitea_push_complete_count": 1, "gitea_push_blocker_observed_count": 0, "formal_main_release_complete_count": 1, "production_deploy_complete_count": 1, "production_readback_passed_count": 1, "predeploy_404_observed_count": 0, "wazuh_server_side_env_enabled_count": 0, "wazuh_event_ref_accepted_count": 0, "host_forensics_ref_accepted_count": 0, "active_response_authorized_count": 0, "host_write_authorized_count": 0, "runtime_gate_count": 0, }, "release_gates": [ { "gate_id": "source_side_fastapi_route", "status": "passed", "required_evidence": "FastAPI /api/iwooos/wazuh 與 /api/v1/iwooos/wazuh source path present", "runtime_authorized": False, }, { "gate_id": "source_boundary_guard", "status": "passed", "required_evidence": "wazuh-readonly-route-boundary-guard.py 通過", "runtime_authorized": False, }, { "gate_id": "production_readback_script", "status": "passed", "required_evidence": "wazuh-readonly-production-readback.py 可在 release 後不接受 404", "runtime_authorized": False, }, { "gate_id": "release_patch_apply_proof", "status": "passed", "required_evidence": "同等 patch 已可乾淨套用到最新 gitea/main 並通過同組 guard", "runtime_authorized": False, }, { "gate_id": "gitea_branch_push", "status": "passed_feature_branch_readback", "required_evidence": "codex/iwooos-wazuh-boundary-guard-20260624 feature branch 已可由 git ls-remote 讀回", "runtime_authorized": False, }, { "gate_id": "formal_main_release", "status": "passed_main_fast_forward_readback", "required_evidence": "main 已快轉到包含 Wazuh fix 的 commit;不得 force push", "runtime_authorized": False, }, { "gate_id": "production_deploy", "status": "passed_deploy_marker_readback", "required_evidence": "Gitea CD deploy marker 指向已合併 Wazuh fix 的 commit", "runtime_authorized": False, }, { "gate_id": "production_readback", "status": "passed_disabled_owner_gate_readback", "required_evidence": "python3 scripts/security/wazuh-readonly-production-readback.py --json 通過且不回 404", "runtime_authorized": False, }, { "gate_id": "wazuh_live_metadata_env", "status": "blocked_owner_gate_required", "required_evidence": "server-side env 與 owner gate;不得硬編 secret", "runtime_authorized": False, }, ], "execution_boundaries": { "runtime_execution_authorized": False, "production_deploy_authorized": False, "wazuh_api_live_query_authorized": False, "wazuh_active_response_authorized": False, "host_read_authorized": False, "host_write_authorized": False, "kali_active_scan_authorized": False, "secret_value_collection_allowed": False, "raw_wazuh_payload_storage_allowed": False, "internal_ip_public_display_allowed": False, "agent_identity_public_display_allowed": False, "force_push_allowed": False, "not_authorization": True, }, "missing_required_source_paths": missing_paths, "operator_interpretation": [ "此 gate 通過代表 source-side、feature branch、main release、deploy marker 與 production route readback 已完成。", "production route 回 200 只代表 IwoooS Wazuh read-only route 已部署;目前狀態仍為 disabled_waiting_iwooos_wazuh_owner_gate。", "不得把 route 200、UI 可見、agent transport 或 service active 當成 Wazuh manager registry 已驗收。", "live Wazuh metadata query 必須另走 owner gate 與 server-side env;active response、host write、Kali active scan 仍為 0 / false。", ], } def load_json(path: Path) -> dict[str, Any]: return json.loads(path.read_text(encoding="utf-8")) def validate(root: Path) -> None: report = build_report(root) snapshot_path = root / SNAPSHOT_PATH if not snapshot_path.exists(): raise SystemExit(f"BLOCKED Wazuh release gate snapshot missing: {SNAPSHOT_PATH.as_posix()}") snapshot = load_json(snapshot_path) expected_summary = report["summary"] for key, expected in expected_summary.items(): actual = snapshot.get("summary", {}).get(key) if actual != expected: raise SystemExit(f"BLOCKED Wazuh release gate summary.{key}: expected {expected!r}, got {actual!r}") if snapshot.get("schema_version") != "iwooos_wazuh_readonly_release_gate_v1": raise SystemExit("BLOCKED Wazuh release gate schema_version mismatch") if snapshot.get("status") != "released_waiting_wazuh_live_metadata_owner_gate": raise SystemExit("BLOCKED Wazuh release gate status mismatch") for key, value in snapshot.get("execution_boundaries", {}).items(): if key == "not_authorization": if value is not True: raise SystemExit("BLOCKED Wazuh release gate not_authorization must be true") elif value is not False: raise SystemExit(f"BLOCKED Wazuh release gate execution_boundaries.{key}: expected false") def main() -> int: parser = argparse.ArgumentParser(description="IwoooS Wazuh 只讀 API release gate") parser.add_argument("--root", default=".", help="repository root") parser.add_argument("--output", help="寫出 JSON 報告") parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用") args = parser.parse_args() root = Path(args.root).resolve() report = build_report(root, args.generated_at) if args.output: output = Path(args.output) output.parent.mkdir(parents=True, exist_ok=True) output.write_text(json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n", encoding="utf-8") validate(root) summary = report["summary"] print( "WAZUH_READONLY_RELEASE_GATE_OK " f"source={summary['source_side_fix_complete_count']} " f"push={summary['gitea_push_complete_count']} " f"main={summary['formal_main_release_complete_count']} " f"deploy={summary['production_deploy_complete_count']} " f"readback={summary['production_readback_passed_count']} " f"runtime_gate={summary['runtime_gate_count']}" ) return 0 if __name__ == "__main__": sys.exit(main())