Files
awoooi/scripts/security/cd-runner-secret-injection-change-evidence-acceptance.py
Your Name 5034e715fb
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
CD Pipeline / tests (push) Successful in 1m29s
CD Pipeline / build-and-deploy (push) Successful in 4m8s
CD Pipeline / post-deploy-checks (push) Successful in 1m29s
fix(iwooos): 新增 cd runner secret 變更證據驗收
2026-06-15 03:46:07 +08:00

516 lines
21 KiB
Python
Raw Permalink 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 CD / Runner / Secret 注入變更證據驗收只讀帳本產生器。
本工具讀取 repo 內既有 workflow / secret 名稱 snapshot 與 `.gitea/workflows`
檔案 metadata建立未來 CD、runner、secret injection 變更證據如何收件、
補件、拒收或交給 reviewer 的 metadata-only ledger。它不呼叫 Gitea / GitHub
API、不讀 secret store、不讀 secret value、不修改 workflow、不啟用 runner、
不 rotate secret、不 dispatch workflow、不觸發部署。
"""
from __future__ import annotations
import argparse
import json
import re
import subprocess
import sys
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",
"source_refs",
"control_tier",
"proposed_workflow_or_config_change_ref",
"workflow_diff_ref",
"runner_attestation_ref",
"secret_name_parity_ref",
"secret_injection_route_ref",
"deploy_marker_readback_ref",
"gitea_action_run_ref",
"guard_result_ref",
"log_redaction_review_ref",
"notification_route_owner_ref",
"blast_radius",
"maintenance_window",
"rollback_owner",
"rollback_plan_ref",
"postcheck_evidence_ref",
"affected_scope",
"redacted_evidence_refs",
"reviewer_outcome",
"not_approval",
]
REQUIRED_EVIDENCE_FIELDS = [
"proposed_workflow_or_config_change_ref",
"workflow_diff_ref",
"runner_attestation_ref",
"secret_name_parity_ref",
"secret_injection_route_ref",
"deploy_marker_readback_ref",
"gitea_action_run_ref",
"guard_result_ref",
"log_redaction_review_ref",
"notification_route_owner_ref",
"blast_radius",
"maintenance_window",
"rollback_owner",
"rollback_plan_ref",
"postcheck_evidence_ref",
"affected_scope",
"redacted_evidence_refs",
"reviewer_outcome",
"not_approval",
]
REVIEWER_CHECKS = [
{
"check_id": "change_ref_present",
"instruction": "必須有 proposed workflow / config / policy change ref不能只寫口頭同意。",
},
{
"check_id": "workflow_diff_ref_only",
"instruction": "只能收 workflow diff ref 或 committed patch ref不保存未脫敏 workflow payload。",
},
{
"check_id": "gitea_actions_run_readback_ref_shape",
"instruction": "Gitea Actions run readback 只能是 run id / job id / status ref不保存 token 或 cookie。",
},
{
"check_id": "deploy_marker_not_runtime_approval",
"instruction": "deploy marker 只能當部署證據,不代表資安 runtime approval。",
},
{
"check_id": "runner_owner_attestation_present",
"instruction": "runner label、executor、host alias、owner 與維護窗口必須可追溯。",
},
{
"check_id": "hosted_minutes_risk_review_present",
"instruction": "涉及 hosted runner 時必須標出額度與供應鏈風險,不得自動啟用。",
},
{
"check_id": "secret_name_parity_ref_only",
"instruction": "secret parity 只能保存 secret name / scope / present-absent / owner metadata。",
},
{
"check_id": "no_secret_value_or_hash",
"instruction": "不得出現 secret value、hash、masked token、partial token 或 credential derivative。",
},
{
"check_id": "secret_injection_path_called_out",
"instruction": "涉及 CD / K8s secret injection 時必須標出 injection path 與 owner不得讀 value。",
},
{
"check_id": "step_env_with_secret_guard_result_present",
"instruction": "必須附 `check-gitea-step-env-secrets` 或等價 guard result ref。",
},
{
"check_id": "telegram_route_owner_present",
"instruction": "涉及通知時必須確認 SRE route owner不得新增 legacy Telegram route。",
},
{
"check_id": "deploy_key_and_webhook_material_absent",
"instruction": "不得保存 webhook secret、deploy key private material、runner token 或 write token。",
},
{
"check_id": "branch_protection_or_required_checks_impact_called_out",
"instruction": "影響 required checks / CODEOWNERS / branch protection 時必須標出影響。",
},
{
"check_id": "blast_radius_present",
"instruction": "必須列出 repo、workflow、runner、secret metadata、notification、deploy path 影響範圍。",
},
{
"check_id": "maintenance_window_present",
"instruction": "任何 future workflow / runner / secret injection 變更都必須另有維護窗口。",
},
{
"check_id": "rollback_owner_present",
"instruction": "必須有 rollback owner 與回復方式;不能只寫『可回復』。",
},
{
"check_id": "postcheck_evidence_present",
"instruction": "需有 post-check evidence ref例如 guard result、run status、route smoke 或 notification receipt。",
},
{
"check_id": "no_execution_claim",
"instruction": "不能把本帳本、owner response、CD success 或 AwoooP approval 當執行批准。",
},
{
"check_id": "cross_project_sync_noted",
"instruction": "若影響 AwoooP、IwoooS、agent-bounty、監控或公開服務需有跨專案同步 ref。",
},
]
OUTCOME_LANES = [
{
"lane_id": "waiting_change_evidence",
"meaning": "尚未收到 CD / runner / secret injection 變更證據;所有 accepted / runtime count 維持 0。",
},
{
"lane_id": "quarantine_sensitive_payload",
"meaning": "收到 secret value、hash、runner token、webhook secret、private key 或未脫敏截圖時只能隔離。",
},
{
"lane_id": "reject_unredacted_or_runtime_claim",
"meaning": "出現未脫敏 payload 或把 evidence 誤當執行批准時直接拒收。",
},
{
"lane_id": "request_supplement",
"meaning": "缺 workflow diff、runner owner、secret parity、guard result、rollback 或 post-check 時要求補件。",
},
{
"lane_id": "ready_for_reviewer_acceptance",
"meaning": "metadata 合格後只能進 reviewer acceptance不得自動修改 workflow / secret / runner。",
},
{
"lane_id": "ready_for_runtime_approval_package",
"meaning": "reviewer 接受後也只能形成 runtime approval package不自動打開 gate。",
},
{
"lane_id": "waiting_maintenance_window",
"meaning": "若未來要改 workflow / runner / secret injection仍需獨立維護窗口。",
},
{
"lane_id": "waiting_runtime_gate",
"meaning": "change evidence accepted 後 runtime gate 仍等待獨立人工批准。",
},
]
BLOCKED_ACTIONS = [
"modify_workflow",
"workflow_dispatch_without_approval",
"enable_runner",
"change_runner_label",
"install_runner",
"restart_runner",
"use_runner_admin_token",
"enable_github_hosted_runner",
"collect_secret_value",
"collect_secret_hash",
"collect_partial_token",
"create_repo_secret",
"update_repo_secret",
"rotate_secret",
"delete_secret",
"read_secret_store",
"change_secret_injection_path",
"modify_webhook",
"change_webhook_secret",
"modify_deploy_key",
"rotate_deploy_key",
"change_branch_protection",
"change_codeowners",
"sync_refs",
"force_push",
"switch_github_primary",
"disable_gitea",
"run_cd_pipeline_as_action",
"inject_k8s_secret",
"argocd_sync",
"production_deploy",
"add_action_button",
]
EXECUTION_BOUNDARIES = {
"runtime_execution_authorized": False,
"workflow_modification_authorized": False,
"webhook_modification_authorized": False,
"runner_change_authorized": False,
"deploy_key_change_authorized": False,
"branch_protection_change_authorized": False,
"codeowners_change_authorized": False,
"repo_secret_change_authorized": False,
"secret_value_collection_allowed": False,
"secret_hash_collection_allowed": False,
"partial_token_collection_allowed": False,
"secret_rotation_authorized": False,
"secret_store_read_authorized": False,
"secret_injection_change_authorized": False,
"step_env_secret_allowed": False,
"github_hosted_runner_enable_authorized": False,
"gitea_action_dispatch_authorized": False,
"cd_pipeline_run_authorized": False,
"deploy_marker_write_authorized": False,
"k8s_secret_injection_authorized": False,
"argocd_sync_authorized": False,
"production_deploy_authorized": False,
"refs_sync_authorized": False,
"force_push_authorized": False,
"github_primary_switch_authorized": False,
"disable_gitea_authorized": False,
"action_buttons_allowed": False,
"not_authorization": True,
}
CANDIDATE_DEFINITIONS = [
{
"candidate_id": "cd_pipeline_deploy_marker_and_k8s_secret_injection",
"title": "CD pipeline / deploy marker / K8s secret 注入路徑",
"control_tier": "C0",
"risk": "HIGH",
"source_refs": [".gitea/workflows/cd.yaml", "scripts/ci/check-gitea-step-env-secrets.js"],
"affected_scope": "main push CD、Harbor build / push、K8s secret injection、ArgoCD deploy marker、post-deploy checks",
},
{
"candidate_id": "code_review_pipeline_secret_and_notification_surface",
"title": "Code Review workflow / notification / secret 使用面",
"control_tier": "C0",
"risk": "HIGH",
"source_refs": [".gitea/workflows/code-review.yaml", "scripts/ci/check-gitea-step-env-secrets.js"],
"affected_scope": "Code Review、Telegram / AWOOI API notification、deterministic review report、Gitea Actions status",
},
{
"candidate_id": "deploy_alerts_and_monitoring_route",
"title": "Deploy alerts workflow / monitoring notification route",
"control_tier": "C1",
"risk": "MEDIUM",
"source_refs": [".gitea/workflows/deploy-alerts.yaml", "scripts/ops/deploy-alerts.sh"],
"affected_scope": "Prometheus / Alertmanager rule deploy、notification route、SRE receipt evidence",
},
{
"candidate_id": "runner_label_and_executor_attestation",
"title": "Runner label / executor / hosted minutes owner attestation",
"control_tier": "C0",
"risk": "HIGH",
"source_refs": ["docs/security/source-control-workflow-secret-name-local-evidence.snapshot.json"],
"affected_scope": "awoooi-host、self-hosted、ubuntu-latest、harbor、k8s runner labels 與 executor owner",
},
{
"candidate_id": "repository_secret_name_parity_and_injection_owner",
"title": "Repository secret name parity / injection owner",
"control_tier": "C0",
"risk": "HIGH",
"source_refs": [
"docs/security/source-control-workflow-secret-name-export-request.snapshot.json",
"docs/security/source-control-workflow-secret-name-owner-response.snapshot.json",
],
"affected_scope": "9 個 in-scope repos 的 secret name parity、rotation owner、CD / K8s injection owner",
},
]
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 workflow_secret_names(root: Path, source_refs: list[str]) -> list[str]:
names: set[str] = set()
expression_re = re.compile(r"\$\{\{(.+?)\}\}")
secret_re = re.compile(
r"secrets(?:\.([A-Za-z_][A-Za-z0-9_]*)|\[['\"]([^'\"]+)['\"]\])"
)
for ref in source_refs:
path = root / ref
if not path.exists() or not path.is_file():
continue
if ".gitea/workflows/" not in ref and ".github/workflows/" not in ref:
continue
text = path.read_text(encoding="utf-8", errors="replace")
for expression in expression_re.findall(text):
if "secrets" not in expression:
continue
for match in secret_re.finditer(expression):
names.update(name for name in match.groups() if name)
return sorted(names)
def build_candidate(root: Path, definition: dict[str, Any]) -> dict[str, Any]:
source_refs = list(definition["source_refs"])
return {
"change_evidence_candidate_id": f"cd_runner_secret_injection:{definition['candidate_id']}",
"status": "waiting_change_evidence",
"title": definition["title"],
"control_tier": definition["control_tier"],
"risk": definition["risk"],
"source_refs": source_refs,
"workflow_secret_name_refs": workflow_secret_names(root, source_refs),
"write_capable": True,
"requires_runtime_approval_package": True,
"affected_scope": definition["affected_scope"],
"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_workflow_or_config_change_ref": None,
"workflow_diff_ref": None,
"runner_attestation_ref": None,
"secret_name_parity_ref": None,
"secret_injection_route_ref": None,
"deploy_marker_readback_ref": None,
"gitea_action_run_ref": None,
"guard_result_ref": None,
"log_redaction_review_ref": None,
"notification_route_owner_ref": None,
"blast_radius": "pending_change_evidence",
"maintenance_window": "pending_change_evidence",
"rollback_owner": "pending_change_evidence",
"rollback_plan_ref": None,
"postcheck_evidence_ref": None,
"redacted_evidence_refs": [],
"reviewer_outcome": "waiting_change_evidence",
"not_approval": True,
"change_evidence_received": False,
"change_evidence_accepted": False,
"change_evidence_rejected": False,
"change_evidence_quarantined": False,
"workflow_diff_accepted": False,
"runner_attestation_accepted": False,
"secret_name_parity_accepted": False,
"secret_injection_route_accepted": False,
"deploy_marker_readback_accepted": False,
"guard_result_accepted": False,
"postcheck_evidence_accepted": False,
"runtime_approval_package_ready": False,
"runtime_gate": False,
**{
key: value
for key, value in EXECUTION_BOUNDARIES.items()
if key != "not_authorization"
},
}
def build_report(root: Path, generated_at: str | None) -> dict[str, Any]:
local_evidence = load_json(
root / "docs/security/source-control-workflow-secret-name-local-evidence.snapshot.json"
)
export_request = load_json(
root / "docs/security/source-control-workflow-secret-name-export-request.snapshot.json"
)
owner_response = load_json(
root / "docs/security/source-control-workflow-secret-name-owner-response.snapshot.json"
)
local_summary = local_evidence["summary"]
export_summary = export_request["summary"]
owner_summary = owner_response["summary"]
candidates = [build_candidate(root, item) for item in CANDIDATE_DEFINITIONS]
c0_candidates = [item for item in candidates if item["control_tier"] == "C0"]
c1_candidates = [item for item in candidates if item["control_tier"] == "C1"]
report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds")
return {
"schema_version": "cd_runner_secret_injection_change_evidence_acceptance_v1",
"generated_at": report_time,
"git_commit": git_short_sha(root),
"status": "change_evidence_acceptance_ledger_ready_no_runtime_action",
"mode": "metadata_only_no_secret_value_no_workflow_change",
"source_paths": [
"docs/security/source-control-workflow-secret-name-local-evidence.snapshot.json",
"docs/security/source-control-workflow-secret-name-export-request.snapshot.json",
"docs/security/source-control-workflow-secret-name-owner-response.snapshot.json",
".gitea/workflows/cd.yaml",
".gitea/workflows/code-review.yaml",
".gitea/workflows/deploy-alerts.yaml",
"scripts/ci/check-gitea-step-env-secrets.js",
],
"summary": {
"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": sum(1 for item in candidates if item["write_capable"]),
"local_workflow_file_count": local_summary["workflow_file_count"],
"gitea_workflow_file_count": local_summary["gitea_workflow_file_count"],
"github_workflow_file_count": local_summary["github_workflow_file_count"],
"local_referenced_secret_name_count": local_summary["unique_secret_name_count"],
"runner_label_count": local_summary["runner_label_count"],
"export_request_count": export_summary["export_request_count"],
"export_lane_count": export_summary["export_lane_count"],
"owner_response_template_count": owner_summary["response_template_count"],
"owner_response_received_count": owner_summary["received_response_count"],
"owner_response_accepted_count": owner_summary["accepted_response_count"],
"required_evidence_field_count": len(REQUIRED_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,
"workflow_diff_accepted_count": 0,
"runner_attestation_accepted_count": 0,
"secret_name_parity_accepted_count": 0,
"secret_injection_route_accepted_count": 0,
"deploy_marker_readback_accepted_count": 0,
"guard_result_accepted_count": 0,
"postcheck_evidence_accepted_count": 0,
"runtime_approval_package_ready_count": 0,
"runtime_gate_count": 0,
"action_button_count": 0,
"secret_metadata_coverage_percent_before_acceptance": 66,
"secret_metadata_coverage_percent_after_acceptance": 68,
"gitea_workflow_runner_coverage_percent_before_acceptance": 70,
"gitea_workflow_runner_coverage_percent_after_acceptance": 72,
},
"execution_boundaries": EXECUTION_BOUNDARIES,
"change_evidence_fields": CHANGE_EVIDENCE_FIELDS,
"required_evidence_fields": REQUIRED_EVIDENCE_FIELDS,
"reviewer_checks": REVIEWER_CHECKS,
"outcome_lanes": OUTCOME_LANES,
"blocked_actions": BLOCKED_ACTIONS,
"change_evidence_candidates": candidates,
"operator_interpretation": [
"此帳本只描述 CD / runner / secret injection 變更證據如何收件與拒收,不是 workflow 變更批准。",
"secret 只能以名稱、scope、present-absent、owner 與 redacted evidence ref 呈現,不得保存 value、hash 或 partial token。",
"deploy marker、Gitea Actions success、AwoooP approval 與 UI 可見狀態都不能被解讀成 runtime gate 已開。",
"未來若要修改 workflow、runner、secret injection、webhook、deploy key 或 branch protection仍需獨立 owner response、維護窗口、rollback owner 與 runtime approval package。",
],
}
def main() -> int:
parser = argparse.ArgumentParser(description="CD / Runner / Secret 注入變更證據驗收只讀帳本")
parser.add_argument("--root", default=".", help="repo 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)
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(
"CD_RUNNER_SECRET_INJECTION_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__":
raise SystemExit(main())