diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index eb183872c..aaa24aeb5 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,34 @@ +## 2026-06-11|IwoooS 高價值配置變更 Gate 第一波 + +**背景**:接續「所有重要配置都要被資安控管」要求,前一波已建立高價值配置清冊與 Nginx 只讀漂移偵測器。本階段補上可重跑的分類型 Gate,讓 reviewer 不必只靠人工記憶判斷變更是否碰到 Nginx、DNS / TLS、K8s、secret、workflow、runner、backup、monitoring、host service、network、AI provider 或 agent-bounty-protocol runtime 邊界。 + +**完成內容:** +- 新增 `scripts/security/high-value-config-change-gate.py`,可讀取 git diff 或手動指定檔案,分類 C0 / C1 / C2 / C3 高價值配置,並列出 owner response、rollback、redacted evidence 與驗證需求。 +- 新增 `docs/security/HIGH-VALUE-CONFIG-CHANGE-GATE.md`,記錄低摩擦分類 Gate 的目的、指令、owner 欄位、必須維持 false 的旗標、判讀規則與禁止事項。 +- 新增 `docs/security/high-value-config-change-gate.snapshot.json`,固定本階段只命中 C3 security evidence / tooling,`impacted_c0_category_count=0`、`impacted_c1_category_count=0`、`runtime_execution_authorized=false`。 +- 更新 `docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md`,將 P0.2「高價值配置變更分類 Gate」完成度列為 `100%`,owner response evidence JSON 欄位檢查列為 `70%`,CI blocking / workflow gate 仍列為 `0%`。 + +**本地驗證:** +- `python3 -m py_compile scripts/security/high-value-config-change-gate.py` 通過。 +- `python3 -m json.tool docs/security/high-value-config-change-gate.snapshot.json` 通過。 +- C0 smoke 通過:`infra/ansible/roles/nginx/templates/188-all-sites.conf.j2` 被分類為 `impacted_c0_category_count=1`。 +- C3 smoke 通過:`scripts/security/high-value-config-change-gate.py` 被分類為 `strongest_tier=C3`,`impacted_c0_category_count=0`、`impacted_c1_category_count=0`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .` 通過。 +- `python3 scripts/security/source-control-owner-response-guard.py --root .` 通過。 +- `node scripts/ci/check-gitea-step-env-secrets.js` 通過。 +- `python3 scripts/ops/doc-secrets-sanity-check.py docs .gitea` 通過,`scanned_files=642`。 +- P0 高風險字串掃描通過:關閉 SSH host key 驗證的逐字參數、舊 Gitea token、Grafana 密碼常值、舊 MinIO credential、舊 MinIO token、Prometheus inline bearer token 均未命中。 +- `git diff --check` 通過。 + +**完成度與邊界:** +- 高價值配置 path pattern 分類:`100%`。 +- owner response evidence JSON 欄位檢查:`70%`;支援欄位與 false flag 檢查,尚未接正式收件 API 或 AwoooP queue。 +- CI blocking / workflow gate:`0%`;本階段刻意不修改 `.gitea/workflows`。 +- live runtime 驗證:`0%`;本工具只分類,不做 live probe。 +- 本階段沒有前端變更,production desktop / mobile 頁面驗證不適用。 +- Nginx `nginx -t`、reload、restart、DNS 修改、TLS renew、ArgoCD sync、kubectl、SSH 主機修改、workflow 修改、runner 啟用、secret rotation、active scan、agent-bounty runtime、payout / withdrawal、deploy 或 runtime execution:全部未執行。 +- IwoooS 整體仍維持 `64%`;active runtime gate 仍為 `0`;owner response received / accepted 仍為 `0 / false`。 + ## 2026-06-11|IwoooS Nginx 只讀漂移偵測器 repo-only 第一波 **背景**:接續高價值配置控管清冊,P0 下一步是先把 Nginx 這個最容易被手動改動的公開入口配置做成可重跑的只讀漂移偵測框架。使用者已要求 Nginx 必須有資安機制控管;本階段仍不 SSH、不讀 live、不 reload、不修改主機。 diff --git a/docs/security/HIGH-VALUE-CONFIG-CHANGE-GATE.md b/docs/security/HIGH-VALUE-CONFIG-CHANGE-GATE.md new file mode 100644 index 000000000..48f0df428 --- /dev/null +++ b/docs/security/HIGH-VALUE-CONFIG-CHANGE-GATE.md @@ -0,0 +1,132 @@ +# IwoooS 高價值配置變更 Gate + +| 項目 | 內容 | +|------|------| +| 日期 | 2026-06-11 | +| 狀態 | `classification_gate_ready` | +| 工具 | `scripts/security/high-value-config-change-gate.py` | +| Snapshot | `docs/security/high-value-config-change-gate.snapshot.json` | +| runtime gate | `0` | + +## 1. 目的 + +此 Gate 將「所有重要配置都要被控管」落成可重跑的只讀分類流程。它會讀取 git diff 或手動指定檔案,判斷是否碰到 C0 / C1 / C2 / C3 高價值配置,並列出後續 owner response、rollback、redacted evidence 與驗證需求。 + +本階段是低摩擦分類 Gate,不接 CI blocking,不修改 workflow、不讀 secret value、不 SSH、不 reload、不部署、不開 runtime gate。 + +## 2. 納管配置 + +| 優先 | 等級 | 配置類別 | +|------|------|----------| +| P0 | C0 | Nginx / reverse proxy / public route | +| P0 | C0 | DNS / TLS / certbot / certificate path | +| P0 | C0 | K8s / ArgoCD / production manifests | +| P0 | C0 | Secret metadata / injection / redaction | +| P0 | C0 | Gitea workflow / runner / deploy key / webhook / branch protection | +| P0 | C0 | Public / admin / API / frontend runtime config | +| P0 | C0 | Backup / restore / escrow / retention | +| P0 | C0 | `agent-bounty-protocol` runtime / MCP / A2A / treasury boundary | +| P1 | C1 | Prometheus / Alertmanager / Grafana / SigNoz / Sentry / Langfuse | +| P1 | C1 | Docker Compose / systemd / host service config | +| P1 | C1 | SSH / sudoers / known_hosts / firewall / WireGuard / NodePort | +| P1 | C1 | AI provider / model routing / Ollama proxy / cost and privacy | +| P2 | C2 | AWOOOI / AwoooP / IwoooS / VibeWork / other product runtime routes | +| P3 | C3 | Security evidence / snapshot / guard tooling | + +## 3. 指令 + +檢查目前 commit 相對前一個 commit: + +```bash +python3 scripts/security/high-value-config-change-gate.py \ + --root . \ + --base HEAD~1 \ + --head HEAD +``` + +手動分類單一檔案: + +```bash +python3 scripts/security/high-value-config-change-gate.py \ + --root . \ + --changed-file infra/ansible/roles/nginx/templates/188-all-sites.conf.j2 +``` + +更新 committed snapshot: + +```bash +python3 scripts/security/high-value-config-change-gate.py \ + --root . \ + --base HEAD~1 \ + --head HEAD \ + --generated-at 2026-06-11T12:30:00+08:00 \ + --output docs/security/high-value-config-change-gate.snapshot.json +``` + +未來若要升級成更嚴格的人工審核,可提供 owner response evidence JSON: + +```bash +python3 scripts/security/high-value-config-change-gate.py \ + --root . \ + --base gitea/main \ + --head HEAD \ + --evidence /path/to/redacted-owner-response.json \ + --fail-on-missing-evidence +``` + +## 4. owner response 欄位 + +所有 C0 / C1 高價值配置變更至少要補: + +1. `owner_role_team` +2. `decision` +3. `decision_reason` +4. `affected_scope` +5. `redacted_evidence_refs` +6. `followup_owner` +7. `rollback_owner` +8. `maintenance_window` +9. `validation_plan` + +這些欄位只是讓 reviewer 可以判斷,不等同 runtime 授權。 + +## 5. 必須維持 false + +| flag | 要求 | +|------|------| +| `runtime_execution_authorized` | `false` | +| `host_write_authorized` | `false` | +| `secret_value_collection_allowed` | `false` | +| `workflow_modification_authorized` | `false` | +| `runner_change_authorized` | `false` | +| `refs_sync_authorized` | `false` | +| `force_push_authorized` | `false` | +| `active_scan_authorized` | `false` | +| `action_buttons_allowed` | `false` | + +## 6. 判讀規則 + +| 狀態 | 意義 | 可做事項 | +|------|------|----------| +| `impacted_c0_category_count > 0` | 變更碰到公開入口、secret、部署、備份、agent runtime 等最高風險配置 | 先建立 owner response packet;不可直接 reload、deploy、sync 或修改主機 | +| `impacted_c1_category_count > 0` | 變更碰到監控、主機服務、網路、AI provider 等近程高風險配置 | 建立 maintenance window、rollback owner 與驗證計畫 | +| 只有 C2 | 產品 runtime route 或前端呈現變更 | 需要產品 owner、i18n、desktop / mobile smoke | +| 只有 C3 | 文件、snapshot、guard 或 evidence tooling | 跑 guard、JSON parse、doc secret sanity;不可提高 runtime gate | + +## 7. 邊界 + +1. 本工具不接 CI blocking。 +2. 本工具不修改 `.gitea/workflows`。 +3. 本工具不讀 secret value、hash、partial token、private key、runner token 或 webhook secret。 +4. 本工具不 SSH、不執行 `nginx -t`、不 reload / restart。 +5. 本工具不做 DNS 修改、TLS renew、ArgoCD sync、kubectl、active scan 或 agent-bounty runtime execution。 +6. IwoooS UI 可顯示分類結果,但不得把分類結果當 runtime 授權。 + +## 8. 完成度 + +| 工作 | 完成度 | 說明 | +|------|--------|------| +| 高價值配置 path pattern 分類 | `100%` | 已覆蓋 Nginx、DNS / TLS、K8s、secret、workflow、runner、backup、monitoring、host service、network、AI provider、agent-bounty-protocol 與產品 route | +| owner response 欄位檢查 | `70%` | 支援 evidence JSON 欄位與 false flag 檢查;尚未接正式收件 API | +| CI blocking | `0%` | 本階段刻意不接,避免初期摩擦過大 | +| live runtime 驗證 | `0%` | 本工具只分類,不執行 live probe | diff --git a/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md b/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md index 3394bad8a..5ccab14c2 100644 --- a/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md +++ b/docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md @@ -3,7 +3,7 @@ | 項目 | 內容 | |------|------| | 日期 | 2026-06-11 | -| 狀態 | `inventory_and_control_policy_ready` | +| 狀態 | `inventory_and_classification_gate_ready` | | 範圍 | AWOOOI / IwoooS 全產品重要配置 | | 本階段模式 | source-control 修補 + 只讀盤點,不做 live reload / restart / sync | | runtime gate | `0` | @@ -31,6 +31,7 @@ | P0 | `ops/monitoring/discover_docker.py` SSH host key 驗證 | 仍使用關閉 host key 驗證的參數 | MITM 或錯誤主機信任風險 | 已改為 `BatchMode=yes` + `accept-new`;後續升級 pinned known_hosts | | P0 | `apps/api/src/api/v1/monitoring.py` Grafana 探測認證 | 程式碼內有 Grafana Basic Auth 常值 | API 程式碼保存 credential,且會被複製到後續部署 | 已改為 `settings.GRAFANA_API_KEY` Bearer token;未設定時不送 Authorization header | | P1 | Nginx 188 / 110 live conf drift | repo 有 templates,但尚未自動比較 live hash / rendered hash | 手改後 repo 不知道,下一次 Ansible 可能覆蓋或保留錯誤路由 | 新增為 P0 後續任務:只讀 drift detector | +| P1 | 高價值配置變更 Gate | 已有 C0-C3 清冊與 Hard Rule,但原本缺少可重跑 path 分類 | reviewer 只能靠人工記憶判斷 Nginx、workflow、secret、K8s、DNS、AI provider 是否需 owner gate | 已新增 `scripts/security/high-value-config-change-gate.py`;本階段只分類,不接 CI blocking | | P1 | DNS / TLS / certbot | 多產品共用 188 / 110 public gateway,憑證路徑與 renewal 仍分散在 runbook / template | 憑證過期、錯誤 cert path、ACME challenge 被覆蓋會造成公開服務中斷 | 納入 C0,需建立 domain / cert / renewal 清冊 | | P1 | workflow / runner / deploy key / secret name | 已有 Gitea / GitHub readiness 盤點,但尚未把配置變更和 IwoooS 高價值配置共用 gate 合併 | workflow 或 runner 改錯會直接影響部署與 secret 注入 | 納入 C0,維持只讀 owner response,不收 secret value | | P1 | Docker Compose / systemd live config | 110 / 188 多服務由 compose、systemd 與 recovery scripts 管理 | restart policy、port、volume、env 改動會影響 Harbor、Sentry、Langfuse、Gitea、agent-bounty-protocol | 納入 C1,先做只讀 inventory | @@ -127,6 +128,9 @@ Nginx 是目前必須最先資安控管的配置,原因是它同時控制公 | Nginx 控管機制定義 | `100%` | 已定義 source-of-truth、live path、gate、drift 原則 | | source-control P0 止血 | `100%` | 已清掉本波掃到的 token 範例、Grafana 密碼常值與 SSH host key 關閉 | | repo-only Nginx drift detector | `100%` | 已新增 `scripts/security/nginx-config-drift-detector.py` 與 repo source-of-truth snapshot | +| 高價值配置變更分類 Gate | `100%` | 已新增 `scripts/security/high-value-config-change-gate.py`,可用 git diff 或手動檔案分類 C0/C1/C2/C3 並列出 owner / rollback / evidence / 驗證欄位 | +| owner response evidence JSON 欄位檢查 | `70%` | Gate 可檢查必要欄位與 false flags;尚未接正式收件 API 或 AwoooP queue | +| CI blocking / workflow gate | `0%` | 本階段刻意不修改 `.gitea/workflows`,避免初期資安流程摩擦過大 | | owner-provided live Nginx file compare | `70%` | 工具可吃 owner 匯出的 live conf 檔比較;本階段不主動 SSH 取得 | | live Nginx evidence collection | `0%` | 尚未 SSH / Ansible check-mode / live hash;需 owner 與維護窗口規則 | | live Nginx reload / restart | `0%` | 未授權,未執行 | @@ -135,13 +139,14 @@ Nginx 是目前必須最先資安控管的配置,原因是它同時控制公 ## 7. 下一階段優先順序 -1. P0:由 owner 提供脫敏 live Nginx conf 匯出檔,重跑 compare mode;不自動覆寫、不 reload。 -2. P0:補 DNS / TLS / certbot domain inventory,先只讀,不 renew、不 reload。 -3. P0:把 workflow / runner / secret name owner response 與高價值配置 C0 gate 串成同一個 IwoooS 狀態。 -4. P1:盤點 110 / 188 Docker Compose 與 systemd live config,標記 Harbor、Sentry、Langfuse、Gitea、agent-bounty-protocol 影響面。 -5. P1:盤點 Prometheus / Alertmanager / Grafana / SigNoz / Sentry 設定 drift 與 secret externalization。 -6. P1:補 Kali 112、111、168 維護窗口 owner 欄位,仍不做 upgrade / restart / scan。 -7. P2:把 `/zh-TW/iwooos` 配置控管摘要產品化,但不得顯示內部工作對話、token、secret 或可執行按鈕。 +1. P0:把高價值配置 Gate 的分類結果接成 owner response packet 草案;先只讀,不接 blocking CI。 +2. P0:由 owner 提供脫敏 live Nginx conf 匯出檔,重跑 compare mode;不自動覆寫、不 reload。 +3. P0:補 DNS / TLS / certbot domain inventory,先只讀,不 renew、不 reload。 +4. P0:把 workflow / runner / secret name owner response 與高價值配置 C0 gate 串成同一個 IwoooS 狀態。 +5. P1:盤點 110 / 188 Docker Compose 與 systemd live config,標記 Harbor、Sentry、Langfuse、Gitea、agent-bounty-protocol 影響面。 +6. P1:盤點 Prometheus / Alertmanager / Grafana / SigNoz / Sentry 設定 drift 與 secret externalization。 +7. P1:補 Kali 112、111、168 維護窗口 owner 欄位,仍不做 upgrade / restart / scan。 +8. P2:把 `/zh-TW/iwooos` 配置控管摘要產品化,但不得顯示內部工作對話、token、secret 或可執行按鈕。 ## 8. 邊界 diff --git a/docs/security/high-value-config-change-gate.snapshot.json b/docs/security/high-value-config-change-gate.snapshot.json new file mode 100644 index 000000000..e69127f01 --- /dev/null +++ b/docs/security/high-value-config-change-gate.snapshot.json @@ -0,0 +1,665 @@ +{ + "changed_files": [ + { + "categories": [ + { + "category_id": "security_evidence_tooling", + "control_tier": "C3", + "label": "Security evidence / snapshot / guard tooling", + "priority": "P3", + "required_gate": "security_evidence_owner_review_required", + "required_validation": [ + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase" + ] + } + ], + "matched": true, + "path": "docs/LOGBOOK.md", + "strongest_priority": "P3", + "strongest_tier": "C3" + }, + { + "categories": [ + { + "category_id": "security_evidence_tooling", + "control_tier": "C3", + "label": "Security evidence / snapshot / guard tooling", + "priority": "P3", + "required_gate": "security_evidence_owner_review_required", + "required_validation": [ + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase" + ] + } + ], + "matched": true, + "path": "docs/security/HIGH-VALUE-CONFIG-CHANGE-GATE.md", + "strongest_priority": "P3", + "strongest_tier": "C3" + }, + { + "categories": [ + { + "category_id": "security_evidence_tooling", + "control_tier": "C3", + "label": "Security evidence / snapshot / guard tooling", + "priority": "P3", + "required_gate": "security_evidence_owner_review_required", + "required_validation": [ + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase" + ] + } + ], + "matched": true, + "path": "docs/security/IWOOOS-CONFIG-CONTROL-INVENTORY.md", + "strongest_priority": "P3", + "strongest_tier": "C3" + }, + { + "categories": [ + { + "category_id": "security_evidence_tooling", + "control_tier": "C3", + "label": "Security evidence / snapshot / guard tooling", + "priority": "P3", + "required_gate": "security_evidence_owner_review_required", + "required_validation": [ + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase" + ] + } + ], + "matched": true, + "path": "docs/security/high-value-config-change-gate.snapshot.json", + "strongest_priority": "P3", + "strongest_tier": "C3" + }, + { + "categories": [ + { + "category_id": "security_evidence_tooling", + "control_tier": "C3", + "label": "Security evidence / snapshot / guard tooling", + "priority": "P3", + "required_gate": "security_evidence_owner_review_required", + "required_validation": [ + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase" + ] + } + ], + "matched": true, + "path": "scripts/security/high-value-config-change-gate.py", + "strongest_priority": "P3", + "strongest_tier": "C3" + } + ], + "control_category_inventory": [ + { + "category_id": "nginx_public_gateway", + "control_tier": "C0", + "label": "Nginx / reverse proxy / public route", + "path_patterns": [ + "infra/ansible/roles/nginx/templates/*.j2", + "infra/ansible/playbooks/nginx-sync.yml", + "ops/nginx/**", + "docs/runbooks/disaster-recovery/DR-Nginx.md" + ], + "priority": "P0", + "required_gate": "public_gateway_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "rendered_diff", + "nginx_t", + "affected_route_smoke", + "admin_route_smoke_if_affected", + "acme_path_smoke_if_affected", + "rollback_ref" + ] + }, + { + "category_id": "dns_tls_certbot", + "control_tier": "C0", + "label": "DNS / TLS / certbot / certificate path", + "path_patterns": [ + "docs/runbooks/REGISTRY-CERTBOT-188.md", + "docs/runbooks/**/*CERTBOT*.md", + "docs/runbooks/**/*TLS*.md", + "ops/**/*cert*", + "ops/**/*tls*", + "infra/**/*cert*", + "infra/**/*tls*", + "k8s/**/*tls*" + ], + "priority": "P0", + "required_gate": "domain_tls_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "domain_inventory", + "certificate_path_check", + "renewal_window", + "acme_path_smoke", + "public_https_smoke", + "rollback_ref" + ] + }, + { + "category_id": "k8s_production_gitops", + "control_tier": "C0", + "label": "K8s / ArgoCD / production manifests", + "path_patterns": [ + "k8s/awoooi-prod/**", + "k8s/argocd/**", + "k8s/velero/**", + "k8s/monitoring/**" + ], + "priority": "P0", + "required_gate": "gitops_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "gitops_diff", + "argocd_health_readback", + "sync_authorization_check", + "rollback_revision", + "post_deploy_health_if_executed" + ] + }, + { + "category_id": "secret_metadata", + "control_tier": "C0", + "label": "Secret metadata / injection / redaction", + "path_patterns": [ + "k8s/**/*secret*", + "k8s/**/*Secret*", + ".gitea/workflows/*.yml", + ".gitea/workflows/*.yaml", + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + "docs/runbooks/SECRETS-MANAGEMENT.md", + "docs/security/SECRETS_REFERENCE.md" + ], + "priority": "P0", + "required_gate": "secret_metadata_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "secret_name_parity", + "metadata_only_check", + "no_secret_value_check", + "rotation_owner", + "injection_readback_if_deployed" + ] + }, + { + "category_id": "gitea_workflow_runner_source_control", + "control_tier": "C0", + "label": "Gitea workflow / runner / deploy key / webhook / branch protection", + "path_patterns": [ + ".gitea/workflows/**", + ".github/workflows/**", + "ops/runner/**", + "scripts/setup-runner*.sh", + "scripts/**/*runner*", + "docs/security/SOURCE-CONTROL-*", + "docs/security/GITEA-*", + "docs/security/GITHUB-*" + ], + "priority": "P0", + "required_gate": "workflow_source_control_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "workflow_diff", + "runner_label_owner", + "deploy_key_metadata_only", + "webhook_metadata_only", + "branch_protection_metadata", + "no_token_value_check" + ] + }, + { + "category_id": "public_admin_api_runtime_config", + "control_tier": "C0", + "label": "Public / admin / API / frontend runtime config", + "path_patterns": [ + "apps/web/next.config.*", + "apps/web/src/lib/config.*", + "apps/api/src/core/config.py", + "apps/api/src/api/v1/monitoring.py", + "apps/api/src/middleware/**", + "apps/web/src/middleware.*" + ], + "priority": "P0", + "required_gate": "public_runtime_config_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "public_url_check", + "frontend_internal_ip_ban", + "cors_boundary_check", + "admin_auth_boundary_check", + "desktop_mobile_smoke_if_frontend" + ] + }, + { + "category_id": "backup_restore_credential", + "control_tier": "C0", + "label": "Backup / restore / escrow / retention", + "path_patterns": [ + "scripts/backup/**", + "k8s/velero/**", + "docs/runbooks/disaster-recovery/**", + "docs/runbooks/**/*RESTORE*.md", + "docs/runbooks/**/*BACKUP*.md" + ], + "priority": "P0", + "required_gate": "backup_restore_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "credential_absence_check", + "restore_drill_gate", + "retention_policy", + "escrow_owner", + "rollback_ref" + ] + }, + { + "category_id": "agent_bounty_protocol_runtime", + "control_tier": "C0", + "label": "agent-bounty-protocol runtime / MCP / A2A / treasury boundary", + "path_patterns": [ + "docs/security/AGENT-BOUNTY-IWOOOS-ONBOARDING-HANDOFF.md", + "docs/security/agent-bounty-iwooos-onboarding-handoff.snapshot.json", + "docs/schemas/agent_bounty_iwooos_onboarding_handoff_v1.schema.json", + "agent-bounty-protocol/**" + ], + "priority": "P0", + "required_gate": "agent_bounty_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "repo_owner_scope", + "runtime_gate_false", + "no_payout_or_treasury_execution", + "no_mcp_a2a_runtime_execution", + "redacted_evidence_refs_only" + ] + }, + { + "category_id": "monitoring_alerting_observability", + "control_tier": "C1", + "label": "Prometheus / Alertmanager / Grafana / SigNoz / Sentry / Langfuse", + "path_patterns": [ + "ops/monitoring/**", + "ops/alertmanager/**", + "ops/grafana/**", + "ops/signoz/**", + "ops/sentry-self-hosted/**", + "infra/langfuse/**", + "k8s/monitoring/**" + ], + "priority": "P1", + "required_gate": "monitoring_observability_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "rule_diff", + "receiver_diff", + "reload_gate", + "failure_notification_policy", + "public_route_smoke_if_affected" + ] + }, + { + "category_id": "docker_compose_systemd_host_config", + "control_tier": "C1", + "label": "Docker Compose / systemd / host service config", + "path_patterns": [ + "docker-compose*.yml", + "docker-compose*.yaml", + "ops/**/docker-compose*.yml", + "ops/**/docker-compose*.yaml", + "scripts/reboot-recovery/**", + "scripts/**/*.service", + "ops/**/*.service" + ], + "priority": "P1", + "required_gate": "host_service_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "port_conflict_check", + "volume_diff", + "env_name_diff", + "restart_window", + "rollback_owner" + ] + }, + { + "category_id": "ssh_firewall_network_access", + "control_tier": "C1", + "label": "SSH / sudoers / known_hosts / firewall / WireGuard / NodePort", + "path_patterns": [ + "infra/ansible/inventory/**", + "infra/ansible/**/*known_hosts*", + "infra/ansible/**/*ssh*", + "scripts/**/*ssh*", + "scripts/**/*known_hosts*", + "ops/**/*wireguard*", + "ops/**/*firewall*", + "k8s/**/*network*", + "k8s/**/*Network*" + ], + "priority": "P1", + "required_gate": "network_access_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "target_whitelist", + "host_key_policy", + "ingress_egress_matrix", + "rollback_owner", + "maintenance_window" + ] + }, + { + "category_id": "ai_provider_model_routing", + "control_tier": "C1", + "label": "AI provider / model routing / Ollama proxy / cost and privacy", + "path_patterns": [ + "apps/api/src/services/ai_providers/**", + "apps/api/src/services/**/*model*", + "apps/api/src/services/**/*provider*", + "infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2", + "docs/ai/**", + "docs/**/*Ollama*" + ], + "priority": "P1", + "required_gate": "ai_provider_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "dry_run", + "benchmark", + "cost_review", + "privacy_review", + "fallback_order_check" + ] + }, + { + "category_id": "product_surface_runtime_routes", + "control_tier": "C2", + "label": "AWOOOI / AwoooP / IwoooS / VibeWork / other product runtime routes", + "path_patterns": [ + "apps/web/src/app/**", + "apps/web/messages/*.json", + "docs/security/VIBEWORK-IWOOOS-ONBOARDING-HANDOFF.md", + "docs/security/vibework-iwooos-onboarding-handoff.snapshot.json" + ], + "priority": "P2", + "required_gate": "product_surface_owner_response_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "product_boundary_check", + "i18n_traditional_chinese_check", + "no_internal_transcript_check", + "desktop_mobile_smoke_if_frontend" + ] + }, + { + "category_id": "security_evidence_tooling", + "control_tier": "C3", + "label": "Security evidence / snapshot / guard tooling", + "path_patterns": [ + "docs/security/**", + "docs/schemas/**", + "scripts/security/**", + "docs/LOGBOOK.md" + ], + "priority": "P3", + "required_gate": "security_evidence_owner_review_required", + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "required_validation": [ + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase" + ] + } + ], + "diff": { + "base": null, + "changed_file_count": 5, + "head": "HEAD" + }, + "execution_boundaries": { + "active_scan_executed": false, + "dns_tls_modified": false, + "host_write_executed": false, + "nginx_reload_executed": false, + "runtime_deploy_executed": false, + "runtime_gate_opened": false, + "secret_value_collected": false, + "ssh_executed": false, + "workflow_modified": false + }, + "generated_at": "2026-06-11T12:30:00+08:00", + "git_commit": "e1cacdf3", + "impacted_categories": [ + { + "category_id": "security_evidence_tooling", + "control_tier": "C3", + "label": "Security evidence / snapshot / guard tooling", + "priority": "P3", + "required_gate": "security_evidence_owner_review_required", + "required_validation": [ + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase" + ] + } + ], + "mode": "classification_only", + "next_steps": [ + "若 impacted_c0_category_count > 0,先建立 owner response packet,不得直接 reload、deploy、sync 或修改主機。", + "owner response 必須包含 owner role / team、decision、decision reason、affected scope、redacted evidence refs、followup owner、rollback owner、maintenance window 與 validation plan。", + "若只有 C3 security evidence / tooling,仍需跑 guard 與 doc secret sanity,但不得藉此提高 runtime gate。" + ], + "owner_evidence_validation": { + "complete": false, + "invalid_false_flags": [], + "missing_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "note": "未提供 owner response evidence;本階段只能分類,不得執行 runtime 變更。", + "provided": false + }, + "required_false_flags": [ + "runtime_execution_authorized", + "host_write_authorized", + "secret_value_collection_allowed", + "workflow_modification_authorized", + "runner_change_authorized", + "refs_sync_authorized", + "force_push_authorized", + "active_scan_authorized", + "action_buttons_allowed" + ], + "required_owner_fields": [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan" + ], + "schema_version": "high_value_config_change_gate_v1", + "summary": { + "changed_file_count": 5, + "impacted_c0_category_count": 0, + "impacted_c1_category_count": 0, + "impacted_category_count": 1, + "matched_high_value_file_count": 5, + "owner_evidence_complete": false, + "owner_evidence_provided": false, + "runtime_execution_authorized": false, + "strongest_priority": "P3", + "strongest_tier": "C3" + } +} diff --git a/scripts/security/high-value-config-change-gate.py b/scripts/security/high-value-config-change-gate.py new file mode 100644 index 000000000..3cd513eee --- /dev/null +++ b/scripts/security/high-value-config-change-gate.py @@ -0,0 +1,628 @@ +#!/usr/bin/env python3 +""" +IwoooS 高價值配置變更 Gate。 + +本工具只讀取 git diff 或手動指定的檔案路徑,將變更分類到 C0/C1/C2/C3 +配置控管範圍,並輸出 owner response、rollback、evidence 與驗證需求。 +它不修改 workflow、不讀 secret value、不 SSH、不碰 runtime。 +""" + +from __future__ import annotations + +import argparse +import fnmatch +import json +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta, timezone +from pathlib import Path +from typing import Any + + +TAIPEI = timezone(timedelta(hours=8)) + +TIER_ORDER = {"C0": 0, "C1": 1, "C2": 2, "C3": 3} +PRIORITY_ORDER = {"P0": 0, "P1": 1, "P2": 2, "P3": 3} + +REQUIRED_OWNER_FIELDS = [ + "owner_role_team", + "decision", + "decision_reason", + "affected_scope", + "redacted_evidence_refs", + "followup_owner", + "rollback_owner", + "maintenance_window", + "validation_plan", +] + +REQUIRED_FALSE_FLAGS = [ + "runtime_execution_authorized", + "host_write_authorized", + "secret_value_collection_allowed", + "workflow_modification_authorized", + "runner_change_authorized", + "refs_sync_authorized", + "force_push_authorized", + "active_scan_authorized", + "action_buttons_allowed", +] + + +@dataclass(frozen=True) +class ControlCategory: + category_id: str + label: str + priority: str + control_tier: str + required_gate: str + patterns: tuple[str, ...] + required_validation: tuple[str, ...] + + +CATEGORIES = [ + ControlCategory( + category_id="nginx_public_gateway", + label="Nginx / reverse proxy / public route", + priority="P0", + control_tier="C0", + required_gate="public_gateway_owner_response_required", + patterns=( + "infra/ansible/roles/nginx/templates/*.j2", + "infra/ansible/playbooks/nginx-sync.yml", + "ops/nginx/**", + "docs/runbooks/disaster-recovery/DR-Nginx.md", + ), + required_validation=( + "rendered_diff", + "nginx_t", + "affected_route_smoke", + "admin_route_smoke_if_affected", + "acme_path_smoke_if_affected", + "rollback_ref", + ), + ), + ControlCategory( + category_id="dns_tls_certbot", + label="DNS / TLS / certbot / certificate path", + priority="P0", + control_tier="C0", + required_gate="domain_tls_owner_response_required", + patterns=( + "docs/runbooks/REGISTRY-CERTBOT-188.md", + "docs/runbooks/**/*CERTBOT*.md", + "docs/runbooks/**/*TLS*.md", + "ops/**/*cert*", + "ops/**/*tls*", + "infra/**/*cert*", + "infra/**/*tls*", + "k8s/**/*tls*", + ), + required_validation=( + "domain_inventory", + "certificate_path_check", + "renewal_window", + "acme_path_smoke", + "public_https_smoke", + "rollback_ref", + ), + ), + ControlCategory( + category_id="k8s_production_gitops", + label="K8s / ArgoCD / production manifests", + priority="P0", + control_tier="C0", + required_gate="gitops_owner_response_required", + patterns=( + "k8s/awoooi-prod/**", + "k8s/argocd/**", + "k8s/velero/**", + "k8s/monitoring/**", + ), + required_validation=( + "gitops_diff", + "argocd_health_readback", + "sync_authorization_check", + "rollback_revision", + "post_deploy_health_if_executed", + ), + ), + ControlCategory( + category_id="secret_metadata", + label="Secret metadata / injection / redaction", + priority="P0", + control_tier="C0", + required_gate="secret_metadata_owner_response_required", + patterns=( + "k8s/**/*secret*", + "k8s/**/*Secret*", + ".gitea/workflows/*.yml", + ".gitea/workflows/*.yaml", + ".github/workflows/*.yml", + ".github/workflows/*.yaml", + "docs/runbooks/SECRETS-MANAGEMENT.md", + "docs/security/SECRETS_REFERENCE.md", + ), + required_validation=( + "secret_name_parity", + "metadata_only_check", + "no_secret_value_check", + "rotation_owner", + "injection_readback_if_deployed", + ), + ), + ControlCategory( + category_id="gitea_workflow_runner_source_control", + label="Gitea workflow / runner / deploy key / webhook / branch protection", + priority="P0", + control_tier="C0", + required_gate="workflow_source_control_owner_response_required", + patterns=( + ".gitea/workflows/**", + ".github/workflows/**", + "ops/runner/**", + "scripts/setup-runner*.sh", + "scripts/**/*runner*", + "docs/security/SOURCE-CONTROL-*", + "docs/security/GITEA-*", + "docs/security/GITHUB-*", + ), + required_validation=( + "workflow_diff", + "runner_label_owner", + "deploy_key_metadata_only", + "webhook_metadata_only", + "branch_protection_metadata", + "no_token_value_check", + ), + ), + ControlCategory( + category_id="public_admin_api_runtime_config", + label="Public / admin / API / frontend runtime config", + priority="P0", + control_tier="C0", + required_gate="public_runtime_config_owner_response_required", + patterns=( + "apps/web/next.config.*", + "apps/web/src/lib/config.*", + "apps/api/src/core/config.py", + "apps/api/src/api/v1/monitoring.py", + "apps/api/src/middleware/**", + "apps/web/src/middleware.*", + ), + required_validation=( + "public_url_check", + "frontend_internal_ip_ban", + "cors_boundary_check", + "admin_auth_boundary_check", + "desktop_mobile_smoke_if_frontend", + ), + ), + ControlCategory( + category_id="backup_restore_credential", + label="Backup / restore / escrow / retention", + priority="P0", + control_tier="C0", + required_gate="backup_restore_owner_response_required", + patterns=( + "scripts/backup/**", + "k8s/velero/**", + "docs/runbooks/disaster-recovery/**", + "docs/runbooks/**/*RESTORE*.md", + "docs/runbooks/**/*BACKUP*.md", + ), + required_validation=( + "credential_absence_check", + "restore_drill_gate", + "retention_policy", + "escrow_owner", + "rollback_ref", + ), + ), + ControlCategory( + category_id="agent_bounty_protocol_runtime", + label="agent-bounty-protocol runtime / MCP / A2A / treasury boundary", + priority="P0", + control_tier="C0", + required_gate="agent_bounty_owner_response_required", + patterns=( + "docs/security/AGENT-BOUNTY-IWOOOS-ONBOARDING-HANDOFF.md", + "docs/security/agent-bounty-iwooos-onboarding-handoff.snapshot.json", + "docs/schemas/agent_bounty_iwooos_onboarding_handoff_v1.schema.json", + "agent-bounty-protocol/**", + ), + required_validation=( + "repo_owner_scope", + "runtime_gate_false", + "no_payout_or_treasury_execution", + "no_mcp_a2a_runtime_execution", + "redacted_evidence_refs_only", + ), + ), + ControlCategory( + category_id="monitoring_alerting_observability", + label="Prometheus / Alertmanager / Grafana / SigNoz / Sentry / Langfuse", + priority="P1", + control_tier="C1", + required_gate="monitoring_observability_owner_response_required", + patterns=( + "ops/monitoring/**", + "ops/alertmanager/**", + "ops/grafana/**", + "ops/signoz/**", + "ops/sentry-self-hosted/**", + "infra/langfuse/**", + "k8s/monitoring/**", + ), + required_validation=( + "rule_diff", + "receiver_diff", + "reload_gate", + "failure_notification_policy", + "public_route_smoke_if_affected", + ), + ), + ControlCategory( + category_id="docker_compose_systemd_host_config", + label="Docker Compose / systemd / host service config", + priority="P1", + control_tier="C1", + required_gate="host_service_owner_response_required", + patterns=( + "docker-compose*.yml", + "docker-compose*.yaml", + "ops/**/docker-compose*.yml", + "ops/**/docker-compose*.yaml", + "scripts/reboot-recovery/**", + "scripts/**/*.service", + "ops/**/*.service", + ), + required_validation=( + "port_conflict_check", + "volume_diff", + "env_name_diff", + "restart_window", + "rollback_owner", + ), + ), + ControlCategory( + category_id="ssh_firewall_network_access", + label="SSH / sudoers / known_hosts / firewall / WireGuard / NodePort", + priority="P1", + control_tier="C1", + required_gate="network_access_owner_response_required", + patterns=( + "infra/ansible/inventory/**", + "infra/ansible/**/*known_hosts*", + "infra/ansible/**/*ssh*", + "scripts/**/*ssh*", + "scripts/**/*known_hosts*", + "ops/**/*wireguard*", + "ops/**/*firewall*", + "k8s/**/*network*", + "k8s/**/*Network*", + ), + required_validation=( + "target_whitelist", + "host_key_policy", + "ingress_egress_matrix", + "rollback_owner", + "maintenance_window", + ), + ), + ControlCategory( + category_id="ai_provider_model_routing", + label="AI provider / model routing / Ollama proxy / cost and privacy", + priority="P1", + control_tier="C1", + required_gate="ai_provider_owner_response_required", + patterns=( + "apps/api/src/services/ai_providers/**", + "apps/api/src/services/**/*model*", + "apps/api/src/services/**/*provider*", + "infra/ansible/roles/nginx/templates/110-ollama-proxy.conf.j2", + "docs/ai/**", + "docs/**/*Ollama*", + ), + required_validation=( + "dry_run", + "benchmark", + "cost_review", + "privacy_review", + "fallback_order_check", + ), + ), + ControlCategory( + category_id="product_surface_runtime_routes", + label="AWOOOI / AwoooP / IwoooS / VibeWork / other product runtime routes", + priority="P2", + control_tier="C2", + required_gate="product_surface_owner_response_required", + patterns=( + "apps/web/src/app/**", + "apps/web/messages/*.json", + "docs/security/VIBEWORK-IWOOOS-ONBOARDING-HANDOFF.md", + "docs/security/vibework-iwooos-onboarding-handoff.snapshot.json", + ), + required_validation=( + "product_boundary_check", + "i18n_traditional_chinese_check", + "no_internal_transcript_check", + "desktop_mobile_smoke_if_frontend", + ), + ), + ControlCategory( + category_id="security_evidence_tooling", + label="Security evidence / snapshot / guard tooling", + priority="P3", + control_tier="C3", + required_gate="security_evidence_owner_review_required", + patterns=( + "docs/security/**", + "docs/schemas/**", + "scripts/security/**", + "docs/LOGBOOK.md", + ), + required_validation=( + "snapshot_parse", + "guard_smoke", + "doc_secret_sanity", + "no_runtime_gate_increase", + ), + ), +] + + +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 diff_files(root: Path, base: str, head: str) -> list[str]: + result = subprocess.run( + ["git", "diff", "--name-only", f"{base}..{head}"], + cwd=root, + check=True, + capture_output=True, + text=True, + ) + return sorted({line.strip() for line in result.stdout.splitlines() if line.strip()}) + + +def matches(pattern: str, path: str) -> bool: + if fnmatch.fnmatch(path, pattern): + return True + if "**" in pattern and fnmatch.fnmatch(path, pattern.replace("**/", "")): + return True + return False + + +def classify_path(path: str) -> list[ControlCategory]: + matched: list[ControlCategory] = [] + for category in CATEGORIES: + if any(matches(pattern, path) for pattern in category.patterns): + matched.append(category) + return sorted(matched, key=lambda item: (TIER_ORDER[item.control_tier], PRIORITY_ORDER[item.priority], item.category_id)) + + +def strongest_tier(categories: list[dict[str, Any]]) -> str | None: + if not categories: + return None + return min((item["control_tier"] for item in categories), key=lambda value: TIER_ORDER[value]) + + +def strongest_priority(categories: list[dict[str, Any]]) -> str | None: + if not categories: + return None + return min((item["priority"] for item in categories), key=lambda value: PRIORITY_ORDER[value]) + + +def load_evidence(path: Path | None) -> dict[str, Any] | None: + if path is None: + return None + return json.loads(path.read_text(encoding="utf-8")) + + +def validate_evidence(evidence: dict[str, Any] | None) -> dict[str, Any]: + if evidence is None: + return { + "provided": False, + "complete": False, + "missing_owner_fields": REQUIRED_OWNER_FIELDS, + "invalid_false_flags": [], + "note": "未提供 owner response evidence;本階段只能分類,不得執行 runtime 變更。", + } + + missing = [field for field in REQUIRED_OWNER_FIELDS if not evidence.get(field)] + false_flags = evidence.get("false_flags", {}) + invalid_false_flags = [ + flag for flag in REQUIRED_FALSE_FLAGS if false_flags.get(flag) is not False + ] + return { + "provided": True, + "complete": not missing and not invalid_false_flags, + "missing_owner_fields": missing, + "invalid_false_flags": invalid_false_flags, + "note": "owner response evidence 僅驗證欄位與 false flags;不代表 runtime 授權。", + } + + +def category_inventory() -> list[dict[str, Any]]: + return [ + { + "category_id": category.category_id, + "label": category.label, + "priority": category.priority, + "control_tier": category.control_tier, + "required_gate": category.required_gate, + "path_patterns": list(category.patterns), + "required_owner_fields": REQUIRED_OWNER_FIELDS, + "required_validation": list(category.required_validation), + } + for category in CATEGORIES + ] + + +def build_report( + root: Path, + changed_files: list[str], + generated_at: str | None, + base: str | None, + head: str | None, + evidence: dict[str, Any] | None, +) -> dict[str, Any]: + report_time = generated_at or datetime.now(TAIPEI).isoformat(timespec="seconds") + path_reports: list[dict[str, Any]] = [] + impacted_by_id: dict[str, dict[str, Any]] = {} + + for path in sorted(set(changed_files)): + matched_categories = classify_path(path) + path_category_reports: list[dict[str, Any]] = [] + for category in matched_categories: + category_report = { + "category_id": category.category_id, + "label": category.label, + "priority": category.priority, + "control_tier": category.control_tier, + "required_gate": category.required_gate, + "required_validation": list(category.required_validation), + } + path_category_reports.append(category_report) + impacted_by_id.setdefault(category.category_id, category_report) + + path_reports.append( + { + "path": path, + "matched": bool(path_category_reports), + "strongest_tier": strongest_tier(path_category_reports), + "strongest_priority": strongest_priority(path_category_reports), + "categories": path_category_reports, + } + ) + + impacted_categories = sorted( + impacted_by_id.values(), + key=lambda item: ( + TIER_ORDER[item["control_tier"]], + PRIORITY_ORDER[item["priority"]], + item["category_id"], + ), + ) + evidence_validation = validate_evidence(evidence) + c0_count = sum(1 for item in impacted_categories if item["control_tier"] == "C0") + c1_count = sum(1 for item in impacted_categories if item["control_tier"] == "C1") + + return { + "schema_version": "high_value_config_change_gate_v1", + "generated_at": report_time, + "git_commit": git_short_sha(root), + "mode": "classification_only", + "diff": { + "base": base, + "head": head, + "changed_file_count": len(path_reports), + }, + "execution_boundaries": { + "ssh_executed": False, + "host_write_executed": False, + "workflow_modified": False, + "runtime_deploy_executed": False, + "nginx_reload_executed": False, + "dns_tls_modified": False, + "secret_value_collected": False, + "active_scan_executed": False, + "runtime_gate_opened": False, + }, + "required_false_flags": REQUIRED_FALSE_FLAGS, + "required_owner_fields": REQUIRED_OWNER_FIELDS, + "summary": { + "changed_file_count": len(path_reports), + "matched_high_value_file_count": sum(1 for item in path_reports if item["matched"]), + "impacted_category_count": len(impacted_categories), + "impacted_c0_category_count": c0_count, + "impacted_c1_category_count": c1_count, + "strongest_tier": strongest_tier(impacted_categories), + "strongest_priority": strongest_priority(impacted_categories), + "owner_evidence_provided": evidence_validation["provided"], + "owner_evidence_complete": evidence_validation["complete"], + "runtime_execution_authorized": False, + }, + "impacted_categories": impacted_categories, + "changed_files": path_reports, + "owner_evidence_validation": evidence_validation, + "control_category_inventory": category_inventory(), + "next_steps": [ + "若 impacted_c0_category_count > 0,先建立 owner response packet,不得直接 reload、deploy、sync 或修改主機。", + "owner response 必須包含 owner role / team、decision、decision reason、affected scope、redacted evidence refs、followup owner、rollback owner、maintenance window 與 validation plan。", + "若只有 C3 security evidence / tooling,仍需跑 guard 與 doc secret sanity,但不得藉此提高 runtime gate。", + ], + } + + +def main() -> int: + parser = argparse.ArgumentParser(description="IwoooS 高價值配置變更 Gate") + parser.add_argument("--root", default=".", help="repo root") + parser.add_argument("--base", help="git diff base") + parser.add_argument("--head", default="HEAD", help="git diff head") + parser.add_argument("--changed-file", action="append", default=[], help="手動指定變更檔案,可重複") + parser.add_argument("--evidence", help="owner response evidence JSON,只驗證欄位與 false flags") + parser.add_argument("--output", help="寫出 JSON 報告") + parser.add_argument("--generated-at", help="固定報告時間,供 committed snapshot 使用") + parser.add_argument("--fail-on-c0", action="store_true", help="若有 C0 分類則回傳 1") + parser.add_argument( + "--fail-on-missing-evidence", + action="store_true", + help="若命中 C0/C1 但 owner evidence 不完整則回傳 1", + ) + args = parser.parse_args() + + root = Path(args.root).resolve() + changed_files = list(args.changed_file) + if args.base: + changed_files.extend(diff_files(root, args.base, args.head)) + + evidence = load_evidence(Path(args.evidence)) if args.evidence else None + report = build_report(root, changed_files, args.generated_at, args.base, args.head, evidence) + 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( + "HIGH_VALUE_CONFIG_CHANGE_GATE_OK " + f"changed_files={summary['changed_file_count']} " + f"matched={summary['matched_high_value_file_count']} " + f"categories={summary['impacted_category_count']} " + f"c0={summary['impacted_c0_category_count']} " + f"c1={summary['impacted_c1_category_count']}", + file=sys.stderr, + ) + + if args.fail_on_c0 and summary["impacted_c0_category_count"] > 0: + return 1 + if ( + args.fail_on_missing_evidence + and (summary["impacted_c0_category_count"] > 0 or summary["impacted_c1_category_count"] > 0) + and not summary["owner_evidence_complete"] + ): + return 1 + return 0 + + +if __name__ == "__main__": + sys.exit(main())