Files
awoooi/scripts/security/github-target-private-backup-evidence-gate.py

299 lines
13 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
"""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())