#!/usr/bin/env python3 """GitHub 私有備援 evidence gate。 此工具只讀既有 GitHub target decision / owner response / approval package snapshot,產生「私有備援是否可進入執行」的 fail-closed gate。它不呼叫 GitHub / Gitea API、不建立 repo、不修改 visibility、不同步 refs、不讀取 或保存任何 secret value。 """ from __future__ import annotations import argparse import json from datetime import datetime, timezone from pathlib import Path from typing import Any SCHEMA_VERSION = "github_target_private_backup_evidence_gate_v1" FORBIDDEN_ACTIONS = [ "create_github_repo", "change_repo_visibility", "push_refs", "delete_refs", "force_push", "mirror_sync", "switch_github_primary", "disable_gitea", "workflow_modification", "workflow_trigger", "secret_value_collection", "private_clone_url_collection", ] def load_json(path: Path) -> dict[str, Any]: with path.open(encoding="utf-8") as handle: payload = json.load(handle) if not isinstance(payload, dict): raise ValueError(f"{path}: expected JSON object") return payload def build_target_gate(decision: dict[str, Any]) -> dict[str, Any]: approval_required = bool(decision.get("approval_required")) probe_status = str(decision.get("probe_status") or "unknown") github_repo = str(decision.get("github_repo") or "") if not approval_required: visibility_status = "external_scope_not_backup_target" blockers = ["external_scope_review_only"] elif probe_status.startswith("exists"): visibility_status = "blocked_public_probe_visible_private_evidence_required" blockers = [ "github_target_publicly_readable_by_unauthenticated_probe", "private_visibility_owner_evidence_missing", "safe_credential_metadata_missing", "refs_sync_not_authorized", ] elif probe_status == "not_found_or_private": visibility_status = "blocked_private_or_absent_not_verified" blockers = [ "not_found_or_private_is_not_private_verification", "private_visibility_owner_evidence_missing", "safe_credential_metadata_missing", "refs_sync_not_authorized", ] else: visibility_status = "blocked_probe_status_unknown" blockers = [ "github_target_probe_status_unknown", "private_visibility_owner_evidence_missing", "safe_credential_metadata_missing", "refs_sync_not_authorized", ] return { "github_repo": github_repo, "source_key": decision.get("source_key"), "approval_required": approval_required, "probe_status": probe_status, "target_state": decision.get("target_state"), "risk": decision.get("risk"), "visibility_evidence_status": visibility_status, "private_backup_verified": False, "private_visibility_owner_evidence_ref": None, "safe_credential_evidence_status": ( "not_required_external_scope" if not approval_required else "missing_safe_credential_metadata" ), "safe_credential_evidence_ref": None, "owner_response_accepted": False, "refs_sync_ready": False, "execution_ready": False, "blockers": blockers, "evidence_refs": decision.get("evidence_refs", []), "forbidden_actions": FORBIDDEN_ACTIONS, "repo_creation_authorized": False, "visibility_change_authorized": False, "refs_sync_authorized": False, "github_primary_switch_authorized": False, "secret_values_collected": False, } def count_targets(targets: list[dict[str, Any]], predicate) -> int: return sum(1 for target in targets if predicate(target)) def build_payload( decision_snapshot: dict[str, Any], owner_response_snapshot: dict[str, Any], approval_package_snapshot: dict[str, Any], ) -> dict[str, Any]: decisions = decision_snapshot.get("decisions") or [] if not isinstance(decisions, list): raise ValueError("github target decision snapshot missing decisions") targets = [build_target_gate(decision) for decision in decisions if isinstance(decision, dict)] approval_targets = [target for target in targets if target["approval_required"]] public_visible_targets = [ target for target in approval_targets if str(target["probe_status"]).startswith("exists") ] unknown_private_targets = [ target for target in approval_targets if target["probe_status"] == "not_found_or_private" ] owner_summary = owner_response_snapshot.get("summary") or {} package_items = approval_package_snapshot.get("approval_items") or [] received_count = int(owner_summary.get("received_response_count", 0) or 0) accepted_count = int(owner_summary.get("accepted_response_count", 0) or 0) status = "blocked_public_visibility_and_safe_credential_evidence_required" if not public_visible_targets: status = "blocked_private_visibility_and_safe_credential_evidence_required" summary = { "target_decision_count": len(targets), "approval_required_target_count": len(approval_targets), "approval_package_item_count": len(package_items), "public_probe_visible_target_count": len(public_visible_targets), "not_found_or_private_target_count": len(unknown_private_targets), "private_backup_verified_count": 0, "private_visibility_evidence_missing_count": len(approval_targets), "safe_credential_required_count": len(approval_targets), "safe_credential_accepted_evidence_count": 0, "owner_response_received_count": received_count, "owner_response_accepted_count": accepted_count, "execution_ready_count": 0, "blocked_target_count": len(approval_targets), "external_scope_target_count": count_targets(targets, lambda target: not target["approval_required"]), "forbidden_action_count": len(FORBIDDEN_ACTIONS), "repo_creation_authorized": False, "visibility_change_authorized": False, "refs_sync_authorized": False, "github_primary_switch_authorized": False, "workflow_modification_authorized": False, "workflow_trigger_authorized": False, "secret_value_collection_allowed": False, "private_clone_url_collection_allowed": False, "not_found_or_private_as_absent_allowed": False, "public_repo_allowed": False, } return { "schema_version": SCHEMA_VERSION, "generated_at": datetime.now(timezone.utc).isoformat(), "status": status, "mode": "read_only_private_backup_evidence_gate", "source_reviews": { "github_target_decision": "docs/security/github-target-decision.snapshot.json", "github_target_owner_decision_response": "docs/security/github-target-owner-decision-response.snapshot.json", "github_target_repo_approval_package": "docs/security/github-target-repo-approval-package.snapshot.json", }, "summary": summary, "targets": targets, "acceptance_requirements": [ "每個 approval-required GitHub target 必須有 private visibility owner evidence ref。", "公開 probe 可讀的 target 不得被視為符合私有備援要求。", "`not_found_or_private` 只代表未授權只讀 probe 看不到,不得當成 private verified 或 repo absent。", "safe credential evidence 只允許 credential storage / owner / scope / rotation metadata,不得收 token value。", "owner response accepted count 在 reviewer acceptance 前必須維持 0。", "private evidence 與 safe credential evidence 完整前不得建立 repo、改 visibility、push refs 或切 GitHub primary。", ], "rejection_rules": [ "任何 public repo 或 unauthenticated readable target 均不得標示 private_backup_verified=true。", "任何 token、PAT、private key、cookie、session、private clone credential 或 partial secret 必須拒收。", "任何 repo creation、visibility change、refs sync、force push、tag rewrite、workflow trigger 或 primary switch request 必須拒收。", "任何把 `not_found_or_private` 解讀為 repo 不存在或可建立新 repo 的 response 必須拒收。", ], "operation_boundaries": { "read_only_api_allowed": True, "github_api_write_allowed": False, "gitea_api_write_allowed": False, "repo_creation_allowed": False, "visibility_change_allowed": False, "refs_sync_allowed": False, "workflow_modification_allowed": False, "workflow_trigger_allowed": False, "github_primary_switch_allowed": False, "secret_value_collection_allowed": False, "private_clone_url_collection_allowed": False, }, "authorization_flags": { "runtime_execution_authorized": False, "repo_creation_authorized": False, "visibility_change_authorized": False, "refs_sync_authorized": False, "workflow_modification_authorized": False, "workflow_trigger_authorized": False, "github_primary_switch_authorized": False, "secret_values_collected": False, }, } def write_markdown(payload: dict[str, Any], path: Path) -> None: summary = payload["summary"] lines = [ "# GitHub Target Private Backup Evidence Gate", "", "| 項目 | 值 |", "|------|----|", f"| 狀態 | `{payload['status']}` |", f"| approval-required targets | `{summary['approval_required_target_count']}` |", f"| public probe visible | `{summary['public_probe_visible_target_count']}` |", f"| not_found_or_private | `{summary['not_found_or_private_target_count']}` |", f"| private backup verified | `{summary['private_backup_verified_count']}` |", f"| safe credential evidence | `{summary['safe_credential_accepted_evidence_count']}/{summary['safe_credential_required_count']}` |", f"| execution ready | `{summary['execution_ready_count']}` |", "", "## Target Gate", "", "| GitHub target | probe | visibility evidence | private verified | blockers |", "|---------------|-------|---------------------|------------------|----------|", ] for target in payload["targets"]: if not target["approval_required"]: continue lines.append( "| " + " | ".join( [ f"`{target['github_repo']}`", f"`{target['probe_status']}`", f"`{target['visibility_evidence_status']}`", f"`{str(target['private_backup_verified']).lower()}`", f"`{len(target['blockers'])}`", ] ) + " |" ) lines.extend( [ "", "## 不可誤讀", "", "- 本 gate 不是 GitHub repo creation / visibility change / refs sync 授權。", "- 公開 probe 可讀的 target 需要 private visibility owner evidence,不能標綠。", "- `not_found_or_private` 不能當成已 private,也不能當成 repo 不存在。", "- safe credential evidence 只收 metadata,不收 secret value。", "", ] ) path.write_text("\n".join(lines), encoding="utf-8") def main() -> int: parser = argparse.ArgumentParser() parser.add_argument("--root", default=".") parser.add_argument("--output-json", default="docs/security/github-target-private-backup-evidence-gate.snapshot.json") parser.add_argument("--output-md", default="docs/security/GITHUB-TARGET-PRIVATE-BACKUP-EVIDENCE-GATE.md") args = parser.parse_args() root = Path(args.root) payload = build_payload( load_json(root / "docs/security/github-target-decision.snapshot.json"), load_json(root / "docs/security/github-target-owner-decision-response.snapshot.json"), load_json(root / "docs/security/github-target-repo-approval-package.snapshot.json"), ) output_json = root / args.output_json output_md = root / args.output_md output_json.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") write_markdown(payload, output_md) summary = payload["summary"] print( "GITHUB_TARGET_PRIVATE_BACKUP_EVIDENCE_GATE_BLOCKED " f"targets={summary['approval_required_target_count']} " f"public_visible={summary['public_probe_visible_target_count']} " f"private_verified={summary['private_backup_verified_count']} " f"credential={summary['safe_credential_accepted_evidence_count']}/{summary['safe_credential_required_count']} " f"refs_sync={summary['refs_sync_authorized']}" ) return 0 if __name__ == "__main__": raise SystemExit(main())