Files
awoooi/scripts/security/iwooos-config-control-guard.py
Your Name 9b8ca2c509
All checks were successful
Code Review / ai-code-review (push) Successful in 24s
CD Pipeline / tests (push) Successful in 1m46s
CD Pipeline / build-and-deploy (push) Successful in 6m27s
CD Pipeline / post-deploy-checks (push) Successful in 2m59s
feat(iwooos): 強化 public gateway 緊急變更回補
2026-06-15 14:06:23 +08:00

536 lines
22 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 高價值配置控管 snapshot 維持只讀邊界。
本 guard 只讀取 repo 內已提交的 Markdown / JSON snapshot不連線主機、不
SSH、不讀 secret、不執行 Nginx / K8s / workflow / runner / backup / scan
動作。它的用途是把 Nginx、TLS、K8s、Secrets、runner、防火牆、backup、
monitoring、public runtime 與 agent-bounty-protocol 等高價值配置,固定成
可重複驗證的資安控管基線。
"""
from __future__ import annotations
import argparse
import json
from pathlib import Path
from typing import Any
EXPECTED_CATEGORIES = {
"nginx_public_gateway": 90,
"dns_tls_certbot": 78,
"k8s_production_gitops": 64,
"secret_metadata": 68,
"gitea_workflow_runner_source_control": 72,
"public_admin_api_runtime_config": 64,
"backup_restore_credential": 62,
"agent_bounty_protocol_runtime": 68,
"monitoring_alerting_observability": 66,
"docker_compose_systemd_host_config": 54,
"ssh_firewall_network_access": 62,
"ai_provider_model_routing": 60,
"product_surface_runtime_routes": 72,
"security_evidence_tooling": 86,
}
REQUIRED_C0_CATEGORIES = {
"nginx_public_gateway",
"dns_tls_certbot",
"k8s_production_gitops",
"secret_metadata",
"gitea_workflow_runner_source_control",
"public_admin_api_runtime_config",
"backup_restore_credential",
"agent_bounty_protocol_runtime",
}
REQUIRED_CONTROL_DOCS = [
"docs/security/HIGH-VALUE-CONFIG-CONTROL-COVERAGE.md",
"docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md",
"docs/security/PUBLIC-GATEWAY-PREFLIGHT-INVENTORY.md",
"docs/security/PUBLIC-GATEWAY-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/PUBLIC-GATEWAY-RENDERED-DIFF-ACCEPTANCE.md",
"docs/security/DOMAIN-TLS-CERTBOT-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/HOST-SERVICE-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/SSH-NETWORK-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/PORT-FIREWALL-CHANGE-EVIDENCE-ACCEPTANCE.md",
"docs/security/BACKUP-RESTORE-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/K8S-ARGOCD-OWNER-RESPONSE-ACCEPTANCE.md",
"docs/security/K8S-ARGOCD-CHANGE-EVIDENCE-ACCEPTANCE.md",
"docs/security/CD-RUNNER-SECRET-INJECTION-CHANGE-EVIDENCE-ACCEPTANCE.md",
"docs/security/PUBLIC-RUNTIME-CONFIG-CHANGE-EVIDENCE-ACCEPTANCE.md",
"docs/security/MONITORING-OWNER-REQUEST-DRAFT.md",
"docs/security/AGENT-BOUNTY-OWNER-REQUEST-DRAFT.md",
"docs/security/SECURITY-SUPPLY-CHAIN-CONTRACT-MANIFEST.md",
]
SUMMARY_ZERO_MARKERS = (
"_authorized_count",
"_executed_count",
"_received_count",
"_accepted_count",
"_allowed_count",
"_confirmed_count",
"_quarantined_count",
"_rejected_count",
"_ready_count",
)
SUMMARY_ZERO_KEYS = {
"action_button_count",
"request_sent_count",
"runtime_gate_count",
"runtime_approval_package_ready_count",
"supplement_requested_count",
"impact_supplement_requested_count",
}
TRUE_BOUNDARY_KEYS = {"not_authorization"}
FALSE_BOUNDARY_KEYS = [
"runtime_execution_authorized",
"host_write_authorized",
"nginx_reload_authorized",
"public_gateway_reload_authorized",
"certbot_renew_authorized",
"argocd_sync_authorized",
"kubectl_action_authorized",
"workflow_modification_authorized",
"runner_change_authorized",
"secret_value_collection_allowed",
"backup_run_authorized",
"restore_run_authorized",
"active_scan_authorized",
"action_buttons_allowed",
]
ARTIFACT_SPECS = [
{
"label": "public gateway owner response acceptance",
"path": "docs/security/public-gateway-owner-response-acceptance.snapshot.json",
"schema": "public_gateway_owner_response_acceptance_v1",
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"acceptance_candidates": 3,
"blocked_actions": 28,
"reviewer_checks": 22,
"outcome_lanes": 8,
"required_owner_response_fields": 22,
},
"summary_counts": {
"acceptance_candidate_count": 3,
"c0_acceptance_candidate_count": 2,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "public gateway rendered diff acceptance",
"path": "docs/security/public-gateway-rendered-diff-acceptance.snapshot.json",
"schema": "public_gateway_rendered_diff_acceptance_v1",
"status": "rendered_diff_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"blocked_actions": 22,
"reviewer_checks": 15,
"outcome_lanes": 8,
"required_evidence_fields": 14,
},
"summary_counts": {
"diff_acceptance_candidate_count": 3,
"c0_diff_acceptance_candidate_count": 2,
"rendered_diff_received_count": 0,
"rendered_diff_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "domain tls certbot owner response acceptance",
"path": "docs/security/domain-tls-certbot-owner-response-acceptance.snapshot.json",
"schema": "domain_tls_certbot_owner_response_acceptance_v1",
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"acceptance_candidates": 4,
"blocked_actions": 20,
"reviewer_checks": 13,
"outcome_lanes": 7,
"required_owner_response_fields": 13,
},
"summary_counts": {
"acceptance_candidate_count": 4,
"c0_acceptance_candidate_count": 4,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "host service owner response acceptance",
"path": "docs/security/host-service-owner-response-acceptance.snapshot.json",
"schema": "host_service_owner_response_acceptance_v1",
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"acceptance_candidates": 9,
"blocked_actions": 20,
"reviewer_checks": 14,
"outcome_lanes": 7,
},
"summary_counts": {
"acceptance_candidate_count": 9,
"write_capable_acceptance_candidate_count": 3,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "ssh network owner response acceptance",
"path": "docs/security/ssh-network-owner-response-acceptance.snapshot.json",
"schema": "ssh_network_owner_response_acceptance_v1",
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"acceptance_candidates": 16,
"blocked_actions": 22,
"reviewer_checks": 15,
"outcome_lanes": 7,
"required_owner_fields": 13,
},
"summary_counts": {
"acceptance_candidate_count": 16,
"write_capable_acceptance_candidate_count": 6,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "port firewall change evidence acceptance",
"path": "docs/security/port-firewall-change-evidence-acceptance.snapshot.json",
"schema": "port_firewall_change_evidence_acceptance_v1",
"status": "change_evidence_acceptance_ready_no_runtime_action",
"list_counts": {
"change_evidence_candidates": 14,
"blocked_actions": 28,
"reviewer_checks": 21,
"outcome_lanes": 9,
"required_evidence_fields": 21,
},
"summary_counts": {
"change_evidence_candidate_count": 14,
"write_capable_change_evidence_candidate_count": 6,
"change_evidence_received_count": 0,
"change_evidence_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "backup restore owner response acceptance",
"path": "docs/security/backup-restore-owner-response-acceptance.snapshot.json",
"schema": "backup_restore_owner_response_acceptance_v1",
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"acceptance_candidates": 38,
"blocked_actions": 22,
"reviewer_checks": 13,
"outcome_lanes": 7,
"required_owner_fields": 14,
},
"summary_counts": {
"acceptance_candidate_count": 38,
"write_capable_acceptance_candidate_count": 27,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "k8s argocd owner response acceptance",
"path": "docs/security/k8s-argocd-owner-response-acceptance.snapshot.json",
"schema": "k8s_argocd_owner_response_acceptance_v1",
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"acceptance_candidates": 4,
"blocked_actions": 18,
"reviewer_checks": 12,
"outcome_lanes": 7,
"required_owner_fields": 11,
},
"summary_counts": {
"acceptance_candidate_count": 4,
"c0_acceptance_candidate_count": 3,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "k8s argocd change evidence acceptance",
"path": "docs/security/k8s-argocd-change-evidence-acceptance.snapshot.json",
"schema": "k8s_argocd_change_evidence_acceptance_v1",
"status": "change_evidence_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"change_evidence_candidates": 4,
"blocked_actions": 28,
"reviewer_checks": 18,
"outcome_lanes": 8,
"required_evidence_fields": 18,
},
"summary_counts": {
"change_evidence_candidate_count": 4,
"c0_change_evidence_candidate_count": 3,
"change_evidence_received_count": 0,
"change_evidence_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "cd runner secret injection change evidence acceptance",
"path": "docs/security/cd-runner-secret-injection-change-evidence-acceptance.snapshot.json",
"schema": "cd_runner_secret_injection_change_evidence_acceptance_v1",
"status": "change_evidence_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"change_evidence_candidates": 5,
"blocked_actions": 32,
"reviewer_checks": 19,
"outcome_lanes": 8,
"required_evidence_fields": 19,
},
"summary_counts": {
"change_evidence_candidate_count": 5,
"c0_change_evidence_candidate_count": 4,
"change_evidence_received_count": 0,
"change_evidence_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "public runtime config change evidence acceptance",
"path": "docs/security/public-runtime-config-change-evidence-acceptance.snapshot.json",
"schema": "public_runtime_config_change_evidence_acceptance_v1",
"status": "change_evidence_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"change_evidence_candidates": 6,
"blocked_actions": 32,
"reviewer_checks": 21,
"outcome_lanes": 8,
"required_evidence_fields": 21,
},
"summary_counts": {
"change_evidence_candidate_count": 6,
"c0_change_evidence_candidate_count": 5,
"change_evidence_received_count": 0,
"change_evidence_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "monitoring owner request draft",
"path": "docs/security/monitoring-owner-request-draft.snapshot.json",
"schema": "monitoring_owner_request_draft_v1",
"status": "owner_request_draft_ready_not_dispatched",
"list_counts": {
"request_drafts": 60,
"blocked_actions": 24,
"required_owner_fields": 14,
},
"summary_counts": {
"request_draft_count": 60,
"write_capable_request_draft_count": 11,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "monitoring owner response acceptance",
"path": "docs/security/monitoring-owner-response-acceptance.snapshot.json",
"schema": "monitoring_owner_response_acceptance_v1",
"status": "owner_response_acceptance_ledger_ready_no_runtime_action",
"list_counts": {
"acceptance_candidates": 60,
"blocked_actions": 28,
"reviewer_checks": 15,
"outcome_lanes": 7,
"required_owner_fields": 14,
},
"summary_counts": {
"acceptance_candidate_count": 60,
"write_capable_acceptance_candidate_count": 11,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
{
"label": "agent bounty owner request draft",
"path": "docs/security/agent-bounty-owner-request-draft.snapshot.json",
"schema": "agent_bounty_owner_request_draft_v1",
"status": "owner_request_draft_ready_not_dispatched",
"list_counts": {
"request_drafts": 11,
"blocked_actions": 28,
"required_owner_fields": 22,
},
"summary_counts": {
"request_draft_count": 11,
"write_capable_request_draft_count": 8,
"owner_response_received_count": 0,
"owner_response_accepted_count": 0,
"runtime_gate_count": 0,
},
},
]
def load_json(path: Path) -> dict[str, Any]:
return json.loads(path.read_text(encoding="utf-8"))
def fail(message: str) -> None:
raise SystemExit(f"BLOCKED {message}")
def assert_equal(label: str, actual: Any, expected: Any) -> None:
if actual != expected:
fail(f"{label}: expected {expected!r}, got {actual!r}")
def assert_at_least(label: str, actual: int, expected_minimum: int) -> None:
if actual < expected_minimum:
fail(f"{label}: expected >= {expected_minimum!r}, got {actual!r}")
def assert_path_exists(root: Path, relative_path: str) -> None:
path = root / relative_path
if not path.exists():
fail(f"path missing: {relative_path}")
def assert_summary_zero_boundaries(label: str, summary: dict[str, Any]) -> None:
for key, value in summary.items():
if not isinstance(value, int):
continue
should_be_zero = key in SUMMARY_ZERO_KEYS or any(key.endswith(marker) for marker in SUMMARY_ZERO_MARKERS)
if should_be_zero and value != 0:
fail(f"{label}.summary.{key}: expected 0, got {value!r}")
def assert_execution_boundaries_false(label: str, data: dict[str, Any]) -> None:
boundaries = data.get("execution_boundaries", {})
if not isinstance(boundaries, dict):
return
for key, value in boundaries.items():
if key in TRUE_BOUNDARY_KEYS:
if value is not True:
fail(f"{label}.execution_boundaries.{key}: expected true, got {value!r}")
continue
if value is not False:
fail(f"{label}.execution_boundaries.{key}: expected false, got {value!r}")
def validate_coverage_snapshot(root: Path) -> None:
coverage_path = root / "docs/security/high-value-config-control-coverage.snapshot.json"
coverage = load_json(coverage_path)
summary = coverage["summary"]
assert_equal("coverage.schema_version", coverage["schema_version"], "high_value_config_control_coverage_v1")
assert_equal("coverage.status", coverage["status"], "coverage_matrix_ready")
assert_equal("coverage.source_category_definition", coverage["source_category_definition"], "scripts/security/high-value-config-change-gate.py")
assert_equal("coverage.summary.category_count", summary["category_count"], len(EXPECTED_CATEGORIES))
assert_equal("coverage.summary.registered_control_count", summary["registered_control_count"], len(EXPECTED_CATEGORIES))
assert_equal("coverage.summary.c0_category_count", summary["c0_category_count"], len(REQUIRED_C0_CATEGORIES))
assert_equal("coverage.summary.c1_category_count", summary["c1_category_count"], 4)
assert_equal("coverage.summary.c2_category_count", summary["c2_category_count"], 1)
assert_equal("coverage.summary.c3_category_count", summary["c3_category_count"], 1)
assert_equal("coverage.summary.owner_response_required_count", summary["owner_response_required_count"], len(EXPECTED_CATEGORIES))
assert_equal("coverage.summary.owner_response_received_count", summary["owner_response_received_count"], 0)
assert_equal("coverage.summary.owner_response_accepted_count", summary["owner_response_accepted_count"], 0)
assert_equal("coverage.summary.runtime_gate_count", summary["runtime_gate_count"], 0)
assert_equal("coverage.summary.action_button_count", summary["action_button_count"], 0)
assert_at_least("coverage.summary.average_coverage_percent", summary["average_coverage_percent"], 68)
assert_at_least("coverage.summary.needs_live_evidence_count", summary["needs_live_evidence_count"], 9)
categories = {item["category_id"]: item for item in coverage["coverage_categories"]}
assert_equal("coverage.category ids", set(categories), set(EXPECTED_CATEGORIES))
for category_id, expected_minimum in EXPECTED_CATEGORIES.items():
category = categories[category_id]
assert_at_least(f"coverage.{category_id}.coverage_percent", category["coverage_percent"], expected_minimum)
assert_equal(f"coverage.{category_id}.owner_response_required", category["owner_response_required"], True)
if category_id in REQUIRED_C0_CATEGORIES:
assert_equal(f"coverage.{category_id}.control_tier", category["control_tier"], "C0")
for ref in category.get("evidence_refs", []):
if ref.startswith(("docs/", "scripts/", "k8s/", "infra/", "ops/")):
assert_path_exists(root, ref)
assert_execution_boundaries_false("coverage", coverage)
boundaries = coverage["execution_boundaries"]
for key in FALSE_BOUNDARY_KEYS:
assert_equal(f"coverage.execution_boundaries.{key}", boundaries.get(key), False)
def validate_artifact_spec(root: Path, spec: dict[str, Any]) -> None:
path = root / spec["path"]
assert_path_exists(root, spec["path"])
data = load_json(path)
summary = data.get("summary", {})
label = spec["label"]
assert_equal(f"{label}.schema_version", data.get("schema_version"), spec["schema"])
assert_equal(f"{label}.status", data.get("status"), spec["status"])
for key, expected_count in spec.get("list_counts", {}).items():
value = data.get(key)
if not isinstance(value, list):
fail(f"{label}.{key}: expected list, got {type(value).__name__}")
assert_equal(f"{label}.{key}.count", len(value), expected_count)
for key, expected_value in spec.get("summary_counts", {}).items():
assert_equal(f"{label}.summary.{key}", summary.get(key), expected_value)
assert_summary_zero_boundaries(label, summary)
assert_execution_boundaries_false(label, data)
def validate_supply_chain_manifest(root: Path) -> None:
manifest = load_json(root / "docs/security/security-supply-chain-contract-manifest.snapshot.json")
assert_equal("supply_chain.schema_version", manifest["schema_version"], "security_supply_chain_contract_manifest_v1")
assert_equal("supply_chain.default_enforcement_level", manifest["default_enforcement_level"], "mirror_only")
assert_equal("supply_chain.contract_count", manifest["contract_count"], 36)
assert_equal("supply_chain.contract list count", len(manifest["contracts"]), manifest["contract_count"])
for item in manifest["contracts"]:
contract = item["contract"]
if not item.get("forbidden_actions"):
fail(f"supply_chain.{contract}.forbidden_actions: expected non-empty list")
for ref_key in ["snapshot_paths", "human_docs"]:
for ref in item.get(ref_key, []):
assert_path_exists(root, ref)
schema_path = item.get("schema_path")
if schema_path:
assert_path_exists(root, schema_path)
if item.get("consumption_mode") not in {"mirror_only", "read_only_policy", "approval_only", "suggest_only"}:
fail(f"supply_chain.{contract}.consumption_mode: unsafe value {item.get('consumption_mode')!r}")
def validate(root: Path) -> None:
for relative_path in REQUIRED_CONTROL_DOCS:
assert_path_exists(root, relative_path)
validate_coverage_snapshot(root)
validate_supply_chain_manifest(root)
for spec in ARTIFACT_SPECS:
validate_artifact_spec(root, spec)
def main() -> None:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--root",
default=Path(__file__).resolve().parents[2],
type=Path,
help="Repository root. Defaults to the current script's repository.",
)
args = parser.parse_args()
validate(args.root.resolve())
print("IWOOOS_CONFIG_CONTROL_GUARD_OK")
if __name__ == "__main__":
main()