Compare commits

...

1 Commits

Author SHA1 Message Date
Your Name
1e359886af feat(iwooos): add runtime security readback board 2026-06-26 17:48:48 +08:00
9 changed files with 897 additions and 1 deletions

View File

@@ -7,15 +7,22 @@ Wazuh 接線採用只讀 metadata 模式:預設關閉、不保存 raw payload
from __future__ import annotations from __future__ import annotations
import asyncio
import json
import os import os
from base64 import b64encode from base64 import b64encode
from typing import Any from typing import Any
from urllib.parse import urljoin, urlparse from urllib.parse import urljoin, urlparse
import httpx import httpx
from fastapi import APIRouter from fastapi import APIRouter, HTTPException, status
from fastapi.responses import JSONResponse from fastapi.responses import JSONResponse
from src.services.iwooos_runtime_security_readback import (
load_latest_iwooos_runtime_security_readback,
)
from src.services.public_redaction import redact_public_lan_topology
router = APIRouter(tags=["IwoooS Security"]) router = APIRouter(tags=["IwoooS Security"])
REQUEST_TIMEOUT_SECONDS = 5.0 REQUEST_TIMEOUT_SECONDS = 5.0
@@ -198,3 +205,30 @@ async def get_iwooos_wazuh_readonly_status_compat() -> JSONResponse:
@router.get("/api/v1/iwooos/wazuh") @router.get("/api/v1/iwooos/wazuh")
async def get_iwooos_wazuh_readonly_status_v1() -> JSONResponse: async def get_iwooos_wazuh_readonly_status_v1() -> JSONResponse:
return await _wazuh_readonly_status() return await _wazuh_readonly_status()
@router.get(
"/api/v1/iwooos/runtime-security-readback",
response_model=dict[str, Any],
summary="取得 IwoooS runtime security readback",
description=(
"讀取最新已提交的 IwoooS 資安只讀快照,彙總 Wazuh、Kali、SOC/SIEM、"
"告警可讀性、owner dispatch 與外部入侵防護 Gate。此端點不呼叫 Wazuh / Kali / "
"主機 / Docker / Nginx / firewall / Telegram不收集 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)
return redact_public_lan_topology(payload)
except FileNotFoundError as exc:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=str(exc),
) from exc
except (json.JSONDecodeError, ValueError) as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"IwoooS runtime security readback 無效:{exc}",
) from exc

View File

