Files
awoooi/scripts/security/wazuh-readonly-release-gate.py

207 lines
9.8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""
IwoooS Wazuh 只讀 API release gate。
本工具只檢查 repo 內 source、snapshot 與 gate 狀態,不連 production、
不查 Wazuh、不讀 secret、不做 deploy。目的在於固定「source-side 已完成」
與「Gitea push / production deploy / production readback 尚未完成」的界線。
"""
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": "blocked_waiting_gitea_push_and_production_deploy",
"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",
"production_readback_status": "predeploy_404_observed",
"gitea_push_blocker": "https_noninteractive_credential_required",
},
"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": 0,
"gitea_push_blocker_observed_count": 1,
"production_deploy_complete_count": 0,
"production_readback_passed_count": 0,
"predeploy_404_observed_count": 1,
"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": "blocked_credential_required",
"required_evidence": "具備正式權限的 lane 推送或合併 codex/iwooos-wazuh-boundary-guard-20260624",
"runtime_authorized": False,
},
{
"gate_id": "production_deploy",
"status": "blocked_waiting_release_lane",
"required_evidence": "Gitea CD / deploy marker 指向已合併 Wazuh fix 的 commit",
"runtime_authorized": False,
},
{
"gate_id": "production_readback",
"status": "blocked_waiting_deploy",
"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 通過不代表 production 已部署,只代表 source-side Wazuh read-only API 與 guard 可交接。",
"正式 release 前不得用 predeploy 404 當成功,也不得為了修 404 直接改 Nginx、Docker、K8s、firewall 或 Wazuh secret。",
"乾淨套用 proof 通過只代表 release patch 可落在最新主線,不代表已 push、已部署或已啟用 Wazuh live metadata。",
"live Wazuh metadata query 必須另走 owner gate 與 server-side envactive 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") != "blocked_waiting_gitea_push_and_production_deploy":
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"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())