Files
awoooi/scripts/security/k8s-argocd-change-evidence-acceptance.py
Your Name f055a97387
All checks were successful
Code Review / ai-code-review (push) Successful in 22s
CD Pipeline / tests (push) Successful in 1m51s
CD Pipeline / build-and-deploy (push) Successful in 7m49s
CD Pipeline / post-deploy-checks (push) Successful in 2m46s
fix(iwooos): 新增 k8s gitops 變更證據驗收
2026-06-15 02:30:02 +08:00

519 lines
21 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 K8s / ArgoCD GitOps 變更證據驗收只讀帳本產生器。
本工具讀取 repo-only K8s / ArgoCD manifest inventory 與 owner response
acceptance snapshot建立未來 GitOps 變更證據如何收件、補件、拒收或進入
人工 reviewer review 的 metadata-only ledger。它不讀 live cluster、不呼叫
ArgoCD API、不 sync、不執行 kubectl、不套用 manifest、不讀 secret value。
"""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from collections import Counter
from datetime import datetime, timedelta, timezone
from pathlib import Path
from typing import Any
TAIPEI = timezone(timedelta(hours=8))
CHANGE_EVIDENCE_FIELDS = [
"change_evidence_candidate_id",
"acceptance_candidate_id",
"group_id",
"root",
"control_tier",
"proposed_commit_ref",
"rendered_manifest_diff_ref",
"argocd_application_readback_ref",
"argocd_sync_revision_ref",
"health_before_ref",
"health_after_ref",
"rollout_status_ref",
"service_route_smoke_ref",
"metrics_alert_ref",
"secret_metadata_parity_ref",
"blast_radius",
"maintenance_window",
"rollback_revision",
"postcheck_owner",
"reviewer_outcome",
"not_approval",
]
REQUIRED_EVIDENCE_FIELDS = [
"proposed_commit_ref",
"rendered_manifest_diff_ref",
"argocd_application_readback_ref",
"argocd_sync_revision_ref",
"health_before_ref",
"health_after_ref",
"rollout_status_ref",
"service_route_smoke_ref",
"metrics_alert_ref",
"secret_metadata_parity_ref",
"blast_radius",
"maintenance_window",
"rollback_revision",
"postcheck_owner",
"affected_scope",
"redacted_evidence_refs",
"reviewer_outcome",
"not_approval",
]
REVIEWER_CHECKS = [
{
"check_id": "change_ref_present",
"instruction": "必須有 proposed commit / PR / patch ref不能只寫聊天同意。",
},
{
"check_id": "group_scope_matches_inventory",
"instruction": "affected scope 必須能對回 manifest inventory group 與 root path。",
},
{
"check_id": "rendered_manifest_diff_ref_only",
"instruction": "只能收 rendered manifest diff ref不保存完整 manifest payload。",
},
{
"check_id": "argocd_readback_ref_shape",
"instruction": "ArgoCD app / sync revision / health readback 只能是 owner-provided redacted ref。",
},
{
"check_id": "secret_value_absent",
"instruction": "不得出現 Secret value、token、kubeconfig、private key、cookie、DSN 或 credential derivative。",
},
{
"check_id": "network_policy_impact_called_out",
"instruction": "涉及 NetworkPolicy、Service、Ingress 或 NodePort 時必須標出 route / port 影響。",
},
{
"check_id": "rbac_impact_called_out",
"instruction": "涉及 RBAC / ServiceAccount 時必須標出權限影響與 rollback revision。",
},
{
"check_id": "workload_rollout_plan_present",
"instruction": "涉及 Deployment、CronJob、HPA 或 image 時必須有 rollout / rollback / smoke plan。",
},
{
"check_id": "health_before_after_present",
"instruction": "需有 health before / after evidence ref沒有 before evidence 只能要求補件。",
},
{
"check_id": "route_smoke_or_not_applicable",
"instruction": "公開 / admin / API route 受影響時需有 smoke ref不適用時要說明原因。",
},
{
"check_id": "metric_alert_postcheck_present",
"instruction": "需有 metric、alert、事件或 log post-check evidence ref。",
},
{
"check_id": "blast_radius_present",
"instruction": "必須列 namespace、workload、route、port、secret metadata、backup / restore 影響範圍。",
},
{
"check_id": "maintenance_window_present",
"instruction": "任何 future sync / apply / rollout 都必須另有維護窗口。",
},
{
"check_id": "rollback_revision_present",
"instruction": "必須有 rollback revision 或明確禁止窗口與 follow-up owner。",
},
{
"check_id": "postcheck_owner_present",
"instruction": "post-check owner 必須可追溯,不能只寫 team generic approval。",
},
{
"check_id": "no_runtime_action_claim",
"instruction": "不能把本帳本、owner response、CD success 或 deploy marker 當 runtime approval。",
},
{
"check_id": "counts_transition_safe",
"instruction": "只有 reviewer record 可更新 received / accepted / rejected不得同時開 runtime gate。",
},
{
"check_id": "cross_project_sync_noted",
"instruction": "若影響 AwoooP、agent-bounty、監控、公開 gateway 或外部產品,需有跨專案同步 ref。",
},
]
OUTCOME_LANES = [
{
"lane_id": "waiting_change_evidence",
"meaning": "尚未收到 GitOps 變更證據;所有 accepted / runtime count 維持 0。",
},
{
"lane_id": "quarantine_sensitive_payload",
"meaning": "收到 manifest payload、kubeconfig、Secret value 或可還原 credential 時只能隔離。",
},
{
"lane_id": "reject_unredacted_or_runtime_claim",
"meaning": "出現未脫敏 payload、secret value 或把 evidence 誤當執行批准時直接拒收。",
},
{
"lane_id": "request_supplement",
"meaning": "缺 before / after、rollback、blast radius、post-check 或 route smoke 時要求補件。",
},
{
"lane_id": "ready_for_reviewer_acceptance",
"meaning": "metadata 合格後,只能進 reviewer acceptance不得自動 sync 或 apply。",
},
{
"lane_id": "ready_for_runtime_approval_package",
"meaning": "reviewer 接受後也只能形成 runtime approval package不自動打開 gate。",
},
{
"lane_id": "waiting_maintenance_window",
"meaning": "若未來要 sync / rollout / rollback仍需獨立維護窗口。",
},
{
"lane_id": "waiting_runtime_gate",
"meaning": "即使 change evidence acceptedruntime gate 仍等待獨立人工批准。",
},
]
BLOCKED_ACTIONS = [
"argocd_api_read_without_approval",
"live_cluster_read_without_approval",
"argocd_sync",
"argocd_rollback",
"kubectl_apply",
"kubectl_patch",
"kubectl_delete",
"kubectl_rollout_restart",
"helm_upgrade",
"kustomize_image_set",
"secret_value_collection",
"kubeconfig_collection",
"live_cluster_write",
"manual_pod_restart",
"scale_workload",
"change_network_policy",
"change_rbac",
"change_service_type",
"change_nodeport",
"change_ingress_route",
"change_configmap_runtime",
"change_secret_metadata_without_owner",
"restore_backup",
"velero_restore",
"prometheus_rule_apply",
"mark_change_evidence_accepted_without_reviewer_record",
"open_runtime_gate",
"add_action_button",
]
def git_short_sha(root: Path) -> str:
try:
result = subprocess.run(
["git", "rev-parse", "--short", "HEAD"],
cwd=root,
check=True,
capture_output=True,
text=True,
)
return result.stdout.strip()
except Exception:
return "unknown"
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def tags_by_group(inventory: dict[str, Any]) -> dict[str, list[str]]:
grouped: dict[str, set[str]] = {}
for row in inventory.get("manifest_rows", []):
group_id = row["group_id"]
grouped.setdefault(group_id, set()).update(row.get("gate_tags", []))
return {key: sorted(value) for key, value in grouped.items()}
def summary_by_group(inventory: dict[str, Any]) -> dict[str, dict[str, Any]]:
return {item["group_id"]: item for item in inventory.get("group_summaries", [])}
def change_candidate(
acceptance: dict[str, Any],
group_summary: dict[str, Any],
group_tags: list[str],
) -> dict[str, Any]:
group_id = acceptance["group_id"]
is_write_capable = bool(
{"network_policy", "rbac", "workload_or_schedule", "backup_restore", "apply_capable_script"}
& set(group_tags)
)
return {
"change_evidence_candidate_id": f"k8s_argocd_change_evidence:{group_id}",
"status": "waiting_change_evidence",
"acceptance_candidate_id": acceptance["acceptance_candidate_id"],
"group_id": group_id,
"root": acceptance["root"],
"control_tier": acceptance["control_tier"],
"file_count": group_summary.get("file_count", 0),
"yaml_manifest_file_count": group_summary.get("yaml_manifest_file_count", 0),
"supporting_source_file_count": group_summary.get("supporting_source_file_count", 0),
"top_level_kind_marker_count": group_summary.get("top_level_kind_marker_count", 0),
"gate_tags": group_tags,
"write_capable": is_write_capable,
"requires_runtime_approval_package": True,
"change_evidence_fields": CHANGE_EVIDENCE_FIELDS,
"required_evidence_fields": REQUIRED_EVIDENCE_FIELDS,
"reviewer_checks": [item["check_id"] for item in REVIEWER_CHECKS],
"outcome_lanes": [item["lane_id"] for item in OUTCOME_LANES],
"blocked_actions": BLOCKED_ACTIONS,
"proposed_commit_ref": None,
"rendered_manifest_diff_ref": None,
"argocd_application_readback_ref": None,
"argocd_sync_revision_ref": None,
"health_before_ref": None,
"health_after_ref": None,
"rollout_status_ref": None,
"service_route_smoke_ref": None,
"metrics_alert_ref": None,
"secret_metadata_parity_ref": None,
"blast_radius": "pending_change_evidence",
"maintenance_window": "pending_change_evidence",
"rollback_revision": "pending_change_evidence",
"postcheck_owner": "pending_change_evidence",
"reviewer_outcome": "waiting_change_evidence",
"not_approval": True,
"change_evidence_received": False,
"change_evidence_accepted": False,
"change_evidence_rejected": False,
"change_evidence_quarantined": False,
"supplement_requested": False,
"proposed_commit_ref_accepted": False,
"rendered_manifest_diff_accepted": False,
"argocd_application_readback_accepted": False,
"argocd_sync_revision_accepted": False,
"health_before_accepted": False,
"health_after_accepted": False,
"rollout_status_accepted": False,
"service_route_smoke_accepted": False,
"metrics_alert_accepted": False,
"secret_metadata_parity_accepted": False,
"blast_radius_accepted": False,
"maintenance_window_accepted": False,
"rollback_revision_accepted": False,
"postcheck_owner_accepted": False,
"cross_project_sync_accepted": False,
"runtime_approval_package_ready": False,
"argocd_api_read_authorized": False,
"argocd_api_read_executed": False,
"argocd_sync_authorized": False,
"argocd_sync_executed": False,
"kubectl_action_authorized": False,
"kubectl_action_executed": False,
"helm_upgrade_authorized": False,
"helm_upgrade_executed": False,
"live_cluster_read_authorized": False,
"live_cluster_read_executed": False,
"network_policy_apply_authorized": False,
"nodeport_change_authorized": False,
"rbac_change_authorized": False,
"secret_value_collection_allowed": False,
"production_write_authorized": False,
"runtime_gate": False,
"action_buttons_allowed": False,
}
def build_report(
root: Path,
inventory: dict[str, Any],
owner_acceptance: dict[str, Any],
generated_at: str | None,
) -> dict[str, Any]:
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
tag_map = tags_by_group(inventory)
group_map = summary_by_group(inventory)
candidates = [
change_candidate(item, group_map.get(item["group_id"], {}), tag_map.get(item["group_id"], []))
for item in owner_acceptance.get("acceptance_candidates", [])
]
c0_candidates = [item for item in candidates if item["control_tier"] == "C0"]
c1_candidates = [item for item in candidates if item["control_tier"] == "C1"]
tag_counter: Counter[str] = Counter(tag for item in candidates for tag in item["gate_tags"])
write_capable_candidates = [item for item in candidates if item["write_capable"]]
inventory_summary = inventory.get("summary", {})
return {
"schema_version": "k8s_argocd_change_evidence_acceptance_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"source_inventory_schema_version": inventory.get("schema_version"),
"source_inventory_status": inventory.get("status"),
"source_acceptance_schema_version": owner_acceptance.get("schema_version"),
"source_acceptance_status": owner_acceptance.get("status"),
"status": "change_evidence_acceptance_ledger_ready_no_runtime_action",
"summary": {
"source_scan_group_count": inventory_summary.get("scan_group_count", 0),
"source_manifest_file_count": inventory_summary.get("file_count", 0),
"source_yaml_manifest_file_count": inventory_summary.get("yaml_manifest_file_count", 0),
"source_c0_file_count": inventory_summary.get("c0_file_count", 0),
"source_acceptance_candidate_count": owner_acceptance.get("summary", {}).get(
"acceptance_candidate_count", 0
),
"deployment_object_count": inventory_summary.get("deployment_object_count", 0),
"cronjob_object_count": inventory_summary.get("cronjob_object_count", 0),
"secret_object_count": inventory_summary.get("secret_object_count", 0),
"network_policy_object_count": inventory_summary.get("network_policy_object_count", 0),
"rbac_object_count": inventory_summary.get("rbac_object_count", 0),
"argocd_application_count": inventory_summary.get("argocd_application_count", 0),
"prometheus_rule_count": inventory_summary.get("prometheus_rule_count", 0),
"change_evidence_candidate_count": len(candidates),
"c0_change_evidence_candidate_count": len(c0_candidates),
"c1_change_evidence_candidate_count": len(c1_candidates),
"write_capable_candidate_count": len(write_capable_candidates),
"gate_tag_count": len(tag_counter),
"required_evidence_field_count": len(REQUIRED_EVIDENCE_FIELDS),
"change_evidence_field_count": len(CHANGE_EVIDENCE_FIELDS),
"reviewer_check_count": len(REVIEWER_CHECKS),
"outcome_lane_count": len(OUTCOME_LANES),
"blocked_action_count": len(BLOCKED_ACTIONS),
"change_evidence_received_count": 0,
"change_evidence_accepted_count": 0,
"change_evidence_rejected_count": 0,
"change_evidence_quarantined_count": 0,
"supplement_requested_count": 0,
"rendered_manifest_diff_accepted_count": 0,
"argocd_application_readback_accepted_count": 0,
"argocd_sync_revision_accepted_count": 0,
"health_before_accepted_count": 0,
"health_after_accepted_count": 0,
"rollout_status_accepted_count": 0,
"service_route_smoke_accepted_count": 0,
"metrics_alert_accepted_count": 0,
"secret_metadata_parity_accepted_count": 0,
"blast_radius_accepted_count": 0,
"maintenance_window_accepted_count": 0,
"rollback_revision_accepted_count": 0,
"postcheck_owner_accepted_count": 0,
"cross_project_sync_accepted_count": 0,
"runtime_approval_package_ready_count": 0,
"argocd_api_read_authorized_count": 0,
"argocd_api_read_executed_count": 0,
"argocd_sync_authorized_count": 0,
"argocd_sync_executed_count": 0,
"kubectl_action_authorized_count": 0,
"kubectl_action_executed_count": 0,
"helm_upgrade_authorized_count": 0,
"helm_upgrade_executed_count": 0,
"live_cluster_read_authorized_count": 0,
"live_cluster_read_executed_count": 0,
"network_policy_apply_authorized_count": 0,
"nodeport_change_authorized_count": 0,
"rbac_change_authorized_count": 0,
"secret_value_collection_allowed_count": 0,
"production_write_authorized_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"coverage_percent_before_acceptance": 62,
"coverage_percent_after_acceptance": 64,
},
"execution_boundaries": {
"change_evidence_received": False,
"change_evidence_accepted": False,
"rendered_manifest_diff_accepted": False,
"argocd_application_readback_accepted": False,
"argocd_sync_revision_accepted": False,
"health_before_after_accepted": False,
"rollout_status_accepted": False,
"service_route_smoke_accepted": False,
"metrics_alert_accepted": False,
"secret_metadata_parity_accepted": False,
"blast_radius_accepted": False,
"maintenance_window_accepted": False,
"rollback_revision_accepted": False,
"postcheck_owner_accepted": False,
"cross_project_sync_accepted": False,
"runtime_approval_package_ready": False,
"argocd_api_read_authorized": False,
"argocd_api_read_executed": False,
"argocd_sync_authorized": False,
"argocd_sync_executed": False,
"kubectl_action_authorized": False,
"kubectl_action_executed": False,
"helm_upgrade_authorized": False,
"helm_upgrade_executed": False,
"live_cluster_read_authorized": False,
"live_cluster_read_executed": False,
"network_policy_apply_authorized": False,
"nodeport_change_authorized": False,
"rbac_change_authorized": False,
"secret_value_collection_allowed": False,
"production_write_authorized": False,
"runtime_execution_authorized": False,
"action_buttons_allowed": False,
"not_authorization": True,
},
"change_evidence_fields": CHANGE_EVIDENCE_FIELDS,
"required_evidence_fields": REQUIRED_EVIDENCE_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"gate_tag_counts": dict(sorted(tag_counter.items())),
"change_evidence_candidates": candidates,
"next_steps": [
"要求 owner 提供 proposed commit、rendered manifest diff、ArgoCD app / sync revision、health before / after、rollout、route smoke、metrics / alert、rollback 與 post-check evidence ref。",
"收到資料後先做敏感 payload 隔離與 reviewer acceptance不得自動 sync、apply、restart、scale 或改 NetworkPolicy / RBAC / NodePort。",
"若未來要進 runtime approval package必須另有維護窗口、rollback revision、跨專案同步與 production post-check gate。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="IwoooS K8s / ArgoCD 變更證據驗收只讀帳本產生器")
parser.add_argument("--root", default=".", help="repo root")
parser.add_argument(
"--inventory-report",
default="docs/security/k8s-argocd-manifest-inventory.snapshot.json",
help="k8s-argocd-manifest-inventory.py 輸出的 JSON",
)
parser.add_argument(
"--acceptance-report",
default="docs/security/k8s-argocd-owner-response-acceptance.snapshot.json",
help="k8s-argocd-owner-response-acceptance.py 輸出的 JSON",
)
parser.add_argument("--output", help="寫出 JSON 報告")
parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用")
args = parser.parse_args()
root = Path(args.root).resolve()
inventory = load_json(root / args.inventory_report)
acceptance = load_json(root / args.acceptance_report)
report = build_report(root, inventory, acceptance, args.generated_at)
payload = json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True)
if args.output:
output = Path(args.output)
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(payload + "\n", encoding="utf-8")
else:
print(payload)
summary = report["summary"]
print(
"K8S_ARGOCD_CHANGE_EVIDENCE_ACCEPTANCE_OK "
f"candidates={summary['change_evidence_candidate_count']} "
f"c0={summary['c0_change_evidence_candidate_count']} "
f"write_capable={summary['write_capable_candidate_count']} "
f"checks={summary['reviewer_check_count']} "
f"lanes={summary['outcome_lane_count']} "
f"accepted={summary['change_evidence_accepted_count']} "
f"runtime_gate={summary['runtime_gate_count']}",
file=sys.stderr,
)
return 0
if __name__ == "__main__":
sys.exit(main())