@@ -0,0 +1,322 @@
"""
IwoooS runtime security readback.
Loads committed security snapshots and exposes a public-safe, read-only runtime
security board. This module never queries Wazuh, Kali, hosts, Docker, Nginx,
firewalls, databases, Telegram, or secrets.
"""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
from src.services.snapshot_paths import default_security_dir
_DEFAULT_SECURITY_DIR = default_security_dir(Path(__file__))
_SNAPSHOT_FILES = {
"owner_gap": "s4-9-owner-response-gap-audit.snapshot.json",
"wazuh_coverage": "wazuh-managed-host-coverage-gate.snapshot.json",
"wazuh_runtime": "wazuh-agent-visibility-runtime-gate.snapshot.json",
"kali_status": "kali-integration-status.snapshot.json",
"soc_control": "soc-siem-kali-wazuh-integration-control.snapshot.json",
"alert_readability": "telegram-alert-readability-guard.snapshot.json",
"owner_dispatch": "monitoring-owner-request-draft.snapshot.json",
"intrusion_prevention": "external-host-intrusion-prevention-control.snapshot.json",
}
_EXPECTED_SCHEMAS = {
"owner_gap": "s4_9_owner_response_gap_audit_v1",
"wazuh_coverage": "wazuh_managed_host_coverage_gate_v1",
"wazuh_runtime": "wazuh_agent_visibility_runtime_gate_v1",
"kali_status": "kali_integration_status_v1",
"soc_control": "soc_siem_kali_wazuh_integration_control_v1",
"alert_readability": "telegram_alert_readability_guard_v1",
"owner_dispatch": "monitoring_owner_request_draft_v1",
"intrusion_prevention": "external_host_intrusion_prevention_control_v1",
}
_FALSE_BOUNDARY_KEYS = {
"active_scan_authorized",
"alertmanager_reload_authorized",
"auto_block_authorized",
"credentialed_scan_authorized",
"firewall_change_authorized",
"host_write_authorized",
"kali_execute_authorized",
"kali_scan_authorized",
"nginx_reload_authorized",
"production_write_authorized",
"runtime_execution_authorized",
"runtime_gate_open",
"secret_value_collection_allowed",
"telegram_send_authorized",
"wazuh_active_response_authorized",
"wazuh_api_live_query_authorized",
}
def load_latest_iwooos_runtime_security_readback(
security_dir: Path | None = None,
) -> dict[str, Any]:
"""Load and normalize the current IwoooS runtime security readback."""
directory = security_dir or _DEFAULT_SECURITY_DIR
snapshots = {key: _load_snapshot(directory, key, filename) for key, filename in _SNAPSHOT_FILES.items()}
_require_runtime_boundaries(snapshots)
owner_gap_summary = _summary(snapshots["owner_gap"])
wazuh_summary = _summary(snapshots["wazuh_coverage"])
soc_summary = _summary(snapshots["soc_control"])
alert_summary = _summary(snapshots["alert_readability"])
dispatch_summary = _summary(snapshots["owner_dispatch"])
intrusion_summary = _summary(snapshots["intrusion_prevention"])
source_refs = [f"docs/security/{filename}" for filename in _SNAPSHOT_FILES.values()]
runtime_gate_count = _max_summary_count(
snapshots,
"runtime_gate_count",
"active_response_authorized_count",
"kali_active_scan_authorized_count",
"telegram_send_authorized_count",
)
return {
"schema_version": "iwooos_runtime_security_readback_v1",
"status": "blocked_waiting_owner_evidence_and_runtime_gates",
"mode": "committed_snapshot_readback_only_no_runtime_query",
"source_refs": source_refs,
"summary": {
"source_snapshot_count": len(source_refs),
"p0_lane_count": 6,
"control_plane_visibility_percent": _average_percent(
soc_summary.get("coverage_percent_after_soc_integration_control"),
intrusion_summary.get("coverage_percent_after_prevention_control"),
_alert_contract_percent(alert_summary),
),
"actual_runtime_acceptance_percent": 0,
"owner_response_received_count": _int(owner_gap_summary.get("owner_response_received_count")),
"owner_response_accepted_count": _int(owner_gap_summary.get("owner_response_accepted_count")),
"redacted_evidence_refs_received_count": 0,
"request_sent_count": _int(dispatch_summary.get("request_sent_count")),
"wazuh_expected_host_scope_count": _int(wazuh_summary.get("expected_host_scope_count")),
"wazuh_manager_registry_accepted_count": _int(wazuh_summary.get("manager_registry_accepted_count")),
"wazuh_transport_observed_count": _int(wazuh_summary.get("manager_transport_established_connection_count")),
"wazuh_dashboard_api_degraded_observed_count": _int(
wazuh_summary.get("dashboard_api_degraded_observed_count")
),
"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")),
"alert_formatter_contract_marker_count": _int(alert_summary.get("source_formatter_marker_count")),
"alert_receipt_runtime_send_count": _int(alert_summary.get("telegram_send_authorized_count")),
"intrusion_prevention_candidate_count": _int(intrusion_summary.get("urgent_prevention_candidate_count")),
"runtime_gate_count": runtime_gate_count,
},
"lanes": [
_lane(
"wazuh_registry",
"blocked_waiting_manager_registry",
0,
"locked",
"manager_registry_cross_check",
{
"expected_hosts": wazuh_summary.get("expected_host_scope_count", 0),
"transport_observed": wazuh_summary.get("manager_transport_established_connection_count", 0),
"registry_accepted": wazuh_summary.get("manager_registry_accepted_count", 0),
},
["docs/security/wazuh-managed-host-coverage-gate.snapshot.json"],
),
_lane(
"wazuh_dashboard_api",
"degraded_api_connection_not_green",
0,
"warn",
"dashboard_api_rbac_tls_repair_readback",
{
"dashboard_api_degraded": wazuh_summary.get("dashboard_api_degraded_observed_count", 0),
"runtime_gate": wazuh_summary.get("runtime_gate_count", 0),
"accepted_evidence": _accepted_evidence_count(snapshots["wazuh_runtime"]),
},
[
"docs/security/wazuh-managed-host-coverage-gate.snapshot.json",
"docs/security/wazuh-agent-visibility-runtime-gate.snapshot.json",
],
),
_lane(
"kali_intake",
snapshots["kali_status"].get("status", "blocked_waiting_kali_scope"),
0,
"locked",
"kali_scope_and_finding_envelope_accepted",
{
"active_scan_authorized": soc_summary.get("kali_active_scan_authorized_count", 0),
"execute_authorized": soc_summary.get("kali_execute_authorized_count", 0),
"finding_envelope_accepted": soc_summary.get("kali_finding_envelope_accepted_count", 0),
},
[
"docs/security/kali-integration-status.snapshot.json",
"docs/security/soc-siem-kali-wazuh-integration-control.snapshot.json",
],
),
_lane(
"alert_readability",
"contract_ready_no_send_receipt",
_alert_contract_percent(alert_summary),
"warn",
"alert_route_receipt_available",
{
"formatter_markers": alert_summary.get("source_formatter_marker_count", 0),
"required_markers": alert_summary.get("required_output_marker_count", 0),
"telegram_send": alert_summary.get("telegram_send_authorized_count", 0),
},
["docs/security/telegram-alert-readability-guard.snapshot.json"],
),
_lane(
"owner_dispatch",
snapshots["owner_dispatch"].get("status", "owner_request_draft_ready_not_dispatched"),
0,
"locked",
"owner_response_packet_delivery",
{
"request_drafts": dispatch_summary.get("request_draft_count", 0),
"request_sent": dispatch_summary.get("request_sent_count", 0),
"owner_accepted": dispatch_summary.get("owner_response_accepted_count", 0),
},
["docs/security/monitoring-owner-request-draft.snapshot.json"],
),
_lane(
"intrusion_prevention",
"candidate_only_no_runtime_containment",
_int(intrusion_summary.get("coverage_percent_after_prevention_control")),
"warn",
"redacted_evidence_refs_and_maintenance_window",
{
"urgent_candidates": intrusion_summary.get("urgent_prevention_candidate_count", 0),
"evidence_received": intrusion_summary.get("evidence_ref_received_count", 0),
"containment_accepted": intrusion_summary.get("containment_decision_accepted_count", 0),
},
["docs/security/external-host-intrusion-prevention-control.snapshot.json"],
),
],
"boundaries": {
"active_response_authorized": False,
"active_scan_authorized": False,
"action_buttons_allowed": False,
"host_write_authorized": False,
"kali_execute_authorized": False,
"nginx_reload_authorized": False,
"raw_payload_storage_allowed": False,
"runtime_execution_authorized": False,
"secret_value_collection_allowed": False,
"telegram_send_authorized": False,
"wazuh_active_response_authorized": False,
"wazuh_api_live_query_authorized": False,
"workflow_modification_authorized": False,
"not_authorization": True,
},
"no_false_green_rules": [
"dashboard_route_200_is_not_wazuh_registry_recovery",
"transport_count_is_not_full_host_management",
"ui_visible_is_not_runtime_authorization",
"owner_request_draft_is_not_owner_acceptance",
"kali_health_is_not_active_scan_authorization",
"alert_format_contract_is_not_telegram_send_receipt",
],
}
def _load_snapshot(directory: Path, key: str, filename: str) -> dict[str, Any]:
path = directory / filename
if not path.is_file():
raise FileNotFoundError(f"{path}: security snapshot not found")
with path.open(encoding="utf-8") as handle:
payload = json.load(handle)
if not isinstance(payload, dict):
raise ValueError(f"{path}: expected JSON object")
expected_schema = _EXPECTED_SCHEMAS[key]
if payload.get("schema_version") != expected_schema:
raise ValueError(f"{path}: expected schema_version={expected_schema}")
return payload
def _summary(payload: dict[str, Any]) -> dict[str, Any]:
summary = payload.get("summary")
return summary if isinstance(summary, dict) else {}
def _int(value: Any) -> int:
return value if isinstance(value, int) 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
def _alert_contract_percent(summary: dict[str, Any]) -> int:
source_markers = _int(summary.get("source_formatter_marker_count"))
required_markers = max(1, _int(summary.get("required_output_marker_count")))
return min(100, int(round((source_markers / required_markers) * 100)))
def _accepted_evidence_count(payload: dict[str, Any]) -> int:
evidence = payload.get("required_evidence_before_green")
if not isinstance(evidence, list):
return 0
return sum(1 for item in evidence if isinstance(item, dict) and item.get("accepted") is True)
def _lane(
lane_id: str,
status_text: Any,
completion_percent: int,
tone: str,
next_gate: str,
metrics: dict[str, Any],
source_refs: list[str],
) -> dict[str, Any]:
return {
"lane_id": lane_id,
"status": str(status_text),
"completion_percent": completion_percent,
"tone": tone,
"next_gate": next_gate,
"metrics": {key: _int(value) for key, value in metrics.items()},
"source_refs": source_refs,
}
def _max_summary_count(
snapshots: dict[str, dict[str, Any]],
*keys: str,
) -> int:
return max((_int(_summary(payload).get(key)) for payload in snapshots.values() for key in keys), default=0)
def _require_runtime_boundaries(snapshots: dict[str, dict[str, Any]]) -> None:
for name, payload in snapshots.items():
summary = _summary(payload)
if _int(summary.get("runtime_gate_count")) != 0:
raise ValueError(f"{name}: runtime_gate_count must remain 0")
for key in (
"owner_response_accepted_count",
"wazuh_active_response_enabled_count",
"active_response_enabled_count",
"active_scan_authorized_count",
"kali_active_scan_authorized_count",
"telegram_send_authorized_count",
"host_write_authorized_count",
"secret_value_collection_allowed_count",
):
if key in summary and _int(summary.get(key)) != 0:
raise ValueError(f"{name}: {key} must remain 0")
boundaries = payload.get("execution_boundaries")
if isinstance(boundaries, dict):
invalid = sorted(
key for key in _FALSE_BOUNDARY_KEYS if key in boundaries and boundaries.get(key) is not False
)
if invalid:
raise ValueError(f"{name}: execution boundaries must remain false: {invalid}")

