From 2239507e0e6c35cf76ecc7d17e9e8d8e2cd2f7b1 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 26 Jun 2026 23:26:04 +0800 Subject: [PATCH 1/5] fix(web): expose approval executor handoff readiness --- apps/web/messages/en.json | 20 +++ apps/web/messages/zh-TW.json | 20 +++ .../app/[locale]/awooop/approvals/page.tsx | 161 +++++++++++++++++- 3 files changed, 200 insertions(+), 1 deletion(-) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index c86fc91a..a3783e09 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -11558,6 +11558,26 @@ "nextAction": "下一步", "reason": "原因" }, + "executorHandoff": { + "title": "Executor handoff readiness", + "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。", + "runtime": { + "open": "runtime gate open", + "closed": "runtime gate closed" + }, + "metrics": { + "readiness": "可交接度", + "ready": "已備妥", + "blocked": "卡點", + "status": "狀態" + }, + "nextAction": "下一步", + "blocker": "阻擋原因", + "missingTitle": "缺少的 owner review / 安全路由欄位", + "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準", + "openWorkItem": "開啟 owner review", + "openRuns": "追蹤 Runs" + }, "evidence": { "executor": "執行器", "ansible": "Ansible", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index c86fc91a..a3783e09 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -11558,6 +11558,26 @@ "nextAction": "下一步", "reason": "原因" }, + "executorHandoff": { + "title": "Executor handoff readiness", + "subtitle": "把批准後是否能進執行器、缺少的 owner 欄位與 verifier 條件集中顯示;批准此卡不代表 runtime gate 已開。", + "runtime": { + "open": "runtime gate open", + "closed": "runtime gate closed" + }, + "metrics": { + "readiness": "可交接度", + "ready": "已備妥", + "blocked": "卡點", + "status": "狀態" + }, + "nextAction": "下一步", + "blocker": "阻擋原因", + "missingTitle": "缺少的 owner review / 安全路由欄位", + "missingEmpty": "未回報缺欄位;請仍以 runtime gate 與 verifier 為準", + "openWorkItem": "開啟 owner review", + "openRuns": "追蹤 Runs" + }, "evidence": { "executor": "執行器", "ansible": "Ansible", diff --git a/apps/web/src/app/[locale]/awooop/approvals/page.tsx b/apps/web/src/app/[locale]/awooop/approvals/page.tsx index 547bf3a4..2089705c 100644 --- a/apps/web/src/app/[locale]/awooop/approvals/page.tsx +++ b/apps/web/src/app/[locale]/awooop/approvals/page.tsx @@ -633,6 +633,159 @@ function Gate5ProjectionBadge() { ); } +function handoffWorkItemHref( + projectId: string, + incidentId: string, + chain: AwoooPStatusChain | null +) { + const promotion = chain?.repair_candidate_promotion; + const href = promotion?.work_item_url + ?? promotion?.contract?.source_work_item_url + ?? null; + if (href && href.startsWith("/")) return href; + + const workItemId = promotion?.work_item_id + ?? promotion?.contract?.source_work_item_id + ?? chain?.automation_handoff?.work_item_id + ?? ""; + const params = new URLSearchParams({ project_id: projectId, incident_id: incidentId }); + if (workItemId) params.set("work_item_id", workItemId); + return `/awooop/work-items?${params.toString()}`; +} + +function ExecutorHandoffReadinessCard({ + projectId, + incidentId, + chain, +}: { + projectId: string; + incidentId: string; + chain: AwoooPStatusChain | null; +}) { + const t = useTranslations("awooop.approvals.incidentFocus.executorHandoff"); + const promotion = chain?.repair_candidate_promotion; + const contract = promotion?.contract; + const closure = chain?.automation_handoff?.closure_readiness; + const runtimeAllowed = Boolean( + promotion?.runtime_execution_authorized + || contract?.runtime_execution_authorized + || closure?.runtime_execution_authorized + || chain?.automation_handoff?.runtime_execution_authorized + ); + const ready = Number(contract?.ready_count ?? closure?.ready_count ?? 0); + const total = Number(contract?.total_count ?? closure?.total_count ?? 0); + const blocked = Number(contract?.blocked_count ?? closure?.blocked_count ?? 0); + const readinessPercent = total > 0 ? Math.min(100, Math.round((ready / total) * 100)) : 0; + const status = promotion?.status ?? contract?.status ?? closure?.status ?? chain?.automation_handoff?.status ?? "not_available"; + const nextAction = chain?.automation_handoff?.next_action + ?? closure?.next_action + ?? promotion?.summary + ?? chain?.operator_outcome?.next_action + ?? chain?.next_step + ?? "--"; + const blocker = closure?.blocked_reason + ?? promotion?.reason + ?? chain?.operator_outcome?.human_action_reason + ?? chain?.blockers?.[0] + ?? "--"; + const blockedFields = [ + ...(contract?.blocked_fields ?? []), + ...(closure?.required_owner_fields ?? []), + ].filter((field, index, fields) => field && fields.indexOf(field) === index).slice(0, 8); + const workItemHref = handoffWorkItemHref(projectId, incidentId, chain); + const runsHref = `/awooop/runs?project_id=${encodeURIComponent(projectId)}&incident_id=${encodeURIComponent(incidentId)}`; + + return ( +
+
+
+
+
+

{t("subtitle")}

+
+ + {runtimeAllowed ? t("runtime.open") : t("runtime.closed")} + +
+ +
+ {[ + [t("metrics.readiness"), `${readinessPercent}%`], + [t("metrics.ready"), `${ready}/${total || "--"}`], + [t("metrics.blocked"), String(blocked)], + [t("metrics.status"), status], + ].map(([label, value]) => ( +
+

{label}

+

+ {value} +

+
+ ))} +
+ +
+
+ +
+
+

{t("nextAction")}

+

{nextAction}

+
+
+

{t("blocker")}

+

{blocker}

+
+
+ +
+

{t("missingTitle")}

+
+ {blockedFields.length > 0 ? blockedFields.map((field) => ( + + {field} + + )) : ( + + {t("missingEmpty")} + + )} +
+
+ +
+ + {t("openWorkItem")} +
+
+
+ ); +} + function approvalDecisionRailToneClass(tone: ApprovalDecisionRailTone) { if (tone === "ok") return "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"; if (tone === "blocked") return "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"; @@ -1459,6 +1612,11 @@ function FocusedIncidentApprovalPanel({

{linkedApprovalIds.length > 0 ? t("linkedExplanation") : t("unlinkedExplanation")}

+
@@ -1530,6 +1688,7 @@ export default function ApprovalsPage() { setError(null); setLegacyError(null); const params = new URLSearchParams(); + params.set("project_id", projectId); if (evidenceFilter) params.set("remediation_status", evidenceFilter); const qs = params.toString(); const [platformResult, legacyResult] = await Promise.allSettled([ @@ -1560,7 +1719,7 @@ export default function ApprovalsPage() { } finally { setLoading(false); } - }, [evidenceFilter, t]); + }, [evidenceFilter, projectId, t]); useEffect(() => { fetchApprovals(); From 18a35c5e621bc8b6cf8e5305daf06042677ee951 Mon Sep 17 00:00:00 2001 From: ogt Date: Fri, 26 Jun 2026 23:25:36 +0800 Subject: [PATCH 2/5] fix(ops): avoid unknown stock blockers when fresh --- scripts/reboot-recovery/post-reboot-readiness-summary.sh | 3 +++ 1 file changed, 3 insertions(+) diff --git a/scripts/reboot-recovery/post-reboot-readiness-summary.sh b/scripts/reboot-recovery/post-reboot-readiness-summary.sh index 76ef5e90..fb9d506f 100755 --- a/scripts/reboot-recovery/post-reboot-readiness-summary.sh +++ b/scripts/reboot-recovery/post-reboot-readiness-summary.sh @@ -117,6 +117,9 @@ fi stock_freshness_status="$(awk '$1 == "STOCK_FRESHNESS_STATUS" {value=$2} END {print value}' "$post_start_log")" stock_latest_trading_date="$(awk '$1 == "STOCK_LATEST_TRADING_DATE" {value=$2} END {print value}' "$post_start_log")" stock_blockers="$(grep -E '^STOCK_BLOCKERS ' "$post_start_log" | tail -n 1 | cut -d' ' -f2- || true)" +if [[ "${stock_freshness_status:-}" == "ok" && -z "${stock_blockers:-}" ]]; then + stock_blockers="none" +fi stock_eod_window_pending="$(awk '$1 == "STOCK_EOD_WINDOW_PENDING" {value=$2} END {print value}' "$post_start_log")" stock_eod_classification="$(awk '$1 == "STOCK_EOD_CLASSIFICATION" {value=$2} END {print value}' "$post_start_log")" stock_eod_next_action="$(awk '$1 == "STOCK_EOD_NEXT_ACTION" {value=$2} END {print value}' "$post_start_log")" From 335d5f4a7b7cf9c4ff47215d084dc965696d7e82 Mon Sep 17 00:00:00 2001 From: AWOOOI CD Date: Fri, 26 Jun 2026 23:32:10 +0800 Subject: [PATCH 3/5] chore(cd): deploy 2239507 [skip ci] --- k8s/awoooi-prod/kustomization.yaml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/k8s/awoooi-prod/kustomization.yaml b/k8s/awoooi-prod/kustomization.yaml index 52f9fe81..ad5d4f79 100644 --- a/k8s/awoooi-prod/kustomization.yaml +++ b/k8s/awoooi-prod/kustomization.yaml @@ -41,7 +41,7 @@ resources: images: - name: 192.168.0.110:5000/library/api:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/api - newTag: 4309c02eb052c2573e2783fa76ec9c1550033863 + newTag: 2239507e0e6c35cf76ecc7d17e9e8d8e2cd2f7b1 - name: 192.168.0.110:5000/library/web:IMAGE_TAG_PLACEHOLDER newName: 192.168.0.110:5000/awoooi/web - newTag: 4309c02eb052c2573e2783fa76ec9c1550033863 + newTag: 2239507e0e6c35cf76ecc7d17e9e8d8e2cd2f7b1 From 9778cc22fcf740e51041ca2ba990ca22a86d5d15 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 26 Jun 2026 23:32:39 +0800 Subject: [PATCH 4/5] feat(iwooos): surface Wazuh live route in runtime readback --- apps/api/src/api/v1/iwooos.py | 188 ++--------------- .../iwooos_runtime_security_readback.py | 62 +++++- .../services/iwooos_wazuh_readonly_status.py | 194 ++++++++++++++++++ .../test_iwooos_runtime_security_readback.py | 62 +++++- apps/web/messages/en.json | 12 +- apps/web/messages/zh-TW.json | 12 +- apps/web/src/app/[locale]/iwooos/page.tsx | 8 +- apps/web/src/lib/api-client.ts | 10 + docs/LOGBOOK.md | 36 ++++ ...OOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md | 13 +- .../wazuh-readonly-route-boundary-guard.py | 40 +++- 11 files changed, 443 insertions(+), 194 deletions(-) create mode 100644 apps/api/src/services/iwooos_wazuh_readonly_status.py diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 80d1eaf7..3398cbcf 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -9,12 +9,8 @@ from __future__ import annotations import asyncio import json -import os -from base64 import b64encode from typing import Any -from urllib.parse import urljoin, urlparse -import httpx from fastapi import APIRouter, HTTPException, status from fastapi.responses import JSONResponse @@ -24,180 +20,18 @@ from src.services.iwooos_runtime_security_readback import ( from src.services.iwooos_security_control_coverage import ( load_latest_iwooos_security_control_coverage, ) +from src.services.iwooos_wazuh_readonly_status import ( + load_iwooos_wazuh_readonly_status, +) from src.services.public_redaction import redact_public_lan_topology router = APIRouter(tags=["IwoooS Security"]) -REQUEST_TIMEOUT_SECONDS = 5.0 - - -def _wazuh_env() -> dict[str, str]: - return { - "enabled": os.getenv("IWOOOS_WAZUH_READONLY_ENABLED", "").strip().lower(), - "base_url": os.getenv("WAZUH_API_BASE_URL", "").strip(), - "username": os.getenv("WAZUH_API_USERNAME", "").strip(), - "password": os.getenv("WAZUH_API_PASSWORD", "").strip(), - "expected_min_agent_count": os.getenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "").strip(), - } - - -def _expected_min_agent_count(value: str) -> int: - try: - return max(0, int(value)) - except ValueError: - return 0 - - -def _https_url(value: str) -> str | None: - parsed = urlparse(value) - if parsed.scheme != "https" or not parsed.netloc: - return None - return value.rstrip("/") + "/" - - -def _boundary_response(status_text: str, http_status: int = 200) -> JSONResponse: - return JSONResponse( - status_code=http_status, - content={ - "schema_version": "iwooos_wazuh_readonly_status_v1", - "status": status_text, - "mode": "metadata_only_no_active_response_no_raw_payload", - "configured": False, - "summary": { - "wazuh_platform_reported_count": 1, - "readonly_api_enabled_count": 0, - "wazuh_manager_query_accepted_count": 0, - "wazuh_event_accepted_count": 0, - "host_forensics_accepted_count": 0, - "active_response_authorized_count": 0, - "host_write_authorized_count": 0, - "runtime_gate_count": 0, - "expected_min_agent_count": _expected_min_agent_count(_wazuh_env()["expected_min_agent_count"]), - "agent_registry_empty_count": 0, - "agent_below_expected_minimum_count": 0, - "agent_visibility_no_false_green_count": 1, - }, - "boundaries": _boundaries(), - }, - ) - - -def _boundaries() -> dict[str, bool]: - return { - "active_response_authorized": False, - "host_write_authorized": False, - "secret_value_collection_allowed": False, - "raw_wazuh_payload_storage_allowed": False, - "agent_identity_public_display_allowed": False, - "internal_ip_public_display_allowed": False, - "not_authorization": True, - } - - -def _redacted_agent(agent: dict[str, Any], index: int) -> dict[str, Any]: - os_info = agent.get("os") if isinstance(agent.get("os"), dict) else {} - return { - "alias": f"agent-{index + 1:02d}", - "status": agent.get("status", "unknown"), - "os": os_info.get("platform") or os_info.get("name") or "unknown", - "last_seen_present": bool(agent.get("lastKeepAlive")), - } - - -def _int_or_default(value: Any, default: int) -> int: - return value if isinstance(value, int) else default - - -def _agent_visibility_status(agent_total: int, expected_min_agent_count: int) -> str: - if agent_total <= 0: - return "wazuh_agent_registry_empty" - if expected_min_agent_count > 0 and agent_total < expected_min_agent_count: - return "wazuh_agent_registry_below_expected" - return "readonly_metadata_available" - - -async def _fetch_json(client: httpx.AsyncClient, url: str, headers: dict[str, str]) -> dict[str, Any]: - response = await client.get(url, headers=headers) - response.raise_for_status() - payload = response.json() - return payload if isinstance(payload, dict) else {} async def _wazuh_readonly_status() -> JSONResponse: - env = _wazuh_env() - if env["enabled"] != "true": - return _boundary_response("disabled_waiting_iwooos_wazuh_owner_gate") - - base_url = _https_url(env["base_url"]) - if not base_url or not env["username"] or not env["password"]: - return _boundary_response("misconfigured_missing_server_side_wazuh_env", 503) - - try: - auth_header = b64encode(f"{env['username']}:{env['password']}".encode("utf-8")).decode("ascii") - async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client: - auth = await _fetch_json( - client, - urljoin(base_url, "security/user/authenticate"), - {"Authorization": f"Basic {auth_header}"}, - ) - token = (auth.get("data") or {}).get("token") - if not token: - return _boundary_response("wazuh_auth_token_missing", 502) - - bearer_headers = {"Authorization": f"Bearer {token}"} - status_payload = await _fetch_json( - client, - urljoin(base_url, "agents/summary/status"), - bearer_headers, - ) - agents_payload = await _fetch_json( - client, - urljoin(base_url, "agents?limit=100&select=id,status,os.name,os.platform,lastKeepAlive"), - bearer_headers, - ) - except (httpx.HTTPError, ValueError): - return _boundary_response("wazuh_readonly_metadata_unavailable", 502) - - connection = ((status_payload.get("data") or {}).get("connection") or {}) - affected_items = ((agents_payload.get("data") or {}).get("affected_items") or []) - if not isinstance(affected_items, list): - affected_items = [] - expected_min_agent_count = _expected_min_agent_count(env["expected_min_agent_count"]) - agent_total = _int_or_default(connection.get("total"), len(affected_items)) - agent_active = _int_or_default(connection.get("active"), 0) - agent_disconnected = _int_or_default(connection.get("disconnected"), 0) - agent_pending = _int_or_default(connection.get("pending"), 0) - agent_registry_empty = agent_total <= 0 - agent_below_expected = expected_min_agent_count > 0 and agent_total < expected_min_agent_count - - return JSONResponse( - content={ - "schema_version": "iwooos_wazuh_readonly_status_v1", - "status": _agent_visibility_status(agent_total, expected_min_agent_count), - "mode": "metadata_only_no_active_response_no_raw_payload", - "configured": True, - "summary": { - "wazuh_platform_reported_count": 1, - "readonly_api_enabled_count": 1, - "agent_total": agent_total, - "agent_active": agent_active, - "agent_disconnected": agent_disconnected, - "agent_pending": agent_pending, - "expected_min_agent_count": expected_min_agent_count, - "agent_registry_empty_count": 1 if agent_registry_empty else 0, - "agent_below_expected_minimum_count": 1 if agent_below_expected else 0, - "agent_visibility_no_false_green_count": 1, - "wazuh_manager_query_accepted_count": 0, - "wazuh_event_accepted_count": 0, - "host_forensics_accepted_count": 0, - "active_response_authorized_count": 0, - "host_write_authorized_count": 0, - "runtime_gate_count": 0, - }, - "agents": [_redacted_agent(agent, index) for index, agent in enumerate(affected_items[:20])], - "boundaries": _boundaries(), - }, - ) + result = await load_iwooos_wazuh_readonly_status() + return JSONResponse(status_code=result.http_status, content=result.payload) @router.get("/api/iwooos/wazuh") @@ -216,14 +50,20 @@ async def get_iwooos_wazuh_readonly_status_v1() -> JSONResponse: summary="取得 IwoooS runtime security readback", description=( "讀取最新已提交的 IwoooS 資安只讀快照,彙總 Wazuh、Kali、SOC/SIEM、" - "告警可讀性、owner dispatch 與外部入侵防護 Gate。此端點不呼叫 Wazuh / Kali / " - "主機 / Docker / Nginx / firewall / Telegram,不收集 secret,不授權 runtime 寫入。" + "告警可讀性、owner dispatch 與外部入侵防護 Gate,並附上 Wazuh 只讀路由的" + "公開安全 aggregate 讀回。此端點不呼叫 Kali / 主機 / Docker / Nginx / firewall / " + "Telegram,不保存 raw Wazuh payload,不收集 secret,不授權 runtime 寫入。" ), ) async def get_iwooos_runtime_security_readback() -> dict[str, Any]: """回傳 IwoooS 資安 runtime readback 只讀總板。""" try: - payload = await asyncio.to_thread(load_latest_iwooos_runtime_security_readback) + wazuh_result = await load_iwooos_wazuh_readonly_status() + payload = await asyncio.to_thread( + load_latest_iwooos_runtime_security_readback, + wazuh_live_status=wazuh_result.payload, + wazuh_live_http_status=wazuh_result.http_status, + ) return redact_public_lan_topology(payload) except FileNotFoundError as exc: raise HTTPException( diff --git a/apps/api/src/services/iwooos_runtime_security_readback.py b/apps/api/src/services/iwooos_runtime_security_readback.py index d277fe40..8304bb05 100644 --- a/apps/api/src/services/iwooos_runtime_security_readback.py +++ b/apps/api/src/services/iwooos_runtime_security_readback.py @@ -60,6 +60,8 @@ _FALSE_BOUNDARY_KEYS = { def load_latest_iwooos_runtime_security_readback( security_dir: Path | None = None, + wazuh_live_status: dict[str, Any] | None = None, + wazuh_live_http_status: int = 0, ) -> dict[str, Any]: """Load and normalize the current IwoooS runtime security readback.""" directory = security_dir or _DEFAULT_SECURITY_DIR @@ -72,6 +74,7 @@ def load_latest_iwooos_runtime_security_readback( alert_summary = _summary(snapshots["alert_readability"]) dispatch_summary = _summary(snapshots["owner_dispatch"]) intrusion_summary = _summary(snapshots["intrusion_prevention"]) + live_wazuh = _wazuh_live_summary(wazuh_live_status, wazuh_live_http_status) source_refs = [f"docs/security/{filename}" for filename in _SNAPSHOT_FILES.values()] runtime_gate_count = _max_summary_count( @@ -85,11 +88,11 @@ def load_latest_iwooos_runtime_security_readback( return { "schema_version": "iwooos_runtime_security_readback_v1", "status": "blocked_waiting_owner_evidence_and_runtime_gates", - "mode": "committed_snapshot_readback_only_no_runtime_query", + "mode": "committed_snapshot_readback_with_public_safe_wazuh_route_metadata", "source_refs": source_refs, "summary": { "source_snapshot_count": len(source_refs), - "p0_lane_count": 6, + "p0_lane_count": 7, "control_plane_visibility_percent": _average_percent( soc_summary.get("coverage_percent_after_soc_integration_control"), intrusion_summary.get("coverage_percent_after_prevention_control"), @@ -106,6 +109,15 @@ def load_latest_iwooos_runtime_security_readback( "wazuh_dashboard_api_degraded_observed_count": _int( wazuh_summary.get("dashboard_api_degraded_observed_count") ), + "wazuh_live_route_http_status": live_wazuh["http_status"], + "wazuh_live_route_degraded_count": live_wazuh["degraded_count"], + "wazuh_live_readonly_api_enabled_count": live_wazuh["readonly_api_enabled_count"], + "wazuh_live_agent_total": live_wazuh["agent_total"], + "wazuh_live_agent_active": live_wazuh["agent_active"], + "wazuh_live_registry_empty_count": live_wazuh["agent_registry_empty_count"], + "wazuh_live_below_expected_count": live_wazuh["agent_below_expected_minimum_count"], + "wazuh_live_metadata_available_count": live_wazuh["metadata_available_count"], + "wazuh_live_status": live_wazuh["status"], "kali_active_scan_authorized_count": _int(soc_summary.get("kali_active_scan_authorized_count")), "kali_execute_authorized_count": _int(soc_summary.get("kali_execute_authorized_count")), "kali_finding_envelope_accepted_count": _int(soc_summary.get("kali_finding_envelope_accepted_count")), @@ -128,6 +140,21 @@ def load_latest_iwooos_runtime_security_readback( }, ["docs/security/wazuh-managed-host-coverage-gate.snapshot.json"], ), + _lane( + "wazuh_live_route", + live_wazuh["status"], + 0 if live_wazuh["degraded_count"] else 30, + "steady" if live_wazuh["metadata_available_count"] else "warn", + "enable_readonly_metadata_owner_gate_and_manager_registry_cross_check", + { + "http_status": live_wazuh["http_status"], + "readonly_enabled": live_wazuh["readonly_api_enabled_count"], + "agent_total": live_wazuh["agent_total"], + "metadata_available": live_wazuh["metadata_available_count"], + "route_degraded": live_wazuh["degraded_count"], + }, + ["GET /api/iwooos/wazuh"], + ), _lane( "wazuh_dashboard_api", "degraded_api_connection_not_green", @@ -223,6 +250,7 @@ def load_latest_iwooos_runtime_security_readback( "owner_request_draft_is_not_owner_acceptance", "kali_health_is_not_active_scan_authorization", "alert_format_contract_is_not_telegram_send_receipt", + "wazuh_live_route_disabled_or_degraded_is_p0_not_green", ], } @@ -250,6 +278,36 @@ def _int(value: Any) -> int: return value if isinstance(value, int) else 0 +def _wazuh_live_summary(payload: dict[str, Any] | None, http_status: int) -> dict[str, Any]: + if not isinstance(payload, dict): + return { + "status": "not_checked_by_snapshot_loader", + "http_status": 0, + "degraded_count": 1, + "readonly_api_enabled_count": 0, + "agent_total": 0, + "agent_active": 0, + "agent_registry_empty_count": 0, + "agent_below_expected_minimum_count": 0, + "metadata_available_count": 0, + } + summary = payload.get("summary") + summary = summary if isinstance(summary, dict) else {} + status_text = str(payload.get("status") or "unknown") + metadata_available = status_text == "readonly_metadata_available" + return { + "status": status_text, + "http_status": http_status if isinstance(http_status, int) else 0, + "degraded_count": 0 if metadata_available else 1, + "readonly_api_enabled_count": _int(summary.get("readonly_api_enabled_count")), + "agent_total": _int(summary.get("agent_total")), + "agent_active": _int(summary.get("agent_active")), + "agent_registry_empty_count": _int(summary.get("agent_registry_empty_count")), + "agent_below_expected_minimum_count": _int(summary.get("agent_below_expected_minimum_count")), + "metadata_available_count": 1 if metadata_available else 0, + } + + def _average_percent(*values: Any) -> int: percents = [_int(value) for value in values if isinstance(value, int)] return int(round(sum(percents) / len(percents))) if percents else 0 diff --git a/apps/api/src/services/iwooos_wazuh_readonly_status.py b/apps/api/src/services/iwooos_wazuh_readonly_status.py new file mode 100644 index 00000000..c78a09d6 --- /dev/null +++ b/apps/api/src/services/iwooos_wazuh_readonly_status.py @@ -0,0 +1,194 @@ +""" +IwoooS Wazuh read-only metadata status. + +This module returns public-safe aggregate metadata only. It never stores raw +Wazuh payloads, never exposes agent identity or LAN topology, and never +authorizes active response or host writes. +""" + +from __future__ import annotations + +import os +from base64 import b64encode +from dataclasses import dataclass +from typing import Any +from urllib.parse import urljoin, urlparse + +import httpx + +REQUEST_TIMEOUT_SECONDS = 5.0 + + +@dataclass(frozen=True) +class WazuhReadonlyStatus: + payload: dict[str, Any] + http_status: int = 200 + + +def wazuh_env() -> dict[str, str]: + return { + "enabled": os.getenv("IWOOOS_WAZUH_READONLY_ENABLED", "").strip().lower(), + "base_url": os.getenv("WAZUH_API_BASE_URL", "").strip(), + "username": os.getenv("WAZUH_API_USERNAME", "").strip(), + "password": os.getenv("WAZUH_API_PASSWORD", "").strip(), + "expected_min_agent_count": os.getenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "").strip(), + } + + +def expected_min_agent_count(value: str) -> int: + try: + return max(0, int(value)) + except ValueError: + return 0 + + +def https_url(value: str) -> str | None: + parsed = urlparse(value) + if parsed.scheme != "https" or not parsed.netloc: + return None + return value.rstrip("/") + "/" + + +def boundaries() -> dict[str, bool]: + return { + "active_response_authorized": False, + "host_write_authorized": False, + "secret_value_collection_allowed": False, + "raw_wazuh_payload_storage_allowed": False, + "agent_identity_public_display_allowed": False, + "internal_ip_public_display_allowed": False, + "not_authorization": True, + } + + +def boundary_status(status_text: str, http_status: int = 200) -> WazuhReadonlyStatus: + return WazuhReadonlyStatus( + http_status=http_status, + payload={ + "schema_version": "iwooos_wazuh_readonly_status_v1", + "status": status_text, + "mode": "metadata_only_no_active_response_no_raw_payload", + "configured": False, + "summary": { + "wazuh_platform_reported_count": 1, + "readonly_api_enabled_count": 0, + "wazuh_manager_query_accepted_count": 0, + "wazuh_event_accepted_count": 0, + "host_forensics_accepted_count": 0, + "active_response_authorized_count": 0, + "host_write_authorized_count": 0, + "runtime_gate_count": 0, + "expected_min_agent_count": expected_min_agent_count(wazuh_env()["expected_min_agent_count"]), + "agent_registry_empty_count": 0, + "agent_below_expected_minimum_count": 0, + "agent_visibility_no_false_green_count": 1, + }, + "boundaries": boundaries(), + }, + ) + + +def redacted_agent(agent: dict[str, Any], index: int) -> dict[str, Any]: + os_info = agent.get("os") if isinstance(agent.get("os"), dict) else {} + return { + "alias": f"agent-{index + 1:02d}", + "status": agent.get("status", "unknown"), + "os": os_info.get("platform") or os_info.get("name") or "unknown", + "last_seen_present": bool(agent.get("lastKeepAlive")), + } + + +def int_or_default(value: Any, default: int) -> int: + return value if isinstance(value, int) else default + + +def agent_visibility_status(agent_total: int, expected_minimum: int) -> str: + if agent_total <= 0: + return "wazuh_agent_registry_empty" + if expected_minimum > 0 and agent_total < expected_minimum: + return "wazuh_agent_registry_below_expected" + return "readonly_metadata_available" + + +async def fetch_json(client: httpx.AsyncClient, url: str, headers: dict[str, str]) -> dict[str, Any]: + response = await client.get(url, headers=headers) + response.raise_for_status() + payload = response.json() + return payload if isinstance(payload, dict) else {} + + +async def load_iwooos_wazuh_readonly_status() -> WazuhReadonlyStatus: + env = wazuh_env() + if env["enabled"] != "true": + return boundary_status("disabled_waiting_iwooos_wazuh_owner_gate") + + base_url = https_url(env["base_url"]) + if not base_url or not env["username"] or not env["password"]: + return boundary_status("misconfigured_missing_server_side_wazuh_env", 503) + + try: + auth_header = b64encode(f"{env['username']}:{env['password']}".encode("utf-8")).decode("ascii") + async with httpx.AsyncClient(timeout=REQUEST_TIMEOUT_SECONDS) as client: + auth = await fetch_json( + client, + urljoin(base_url, "security/user/authenticate"), + {"Authorization": f"Basic {auth_header}"}, + ) + token = (auth.get("data") or {}).get("token") + if not token: + return boundary_status("wazuh_auth_token_missing", 502) + + bearer_headers = {"Authorization": f"Bearer {token}"} + status_payload = await fetch_json( + client, + urljoin(base_url, "agents/summary/status"), + bearer_headers, + ) + agents_payload = await fetch_json( + client, + urljoin(base_url, "agents?limit=100&select=id,status,os.name,os.platform,lastKeepAlive"), + bearer_headers, + ) + except (httpx.HTTPError, ValueError): + return boundary_status("wazuh_readonly_metadata_unavailable", 502) + + connection = ((status_payload.get("data") or {}).get("connection") or {}) + affected_items = ((agents_payload.get("data") or {}).get("affected_items") or []) + if not isinstance(affected_items, list): + affected_items = [] + expected_minimum = expected_min_agent_count(env["expected_min_agent_count"]) + agent_total = int_or_default(connection.get("total"), len(affected_items)) + agent_active = int_or_default(connection.get("active"), 0) + agent_disconnected = int_or_default(connection.get("disconnected"), 0) + agent_pending = int_or_default(connection.get("pending"), 0) + agent_registry_empty = agent_total <= 0 + agent_below_expected = expected_minimum > 0 and agent_total < expected_minimum + + return WazuhReadonlyStatus( + payload={ + "schema_version": "iwooos_wazuh_readonly_status_v1", + "status": agent_visibility_status(agent_total, expected_minimum), + "mode": "metadata_only_no_active_response_no_raw_payload", + "configured": True, + "summary": { + "wazuh_platform_reported_count": 1, + "readonly_api_enabled_count": 1, + "agent_total": agent_total, + "agent_active": agent_active, + "agent_disconnected": agent_disconnected, + "agent_pending": agent_pending, + "expected_min_agent_count": expected_minimum, + "agent_registry_empty_count": 1 if agent_registry_empty else 0, + "agent_below_expected_minimum_count": 1 if agent_below_expected else 0, + "agent_visibility_no_false_green_count": 1, + "wazuh_manager_query_accepted_count": 0, + "wazuh_event_accepted_count": 0, + "host_forensics_accepted_count": 0, + "active_response_authorized_count": 0, + "host_write_authorized_count": 0, + "runtime_gate_count": 0, + }, + "agents": [redacted_agent(agent, index) for index, agent in enumerate(affected_items[:20])], + "boundaries": boundaries(), + }, + ) diff --git a/apps/api/tests/test_iwooos_runtime_security_readback.py b/apps/api/tests/test_iwooos_runtime_security_readback.py index abcd2e1d..652bd2f9 100644 --- a/apps/api/tests/test_iwooos_runtime_security_readback.py +++ b/apps/api/tests/test_iwooos_runtime_security_readback.py @@ -21,11 +21,15 @@ def test_iwooos_runtime_security_readback_preserves_zero_runtime_gates() -> None assert payload["schema_version"] == "iwooos_runtime_security_readback_v1" assert payload["status"] == "blocked_waiting_owner_evidence_and_runtime_gates" assert payload["summary"]["source_snapshot_count"] == 8 - assert payload["summary"]["p0_lane_count"] == 6 + assert payload["summary"]["p0_lane_count"] == 7 assert payload["summary"]["runtime_gate_count"] == 0 assert payload["summary"]["owner_response_received_count"] == 0 assert payload["summary"]["owner_response_accepted_count"] == 0 assert payload["summary"]["wazuh_manager_registry_accepted_count"] == 0 + assert payload["summary"]["wazuh_live_status"] == "not_checked_by_snapshot_loader" + assert payload["summary"]["wazuh_live_route_degraded_count"] == 1 + assert payload["summary"]["wazuh_live_readonly_api_enabled_count"] == 0 + assert payload["summary"]["wazuh_live_metadata_available_count"] == 0 assert payload["summary"]["kali_active_scan_authorized_count"] == 0 assert payload["summary"]["kali_execute_authorized_count"] == 0 assert payload["summary"]["alert_receipt_runtime_send_count"] == 0 @@ -41,6 +45,7 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None: lane_ids = {lane["lane_id"] for lane in payload["lanes"]} assert lane_ids == { "wazuh_registry", + "wazuh_live_route", "wazuh_dashboard_api", "kali_intake", "alert_readability", @@ -52,16 +57,69 @@ def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None: assert all(lane["source_refs"] for lane in payload["lanes"]) assert any(lane["completion_percent"] > 0 for lane in payload["lanes"]) assert all(lane["lane_id"] != "wazuh_registry" or lane["completion_percent"] == 0 for lane in payload["lanes"]) + assert all(lane["lane_id"] != "wazuh_live_route" or lane["metrics"]["route_degraded"] == 1 for lane in payload["lanes"]) -def test_iwooos_runtime_security_readback_api_is_public_safe() -> None: +def test_iwooos_runtime_security_readback_api_is_public_safe(monkeypatch) -> None: + monkeypatch.delenv("IWOOOS_WAZUH_READONLY_ENABLED", raising=False) + monkeypatch.delenv("WAZUH_API_BASE_URL", raising=False) + monkeypatch.delenv("WAZUH_API_USERNAME", raising=False) + monkeypatch.delenv("WAZUH_API_PASSWORD", raising=False) + response = _client().get("/api/v1/iwooos/runtime-security-readback") assert response.status_code == 200 data = response.json() assert data["schema_version"] == "iwooos_runtime_security_readback_v1" assert data["summary"]["runtime_gate_count"] == 0 + assert data["summary"]["wazuh_live_status"] == "disabled_waiting_iwooos_wazuh_owner_gate" + assert data["summary"]["wazuh_live_route_http_status"] == 200 + assert data["summary"]["wazuh_live_route_degraded_count"] == 1 + assert data["summary"]["wazuh_live_metadata_available_count"] == 0 assert data["boundaries"]["secret_value_collection_allowed"] is False assert "192.168.0." not in response.text assert "工作視窗" not in response.text assert "批准!繼續" not in response.text + + +def test_iwooos_runtime_security_readback_api_includes_live_wazuh_empty_registry(monkeypatch) -> None: + import httpx + + monkeypatch.setenv("IWOOOS_WAZUH_READONLY_ENABLED", "true") + monkeypatch.setenv("WAZUH_API_BASE_URL", "https://wazuh.example.test:55000") + monkeypatch.setenv("WAZUH_API_USERNAME", "readonly") + monkeypatch.setenv("WAZUH_API_PASSWORD", "placeholder") + monkeypatch.setenv("IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "6") + + def handler(request: httpx.Request) -> httpx.Response: + if request.url.path == "/security/user/authenticate": + return httpx.Response(200, json={"data": {"token": "token-value"}}) + if request.url.path == "/agents/summary/status": + return httpx.Response( + 200, + json={"data": {"connection": {"total": 0, "active": 0, "disconnected": 0, "pending": 0}}}, + ) + if request.url.path == "/agents": + return httpx.Response(200, json={"data": {"affected_items": []}}) + return httpx.Response(404) + + transport = httpx.MockTransport(handler) + original_async_client = httpx.AsyncClient + + def client_factory(*args, **kwargs): + kwargs["transport"] = transport + return original_async_client(*args, **kwargs) + + monkeypatch.setattr(httpx, "AsyncClient", client_factory) + + response = _client().get("/api/v1/iwooos/runtime-security-readback") + + assert response.status_code == 200 + data = response.json() + assert data["summary"]["wazuh_live_status"] == "wazuh_agent_registry_empty" + assert data["summary"]["wazuh_live_readonly_api_enabled_count"] == 1 + assert data["summary"]["wazuh_live_agent_total"] == 0 + assert data["summary"]["wazuh_live_registry_empty_count"] == 1 + assert data["summary"]["wazuh_live_route_degraded_count"] == 1 + assert data["summary"]["runtime_gate_count"] == 0 + assert "token-value" not in response.text diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index a3783e09..cfbd1088 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -20206,8 +20206,8 @@ }, "runtimeSecurityReadback": { "eyebrow": "IwoooS Runtime 資安讀回", - "title": "六條 P0 資安線先接到同一張讀回板", - "subtitle": "這張板讀取後端彙整的只讀快照,集中顯示 Wazuh 清單驗收、儀表板 API、資安觀測節點 intake、告警可讀性、負責人送件與外部入侵防護;它不查 live Wazuh、不啟動掃描、不送訊息、不改主機。", + "title": "七條 P0 資安線先接到同一張讀回板", + "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由的公開安全 aggregate 讀回;它不保存 raw Wazuh payload、不啟動掃描、不送訊息、不改主機。", "statusLabel": "讀回狀態", "statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。", "laneStatusLabel": "目前狀態", @@ -20233,6 +20233,10 @@ "label": "Wazuh 清單", "detail": "管理器清單接受數仍為 0。" }, + "wazuhLive": { + "label": "Wazuh live", + "detail": "正式只讀路由若 disabled、空清單或低於預期,首屏直接顯示警示。" + }, "ownerAccepted": { "label": "負責人驗收", "detail": "收到 / 接受都必須由正式 owner response 證明。" @@ -20251,6 +20255,10 @@ "title": "Wazuh manager registry", "body": "必須拿到總數、在線、離線、從未連線與時間窗;transport 或儀表板可見不能代替驗收。" }, + "wazuh_live_route": { + "title": "Wazuh 正式只讀路由", + "body": "直接讀正式路由的公開安全 aggregate;disabled、misconfigured、empty、below expected 都是 P0 紅燈,不會被 route 200 蓋過。" + }, "wazuh_dashboard_api": { "title": "Wazuh Dashboard API", "body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。" diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index a3783e09..cfbd1088 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -20206,8 +20206,8 @@ }, "runtimeSecurityReadback": { "eyebrow": "IwoooS Runtime 資安讀回", - "title": "六條 P0 資安線先接到同一張讀回板", - "subtitle": "這張板讀取後端彙整的只讀快照,集中顯示 Wazuh 清單驗收、儀表板 API、資安觀測節點 intake、告警可讀性、負責人送件與外部入侵防護;它不查 live Wazuh、不啟動掃描、不送訊息、不改主機。", + "title": "七條 P0 資安線先接到同一張讀回板", + "subtitle": "這張板讀取後端彙整的只讀快照,並附上 Wazuh 正式只讀路由的公開安全 aggregate 讀回;它不保存 raw Wazuh payload、不啟動掃描、不送訊息、不改主機。", "statusLabel": "讀回狀態", "statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界;runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。", "laneStatusLabel": "目前狀態", @@ -20233,6 +20233,10 @@ "label": "Wazuh 清單", "detail": "管理器清單接受數仍為 0。" }, + "wazuhLive": { + "label": "Wazuh live", + "detail": "正式只讀路由若 disabled、空清單或低於預期,首屏直接顯示警示。" + }, "ownerAccepted": { "label": "負責人驗收", "detail": "收到 / 接受都必須由正式 owner response 證明。" @@ -20251,6 +20255,10 @@ "title": "Wazuh manager registry", "body": "必須拿到總數、在線、離線、從未連線與時間窗;transport 或儀表板可見不能代替驗收。" }, + "wazuh_live_route": { + "title": "Wazuh 正式只讀路由", + "body": "直接讀正式路由的公開安全 aggregate;disabled、misconfigured、empty、below expected 都是 P0 紅燈,不會被 route 200 蓋過。" + }, "wazuh_dashboard_api": { "title": "Wazuh Dashboard API", "body": "API connection / API version 還要補 readback;index pattern 通過不能宣稱 Wazuh 全綠。" diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index bbc7d658..41953903 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -326,7 +326,7 @@ type WazuhReadonlyStatusResponse = { } type RuntimeSecurityReadbackSummaryItem = { - key: 'controlPlane' | 'runtimeAcceptance' | 'wazuhRegistry' | 'ownerAccepted' | 'kaliRuntime' | 'runtimeGate' + key: 'controlPlane' | 'runtimeAcceptance' | 'wazuhRegistry' | 'wazuhLive' | 'ownerAccepted' | 'kaliRuntime' | 'runtimeGate' value: string icon: typeof ShieldCheck tone: 'steady' | 'warn' | 'locked' @@ -8124,6 +8124,12 @@ function IwoooSRuntimeSecurityReadbackBoard() { icon: Radar, tone: 'locked', }, + { + key: 'wazuhLive', + value: summary ? `${summary.wazuh_live_agent_total}/${summary.wazuh_live_status}` : '...', + icon: Route, + tone: summary && summary.wazuh_live_metadata_available_count === 1 && summary.wazuh_live_route_degraded_count === 0 ? 'steady' : 'warn', + }, { key: 'ownerAccepted', value: summary ? `${summary.owner_response_accepted_count}/${summary.owner_response_received_count}` : '...', diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 57fe8ba7..2c64aab0 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -100,6 +100,7 @@ export type IwoooSRuntimeSecurityReadbackTone = 'steady' | 'warn' | 'locked' export interface IwoooSRuntimeSecurityReadbackLane { lane_id: | 'wazuh_registry' + | 'wazuh_live_route' | 'wazuh_dashboard_api' | 'kali_intake' | 'alert_readability' @@ -131,6 +132,15 @@ export interface IwoooSRuntimeSecurityReadbackResponse { wazuh_manager_registry_accepted_count: number wazuh_transport_observed_count: number wazuh_dashboard_api_degraded_observed_count: number + wazuh_live_route_http_status: number + wazuh_live_route_degraded_count: number + wazuh_live_readonly_api_enabled_count: number + wazuh_live_agent_total: number + wazuh_live_agent_active: number + wazuh_live_registry_empty_count: number + wazuh_live_below_expected_count: number + wazuh_live_metadata_available_count: number + wazuh_live_status: string kali_active_scan_authorized_count: number kali_execute_authorized_count: number kali_finding_envelope_accepted_count: number diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index baf712a3..775234d5 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -151,6 +151,42 @@ **邊界**:本段只改前端全域 App Shell、Sidebar/Header 與 i18n;沒有改 AI runtime gate、Telegram send、告警路由、主機、Nginx、K8s、secret、DB、workflow 或自動修復授權。 +## 2026-06-26|P0 Wazuh live route 進 Runtime 資安讀回:disabled / empty 不再藏在下方卡片 + +**背景**:正式站 live readback 顯示 `GET /api/iwooos/wazuh` 與 `GET /api/v1/iwooos/wazuh` 目前皆為 `200 disabled_waiting_iwooos_wazuh_owner_gate`,代表 Wazuh 只讀 live metadata 尚未啟用;這不是 manager registry 恢復、不是 agent 全部納管,也不是 API connection 已修復。為避免 Wazuh 退化只藏在 IwoooS 頁面下方卡片,本段把正式 Wazuh 只讀路由的公開安全 aggregate 結果接進 Runtime 資安讀回總板。 + +**完成**: +- 新增 `apps/api/src/services/iwooos_wazuh_readonly_status.py`,把 Wazuh 只讀 metadata 邏輯從 API router 抽成可重用 service;仍只回 aggregate / redacted agent alias,不保存 raw Wazuh payload、不顯示 agent 原名、內網位址、token、password 或 secret。 +- `GET /api/iwooos/wazuh` 與 `GET /api/v1/iwooos/wazuh` 保持相容,改由 service 回傳相同 disabled / misconfigured / unavailable / empty / below expected / available 狀態。 +- `GET /api/v1/iwooos/runtime-security-readback` 新增 `wazuh_live_route` P0 lane 與 `wazuh_live_*` summary:正式路由 HTTP、唯讀查詢啟用數、agent total / active、registry empty、below expected、metadata available 與 degraded count 都會進 Runtime board。 +- `/zh-TW/iwooos` Runtime 資安讀回摘要新增 `Wazuh live`,將 `agent_total / status` 顯示在首屏板;disabled、misconfigured、empty、below expected 或 unavailable 都以警示色呈現,不能被 route 200 蓋過。 +- `scripts/security/wazuh-readonly-route-boundary-guard.py` 已從掃 2 個 route 擴充為掃 3 個 source:Next route、FastAPI route、新 Wazuh service;避免 service 內硬編 Wazuh URL、帳密、關 TLS、raw payload 或假 SOC 文案。 +- `IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md` 已同步新 service、Runtime lane 與 `route=3` guard 結果。 + +**本地驗證**: +- `pytest apps/api/tests/test_iwooos_runtime_security_readback.py apps/api/tests/test_iwooos_wazuh_api.py -q`:`10 passed, 1 warning`。 +- IwoooS / Telegram / operator 關鍵子集:`255 passed, 2 warnings`。 +- `python3 scripts/security/wazuh-readonly-route-boundary-guard.py --root .`:`WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK route=3 public_ui_files=1 forbidden=0 runtime_gate=0`。 +- `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。 +- `python3 scripts/security/source-control-owner-response-guard.py --root .`:`SOURCE_CONTROL_OWNER_RESPONSE_GUARD_OK`。 +- `python3 scripts/ops/doc-secrets-sanity-check.py docs/security docs/templates docs/LOGBOOK.md apps/web/messages apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_runtime_security_readback.py apps/api/src/services/iwooos_wazuh_readonly_status.py apps/web/src/app/[locale]/iwooos/page.tsx apps/web/src/lib/api-client.ts scripts/security/wazuh-readonly-route-boundary-guard.py`:`DOC_SECRET_SANITY_OK scanned_files=274`。 +- `python3 -m json.tool apps/web/messages/zh-TW.json` / `apps/web/messages/en.json`:通過。 +- `python3 -m py_compile apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_runtime_security_readback.py apps/api/src/services/iwooos_wazuh_readonly_status.py scripts/security/wazuh-readonly-route-boundary-guard.py`:通過。 +- `git diff --check`:通過。 +- 本地 FastAPI TestClient smoke:`/api/v1/iwooos/runtime-security-readback` 回 `200`、`p0_lane_count=7`、`wazuh_live_status=disabled_waiting_iwooos_wazuh_owner_gate`、`wazuh_live_route_degraded_count=1`、`wazuh_live_route` lane 存在,且未含 `192.168.0.`、`工作視窗`、`批准!繼續`、`My request for Codex`。 +- `pnpm --dir apps/web typecheck`:本臨時 worktree 缺 `apps/web/node_modules/typescript`,未能本地執行;需以 Gitea CD / production browser readback 補正式驗證。 + +**完成度同步**: +- 本階段 source-side 實作:`100%`。 +- Runtime 資安讀回納入 Wazuh live route:`0% -> 100%`。 +- Wazuh live metadata enable:仍 `0%`。 +- Wazuh manager registry accepted:仍 `0`。 +- IwoooS Runtime 資安讀回層:`94% -> 95%`。 +- IwoooS 整體資安推進保守維持:`65%`;不因 route 可見或 lane 接上而提高 runtime acceptance。 +- Runtime acceptance、owner accepted、active response、host write、Kali active scan、Telegram send、secret collection:仍全部 `0 / false`。 + +**邊界**:本段沒有啟用 Wazuh live metadata env、沒有收集 Wazuh secret、沒有修 dashboard stored API、沒有重新註冊 agent、沒有重啟 Wazuh manager / dashboard、沒有 SSH 主機、沒有改 Nginx / Docker / firewall / K8s、沒有 active response、沒有 Kali scan、沒有 Telegram send、沒有 force push。下一個 P0 仍是 Wazuh live metadata owner gate、server-side secret metadata、readonly account scope、manager health ref、post-enable readback 與 manager registry 全量交叉驗收。 + ## 2026-06-26|IwoooS controlled apply guard 收斂:資安讀回、防退化與正式站驗證完成 **背景**:P2-414B 已把 AwoooP allowlisted low / medium / high 修復路徑改成 `controlled_apply`,但部分資安 guard 與文件仍停在舊的 `runtime_write_gate=0` / `candidate_only` / 高風險人工 Gate 語意,造成 CD guard 與最新產品方向衝突。本段只收斂 source / guard / readback / production verification,不碰主機、Wazuh live、Kali、Nginx、Docker、firewall、secret 或 Telegram send。 diff --git a/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md b/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md index 258915e9..2e2d2c18 100644 --- a/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md +++ b/docs/security/IWOOOS-WAZUH-READONLY-API-RELEASE-HANDOFF.md @@ -35,8 +35,11 @@ 變更範圍: - `apps/api/src/api/v1/iwooos.py` +- `apps/api/src/services/iwooos_wazuh_readonly_status.py` +- `apps/api/src/services/iwooos_runtime_security_readback.py` - `apps/api/src/main.py` - `apps/api/tests/test_iwooos_wazuh_api.py` +- `apps/api/tests/test_iwooos_runtime_security_readback.py` - `scripts/security/wazuh-readonly-route-boundary-guard.py` - `scripts/security/wazuh-readonly-production-readback.py` - `scripts/security/wazuh-readonly-release-gate.py` @@ -59,6 +62,8 @@ - 新增 FastAPI `GET /api/iwooos/wazuh`。 - 新增 FastAPI `GET /api/v1/iwooos/wazuh`。 +- 將 Wazuh 只讀 metadata 邏輯抽成 `iwooos_wazuh_readonly_status.py`,讓正式 route、Runtime 資安讀回與後續告警鏈共用同一份脫敏 aggregate 結果。 +- `GET /api/v1/iwooos/runtime-security-readback` 新增 `wazuh_live_route` P0 lane 與 `wazuh_live_*` summary;正式路由 disabled、misconfigured、registry empty、below expected 或 unavailable 都會在 Runtime board 顯示退化,不再只藏在下方卡片。 - 預設回 `disabled_waiting_iwooos_wazuh_owner_gate`,避免 production 繼續用 404 表示未啟用。 - live Wazuh 查詢仍需 `IWOOOS_WAZUH_READONLY_ENABLED=true` 與 server-side env:`WAZUH_API_BASE_URL`、`WAZUH_API_USERNAME`、`WAZUH_API_PASSWORD`。 - 強制 Wazuh base URL 使用 HTTPS。 @@ -87,8 +92,8 @@ python3 scripts/security/wazuh-readonly-release-owner-request.py --root . python3 scripts/security/wazuh-readonly-release-owner-response-acceptance.py --root . python3 scripts/security/wazuh-readonly-live-metadata-env-gate.py --root . python3 scripts/security/security-mirror-progress-guard.py --root . -python3 scripts/ops/doc-secrets-sanity-check.py docs apps/api/src/api/v1/iwooos.py apps/web/src/app/api/iwooos/wazuh/route.ts scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py -python3 -m py_compile apps/api/src/api/v1/iwooos.py scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py scripts/security/security-mirror-progress-guard.py +python3 scripts/ops/doc-secrets-sanity-check.py docs apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_wazuh_readonly_status.py apps/api/src/services/iwooos_runtime_security_readback.py apps/web/src/app/api/iwooos/wazuh/route.ts scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py +python3 -m py_compile apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_wazuh_readonly_status.py apps/api/src/services/iwooos_runtime_security_readback.py scripts/security/wazuh-readonly-route-boundary-guard.py scripts/security/wazuh-readonly-production-readback.py scripts/security/wazuh-readonly-release-gate.py scripts/security/wazuh-readonly-release-lane-preflight.py scripts/security/wazuh-readonly-release-owner-request.py scripts/security/wazuh-readonly-release-owner-response-acceptance.py scripts/security/wazuh-readonly-live-metadata-env-gate.py scripts/security/security-mirror-progress-guard.py git diff --check node -e "JSON.parse(require('fs').readFileSync('apps/web/messages/zh-TW.json','utf8')); JSON.parse(require('fs').readFileSync('apps/web/messages/en.json','utf8')); console.log('i18n json ok')" cmp -s apps/web/messages/zh-TW.json apps/web/messages/en.json @@ -99,7 +104,7 @@ NEXT_PUBLIC_API_URL=https://awoooi.wooo.work NEXT_PRIVATE_BUILD_WORKER_COUNT=1 S 驗證結果: - `pytest apps/api/tests/test_iwooos_wazuh_api.py`:`6 passed`。 -- `wazuh-readonly-route-boundary-guard`:`route=2 public_ui_files=1 forbidden=0 runtime_gate=0`。 +- `wazuh-readonly-route-boundary-guard`:`route=3 public_ui_files=1 forbidden=0 runtime_gate=0`。 - `wazuh-readonly-release-gate`:`source=1 push=1 main=1 deploy=1 readback=1 runtime_gate=0`。 - `wazuh-readonly-release-lane-preflight`:`ready=0 acks=0/6 evidence=0/6 runtime_gate=0`。 - `wazuh-readonly-release-owner-request`:`drafts=1 sent=0 accepted=0 runtime_gate=0`。 @@ -130,7 +135,7 @@ git am /private/tmp/awoooi-iwooos-wazuh-boundary-release-patch-/*.pat 乾淨套用 worktree 驗證結果: - `pytest apps/api/tests/test_iwooos_wazuh_api.py`:`6 passed`。 -- `python3 scripts/security/wazuh-readonly-route-boundary-guard.py --root .`:`WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK route=2 public_ui_files=1 forbidden=0 runtime_gate=0`。 +- `python3 scripts/security/wazuh-readonly-route-boundary-guard.py --root .`:`WAZUH_READONLY_ROUTE_BOUNDARY_GUARD_OK route=3 public_ui_files=1 forbidden=0 runtime_gate=0`。 - `python3 scripts/security/wazuh-readonly-release-gate.py --root .`:`WAZUH_READONLY_RELEASE_GATE_OK source=1 push=1 main=1 deploy=1 readback=1 runtime_gate=0`。 - `python3 scripts/security/wazuh-readonly-release-lane-preflight.py --root .`:`WAZUH_READONLY_RELEASE_LANE_PREFLIGHT_OK ready=0 acks=0/6 evidence=0/6 runtime_gate=0`。 - `python3 scripts/security/security-mirror-progress-guard.py --root .`:`SECURITY_MIRROR_PROGRESS_GUARD_OK`。 diff --git a/scripts/security/wazuh-readonly-route-boundary-guard.py b/scripts/security/wazuh-readonly-route-boundary-guard.py index a8ae2e9b..fec308ae 100644 --- a/scripts/security/wazuh-readonly-route-boundary-guard.py +++ b/scripts/security/wazuh-readonly-route-boundary-guard.py @@ -20,6 +20,7 @@ from typing import Any TAIPEI = timezone(timedelta(hours=8)) NEXT_ROUTE_PATH = Path("apps/web/src/app/api/iwooos/wazuh/route.ts") BACKEND_ROUTE_PATH = Path("apps/api/src/api/v1/iwooos.py") +BACKEND_SERVICE_PATH = Path("apps/api/src/services/iwooos_wazuh_readonly_status.py") PUBLIC_PAGE_PATH = Path("apps/web/src/app/[locale]/iwooos/page.tsx") PUBLIC_COMPONENT_ROOT = Path("apps/web/src/components/iwooos") @@ -60,6 +61,10 @@ ROUTE_REQUIRED_TOKENS = [ BACKEND_REQUIRED_TOKENS = [ "/api/iwooos/wazuh", "/api/v1/iwooos/wazuh", + "load_iwooos_wazuh_readonly_status", +] + +BACKEND_SERVICE_REQUIRED_TOKENS = [ "IWOOOS_WAZUH_READONLY_ENABLED", "IWOOOS_WAZUH_EXPECTED_MIN_AGENT_COUNT", "WAZUH_API_BASE_URL", @@ -71,7 +76,7 @@ BACKEND_REQUIRED_TOKENS = [ "runtime_gate_count", "raw_wazuh_payload_storage_allowed", "internal_ip_public_display_allowed", - "_redacted_agent", + "redacted_agent", "wazuh_agent_registry_empty", "wazuh_agent_registry_below_expected", "agent_registry_empty_count", @@ -175,7 +180,11 @@ def pattern_applies(pattern: ForbiddenPattern, source_kind: str) -> bool: def collect_forbidden_matches(root: Path) -> list[dict[str, Any]]: - targets: list[tuple[str, Path]] = [("route", NEXT_ROUTE_PATH), ("route", BACKEND_ROUTE_PATH)] + targets: list[tuple[str, Path]] = [ + ("route", NEXT_ROUTE_PATH), + ("route", BACKEND_ROUTE_PATH), + ("route", BACKEND_SERVICE_PATH), + ] targets.extend(("public_ui", path) for path in collect_public_ui_files(root)) matches: list[dict[str, Any]] = [] @@ -204,14 +213,20 @@ def collect_missing_required_tokens(route_text: str, required_tokens: list[str]) def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]: next_route = root / NEXT_ROUTE_PATH backend_route = root / BACKEND_ROUTE_PATH + backend_service = root / BACKEND_SERVICE_PATH next_route_present = next_route.exists() backend_route_present = backend_route.exists() + backend_service_present = backend_service.exists() next_route_text = read_text(next_route) if next_route_present else "" backend_route_text = read_text(backend_route) if backend_route_present else "" + backend_service_text = read_text(backend_service) if backend_service_present else "" public_ui_files = collect_public_ui_files(root) missing_required_tokens = { NEXT_ROUTE_PATH.as_posix(): collect_missing_required_tokens(next_route_text, ROUTE_REQUIRED_TOKENS), BACKEND_ROUTE_PATH.as_posix(): collect_missing_required_tokens(backend_route_text, BACKEND_REQUIRED_TOKENS), + BACKEND_SERVICE_PATH.as_posix(): collect_missing_required_tokens( + backend_service_text, BACKEND_SERVICE_REQUIRED_TOKENS + ), } missing_required_token_count = sum(len(tokens) for tokens in missing_required_tokens.values()) forbidden_matches = collect_forbidden_matches(root) @@ -225,28 +240,37 @@ def build_report(root: Path, generated_at: str | None = None) -> dict[str, Any]: else "blocked" ), "mode": "repo_source_scan_no_runtime_no_secret_collection", - "guarded_route_paths": [NEXT_ROUTE_PATH.as_posix(), BACKEND_ROUTE_PATH.as_posix()], + "guarded_route_paths": [ + NEXT_ROUTE_PATH.as_posix(), + BACKEND_ROUTE_PATH.as_posix(), + BACKEND_SERVICE_PATH.as_posix(), + ], "guarded_public_ui_paths": [path.as_posix() for path in public_ui_files], "required_route_tokens": { NEXT_ROUTE_PATH.as_posix(): ROUTE_REQUIRED_TOKENS, BACKEND_ROUTE_PATH.as_posix(): BACKEND_REQUIRED_TOKENS, + BACKEND_SERVICE_PATH.as_posix(): BACKEND_SERVICE_REQUIRED_TOKENS, }, "forbidden_pattern_ids": [pattern.pattern_id for pattern in FORBIDDEN_PATTERNS], "summary": { - "route_present_count": int(next_route_present) + int(backend_route_present), + "route_present_count": int(next_route_present) + int(backend_route_present) + int(backend_service_present), "next_route_present_count": 1 if next_route_present else 0, "backend_route_present_count": 1 if backend_route_present else 0, + "backend_service_present_count": 1 if backend_service_present else 0, "public_ui_file_count": len(public_ui_files), - "required_token_count": len(ROUTE_REQUIRED_TOKENS) + len(BACKEND_REQUIRED_TOKENS), + "required_token_count": len(ROUTE_REQUIRED_TOKENS) + + len(BACKEND_REQUIRED_TOKENS) + + len(BACKEND_SERVICE_REQUIRED_TOKENS), "missing_required_token_count": missing_required_token_count, "forbidden_pattern_count": len(FORBIDDEN_PATTERNS), "forbidden_match_count": len(forbidden_matches), "readonly_api_default_closed_count": sum( - "IWOOOS_WAZUH_READONLY_ENABLED" in text for text in [next_route_text, backend_route_text] + "IWOOOS_WAZUH_READONLY_ENABLED" in text + for text in [next_route_text, backend_route_text, backend_service_text] ), "server_side_env_required_count": sum( token in text - for text in [next_route_text, backend_route_text] + for text in [next_route_text, backend_route_text, backend_service_text] for token in ["WAZUH_API_BASE_URL", "WAZUH_API_USERNAME", "WAZUH_API_PASSWORD"] ), "tls_disable_match_count": sum( @@ -299,6 +323,8 @@ def validate(root: Path) -> None: errors.append(f"{NEXT_ROUTE_PATH.as_posix()}: Wazuh Next.js 只讀 route 不存在") if report["summary"]["backend_route_present_count"] != 1: errors.append(f"{BACKEND_ROUTE_PATH.as_posix()}: Wazuh FastAPI 相容 route 不存在") + if report["summary"]["backend_service_present_count"] != 1: + errors.append(f"{BACKEND_SERVICE_PATH.as_posix()}: Wazuh FastAPI 只讀 service 不存在") for path, tokens in report["missing_required_tokens"].items(): for token in tokens: errors.append(f"{path}: 缺少必要只讀邊界 token {token!r}") From 88b63f4d1de0e5d89e6247ba350b3a3a3ad34a2b Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 26 Jun 2026 23:36:37 +0800 Subject: [PATCH 5/5] docs(logbook): record approval handoff readiness rollout [skip ci] --- docs/LOGBOOK.md | 34 +++++++++++++++++++ ...026-06-04-iwooos-security-governance-p0.md | 4 +-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 775234d5..47cffdf4 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,37 @@ +## 2026-06-26|D1E AwoooP Approvals:批准後 executor handoff readiness 前台可見 + +**背景**:使用者指出 Telegram 告警批准後仍沒有真正自動化,AwoooP Approvals 也看不到可操作選項或清楚人工接手方式。程式碼讀回確認 `adr100_runtime_replay_gate5` 投影型 approval 會被 API 以 `409` 阻擋,原因是尚未接上 `auto_repair_executor` 執行 handoff;但 Approvals 頁只顯示「等待 executor handoff」與一般 Work Items 連結,沒有把缺少的 owner review / 安全路由 / verifier 條件前移。 + +**完成內容**: +- `/zh-TW/awooop/approvals` 的焦點 Incident 區塊新增 `Executor handoff readiness` 卡。 +- 直接顯示可交接度、ready / total、blocked count、status、`runtime gate closed`、下一步、阻擋原因與缺少的 owner review / 安全路由欄位。 +- `開啟 owner review` 連到同一 Incident / Work Item;`追蹤 Runs` 連到同一 Incident 的 Runs。 +- 平台 approval API 查詢補上 `project_id` filter,避免跨產品納管後把不同專案的待審資料混在一起。 +- 不把批准卡誤導成執行卡;不觸發 executor、不套用 PlayBook、不執行 Ansible、不發 Telegram、不重啟服務、不開 runtime gate。 + +**Commit / deploy**: +- Code commit:`2239507e0 fix(web): expose approval executor handoff readiness`。 +- Deploy marker:`335d5f4a7 chore(cd): deploy 2239507 [skip ci]`。 +- 中間平行 commit:`18a35c5e6 fix(ops): avoid unknown stock blockers when fresh` 已包含本段 code commit,且本地已 fast-forward 到 deploy marker。 + +**正式站驗證**: +- Desktop:`https://awoooi.wooo.work/zh-TW/awooop/approvals?project_id=awoooi&incident_id=INC-PROD-D4&_v=335d5f4a-approval-handoff-readiness-desktop`,`Executor handoff readiness`、`可交接度`、`runtime gate closed`、`開啟 owner review`、`追蹤 Runs` 可見;Work Items / Runs href 可用;`horizontalOverflow=false`、`appError=false`。 +- Mobile:`https://awoooi.wooo.work/zh-TW/awooop/approvals?project_id=awoooi&incident_id=INC-PROD-D4&_v=335d5f4a-approval-handoff-readiness-mobile`,同組內容可見;`clientWidth=384`、`scrollWidth=384`、`horizontalOverflow=false`、`appError=false`、操作入口 2 個且皆為導覽入口。 +- 截圖:`/tmp/awoooi-approvals-handoff-readiness-desktop-335d5f4a.png`、`/tmp/awoooi-approvals-handoff-readiness-mobile-335d5f4a.png`。 + +**完成度**: +- Approvals executor handoff readiness 可視化:正式站 `100%`。 +- Telegram / AwoooP 告警自動化可追蹤性:`98% -> 99%`。 +- 真正 AI 自動化 runtime 閉環:仍 `15-25%`。 +- active runtime gate:仍 `0`。 + +**後續缺口**: +- 下一步必須讓新告警或重診真的產生 `repair_candidate_promotion_contract_v1`,再走 owner release、maintenance window、rollback owner、controlled execution、post-apply verifier 與 KM / PlayBook trust 回寫。 +- 舊 Incident 不會 retroactive 生成完整 promotion contract;需以新 incident / 重診驗證 approval -> controlled execution -> verifier -> KM / PlayBook trust 全鏈。 + +**邊界**: +- 本段沒有 runtime execution、沒有 service restart、沒有 Ansible apply、沒有 Telegram send、沒有 provider switch、沒有 active scan、沒有 SSH、沒有 secret read。 + ## 2026-06-26|D1D Knowledge Base 首屏補強:KM / PlayBook / RAG 缺口可見化 **背景**:使用者指出 KM、PlayBook、腳本、排程、自動化機制與 Verifier 沉澱結果在頁面看不到,會讓 AI 自動化成果等於沒有做。正式 API 讀回確認知識庫並非無資料:`/api/v1/knowledge?project_id=awoooi&limit=50` 回 `total=667`;真正問題是首屏沒有把「哪些資產有沉澱、哪些仍缺」說清楚,且 `/api/v1/knowledge/rag/stats` 顯示 RAG chunks / sources 仍為 `0 / 0`。 diff --git a/docs/workplans/2026-06-04-iwooos-security-governance-p0.md b/docs/workplans/2026-06-04-iwooos-security-governance-p0.md index eaff4598..eb56df4e 100644 --- a/docs/workplans/2026-06-04-iwooos-security-governance-p0.md +++ b/docs/workplans/2026-06-04-iwooos-security-governance-p0.md @@ -9,7 +9,7 @@ | 工作視窗 | IwoooS / AWOOOI 資安治理 P0 | | 本次乾淨 worktree | `/private/tmp/awoooi-owner-release-closure-20260626` | | 本次分支 | `codex/owner-release-closure-20260626`;推送時使用一般 push,不 force push | -| 最新觀察到的 `gitea/main` | `6be83053 chore(cd): deploy 06dd4d0 [skip ci]`;本輪 AwoooP 修復候選升級合約由 `06dd4d0f` 完成,並隨 deploy marker `6be83053` 完成 production health / status-chain readback;active runtime gate 仍為 `0` | +| 最新觀察到的 `gitea/main` | `335d5f4a chore(cd): deploy 2239507 [skip ci]`;本輪 AwoooP Approvals executor handoff readiness 由 `2239507e` 完成,正式站 desktop / mobile 已確認 `Executor handoff readiness`、`可交接度`、`runtime gate closed`、owner review / Runs 導覽可見且無水平溢出;active runtime gate 仍為 `0` | | 最新 P0 Telegram 告警 / 批准執行真相鏈基準 | code `32e4beca`、deploy marker `717b5870`、code-review `2658`、CD `2657`;no-action approval 不再觸發 executor,可執行修復 approval 會寫入 `auto_repair_executions`、KM 與 verifier | | 最新 P0 Telegram no-action 人工處置包基準 | code `cd928852`、deploy marker `9181cc0e`、code-review `2666`;正式部署 tree 已包含 no-action 人工處置包、`處置包 / 重診 / 歷史 / 靜默 / 真相鏈 / Runs` 鍵盤、production pod render / keyboard smoke | | 最新 P0 MCP evidence / PlayBook 修復候選基準 | code `cc614023`、D1 blocker clarity `47d677ac`、D2 manual draft package `febe9ecf`、D3 draft work item `e8d5eafb`、D4 work item detail panel `e8a5bac5`、D5 coverage gap contract、D6 PostgreSQL 慢查詢分類 / database owner-review candidate、blocker normalization `4c85db18`、apply candidate 語意 `5ce6fc49` / `ef3ee4c4`、apply gate 閉環準備度 `d798d09e` / deploy marker `e0fbedfd`、Owner 放行閉環任務板 `c67dc92f` / deploy marker `7f204ca7`、受控執行前檢 `7c220fd0` / deploy marker `f068826f`、執行放行合約 `5055d6a4` / final deploy marker `5d41fe26`、告警自動化卡點總盤 `94800473` / final deploy marker `b1a15114`、修復候選升級合約 `06dd4d0f` / deploy marker `6be83053`。正式站已確認可由 MCP evidence + approved PlayBook trust 產生 medium approval candidate;若只跑 Ansible check-mode,Work Items / Runs 會顯示 `3/8 ready` 閉環矩陣、5 個閉環任務、`2/7 ready` 受控執行前檢、`4/11 ready` 執行放行合約,以及 7 條告警自動化卡點 lane;draft-ready path 會在下一次候選生成時帶出 `repair_candidate_promotion_contract_v1`、ready / total / blocked 計數、route、repair template、rollback 與 verifier;runtime gate 仍為 `0` | @@ -65,7 +65,7 @@ | Observability AI 自動化資產與訊號總帳 | 本地 `100%`;正式站 desktop / mobile `100%` | 是,僅限主機 / 專案 / 網站 / 服務 / 監控訊號 / KM / PlayBook / Verifier / SRE 路由 readback | `/zh-TW/observability` 已把 `AI 自動化資產與訊號總帳` 前移到首屏,6 張卡顯示全域資產、監控訊號、服務健康、KM / PlayBook / Verifier、SRE 戰情室、Runtime Gate;總帳區操作入口 `0`,不 live probe、不 reload、不改規則、不發 Telegram、不套用修復 | | Tenants 全域產品 / 網站 / 來源資產地圖 | 本地 `100%`;正式站 desktop / mobile `100%` | 是,僅限產品 / 專案、網站 / 服務入口、來源範圍、租戶資料與 gate readback | `/zh-TW/awooop/tenants` 已把 `全域資產地圖` 前移到首屏,直接顯示 `57` 個可視資產、`16 個產品 / 專案`、`31 個網站 / 服務入口`、`10 個來源範圍`、分類堆疊、route chips、主要來源就緒、已接受回覆、執行閘門與操作入口;不改租戶政策、不改路由、不部署、不掃描、不建立 repo、不開 runtime gate | | 日報 / 週報 / 月報與 AI Agent 報表資料鏈路 | Weekly report 資料缺口止血本地 `100%`、正式部署 `100%`;Reports 總控正式站 `100%`;P2-109 source health read model 正式站 `100%`;P2-110 weekly no-send preview 正式站 `100%`;P2-110B daily / monthly no-send preview 正式站 `100%`;P2-110C SRE digest no-send preview 正式站 `100%`;P2-110D source gap PlayBook / Verifier readback 正式站 `100%`;P2-110E AwoooP Work Items owner review 正式站 `100%`;P2-410 action audit ledger production API `100%`;報表產品化總控 `94%` | 是,僅限資料源 truthfulness、全 0 判讀、Reports 首屏總控、no-send preview、資產沉澱、PlayBook / Verifier 缺口處置板、AwoooP Work Items owner review、audit event template 與下一步工作項 | `ac325852` / deploy marker `a4b30964` 已修正週報 Git 活動讀取失敗時假性輸出 `0`;`6d4fa7bf` / `5e849225` / `63a75f77` 已把 `/zh-TW/reports` 改成 `報表 / 告警 AI 接管總控`;`27d9f394` / deploy marker `d8862123` 新增 `agent-report-source-health`;`a46e31ba` / `48e06c6a` / deploy marker `3057342a` 已讓 weekly preview 回傳 source `2/5`、confidence `40`、三個 `report-source-gap:*` 與 KM / PlayBook / Verifier 沉澱;`77fe2a85` / deploy marker `29fe6ec8` 已讓 daily / monthly preview 也回傳同一 source health、formatted preview 與 KM / PlayBook / 腳本 / 排程 / Verifier 沉澱;`7e03b923` / deploy marker `c7c0d874` 已新增 SRE 戰情室 digest preview,回傳 live send allowed `0`、runtime gate `0` 與同一批沉澱;`6ab640e4` / deploy marker `049dc0a8` 已讓 `agent-report-source-health` 與 `/zh-TW/reports` 顯示三張 source gap PlayBook / Verifier 處置卡;`ca04b49d` / deploy marker `c33dd9a6` 已讓 `/zh-TW/awooop/work-items` 顯示 source `2/5`、資料缺口 `3`、PlayBook 草案 `3`、Verifier 計畫 `3`、owner review `3`、腳本 readback、排程 no-send 與 runtime gate `0`;P2-410 feature commit `e13f716c` / deploy marker `38e60192` 已讓 `/api/v1/agents/agent-action-audit-ledger` 正式回讀 report source gap 與 SRE digest no-send preview audit event template;仍需接 governance projection 與 receipt gate,不發 live Telegram、不改排程、不開 runtime gate | -| Telegram 監控告警 / 批准執行真相鏈 | outbound 主鏈路 `100%`;批准後執行止血 `100%`;no-action 人工處置包 D0 `100%`;MCP / PlayBook 修復候選 D10 `97%`;Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱矩陣 `100% / incident focus desktop+mobile 100% / 100% / 100% / 100% / 100% / formatter+deploy 100%`;blocker 語意正確性 `88%`;apply candidate 語意 `100%`;apply gate 閉環準備度正式站 `100%`;Owner 放行閉環任務板正式站 `100%`;受控執行前檢正式站 `100%`;執行放行合約正式站 `100%`;告警自動化卡點總盤正式站 `100%`;治理長期項 `98%` | 是,僅限候選產生、阻擋原因、人工草案包、AwoooP 工作項可追蹤性、Work Items 詳細接手板、Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱欄、coverage gap metadata、apply gate 閉環矩陣、Owner 放行閉環任務板、受控執行前檢、執行放行合約與告警自動化卡點總盤 | 已修復 Alertmanager tenant context、既有 approval 收斂告警 recurrence、AI 分析中重複告警 recurrence、no-action approval 誤導執行、可執行修復 execution / KM / verifier 紀錄、no-action 人工處置包、MCP evidence / PlayBook trust 候選產生、通用兜底 / 診斷型 PlayBook 阻擋理由、缺候選時的 PlayBook 草案欄位 / 下一步 / AwoooP work item 入口與詳細處置板、blocked result 的服務 coverage gap / blocking stage / required MCP evidence refs、PostgreSQL 慢查詢告警分類防混線與 database owner-review candidate,以及 Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡的 `KM / PlayBook / 腳本 / 排程 / Verifier` 資產沉澱矩陣;Runs / Work Items 已顯示 Work Item、dry-run、apply candidate、verifier、下一步資產 ID、`3/8 ready` apply gate 閉環矩陣、Owner 放行包 / Verifier 放行前檢 / 5 個閉環任務、`2/7 ready` 受控執行前檢、`4/11 ready` 執行放行合約,以及告警自動化卡點總盤 7 條 lane:證據、候選、PlayBook / Ansible、安全路由、執行放行、Verifier、KM / Trust 回寫;status-chain blocker 已把缺失顯示為 `auto_repair_missing` / `verification_missing` / `learning_missing`,不再把未完成 gate 顯示成 `*_recorded`;Ansible check-mode-only 已改顯示為 `apply_candidate_owner_review_ready` 與 `dry_run_passed_apply_candidate_ready`,下一步固定指向 owner 放行、維護窗口、rollback owner、blast radius、受控執行、post-apply verifier、KM 與 PlayBook trust 回寫;完整自動修復飛輪仍需用真實告警驗證 approval -> controlled execution -> verifier -> KM / PlayBook trust 全鏈,不調高 runtime gate | +| Telegram 監控告警 / 批准執行真相鏈 | outbound 主鏈路 `100%`;批准後執行止血 `100%`;no-action 人工處置包 D0 `100%`;MCP / PlayBook 修復候選 D10 `97%`;Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱矩陣 `100% / incident focus desktop+mobile 100% / 100% / 100% / 100% / 100% / formatter+deploy 100%`;Approvals executor handoff readiness 正式站 `100%`;blocker 語意正確性 `88%`;apply candidate 語意 `100%`;apply gate 閉環準備度正式站 `100%`;Owner 放行閉環任務板正式站 `100%`;受控執行前檢正式站 `100%`;執行放行合約正式站 `100%`;告警自動化卡點總盤正式站 `100%`;治理長期項 `99%` | 是,僅限候選產生、阻擋原因、人工草案包、AwoooP 工作項可追蹤性、Work Items 詳細接手板、Approvals executor handoff readiness、Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡資產沉澱欄、coverage gap metadata、apply gate 閉環矩陣、Owner 放行閉環任務板、受控執行前檢、執行放行合約與告警自動化卡點總盤 | 已修復 Alertmanager tenant context、既有 approval 收斂告警 recurrence、AI 分析中重複告警 recurrence、no-action approval 誤導執行、可執行修復 execution / KM / verifier 紀錄、no-action 人工處置包、MCP evidence / PlayBook trust 候選產生、通用兜底 / 診斷型 PlayBook 阻擋理由、缺候選時的 PlayBook 草案欄位 / 下一步 / AwoooP work item 入口與詳細處置板、blocked result 的服務 coverage gap / blocking stage / required MCP evidence refs、PostgreSQL 慢查詢告警分類防混線與 database owner-review candidate,以及 Approvals / Runs / Alerts / Knowledge Base / Observability / Tenants / Telegram 告警卡的 `KM / PlayBook / 腳本 / 排程 / Verifier` 資產沉澱矩陣;Approvals 焦點 Incident 已新增 `Executor handoff readiness`,顯示可交接度、runtime gate、下一步、阻擋原因、缺 owner review / 安全路由欄位與 Work Item / Runs 導覽;Runs / Work Items 已顯示 Work Item、dry-run、apply candidate、verifier、下一步資產 ID、`3/8 ready` apply gate 閉環矩陣、Owner 放行包 / Verifier 放行前檢 / 5 個閉環任務、`2/7 ready` 受控執行前檢、`4/11 ready` 執行放行合約,以及告警自動化卡點總盤 7 條 lane:證據、候選、PlayBook / Ansible、安全路由、執行放行、Verifier、KM / Trust 回寫;status-chain blocker 已把缺失顯示為 `auto_repair_missing` / `verification_missing` / `learning_missing`,不再把未完成 gate 顯示成 `*_recorded`;Ansible check-mode-only 已改顯示為 `apply_candidate_owner_review_ready` 與 `dry_run_passed_apply_candidate_ready`,下一步固定指向 owner 放行、維護窗口、rollback owner、blast radius、受控執行、post-apply verifier、KM 與 PlayBook trust 回寫;完整自動修復飛輪仍需用真實告警驗證 approval -> controlled execution -> verifier -> KM / PlayBook trust 全鏈,不調高 runtime gate | ## 2. P0 工作拆解與優先順序