feat(security): add GitHub private backup evidence gate
This commit is contained in:
298
scripts/security/github-target-private-backup-evidence-gate.py
Normal file
298
scripts/security/github-target-private-backup-evidence-gate.py
Normal file
@@ -0,0 +1,298 @@
|
||||
#!/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())
|
||||
Reference in New Issue
Block a user