View File

@@ -35,3 +35,8 @@ def default_evaluations_dir(anchor: Path) -> Path:
def default_operations_dir(anchor: Path) -> Path: def default_operations_dir(anchor: Path) -> Path:
"""Resolve the default committed operations snapshot directory.""" """Resolve the default committed operations snapshot directory."""
return resolve_repo_root(anchor) / "docs" / "operations" return resolve_repo_root(anchor) / "docs" / "operations"
def default_security_dir(anchor: Path) -> Path:
"""Resolve the default committed security snapshot directory."""
return resolve_repo_root(anchor) / "docs" / "security"

View File

@@ -0,0 +1,67 @@
from __future__ import annotations
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.v1.iwooos import router
from src.services.iwooos_runtime_security_readback import (
load_latest_iwooos_runtime_security_readback,
)
def _client() -> TestClient:
app = FastAPI()
app.include_router(router)
return TestClient(app)
def test_iwooos_runtime_security_readback_preserves_zero_runtime_gates() -> None:
payload = load_latest_iwooos_runtime_security_readback()
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"]["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"]["kali_active_scan_authorized_count"] == 0
assert payload["summary"]["kali_execute_authorized_count"] == 0
assert payload["summary"]["alert_receipt_runtime_send_count"] == 0
assert payload["boundaries"]["runtime_execution_authorized"] is False
assert payload["boundaries"]["active_scan_authorized"] is False
assert payload["boundaries"]["wazuh_active_response_authorized"] is False
assert payload["boundaries"]["telegram_send_authorized"] is False
def test_iwooos_runtime_security_readback_lanes_are_candidate_only() -> None:
payload = load_latest_iwooos_runtime_security_readback()
lane_ids = {lane["lane_id"] for lane in payload["lanes"]}
assert lane_ids == {
"wazuh_registry",
"wazuh_dashboard_api",
"kali_intake",
"alert_readability",
"owner_dispatch",
"intrusion_prevention",
}
assert all(lane["metrics"] for lane in payload["lanes"])
assert all(lane["next_gate"] for lane in payload["lanes"])
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"])
def test_iwooos_runtime_security_readback_api_is_public_safe() -> None:
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["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

View File

@@ -20151,6 +20151,75 @@
} }
} }
}, },
"runtimeSecurityReadback": {
"eyebrow": "IwoooS Runtime 資安讀回",
"title": "六條 P0 資安線先接到同一張讀回板",
"subtitle": "這張板讀取後端彙整的只讀快照,集中顯示 Wazuh 清單驗收、儀表板 API、資安觀測節點 intake、告警可讀性、負責人送件與外部入侵防護它不查 live Wazuh、不啟動掃描、不送訊息、不改主機。",
"statusLabel": "讀回狀態",
"statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。",
"laneStatusLabel": "目前狀態",
"laneNextGateLabel": "下一個 Gate",
"emptyLanes": "尚未讀回 P0 線,維持阻擋狀態。",
"boundaryTitle": "禁止誤判規則",
"boundaryIntro": "以下規則由後端 payload 回傳;若讀回失敗,前端只顯示保守 fallback。任何一條都不能被當成 runtime 授權。",
"status": {
"loading": "正在讀取只讀資安總板",
"failed": "只讀總板尚未部署或讀取失敗",
"readbackReady": "只讀總板已讀回,但 runtime 閘門仍關閉"
},
"summary": {
"controlPlane": {
"label": "控制面可視",
"detail": "框架、候選與證據線已接上,但不等於 runtime 完成。"
},
"runtimeAcceptance": {
"label": "執行驗收",
"detail": "實際 runtime acceptance 仍維持 0%。"
},
"wazuhRegistry": {
"label": "Wazuh 清單",
"detail": "管理器清單接受數仍為 0。"
},
"ownerAccepted": {
"label": "負責人驗收",
"detail": "收到 / 接受都必須由正式 owner response 證明。"
},
"kaliRuntime": {
"label": "Kali 執行",
"detail": "active scan / execute 都維持 0。"
},
"runtimeGate": {
"label": "Runtime Gate",
"detail": "任何修復、封鎖、重啟或 reload 都還沒開門。"
}
},
"lanes": {
"wazuh_registry": {
"title": "Wazuh manager registry",
"body": "必須拿到總數、在線、離線、從未連線與時間窗transport 或儀表板可見不能代替驗收。"
},
"wazuh_dashboard_api": {
"title": "Wazuh Dashboard API",
"body": "API connection / API version 還要補 readbackindex pattern 通過不能宣稱 Wazuh 全綠。"
},
"kali_intake": {
"title": "資安觀測節點 intake",
"body": "先收 health、scope、工具版本與 normalized finding envelopeactive scan 與 /execute 仍需獨立批准。"
},
"alert_readability": {
"title": "告警可讀性 receipt",
"body": "格式合約已能阻擋 raw output但 Telegram 實發、receipt 與修復結果仍未啟用。"
},
"owner_dispatch": {
"title": "負責人送件",
"body": "目前只有送件草案與欄位包request sent、received、accepted 全都不可假性上修。"
},
"intrusion_prevention": {
"title": "外部入侵防護",
"body": "候選已按公開入口、SSH、firewall、主機 runtime、runner、secret、Wazuh 等域分類;圍堵仍只能是候選。"
}
}
},
"wazuhLiveRouteReadback": { "wazuhLiveRouteReadback": {
"eyebrow": "Wazuh 正式路由只讀讀回", "eyebrow": "Wazuh 正式路由只讀讀回",
"title": "正式站必須直接顯示 Wazuh 只讀路由狀態", "title": "正式站必須直接顯示 Wazuh 只讀路由狀態",

View File

@@ -20151,6 +20151,75 @@
} }
} }
}, },
"runtimeSecurityReadback": {
"eyebrow": "IwoooS Runtime 資安讀回",
"title": "六條 P0 資安線先接到同一張讀回板",
"subtitle": "這張板讀取後端彙整的只讀快照,集中顯示 Wazuh 清單驗收、儀表板 API、資安觀測節點 intake、告警可讀性、負責人送件與外部入侵防護它不查 live Wazuh、不啟動掃描、不送訊息、不改主機。",
"statusLabel": "讀回狀態",
"statusDetail": "讀回成功只代表 IwoooS 能看見目前的證據邊界runtime 寫入、主動回應、掃描、重啟、Nginx reload、workflow 修改與機密操作仍全部關閉。",
"laneStatusLabel": "目前狀態",
"laneNextGateLabel": "下一個 Gate",
"emptyLanes": "尚未讀回 P0 線,維持阻擋狀態。",
"boundaryTitle": "禁止誤判規則",
"boundaryIntro": "以下規則由後端 payload 回傳;若讀回失敗,前端只顯示保守 fallback。任何一條都不能被當成 runtime 授權。",
"status": {
"loading": "正在讀取只讀資安總板",
"failed": "只讀總板尚未部署或讀取失敗",
"readbackReady": "只讀總板已讀回,但 runtime 閘門仍關閉"
},
"summary": {
"controlPlane": {
"label": "控制面可視",
"detail": "框架、候選與證據線已接上,但不等於 runtime 完成。"
},
"runtimeAcceptance": {
"label": "執行驗收",
"detail": "實際 runtime acceptance 仍維持 0%。"
},
"wazuhRegistry": {
"label": "Wazuh 清單",
"detail": "管理器清單接受數仍為 0。"
},
"ownerAccepted": {
"label": "負責人驗收",
"detail": "收到 / 接受都必須由正式 owner response 證明。"
},
"kaliRuntime": {
"label": "Kali 執行",
"detail": "active scan / execute 都維持 0。"
},
"runtimeGate": {
"label": "Runtime Gate",
"detail": "任何修復、封鎖、重啟或 reload 都還沒開門。"
}
},
"lanes": {
"wazuh_registry": {
"title": "Wazuh manager registry",
"body": "必須拿到總數、在線、離線、從未連線與時間窗transport 或儀表板可見不能代替驗收。"
},
"wazuh_dashboard_api": {
"title": "Wazuh Dashboard API",
"body": "API connection / API version 還要補 readbackindex pattern 通過不能宣稱 Wazuh 全綠。"
},
"kali_intake": {
"title": "資安觀測節點 intake",
"body": "先收 health、scope、工具版本與 normalized finding envelopeactive scan 與 /execute 仍需獨立批准。"
},
"alert_readability": {
"title": "告警可讀性 receipt",
"body": "格式合約已能阻擋 raw output但 Telegram 實發、receipt 與修復結果仍未啟用。"
},
"owner_dispatch": {
"title": "負責人送件",
"body": "目前只有送件草案與欄位包request sent、received、accepted 全都不可假性上修。"
},
"intrusion_prevention": {
"title": "外部入侵防護",
"body": "候選已按公開入口、SSH、firewall、主機 runtime、runner、secret、Wazuh 等域分類;圍堵仍只能是候選。"
}
}
},
"wazuhLiveRouteReadback": { "wazuhLiveRouteReadback": {
"eyebrow": "Wazuh 正式路由只讀讀回", "eyebrow": "Wazuh 正式路由只讀讀回",
"title": "正式站必須直接顯示 Wazuh 只讀路由狀態", "title": "正式站必須直接顯示 Wazuh 只讀路由狀態",

View File

@@ -33,6 +33,7 @@ import Link from 'next/link'
import { useTranslations } from 'next-intl' import { useTranslations } from 'next-intl'
import { useEffect, useRef, useState, type ReactNode } from 'react' import { useEffect, useRef, useState, type ReactNode } from 'react'
import { AppLayout } from '@/components/layout' import { AppLayout } from '@/components/layout'
import { apiClient, type IwoooSRuntimeSecurityReadbackResponse } from '@/lib/api-client'
type PostureMetric = { type PostureMetric = {
key: string key: string
@@ -319,6 +320,13 @@ type WazuhReadonlyStatusResponse = {
} }
} }
type RuntimeSecurityReadbackSummaryItem = {
key: 'controlPlane' | 'runtimeAcceptance' | 'wazuhRegistry' | 'ownerAccepted' | 'kaliRuntime' | 'runtimeGate'
value: string
icon: typeof ShieldCheck
tone: 'steady' | 'warn' | 'locked'
}
type SocSiemKaliWazuhIntegrationItem = { type SocSiemKaliWazuhIntegrationItem = {
key: string key: string
check: string check: string
@@ -1143,6 +1151,15 @@ const progressIntegrityRibbonBoundaries = [
'not_authorization=true', 'not_authorization=true',
] ]
const runtimeSecurityReadbackFallbackRules = [
'dashboard_route_200_is_not_wazuh_registry_recovery',
'transport_count_is_not_full_host_management',
'ui_visible_is_not_runtime_authorization',
'owner_request_draft_is_not_owner_acceptance',
'kali_health_is_not_active_scan_authorization',
'alert_format_contract_is_not_telegram_send_receipt',
]
const firstScreenDepthLayers: FirstScreenDepthLayer[] = [ const firstScreenDepthLayers: FirstScreenDepthLayer[] = [
{ key: 'visible', value: '4', icon: ShieldCheck, tone: 'steady' }, { key: 'visible', value: '4', icon: ShieldCheck, tone: 'steady' },
{ key: 'advanced', value: '2', icon: SearchCheck, tone: 'warn', href: '#iwooos-decision-gate-visuals' }, { key: 'advanced', value: '2', icon: SearchCheck, tone: 'warn', href: '#iwooos-decision-gate-visuals' },
@@ -8037,6 +8054,233 @@ function IwoooSWazuhIntrusionReadbackBoard() {
) )
} }
function IwoooSRuntimeSecurityReadbackBoard() {
const t = useTranslations('iwooos.runtimeSecurityReadback')
const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const }
const [data, setData] = useState<IwoooSRuntimeSecurityReadbackResponse | null>(null)
const [loading, setLoading] = useState(true)
const [failed, setFailed] = useState(false)
useEffect(() => {
let mounted = true
async function loadReadback() {
setLoading(true)
setFailed(false)
try {
const payload = await apiClient.getIwoooSRuntimeSecurityReadback()
if (mounted) {
setData(payload)
}
} catch {
if (mounted) {
setData(null)
setFailed(true)
}
} finally {
if (mounted) {
setLoading(false)
}
}
}
loadReadback()
return () => {
mounted = false
}
}, [])
const summary = data?.summary
const statusKey = loading ? 'loading' : failed || !data ? 'failed' : 'readbackReady'
const statusTone: 'steady' | 'warn' | 'locked' = loading ? 'warn' : failed || !data ? 'warn' : 'locked'
const summaryItems: RuntimeSecurityReadbackSummaryItem[] = [
{
key: 'controlPlane',
value: summary ? `${summary.control_plane_visibility_percent}%` : '...',
icon: ShieldCheck,
tone: summary && summary.control_plane_visibility_percent > 0 ? 'steady' : 'warn',
},
{
key: 'runtimeAcceptance',
value: summary ? `${summary.actual_runtime_acceptance_percent}%` : '...',
icon: Lock,
tone: 'locked',
},
{
key: 'wazuhRegistry',
value: summary ? String(summary.wazuh_manager_registry_accepted_count) : '...',
icon: Radar,
tone: 'locked',
},
{
key: 'ownerAccepted',
value: summary ? `${summary.owner_response_accepted_count}/${summary.owner_response_received_count}` : '...',
icon: ClipboardCheck,
tone: 'locked',
},
{
key: 'kaliRuntime',
value: summary ? `${summary.kali_active_scan_authorized_count}/${summary.kali_execute_authorized_count}` : '...',
icon: SearchCheck,
tone: 'locked',
},
{
key: 'runtimeGate',
value: summary ? String(summary.runtime_gate_count) : '...',
icon: Lock,
tone: 'locked',
},
]
const lanes = data?.lanes ?? []
return (
<section
style={{ marginBottom: 14, maxWidth: '100%', overflow: 'hidden' }}
data-testid="iwooos-runtime-security-readback-board"
>
<div style={{ ...band, padding: 16, background: '#fbfcfb', borderColor: '#c9ded2' }}>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 300px), 1fr))', gap: 14 }}>
<div style={{ minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, color: '#315f45', fontSize: 12, fontWeight: 700 }}>
<ShieldCheck size={17} color="#2f7d48" />
{t('eyebrow')}
</div>
<h2 style={{ fontSize: 17, margin: '8px 0 0', color: '#141413' }}>{t('title')}</h2>
<p style={{ fontSize: 12, color: '#405f4f', margin: '6px 0 0', lineHeight: 1.55, ...textWrap }}>
{t('subtitle')}
</p>
</div>
<div style={{ border: '0.5px solid #d3e4d9', borderRadius: 8, padding: 12, background: '#fff', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 10 }}>
<span style={{ fontSize: 11, color: '#627466', fontWeight: 700 }}>{t('statusLabel')}</span>
<ToneDot tone={statusTone} />
</div>
<div style={{ marginTop: 8, fontSize: 15, color: toneColors[statusTone], fontWeight: 700, ...textWrap }}>
{t(`status.${statusKey}` as never)}
</div>
<p style={{ fontSize: 11, color: '#405f4f', lineHeight: 1.5, margin: '8px 0 0', ...textWrap }}>
{t('statusDetail')}
</p>
</div>
</div>
<div
style={{
marginTop: 14,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 132px), 1fr))',
gap: 8,
}}
>
{summaryItems.map(item => {
const Icon = item.icon
return (
<div key={item.key} style={{ border: '0.5px solid #d3e4d9', borderRadius: 8, padding: 12, background: '#fff', minWidth: 0 }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<span style={{ fontSize: 11, color: '#627466', ...textWrap }}>{t(`summary.${item.key}.label` as never)}</span>
<Icon size={16} color={toneColors[item.tone]} />
</div>
<div style={{ fontSize: 20, fontWeight: 700, color: toneColors[item.tone], lineHeight: 1.1, marginTop: 8, ...textWrap }}>
{item.value}
</div>
<p style={{ fontSize: 11, color: '#405f4f', lineHeight: 1.45, margin: '8px 0 0', ...textWrap }}>
{t(`summary.${item.key}.detail` as never)}
</p>
</div>
)
})}
</div>
<div
style={{
marginTop: 14,
display: 'grid',
gridTemplateColumns: 'repeat(auto-fit, minmax(min(100%, 220px), 1fr))',
gap: 10,
}}
>
{lanes.length === 0 ? (
<div style={{ border: '0.5px solid #d3e4d9', borderRadius: 8, background: '#fff', padding: 13, color: '#405f4f', fontSize: 12 }}>
{t('emptyLanes')}
</div>
) : lanes.map(lane => (
<div
key={lane.lane_id}
style={{
border: `0.5px solid ${lane.tone === 'steady' ? '#bddfc9' : lane.tone === 'warn' ? '#e6c8b8' : '#dad7ce'}`,
borderRadius: 8,
background: lane.tone === 'warn' ? '#fffaf7' : lane.tone === 'locked' ? '#f8f7f3' : '#fff',
padding: 13,
display: 'grid',
gap: 10,
minWidth: 0,
}}
>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 8 }}>
<div style={{ fontSize: 12, color: toneColors[lane.tone], fontWeight: 700, ...textWrap }}>
{t(`lanes.${lane.lane_id}.title` as never)}
</div>
<span style={{ fontSize: 12, color: toneColors[lane.tone], fontWeight: 700 }}>{lane.completion_percent}%</span>
</div>
<div style={{ fontSize: 11, color: '#405f4f', lineHeight: 1.45, ...textWrap }}>
{t(`lanes.${lane.lane_id}.body` as never)}
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 6 }}>
<div style={{ border: '0.5px solid #e1e8df', borderRadius: 8, background: '#fff', padding: 8, minWidth: 0 }}>
<div style={{ fontSize: 10, color: '#627466', fontWeight: 700 }}>{t('laneStatusLabel')}</div>
<div style={{ marginTop: 5, fontSize: 11, color: '#141413', fontWeight: 700, ...textWrap }}>{lane.status}</div>
</div>
<div style={{ border: '0.5px solid #e1e8df', borderRadius: 8, background: '#fff', padding: 8, minWidth: 0 }}>
<div style={{ fontSize: 10, color: '#627466', fontWeight: 700 }}>{t('laneNextGateLabel')}</div>
<div style={{ marginTop: 5, fontSize: 11, color: '#141413', fontWeight: 700, ...textWrap }}>{lane.next_gate}</div>
</div>
</div>
</div>
))}
</div>
<details
data-testid="iwooos-runtime-security-readback-boundaries"
style={{
marginTop: 12,
border: '0.5px solid #d3e4d9',
borderRadius: 8,
background: '#fff',
padding: '8px 10px',
}}
>
<summary style={{ cursor: 'pointer', fontSize: 12, fontWeight: 700, color: '#315f45' }}>
{t('boundaryTitle')}
</summary>
<p style={{ fontSize: 11, color: '#405f4f', lineHeight: 1.5, margin: '8px 0', ...textWrap }}>
{t('boundaryIntro')}
</p>
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(230px, 1fr))', gap: 6 }}>
{(data?.no_false_green_rules ?? runtimeSecurityReadbackFallbackRules).map(item => (
<code
key={item}
style={{
border: '0.5px solid #d3e4d9',
borderRadius: 8,
padding: '6px 8px',
color: '#405f4f',
fontSize: 11,
lineHeight: 1.4,
background: '#f7fbf8',
overflowWrap: 'anywhere',
}}
>
{item}
</code>
))}
</div>
</details>
</div>
</section>
)
}
function wazuhReadonlyStatusKey(httpStatus: number | null, data: WazuhReadonlyStatusResponse | null) { function wazuhReadonlyStatusKey(httpStatus: number | null, data: WazuhReadonlyStatusResponse | null) {
if (httpStatus === 404) return 'predeploy' if (httpStatus === 404) return 'predeploy'
if (!data) return 'unavailable' if (!data) return 'unavailable'
@@ -21933,6 +22177,7 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) {
<IwoooSAgentBountySecurityOnboardingBoard /> <IwoooSAgentBountySecurityOnboardingBoard />
<IwoooSRolloutRiskReadOnlyBoard /> <IwoooSRolloutRiskReadOnlyBoard />
<IwoooSWazuhIntrusionReadbackBoard /> <IwoooSWazuhIntrusionReadbackBoard />
<IwoooSRuntimeSecurityReadbackBoard />
<IwoooSWazuhLiveRouteReadbackBoard /> <IwoooSWazuhLiveRouteReadbackBoard />
<IwoooSWazuhReleaseGateBoard /> <IwoooSWazuhReleaseGateBoard />
<IwoooSWazuhOwnerEvidencePreflightBoard /> <IwoooSWazuhOwnerEvidencePreflightBoard />

View File

@@ -95,6 +95,55 @@ export class ApiError extends Error {
} }
} }
export type IwoooSRuntimeSecurityReadbackTone = 'steady' | 'warn' | 'locked'
export interface IwoooSRuntimeSecurityReadbackLane {
lane_id:
| 'wazuh_registry'
| 'wazuh_dashboard_api'
| 'kali_intake'
| 'alert_readability'
| 'owner_dispatch'
| 'intrusion_prevention'
status: string
completion_percent: number
tone: IwoooSRuntimeSecurityReadbackTone
next_gate: string
metrics: Record<string, number>
source_refs: string[]
}
export interface IwoooSRuntimeSecurityReadbackResponse {
schema_version: 'iwooos_runtime_security_readback_v1'
status: string
mode: string
source_refs: string[]
summary: {
source_snapshot_count: number
p0_lane_count: number
control_plane_visibility_percent: number
actual_runtime_acceptance_percent: number
owner_response_received_count: number
owner_response_accepted_count: number
redacted_evidence_refs_received_count: number
request_sent_count: number
wazuh_expected_host_scope_count: number
wazuh_manager_registry_accepted_count: number
wazuh_transport_observed_count: number
wazuh_dashboard_api_degraded_observed_count: number
kali_active_scan_authorized_count: number
kali_execute_authorized_count: number
kali_finding_envelope_accepted_count: number
alert_formatter_contract_marker_count: number
alert_receipt_runtime_send_count: number
intrusion_prevention_candidate_count: number
runtime_gate_count: number
}
lanes: IwoooSRuntimeSecurityReadbackLane[]
boundaries: Record<string, boolean>
no_false_green_rules: string[]
}
async function handleResponse<T>(response: Response): Promise<T> { async function handleResponse<T>(response: Response): Promise<T> {
if (!response.ok) { if (!response.ok) {
const error = await response.json().catch(() => ({})) const error = await response.json().catch(() => ({}))
@@ -128,6 +177,11 @@ export const apiClient = {
}>(res) }>(res)
}, },
async getIwoooSRuntimeSecurityReadback() {
const res = await fetch(`${API_BASE_URL}/iwooos/runtime-security-readback`, { cache: 'no-store' })
return handleResponse<IwoooSRuntimeSecurityReadbackResponse>(res)
},
// Agent // Agent
async getAgentStatus() { async getAgentStatus() {
const res = await fetch(`${API_BASE_URL}/agent/status`) const res = await fetch(`${API_BASE_URL}/agent/status`)

View File

@@ -1,3 +1,34 @@
## 2026-06-26IwoooS Runtime 資安讀回總板Wazuh / Kali / 告警 / owner dispatch 六條 P0 線接上只讀 API 與前台
**背景**IwoooS 資安工作不能停在文件、靜態卡片或截圖Wazuh、資安觀測節點、告警可讀性、owner dispatch、外部入侵防護與 runtime gate 必須能被前台讀回,同時不得把 UI 可見、route 200、transport count 或一般操作批准誤判成 runtime 授權。
**完成**
- 新增 `iwooos_runtime_security_readback_v1` 後端只讀 service彙整 8 份已提交資安 snapshotS4.9 owner gap、Wazuh managed host coverage、Wazuh agent visibility runtime gate、Kali integration、SOC/SIEM/Kali/Wazuh integration、Telegram alert readability、monitoring owner request draft、external host intrusion prevention control。
- 新增 `GET /api/v1/iwooos/runtime-security-readback`;端點只讀 repo snapshot不呼叫 Wazuh live API、不呼叫 Kali、不 SSH、不碰 Docker / Nginx / firewall / Telegram / secret。
- 前台 `/zh-TW/iwooos` 新增 `IwoooS Runtime 資安讀回` 總板,顯示六條 P0 線:`wazuh_registry``wazuh_dashboard_api``kali_intake``alert_readability``owner_dispatch``intrusion_prevention`
- `zh-TW` 與目前鏡像 `en` 都新增繁中產品文案;新增 API client 型別與 `getIwoooSRuntimeSecurityReadback()`
**只讀驗證結果**
- `pytest apps/api/tests/test_iwooos_runtime_security_readback.py apps/api/tests/test_iwooos_wazuh_api.py -q``9 passed`
- `python3.11 -m py_compile apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_runtime_security_readback.py apps/api/src/services/snapshot_paths.py`:通過。
- `pnpm --dir apps/web typecheck`:通過;本輪先以離線 pnpm store 補最小依賴連結,未下載新套件。
- `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 apps/web/messages docs/LOGBOOK.md 'apps/web/src/app/[locale]/iwooos/page.tsx' apps/web/src/lib/api-client.ts apps/api/src/api/v1/iwooos.py apps/api/src/services/iwooos_runtime_security_readback.py``DOC_SECRET_SANITY_OK scanned_files=274`
- `git diff --check`:通過。
- readback summary`control_plane_visibility_percent=84``actual_runtime_acceptance_percent=0``runtime_gate_count=0``owner_response_received_count=0``owner_response_accepted_count=0``wazuh_manager_registry_accepted_count=0``kali_active_scan_authorized_count=0``alert_receipt_runtime_send_count=0`
**完成度同步**
- 後端 readback API source`100%`
- 前台 IwoooS runtime readback source`100%`
- 本地驗證:`95%`;尚未做 production deploy / desktop / mobile 驗證。
- IwoooS runtime acceptance`0%`
- Wazuh manager registry accepted`0`
- Owner response received / accepted`0 / 0`
- Active runtime gate / active scan / active response / Telegram send`0 / false`
**邊界**:本段只做只讀 API、前台讀回與測試不主動修 Wazuh、不重新註冊 agent、不重啟 manager / dashboard、不啟動 Kali scan、不執行 `/execute`、不改 Nginx、不 reload、不改 firewall、不收 secret、不送 Telegram、不開 runtime gate。
## 2026-06-26AwoooP Owner release AI 預填草案正式上線:把人工處理縮成決策確認,不再整包丟回值班者 ## 2026-06-26AwoooP Owner release AI 預填草案正式上線:把人工處理縮成決策確認,不再整包丟回值班者
**背景**:使用者指出 `node-exporter-110` 類 Telegram 告警仍顯示 `manual_required` / `DRAFT_READY`,但最後看起來仍要人工處理所有欄位。前一段已把 Work Items 顯示到 apply gate readiness本段進一步把 `execution_release_contract` 中可由 AI 先產出的 owner release 資訊預填,讓 operator 看到 AI 已準備哪些欄位、人工只剩哪些決策不再把維護窗口、rollback、blast radius、verifier、KM / PlayBook trust 全部標成 raw missing。 **背景**:使用者指出 `node-exporter-110` 類 Telegram 告警仍顯示 `manual_required` / `DRAFT_READY`,但最後看起來仍要人工處理所有欄位。前一段已把 Work Items 顯示到 apply gate readiness本段進一步把 `execution_release_contract` 中可由 AI 先產出的 owner release 資訊預填,讓 operator 看到 AI 已準備哪些欄位、人工只剩哪些決策不再把維護窗口、rollback、blast radius、verifier、KM / PlayBook trust 全部標成 raw missing。