From 0107eae239e7591179de6a511a49dfa8fa6432b5 Mon Sep 17 00:00:00 2001 From: ogt Date: Sat, 11 Jul 2026 19:45:58 +0800 Subject: [PATCH] feat(iwooos): add live security control plane --- apps/api/src/api/v1/iwooos.py | 20 + .../iwooos_security_asset_control_plane.py | 1236 +++++++++++++++++ ...est_iwooos_security_asset_control_plane.py | 248 ++++ .../app/[locale]/awooop/work-items/page.tsx | 3 + apps/web/src/app/[locale]/iwooos/page.tsx | 2 + .../security-asset-control-plane-cockpit.tsx | 402 ++++++ .../security-asset-control-plane.test.ts | 135 ++ .../src/lib/security-asset-control-plane.ts | 190 +++ 8 files changed, 2236 insertions(+) create mode 100644 apps/api/src/services/iwooos_security_asset_control_plane.py create mode 100644 apps/api/tests/test_iwooos_security_asset_control_plane.py create mode 100644 apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx create mode 100644 apps/web/src/lib/__tests__/security-asset-control-plane.test.ts create mode 100644 apps/web/src/lib/security-asset-control-plane.ts diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 98a7abc3c..165605c25 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -25,6 +25,9 @@ from src.services.iwooos_owner_evidence_intake_preflight import ( from src.services.iwooos_runtime_security_readback import ( load_latest_iwooos_runtime_security_readback, ) +from src.services.iwooos_security_asset_control_plane import ( + get_iwooos_security_asset_control_plane_service, +) from src.services.iwooos_security_control_coverage import ( load_latest_iwooos_security_control_coverage, ) @@ -1005,6 +1008,23 @@ async def get_iwooos_security_control_coverage() -> dict[str, Any]: ) from exc +@router.get( + "/api/v1/iwooos/security-asset-control-plane", + response_model=dict[str, Any], + summary="取得 IwoooS live 資產、SIEM、稽核與 AI 自動化控制平面", + description=( + "從 production DB 只讀聚合 asset inventory、coverage、compliance、SIEM rule/case、" + "AI operation receipts 與 audit trail,輸出 NIST CSF、SIEM pipeline 與排序工作項。" + "端點不回傳資產名稱、主機、IP、event payload、target resource、prompt/output 或 secret," + "也不觸發掃描、告警、SOAR、Wazuh active response 或任何 runtime action。" + ), +) +async def get_iwooos_security_asset_control_plane() -> dict[str, Any]: + """回傳公開安全的 production security control-plane aggregate。""" + payload = await get_iwooos_security_asset_control_plane_service().get_snapshot() + return redact_public_lan_topology(payload) + + @router.get( "/api/v1/iwooos/high-value-config-control-coverage", response_model=dict[str, Any], diff --git a/apps/api/src/services/iwooos_security_asset_control_plane.py b/apps/api/src/services/iwooos_security_asset_control_plane.py new file mode 100644 index 000000000..72b5639cc --- /dev/null +++ b/apps/api/src/services/iwooos_security_asset_control_plane.py @@ -0,0 +1,1236 @@ +"""Public-safe live asset, SIEM, audit, and AI automation control-plane readback.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Iterable +from datetime import UTC, datetime +from typing import Any + +import structlog +from sqlalchemy import text as _sql + +from src.db.base import get_db_context + +logger = structlog.get_logger(__name__) + +_SCHEMA_VERSION = "iwooos_security_asset_control_plane_v1" +_QUERY_TIMEOUT_SECONDS = 5.0 +_DISCOVERY_FRESHNESS_SECONDS = 2 * 60 * 60 +_WINDOW_HOURS = 24 + +_ASSET_TYPES = ( + "host", + "container", + "k8s_workload", + "k8s_resource", + "database", + "table", + "website", + "api_endpoint", + "package", + "log_stream", + "km_entry", + "frontend", + "backend", + "ci_pipeline", + "gitea_repo", + "monitoring_target", + "secret", + "volume", + "network", + "certificate", + "scheduled_job", + "message_queue", + "cache", + "dashboard", + "ai_agent", + "llm_model", + "third_party_service", + "backup_target", +) + +_ASSET_SCOPES: tuple[tuple[str, str, tuple[str, ...]], ...] = ( + ( + "infrastructure", + "主機與執行環境", + ("host", "network", "volume", "container", "k8s_workload", "k8s_resource"), + ), + ( + "products", + "產品、網站與 API", + ("website", "api_endpoint", "frontend", "backend", "dashboard", "gitea_repo"), + ), + ( + "data", + "資料、佇列與備份", + ("database", "table", "message_queue", "cache", "backup_target"), + ), + ( + "security_operations", + "監控、日誌與安全控制", + ("monitoring_target", "log_stream", "certificate", "secret", "scheduled_job"), + ), + ( + "supply_chain", + "套件與交付供應鏈", + ("package", "third_party_service", "ci_pipeline"), + ), + ( + "ai_knowledge", + "AI Agent、模型與知識", + ("ai_agent", "llm_model", "km_entry"), + ), +) + +_COVERAGE_DIMENSIONS = ( + "auto_monitoring", + "auto_alerting", + "auto_rule_creation", + "auto_rule_matching", + "auto_playbook", + "auto_remediation", + "auto_km_creation", +) + +_COMPLIANCE_DIMENSIONS = ( + "ssl_cert_valid", + "cve_scan", + "secret_rotated", + "backup_tested", + "audit_log_enabled", + "access_reviewed", + "encryption_at_rest", +) + +_AI_LOOP_STAGES: tuple[tuple[str, str, frozenset[str]], ...] = ( + ( + "detect", + "偵測", + frozenset({"asset_discovered", "alert_fired", "coverage_recalculated"}), + ), + ( + "correlate", + "關聯", + frozenset({"rule_matched", "alert_routed", "alert_suppressed"}), + ), + ( + "decide", + "AI 決策", + frozenset( + { + "rule_created", + "playbook_generated", + "ansible_candidate_matched", + "capacity_recommendation", + } + ), + ), + ("check", "檢查模式", frozenset({"ansible_check_mode_executed"})), + ( + "apply", + "受控執行", + frozenset({"ansible_apply_executed", "remediation_executed", "quota_enforced"}), + ), + ("verify", "獨立驗證", frozenset({"remediation_verified"})), + ("learn", "學習回寫", frozenset({"km_created", "km_updated", "km_linked"})), +) + +_SIEM_STAGES = ( + "collect", + "normalize", + "detect", + "correlate", + "prioritize", + "case_manage", + "respond", + "verify_learn", +) + + +def _value(row: Any, name: str, default: Any = None) -> Any: + if row is None: + return default + if isinstance(row, dict): + return row.get(name, default) + mapping = getattr(row, "_mapping", None) + if mapping is not None: + return mapping.get(name, default) + return getattr(row, name, default) + + +def _count(rows: Iterable[Any], key: str, value: str, count_key: str = "cnt") -> int: + return sum( + int(_value(row, count_key, 0) or 0) + for row in rows + if str(_value(row, key, "")) == value + ) + + +def _percent(numerator: int | float, denominator: int | float) -> int: + if denominator <= 0: + return 0 + return round(float(numerator) / float(denominator) * 100) + + +def _status_for_percent(percent: int, *, has_evidence: bool = True) -> str: + if not has_evidence: + return "not_evidenced" + if percent >= 80: + return "controlled" + if percent > 0: + return "partial" + return "uncovered" + + +def _iso(value: Any) -> str | None: + return value.isoformat() if isinstance(value, datetime) else None + + +def _age_seconds(value: Any, now: datetime) -> int | None: + if not isinstance(value, datetime): + return None + timestamp = value + if timestamp.tzinfo is None: + timestamp = timestamp.replace(tzinfo=UTC) + return max( + 0, int((now.astimezone(UTC) - timestamp.astimezone(UTC)).total_seconds()) + ) + + +def _rows_by_dimension( + rows: Iterable[Any], dimensions: Iterable[str] +) -> list[dict[str, Any]]: + source = list(rows) + output: list[dict[str, Any]] = [] + for dimension in dimensions: + statuses = { + status: _count(source, "dimension", dimension, f"{status}_count") + for status in ("green", "yellow", "red", "unknown") + } + if not any(statuses.values()): + statuses = { + status: sum( + int(_value(row, "cnt", 0) or 0) + for row in source + if str(_value(row, "dimension", "")) == dimension + and str(_value(row, "status", "")) == status + ) + for status in ("green", "yellow", "red", "unknown") + } + total = sum(statuses.values()) + output.append( + { + "dimension": dimension, + "total": total, + "green_count": statuses["green"], + "yellow_count": statuses["yellow"], + "red_count": statuses["red"], + "unknown_count": statuses["unknown"], + "controlled_percent": _percent(statuses["green"], total), + } + ) + return output + + +def _compliance_rows(rows: Iterable[Any]) -> list[dict[str, Any]]: + source = list(rows) + output: list[dict[str, Any]] = [] + for dimension in _COMPLIANCE_DIMENSIONS: + statuses = { + status: sum( + int(_value(row, "cnt", 0) or 0) + for row in source + if str(_value(row, "dimension", "")) == dimension + and str(_value(row, "status", "")) == status + ) + for status in ("compliant", "warning", "violation", "unknown") + } + total = sum(statuses.values()) + output.append( + { + "dimension": dimension, + "total": total, + "compliant_count": statuses["compliant"], + "warning_count": statuses["warning"], + "violation_count": statuses["violation"], + "unknown_count": statuses["unknown"], + "compliant_percent": _percent(statuses["compliant"], total), + } + ) + return output + + +def _automation_stage_rows( + rows: Iterable[Any], accepted_ai_trace_count: int +) -> list[dict[str, Any]]: + source = list(rows) + stages: list[dict[str, Any]] = [] + for stage_id, label, operation_types in _AI_LOOP_STAGES: + receipt_count = sum( + int(_value(row, "cnt", 0) or 0) + for row in source + if str(_value(row, "operation_type", "")) in operation_types + and str(_value(row, "status", "")) in {"success", "dry_run"} + ) + if stage_id == "decide": + receipt_count += accepted_ai_trace_count + stages.append( + { + "stage_id": stage_id, + "label": label, + "receipt_count_24h": receipt_count, + "status": "observed" if receipt_count > 0 else "missing", + } + ) + return stages + + +def _coverage_ratio(dimensions: list[dict[str, Any]], selected: set[str]) -> int: + rows = [row for row in dimensions if row["dimension"] in selected] + total = sum(int(row["total"]) for row in rows) + green = sum(int(row["green_count"]) for row in rows) + return _percent(green, total) + + +def _compliance_ratio(dimensions: list[dict[str, Any]], selected: set[str]) -> int: + rows = [row for row in dimensions if row["dimension"] in selected] + total = sum(int(row["total"]) for row in rows) + compliant = sum(int(row["compliant_count"]) for row in rows) + return _percent(compliant, total) + + +def _build_control_functions( + *, + owned_percent: int, + identify_percent: int, + coverage: list[dict[str, Any]], + compliance: list[dict[str, Any]], + verified_receipts: int, +) -> list[dict[str, Any]]: + protect = _compliance_ratio( + compliance, + { + "ssl_cert_valid", + "cve_scan", + "secret_rotated", + "access_reviewed", + "encryption_at_rest", + }, + ) + detect = _coverage_ratio( + coverage, + { + "auto_monitoring", + "auto_alerting", + "auto_rule_creation", + "auto_rule_matching", + }, + ) + respond = _coverage_ratio(coverage, {"auto_playbook", "auto_remediation"}) + backup = _compliance_ratio(compliance, {"backup_tested"}) + recover = round((backup + (100 if verified_receipts > 0 else 0)) / 2) + values = ( + ("govern", "治理", owned_percent), + ("identify", "識別", identify_percent), + ("protect", "防護", protect), + ("detect", "偵測", detect), + ("respond", "應變", respond), + ("recover", "復原", recover), + ) + return [ + { + "function_id": function_id, + "label": label, + "controlled_percent": percent, + "status": _status_for_percent(percent), + } + for function_id, label, percent in values + ] + + +def _build_siem_pipeline( + *, + by_type: dict[str, int], + coverage: list[dict[str, Any]], + automation_stages: list[dict[str, Any]], + siem: dict[str, Any], +) -> list[dict[str, Any]]: + coverage_by_id = {row["dimension"]: row for row in coverage} + automation_by_id = {row["stage_id"]: row for row in automation_stages} + approved_rules = int(siem.get("approved_rule_count", 0) or 0) + fired_rules = int(siem.get("fired_rule_count", 0) or 0) + incident_total = int(siem.get("incident_total_24h", 0) or 0) + decision_chain_count = int(siem.get("decision_chain_count_24h", 0) or 0) + resolved_count = int(siem.get("resolved_incident_count_24h", 0) or 0) + + signals = { + "collect": by_type.get("log_stream", 0) + by_type.get("monitoring_target", 0), + "normalize": int(siem.get("normalized_event_count_24h", 0) or 0), + "detect": approved_rules + + fired_rules + + int(coverage_by_id.get("auto_alerting", {}).get("green_count", 0)), + "correlate": int( + automation_by_id.get("correlate", {}).get("receipt_count_24h", 0) + ), + "prioritize": decision_chain_count, + "case_manage": incident_total, + "respond": int(automation_by_id.get("apply", {}).get("receipt_count_24h", 0)), + "verify_learn": resolved_count + + int(automation_by_id.get("verify", {}).get("receipt_count_24h", 0)) + + int(automation_by_id.get("learn", {}).get("receipt_count_24h", 0)), + } + labels = { + "collect": "遙測收集", + "normalize": "事件正規化", + "detect": "偵測規則", + "correlate": "關聯分析", + "prioritize": "AI 風險排序", + "case_manage": "事件案件", + "respond": "SOAR 受控處置", + "verify_learn": "驗證與學習", + } + return [ + { + "stage_id": stage_id, + "label": labels[stage_id], + "evidence_count_24h": signals[stage_id], + "status": "observed" if signals[stage_id] > 0 else "missing", + } + for stage_id in _SIEM_STAGES + ] + + +def _work_item( + work_item_id: str, + priority: str, + control_id: str, + title: str, + gap_count: int, + next_action: str, +) -> dict[str, Any]: + return { + "work_item_id": work_item_id, + "priority": priority, + "control_id": control_id, + "title": title, + "gap_count": gap_count, + "status": "open", + "next_action": next_action, + } + + +def build_iwooos_security_asset_control_plane( + *, + discovery_row: Any, + inventory_rows: Iterable[Any], + coverage_rows: Iterable[Any], + relationship_row: Any, + compliance_rows: Iterable[Any], + automation_rows: Iterable[Any], + ai_trace_row: Any, + siem_row: Any, + audit_row: Any, + generated_at: datetime | None = None, +) -> dict[str, Any]: + """Build a public-safe aggregate without exposing raw asset or event identities.""" + now = generated_at or datetime.now(UTC) + inventory_source = list(inventory_rows) + coverage_source = list(coverage_rows) + compliance_source = list(compliance_rows) + automation_source = list(automation_rows) + + by_type = { + str(_value(row, "asset_type", "unknown")): int(_value(row, "cnt", 0) or 0) + for row in inventory_source + } + asset_total = sum(by_type.values()) + unowned_total = sum( + int(_value(row, "unowned_count", 0) or 0) for row in inventory_source + ) + stale_total = sum( + int(_value(row, "stale_count", 0) or 0) for row in inventory_source + ) + present_types = { + asset_type for asset_type in _ASSET_TYPES if by_type.get(asset_type, 0) > 0 + } + + asset_scopes: list[dict[str, Any]] = [] + for scope_id, label, required_types in _ASSET_SCOPES: + present = [ + asset_type + for asset_type in required_types + if by_type.get(asset_type, 0) > 0 + ] + managed_count = sum(by_type.get(asset_type, 0) for asset_type in required_types) + percent = _percent(len(present), len(required_types)) + asset_scopes.append( + { + "scope_id": scope_id, + "label": label, + "managed_asset_count": managed_count, + "expected_type_count": len(required_types), + "present_type_count": len(present), + "missing_type_count": len(required_types) - len(present), + "type_coverage_percent": percent, + "status": _status_for_percent(percent, has_evidence=managed_count > 0), + } + ) + + latest_success_at = _value(discovery_row, "latest_success_ended_at") + discovery_age = _age_seconds(latest_success_at, now) + discovery_fresh = ( + discovery_age is not None and discovery_age <= _DISCOVERY_FRESHNESS_SECONDS + ) + discovery = { + "latest_status": str( + _value(discovery_row, "latest_status", "missing") or "missing" + ), + "latest_scan_depth": str( + _value(discovery_row, "latest_scan_depth", "unknown") or "unknown" + ), + "latest_started_at": _iso(_value(discovery_row, "latest_started_at")), + "latest_ended_at": _iso(_value(discovery_row, "latest_ended_at")), + "latest_success_at": _iso(latest_success_at), + "latest_total_assets": int( + _value(discovery_row, "latest_total_assets", 0) or 0 + ), + "new_assets": int(_value(discovery_row, "latest_new_assets", 0) or 0), + "modified_assets": int(_value(discovery_row, "latest_modified_assets", 0) or 0), + "disappeared_assets": int( + _value(discovery_row, "latest_disappeared_assets", 0) or 0 + ), + "age_seconds": discovery_age, + "freshness_slo_seconds": _DISCOVERY_FRESHNESS_SECONDS, + "fresh": discovery_fresh, + } + + coverage = _rows_by_dimension(coverage_source, _COVERAGE_DIMENSIONS) + compliance = _compliance_rows(compliance_source) + coverage_total = sum(row["total"] for row in coverage) + coverage_green = sum(row["green_count"] for row in coverage) + coverage_unknown = sum(row["unknown_count"] for row in coverage) + compliance_violations = sum(row["violation_count"] for row in compliance) + compliance_unknown = sum(row["unknown_count"] for row in compliance) + + accepted_ai_trace_count = int(_value(ai_trace_row, "accepted_trace_count", 0) or 0) + automation_stages = _automation_stage_rows( + automation_source, accepted_ai_trace_count + ) + observed_automation_stages = sum( + 1 for stage in automation_stages if stage["status"] == "observed" + ) + verified_receipts = int( + next( + ( + stage["receipt_count_24h"] + for stage in automation_stages + if stage["stage_id"] == "verify" + ), + 0, + ) + ) + rollback_receipts = sum( + int(_value(row, "cnt", 0) or 0) + for row in automation_source + if str(_value(row, "operation_type", "")) + in {"remediation_rolled_back", "ansible_rollback_executed"} + and str(_value(row, "status", "")) == "success" + ) + failed_operations = sum( + int(_value(row, "cnt", 0) or 0) + for row in automation_source + if str(_value(row, "status", "")) == "failed" + ) + + relationship_count = int(_value(relationship_row, "relationship_count", 0) or 0) + orphan_asset_count = int( + _value(relationship_row, "orphan_asset_count", asset_total) or 0 + ) + owned_percent = _percent(asset_total - unowned_total, asset_total) + type_coverage_percent = _percent(len(present_types), len(_ASSET_TYPES)) + relationship_percent = _percent(asset_total - orphan_asset_count, asset_total) + identify_percent = round( + (type_coverage_percent + relationship_percent + (100 if discovery_fresh else 0)) + / 3 + ) + + control_functions = _build_control_functions( + owned_percent=owned_percent, + identify_percent=identify_percent, + coverage=coverage, + compliance=compliance, + verified_receipts=verified_receipts, + ) + + siem = { + "rule_total": int(_value(siem_row, "rule_total", 0) or 0), + "approved_rule_count": int(_value(siem_row, "approved_rule_count", 0) or 0), + "fired_rule_count": int(_value(siem_row, "fired_rule_count", 0) or 0), + "noisy_rule_count": int(_value(siem_row, "noisy_rule_count", 0) or 0), + "ai_generated_rule_count": int( + _value(siem_row, "ai_generated_rule_count", 0) or 0 + ), + "incident_total_24h": int(_value(siem_row, "incident_total_24h", 0) or 0), + "open_incident_count_24h": int( + _value(siem_row, "open_incident_count_24h", 0) or 0 + ), + "resolved_incident_count_24h": int( + _value(siem_row, "resolved_incident_count_24h", 0) or 0 + ), + "decision_chain_count_24h": int( + _value(siem_row, "decision_chain_count_24h", 0) or 0 + ), + "normalized_event_count_24h": int( + _value(siem_row, "normalized_event_count_24h", 0) or 0 + ), + "mean_time_to_resolve_minutes_24h": ( + round(float(_value(siem_row, "mttr_minutes_24h")), 1) + if _value(siem_row, "mttr_minutes_24h") is not None + else None + ), + } + siem_pipeline = _build_siem_pipeline( + by_type=by_type, + coverage=coverage, + automation_stages=automation_stages, + siem=siem, + ) + siem_observed_stages = sum( + 1 for stage in siem_pipeline if stage["status"] == "observed" + ) + + audit = { + "k8s_execution_audit_count_24h": int( + _value(audit_row, "k8s_audit_count_24h", 0) or 0 + ), + "k8s_execution_failure_count_24h": int( + _value(audit_row, "k8s_audit_failure_count_24h", 0) or 0 + ), + "mcp_tool_audit_count_24h": int( + _value(audit_row, "mcp_audit_count_24h", 0) or 0 + ), + "mcp_tool_failure_count_24h": int( + _value(audit_row, "mcp_audit_failure_count_24h", 0) or 0 + ), + "asset_change_event_count_24h": int( + _value(audit_row, "asset_change_count_24h", 0) or 0 + ), + "asset_change_ai_analysis_count_24h": int( + _value(audit_row, "asset_change_ai_analysis_count_24h", 0) or 0 + ), + "automation_operation_count_24h": sum( + int(_value(row, "cnt", 0) or 0) for row in automation_source + ), + "automation_failure_count_24h": failed_operations, + "ai_trace_count_24h": int(_value(ai_trace_row, "trace_count", 0) or 0), + "accepted_ai_trace_count_24h": accepted_ai_trace_count, + "immutable_external_audit_store_evidenced": False, + } + + work_items: list[dict[str, Any]] = [] + if asset_total == 0 or not discovery_fresh: + work_items.append( + _work_item( + "AIA-P0-006-01", + "P0", + "asset_discovery", + "恢復全資產盤點新鮮度", + max(1, asset_total if not discovery_fresh else 0), + "執行 inventory reconciliation 並以最新成功 run 驗證。", + ) + ) + missing_asset_types = len(_ASSET_TYPES) - len(present_types) + if missing_asset_types > 0: + work_items.append( + _work_item( + "AIA-P0-006-02", + "P0", + "asset_scope", + "接入缺席的資產類型", + missing_asset_types, + "依 scope connector 接入網站、資料、供應鏈、安全工具與 AI 資產。", + ) + ) + if coverage_total == 0 or coverage_unknown > 0: + work_items.append( + _work_item( + "AIA-P0-006-03", + "P0", + "automation_coverage", + "消除自動化 coverage unknown", + max(1, coverage_unknown), + "用 runtime receipts 評估七段 coverage,不以 snapshot 預設值計分。", + ) + ) + if unowned_total > 0: + work_items.append( + _work_item( + "AIA-P0-006-04", + "P0", + "asset_ownership", + "補齊資產 owner", + unowned_total, + "以 repo、namespace 與 service catalog 自動 reconciliation owner。", + ) + ) + if orphan_asset_count > 0: + work_items.append( + _work_item( + "AIA-P0-006-05", + "P1", + "asset_relationship", + "補齊孤立資產關聯", + orphan_asset_count, + "建立 calls、routes、stores、monitors、backs_up 關聯與 blast radius。", + ) + ) + if compliance_violations > 0 or compliance_unknown > 0: + work_items.append( + _work_item( + "AIA-P0-006-06", + "P0", + "security_audit", + "完成資安稽核控制證據", + compliance_violations + compliance_unknown, + "接入 CVE、TLS、backup、audit、access review 與 encryption verifier。", + ) + ) + missing_siem_stages = len(_SIEM_STAGES) - siem_observed_stages + if missing_siem_stages > 0: + work_items.append( + _work_item( + "AIA-P0-007-01", + "P0", + "siem_pipeline", + "完成 SIEM 事件閉環", + missing_siem_stages, + "依序補 telemetry、normalize、detect、correlate、case、SOAR、verify/learn receipts。", + ) + ) + missing_ai_stages = len(_AI_LOOP_STAGES) - observed_automation_stages + if missing_ai_stages > 0 or verified_receipts == 0: + work_items.append( + _work_item( + "AIA-P0-008-01", + "P0", + "ai_security_automation", + "完成 AI Agent 同 run 受控閉環", + max(1, missing_ai_stages), + "同 trace 串接 decision、check、bounded apply、verifier、rollback/no-write 與 learning。", + ) + ) + if not audit["immutable_external_audit_store_evidenced"]: + work_items.append( + _work_item( + "AIA-P1-001-01", + "P1", + "audit_integrity", + "建立不可竄改稽核保存", + 1, + "將 execution、MCP、SIEM 與 verifier receipts 寫入有 retention 的外部稽核層。", + ) + ) + + work_items.sort(key=lambda item: (item["priority"], item["work_item_id"])) + overall_control_percent = round( + sum(function["controlled_percent"] for function in control_functions) + / len(control_functions) + ) + + return { + "schema_version": _SCHEMA_VERSION, + "generated_at": now.isoformat(), + "status": "ready" if asset_total > 0 and discovery_fresh else "degraded", + "source_status": "live_database_aggregate", + "summary": { + "managed_asset_count": asset_total, + "expected_asset_type_count": len(_ASSET_TYPES), + "present_asset_type_count": len(present_types), + "asset_type_coverage_percent": type_coverage_percent, + "scope_count": len(_ASSET_SCOPES), + "controlled_scope_count": sum( + 1 for scope in asset_scopes if scope["status"] == "controlled" + ), + "unowned_asset_count": unowned_total, + "stale_asset_count": stale_total, + "orphan_asset_count": orphan_asset_count, + "relationship_count": relationship_count, + "automation_coverage_green_percent": _percent( + coverage_green, coverage_total + ), + "automation_coverage_unknown_count": coverage_unknown, + "compliance_violation_count": compliance_violations, + "compliance_unknown_count": compliance_unknown, + "nist_controlled_percent": overall_control_percent, + "siem_stage_count": len(_SIEM_STAGES), + "siem_observed_stage_count": siem_observed_stages, + "siem_stage_coverage_percent": _percent( + siem_observed_stages, len(_SIEM_STAGES) + ), + "ai_loop_stage_count": len(_AI_LOOP_STAGES), + "ai_loop_observed_stage_count": observed_automation_stages, + "ai_loop_stage_coverage_percent": _percent( + observed_automation_stages, len(_AI_LOOP_STAGES) + ), + "verified_remediation_receipt_count_24h": verified_receipts, + "active_work_item_count": len(work_items), + }, + "discovery": discovery, + "asset_scopes": asset_scopes, + "coverage_dimensions": coverage, + "compliance_dimensions": compliance, + "nist_csf_functions": control_functions, + "siem": {**siem, "pipeline": siem_pipeline}, + "ai_automation": { + "window_hours": _WINDOW_HOURS, + "stages": automation_stages, + "aggregate_stage_coverage_percent": _percent( + observed_automation_stages, len(_AI_LOOP_STAGES) + ), + "accepted_ai_trace_count": accepted_ai_trace_count, + "verified_remediation_receipt_count": verified_receipts, + "rollback_receipt_count": rollback_receipts, + "failed_operation_count": failed_operations, + "same_run_closed_loop_proven": False, + "same_run_closed_loop_count": 0, + }, + "security_audit": audit, + "work_items": work_items, + "boundaries": { + "aggregate_only": True, + "raw_asset_identity_returned": False, + "raw_event_payload_returned": False, + "secret_value_collection_allowed": False, + "live_scan_triggered": False, + "runtime_action_triggered": False, + "completion_requires_same_run_receipts": True, + }, + } + + +def build_unavailable_iwooos_security_asset_control_plane( + reason_code: str, +) -> dict[str, Any]: + """Return a stable public-safe degraded response without exception details.""" + now = datetime.now(UTC) + return { + "schema_version": _SCHEMA_VERSION, + "generated_at": now.isoformat(), + "status": "degraded", + "source_status": "live_database_unavailable", + "reason_code": reason_code, + "summary": { + "managed_asset_count": 0, + "expected_asset_type_count": len(_ASSET_TYPES), + "present_asset_type_count": 0, + "asset_type_coverage_percent": 0, + "scope_count": len(_ASSET_SCOPES), + "controlled_scope_count": 0, + "unowned_asset_count": 0, + "stale_asset_count": 0, + "orphan_asset_count": 0, + "relationship_count": 0, + "automation_coverage_green_percent": 0, + "automation_coverage_unknown_count": 0, + "compliance_violation_count": 0, + "compliance_unknown_count": 0, + "nist_controlled_percent": 0, + "siem_stage_count": len(_SIEM_STAGES), + "siem_observed_stage_count": 0, + "siem_stage_coverage_percent": 0, + "ai_loop_stage_count": len(_AI_LOOP_STAGES), + "ai_loop_observed_stage_count": 0, + "ai_loop_stage_coverage_percent": 0, + "verified_remediation_receipt_count_24h": 0, + "active_work_item_count": 3, + }, + "discovery": { + "latest_status": "unavailable", + "latest_scan_depth": "unknown", + "latest_started_at": None, + "latest_ended_at": None, + "latest_success_at": None, + "latest_total_assets": 0, + "new_assets": 0, + "modified_assets": 0, + "disappeared_assets": 0, + "age_seconds": None, + "freshness_slo_seconds": _DISCOVERY_FRESHNESS_SECONDS, + "fresh": False, + }, + "asset_scopes": [ + { + "scope_id": scope_id, + "label": label, + "managed_asset_count": 0, + "expected_type_count": len(required_types), + "present_type_count": 0, + "missing_type_count": len(required_types), + "type_coverage_percent": 0, + "status": "not_evidenced", + } + for scope_id, label, required_types in _ASSET_SCOPES + ], + "coverage_dimensions": [], + "compliance_dimensions": [], + "nist_csf_functions": [ + { + "function_id": function_id, + "label": label, + "controlled_percent": 0, + "status": "not_evidenced", + } + for function_id, label in ( + ("govern", "治理"), + ("identify", "識別"), + ("protect", "防護"), + ("detect", "偵測"), + ("respond", "應變"), + ("recover", "復原"), + ) + ], + "siem": { + "pipeline": [ + { + "stage_id": stage_id, + "label": stage_id, + "evidence_count_24h": 0, + "status": "missing", + } + for stage_id in _SIEM_STAGES + ] + }, + "ai_automation": { + "window_hours": _WINDOW_HOURS, + "stages": [ + { + "stage_id": stage_id, + "label": label, + "receipt_count_24h": 0, + "status": "missing", + } + for stage_id, label, _ in _AI_LOOP_STAGES + ], + "aggregate_stage_coverage_percent": 0, + "accepted_ai_trace_count": 0, + "verified_remediation_receipt_count": 0, + "rollback_receipt_count": 0, + "failed_operation_count": 0, + "same_run_closed_loop_proven": False, + "same_run_closed_loop_count": 0, + }, + "security_audit": { + "k8s_execution_audit_count_24h": 0, + "k8s_execution_failure_count_24h": 0, + "mcp_tool_audit_count_24h": 0, + "mcp_tool_failure_count_24h": 0, + "asset_change_event_count_24h": 0, + "asset_change_ai_analysis_count_24h": 0, + "automation_operation_count_24h": 0, + "automation_failure_count_24h": 0, + "ai_trace_count_24h": 0, + "accepted_ai_trace_count_24h": 0, + "immutable_external_audit_store_evidenced": False, + }, + "work_items": [ + _work_item( + "AIA-P0-006-01", + "P0", + "asset_discovery", + "恢復 live 資產控制平面", + 1, + "恢復 DB readback 後重跑 inventory reconciliation。", + ), + _work_item( + "AIA-P0-007-01", + "P0", + "siem_pipeline", + "恢復 SIEM runtime readback", + len(_SIEM_STAGES), + "恢復 telemetry、case 與 response aggregate readback。", + ), + _work_item( + "AIA-P0-008-01", + "P0", + "ai_security_automation", + "恢復 AI Agent execution receipts", + len(_AI_LOOP_STAGES), + "恢復同 trace check、apply、verify 與 learning receipts。", + ), + ], + "boundaries": { + "aggregate_only": True, + "raw_asset_identity_returned": False, + "raw_event_payload_returned": False, + "secret_value_collection_allowed": False, + "live_scan_triggered": False, + "runtime_action_triggered": False, + "completion_requires_same_run_receipts": True, + }, + } + + +class IwoooSSecurityAssetControlPlaneService: + """Read aggregate production security state with a bounded DB timeout.""" + + async def get_snapshot(self) -> dict[str, Any]: + try: + return await asyncio.wait_for( + self._load_snapshot(), timeout=_QUERY_TIMEOUT_SECONDS + ) + except TimeoutError: + logger.warning("iwooos_security_asset_control_plane_timeout") + return build_unavailable_iwooos_security_asset_control_plane( + "database_timeout" + ) + except Exception as exc: # noqa: BLE001 - public readback must degrade safely + logger.warning( + "iwooos_security_asset_control_plane_unavailable", + error_type=type(exc).__name__, + ) + return build_unavailable_iwooos_security_asset_control_plane( + "database_query_failed" + ) + + async def _load_snapshot(self) -> dict[str, Any]: + async with get_db_context() as db: + discovery_result = await db.execute( + _sql( + """ + SELECT + latest.status AS latest_status, + latest.scan_depth AS latest_scan_depth, + latest.started_at AS latest_started_at, + latest.ended_at AS latest_ended_at, + latest.total_assets AS latest_total_assets, + latest.new_assets AS latest_new_assets, + latest.modified_assets AS latest_modified_assets, + latest.disappeared_assets AS latest_disappeared_assets, + successful.ended_at AS latest_success_ended_at + FROM (SELECT 1) seed + LEFT JOIN LATERAL ( + SELECT status, scan_depth, started_at, ended_at, total_assets, + new_assets, modified_assets, disappeared_assets + FROM asset_discovery_run + ORDER BY started_at DESC + LIMIT 1 + ) latest ON true + LEFT JOIN LATERAL ( + SELECT ended_at + FROM asset_discovery_run + WHERE status = 'success' + ORDER BY ended_at DESC + LIMIT 1 + ) successful ON true + """ + ) + ) + discovery_row = discovery_result.one_or_none() + + inventory_result = await db.execute( + _sql( + """ + SELECT + asset_type, + count(*) AS cnt, + count(*) FILTER ( + WHERE owner_team IS NULL OR btrim(owner_team) = '' + ) AS unowned_count, + count(*) FILTER ( + WHERE last_seen_at < NOW() - INTERVAL '2 hours' + ) AS stale_count + FROM asset_inventory + WHERE lifecycle_state = 'active' + GROUP BY asset_type + ORDER BY asset_type + """ + ) + ) + inventory_rows = inventory_result.fetchall() + + coverage_result = await db.execute( + _sql( + """ + SELECT dimension, coverage_status AS status, count(*) AS cnt + FROM asset_coverage_snapshot + WHERE run_id = ( + SELECT run_id + FROM asset_discovery_run + WHERE status = 'success' + ORDER BY ended_at DESC + LIMIT 1 + ) + GROUP BY dimension, coverage_status + ORDER BY dimension, coverage_status + """ + ) + ) + coverage_rows = coverage_result.fetchall() + + relationship_result = await db.execute( + _sql( + """ + SELECT + (SELECT count(*) FROM asset_relationship WHERE is_active) AS relationship_count, + count(*) FILTER ( + WHERE NOT EXISTS ( + SELECT 1 + FROM asset_relationship rel + WHERE rel.is_active + AND (rel.from_asset_id = asset.asset_id OR rel.to_asset_id = asset.asset_id) + ) + ) AS orphan_asset_count + FROM asset_inventory asset + WHERE asset.lifecycle_state = 'active' + """ + ) + ) + relationship_row = relationship_result.one() + + compliance_result = await db.execute( + _sql( + """ + WITH latest AS ( + SELECT DISTINCT ON (asset_id, dimension) + asset_id, dimension, status + FROM asset_compliance_snapshot + ORDER BY asset_id, dimension, detected_at DESC + ) + SELECT dimension, status, count(*) AS cnt + FROM latest + GROUP BY dimension, status + ORDER BY dimension, status + """ + ) + ) + compliance_rows = compliance_result.fetchall() + + automation_result = await db.execute( + _sql( + """ + SELECT operation_type, status, count(*) AS cnt + FROM automation_operation_log + WHERE created_at >= NOW() - INTERVAL '24 hours' + GROUP BY operation_type, status + ORDER BY operation_type, status + """ + ) + ) + automation_rows = automation_result.fetchall() + + ai_trace_result = await db.execute( + _sql( + """ + SELECT + count(*) AS trace_count, + count(*) FILTER (WHERE trace.accepted IS TRUE) AS accepted_trace_count + FROM ai_collaboration_trace trace + JOIN automation_operation_log operation ON operation.op_id = trace.op_id + WHERE operation.created_at >= NOW() - INTERVAL '24 hours' + """ + ) + ) + ai_trace_row = ai_trace_result.one() + + siem_result = await db.execute( + _sql( + """ + SELECT + (SELECT count(*) FROM alert_rule_catalog) AS rule_total, + (SELECT count(*) FROM alert_rule_catalog WHERE review_status = 'approved') + AS approved_rule_count, + (SELECT count(*) FROM alert_rule_catalog WHERE last_fired_at IS NOT NULL) + AS fired_rule_count, + (SELECT count(*) FROM alert_rule_catalog WHERE noise_rate > 0.5) + AS noisy_rule_count, + (SELECT count(*) FROM alert_rule_catalog WHERE source = 'ai_generated') + AS ai_generated_rule_count, + (SELECT count(*) FROM incidents + WHERE project_id = 'awoooi' + AND created_at >= NOW() - INTERVAL '24 hours') + AS incident_total_24h, + (SELECT count(*) FROM incidents + WHERE project_id = 'awoooi' + AND created_at >= NOW() - INTERVAL '24 hours' + AND status::text NOT IN ('resolved', 'closed')) + AS open_incident_count_24h, + (SELECT count(*) FROM incidents + WHERE project_id = 'awoooi' + AND created_at >= NOW() - INTERVAL '24 hours' + AND status::text IN ('resolved', 'closed')) + AS resolved_incident_count_24h, + (SELECT count(*) FROM incidents + WHERE project_id = 'awoooi' + AND created_at >= NOW() - INTERVAL '24 hours' + AND decision_chain IS NOT NULL) + AS decision_chain_count_24h, + (SELECT count(*) FROM awooop_conversation_event + WHERE project_id = 'awoooi' + AND received_at >= NOW() - INTERVAL '24 hours') + AS normalized_event_count_24h, + (SELECT avg(EXTRACT(EPOCH FROM (resolved_at - created_at)) / 60.0) + FROM incidents + WHERE project_id = 'awoooi' + AND resolved_at >= NOW() - INTERVAL '24 hours') + AS mttr_minutes_24h + """ + ) + ) + siem_row = siem_result.one() + + audit_result = await db.execute( + _sql( + """ + SELECT + (SELECT count(*) FROM audit_logs + WHERE project_id = 'awoooi' + AND created_at >= NOW() - INTERVAL '24 hours') + AS k8s_audit_count_24h, + (SELECT count(*) FROM audit_logs + WHERE project_id = 'awoooi' + AND created_at >= NOW() - INTERVAL '24 hours' + AND success IS FALSE) + AS k8s_audit_failure_count_24h, + (SELECT count(*) FROM mcp_audit_log + WHERE created_at >= NOW() - INTERVAL '24 hours') + AS mcp_audit_count_24h, + (SELECT count(*) FROM mcp_audit_log + WHERE created_at >= NOW() - INTERVAL '24 hours' + AND success IS FALSE) + AS mcp_audit_failure_count_24h, + (SELECT count(*) FROM asset_change_event + WHERE detected_at >= NOW() - INTERVAL '24 hours') + AS asset_change_count_24h, + (SELECT count(*) FROM asset_change_event + WHERE detected_at >= NOW() - INTERVAL '24 hours' + AND ai_analysis IS NOT NULL) + AS asset_change_ai_analysis_count_24h + """ + ) + ) + audit_row = audit_result.one() + + return build_iwooos_security_asset_control_plane( + discovery_row=discovery_row, + inventory_rows=inventory_rows, + coverage_rows=coverage_rows, + relationship_row=relationship_row, + compliance_rows=compliance_rows, + automation_rows=automation_rows, + ai_trace_row=ai_trace_row, + siem_row=siem_row, + audit_row=audit_row, + ) + + +_service: IwoooSSecurityAssetControlPlaneService | None = None + + +def get_iwooos_security_asset_control_plane_service() -> ( + IwoooSSecurityAssetControlPlaneService +): + global _service + if _service is None: + _service = IwoooSSecurityAssetControlPlaneService() + return _service diff --git a/apps/api/tests/test_iwooos_security_asset_control_plane.py b/apps/api/tests/test_iwooos_security_asset_control_plane.py new file mode 100644 index 000000000..d22917ecc --- /dev/null +++ b/apps/api/tests/test_iwooos_security_asset_control_plane.py @@ -0,0 +1,248 @@ +from __future__ import annotations + +# ruff: noqa: E402, I001 + +import asyncio +import json +import os +from datetime import UTC, datetime, timedelta +from types import SimpleNamespace + +os.environ.setdefault("DATABASE_URL", "postgresql+asyncpg://test:test@localhost/test") + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.iwooos import router +from src.services.iwooos_security_asset_control_plane import ( + IwoooSSecurityAssetControlPlaneService, + build_iwooos_security_asset_control_plane, + build_unavailable_iwooos_security_asset_control_plane, +) + + +def _row(**values): + return SimpleNamespace(**values) + + +def _ready_payload() -> dict: + now = datetime(2026, 7, 11, 4, 0, tzinfo=UTC) + discovery = _row( + latest_status="success", + latest_scan_depth="full", + latest_started_at=now - timedelta(minutes=31), + latest_ended_at=now - timedelta(minutes=30), + latest_success_ended_at=now - timedelta(minutes=30), + latest_total_assets=24, + latest_new_assets=2, + latest_modified_assets=1, + latest_disappeared_assets=0, + ) + inventory = [ + _row(asset_type="host", cnt=3, unowned_count=1, stale_count=0), + _row(asset_type="k8s_workload", cnt=5, unowned_count=0, stale_count=1), + _row(asset_type="website", cnt=2, unowned_count=0, stale_count=0), + _row(asset_type="api_endpoint", cnt=4, unowned_count=0, stale_count=0), + _row(asset_type="database", cnt=2, unowned_count=0, stale_count=0), + _row(asset_type="log_stream", cnt=4, unowned_count=0, stale_count=0), + _row(asset_type="monitoring_target", cnt=3, unowned_count=0, stale_count=0), + _row(asset_type="ai_agent", cnt=1, unowned_count=0, stale_count=0), + ] + coverage = [ + _row(dimension=dimension, status="green", cnt=20) + for dimension in ( + "auto_monitoring", + "auto_alerting", + "auto_rule_creation", + "auto_rule_matching", + "auto_playbook", + "auto_remediation", + "auto_km_creation", + ) + ] + compliance = [ + _row(dimension=dimension, status="compliant", cnt=20) + for dimension in ( + "ssl_cert_valid", + "cve_scan", + "secret_rotated", + "backup_tested", + "audit_log_enabled", + "access_reviewed", + "encryption_at_rest", + ) + ] + automation = [ + _row(operation_type="asset_discovered", status="success", cnt=1), + _row(operation_type="rule_matched", status="success", cnt=3), + _row(operation_type="rule_created", status="success", cnt=2), + _row(operation_type="ansible_check_mode_executed", status="dry_run", cnt=2), + _row(operation_type="ansible_apply_executed", status="success", cnt=1), + _row(operation_type="remediation_verified", status="success", cnt=1), + _row(operation_type="km_created", status="success", cnt=1), + _row(operation_type="ansible_apply_executed", status="failed", cnt=1), + ] + return build_iwooos_security_asset_control_plane( + discovery_row=discovery, + inventory_rows=inventory, + coverage_rows=coverage, + relationship_row=_row(relationship_count=21, orphan_asset_count=3), + compliance_rows=compliance, + automation_rows=automation, + ai_trace_row=_row(trace_count=4, accepted_trace_count=3), + siem_row=_row( + rule_total=12, + approved_rule_count=10, + fired_rule_count=8, + noisy_rule_count=1, + ai_generated_rule_count=3, + incident_total_24h=5, + open_incident_count_24h=2, + resolved_incident_count_24h=3, + decision_chain_count_24h=5, + normalized_event_count_24h=18, + mttr_minutes_24h=14.25, + ), + audit_row=_row( + k8s_audit_count_24h=2, + k8s_audit_failure_count_24h=0, + mcp_audit_count_24h=11, + mcp_audit_failure_count_24h=1, + asset_change_count_24h=4, + asset_change_ai_analysis_count_24h=3, + ), + generated_at=now, + ) + + +def test_builds_live_asset_siem_audit_and_ai_control_plane_without_false_closure() -> ( + None +): + payload = _ready_payload() + + assert payload["schema_version"] == "iwooos_security_asset_control_plane_v1" + assert payload["status"] == "ready" + assert payload["source_status"] == "live_database_aggregate" + assert payload["summary"]["managed_asset_count"] == 24 + assert payload["summary"]["present_asset_type_count"] == 8 + assert payload["summary"]["siem_stage_coverage_percent"] == 100 + assert payload["summary"]["ai_loop_stage_coverage_percent"] == 100 + assert payload["summary"]["verified_remediation_receipt_count_24h"] == 1 + assert payload["discovery"]["fresh"] is True + assert payload["ai_automation"]["same_run_closed_loop_proven"] is False + assert payload["ai_automation"]["same_run_closed_loop_count"] == 0 + assert len(payload["nist_csf_functions"]) == 6 + assert len(payload["siem"]["pipeline"]) == 8 + assert payload["security_audit"]["mcp_tool_audit_count_24h"] == 11 + assert any( + item["work_item_id"] == "AIA-P0-006-02" for item in payload["work_items"] + ) + assert any( + item["work_item_id"] == "AIA-P1-001-01" for item in payload["work_items"] + ) + + public_text = json.dumps(payload, ensure_ascii=False) + assert "192.168." not in public_text + assert "postgresql://" not in public_text + assert "target_resource" not in public_text + assert '"event_payload":' not in public_text + assert 'secret_value_collection_allowed": true' not in public_text.lower() + + +def test_unknown_coverage_and_compliance_create_p0_work_items() -> None: + now = datetime(2026, 7, 11, 4, 0, tzinfo=UTC) + payload = build_iwooos_security_asset_control_plane( + discovery_row=_row( + latest_status="success", + latest_scan_depth="shallow", + latest_started_at=now - timedelta(minutes=10), + latest_ended_at=now - timedelta(minutes=9), + latest_success_ended_at=now - timedelta(minutes=9), + latest_total_assets=1, + ), + inventory_rows=[ + _row(asset_type="host", cnt=1, unowned_count=1, stale_count=0), + ], + coverage_rows=[ + _row(dimension="auto_monitoring", status="unknown", cnt=1), + ], + relationship_row=_row(relationship_count=0, orphan_asset_count=1), + compliance_rows=[ + _row(dimension="cve_scan", status="unknown", cnt=1), + ], + automation_rows=[], + ai_trace_row=_row(trace_count=0, accepted_trace_count=0), + siem_row=_row(), + audit_row=_row(), + generated_at=now, + ) + + assert payload["summary"]["automation_coverage_green_percent"] == 0 + assert payload["summary"]["automation_coverage_unknown_count"] == 1 + assert payload["summary"]["compliance_unknown_count"] == 1 + assert payload["summary"]["siem_observed_stage_count"] == 0 + assert payload["summary"]["ai_loop_observed_stage_count"] == 0 + work_item_ids = {item["work_item_id"] for item in payload["work_items"]} + assert { + "AIA-P0-006-02", + "AIA-P0-006-03", + "AIA-P0-006-04", + "AIA-P0-006-05", + "AIA-P0-006-06", + "AIA-P0-007-01", + "AIA-P0-008-01", + } <= work_item_ids + + +def test_unavailable_payload_is_fixed_degraded_contract_without_raw_error() -> None: + payload = build_unavailable_iwooos_security_asset_control_plane( + "database_query_failed" + ) + + assert payload["status"] == "degraded" + assert payload["source_status"] == "live_database_unavailable" + assert payload["reason_code"] == "database_query_failed" + assert payload["summary"]["managed_asset_count"] == 0 + assert payload["discovery"]["fresh"] is False + assert payload["boundaries"]["raw_asset_identity_returned"] is False + assert payload["boundaries"]["raw_event_payload_returned"] is False + + +def test_service_redacts_database_exception_details(monkeypatch) -> None: + service = IwoooSSecurityAssetControlPlaneService() + + async def fail_load(): + raise RuntimeError("postgresql://operator:raw-secret@private-host/runtime") + + monkeypatch.setattr(service, "_load_snapshot", fail_load) + payload = asyncio.run(service.get_snapshot()) + text = json.dumps(payload) + + assert payload["reason_code"] == "database_query_failed" + assert "raw-secret" not in text + assert "private-host" not in text + + +def test_public_api_returns_live_aggregate(monkeypatch) -> None: + payload = _ready_payload() + + class FakeService: + async def get_snapshot(self): + return payload + + monkeypatch.setattr( + "src.api.v1.iwooos.get_iwooos_security_asset_control_plane_service", + lambda: FakeService(), + ) + app = FastAPI() + app.include_router(router) + response = TestClient(app).get("/api/v1/iwooos/security-asset-control-plane") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "iwooos_security_asset_control_plane_v1" + assert data["summary"]["managed_asset_count"] == 24 + assert data["summary"]["siem_stage_coverage_percent"] == 100 + assert data["ai_automation"]["same_run_closed_loop_proven"] is False + assert "192.168." not in response.text + assert "raw-secret" not in response.text diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 53c8c4e9b..ba8881c18 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -33,6 +33,7 @@ import { import { Link } from "@/i18n/routing"; import { AutonomousRuntimeReceiptPanel } from "@/components/awooop/autonomous-runtime-receipt-panel"; import { IncidentEvidenceHeader } from "@/components/awooop/incident-evidence-header"; +import { SecurityAssetControlPlaneCockpit } from "@/components/iwooos/security-asset-control-plane-cockpit"; import { isMonotonicPriorityWorkOrderProjection } from "@/lib/priority-work-order-projection"; import { getRuntimeApiBaseUrl } from "@/lib/runtime-api-base"; import { @@ -13077,6 +13078,8 @@ export default function AwoooPWorkItemsPage() { locale={locale} /> + + + diff --git a/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx b/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx new file mode 100644 index 000000000..0a109cee6 --- /dev/null +++ b/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx @@ -0,0 +1,402 @@ +"use client"; + +import { + Activity, + Bot, + Boxes, + CheckCircle2, + RefreshCw, + Radar, + ShieldAlert, + ShieldCheck, + TriangleAlert, +} from "lucide-react"; +import { useCallback, useEffect, useMemo, useState } from "react"; + +import { + isSecurityAssetControlPlanePayload, + securityControlPlaneTone, + topSecurityControlPlaneWorkItems, + type SecurityAssetControlPlanePayload, + type SecurityControlPlaneStage, +} from "@/lib/security-asset-control-plane"; +import { getRuntimeApiBaseUrl } from "@/lib/runtime-api-base"; +import { cn } from "@/lib/utils"; + +type Copy = { + title: string; + live: string; + degraded: string; + assets: string; + assetTypes: string; + nist: string; + siem: string; + aiLoop: string; + verified: string; + functions: string; + siemFlow: string; + aiFlow: string; + scope: string; + priority: string; + audit: string; + aiTrace: string; + sameRunMissing: string; + unavailable: string; + refresh: string; +}; + +const COPY: Record<"zh" | "en", Copy> = { + zh: { + title: "AI 資安控制平面", + live: "LIVE", + degraded: "DEGRADED", + assets: "納管資產", + assetTypes: "資產類型", + nist: "NIST 控制", + siem: "SIEM 閉環", + aiLoop: "AI 閉環", + verified: "24h 已驗證", + functions: "治理六功能", + siemFlow: "SIEM 八段", + aiFlow: "AI Agent 七段", + scope: "全資產範圍", + priority: "優先處理", + audit: "稽核軌跡", + aiTrace: "AI 軌跡", + sameRunMissing: "尚無同 run 閉環證據", + unavailable: "Live readback 無法取得", + refresh: "重新整理資安控制平面", + }, + en: { + title: "AI Security Control Plane", + live: "LIVE", + degraded: "DEGRADED", + assets: "Managed assets", + assetTypes: "Asset types", + nist: "NIST control", + siem: "SIEM closure", + aiLoop: "AI closure", + verified: "Verified 24h", + functions: "Six functions", + siemFlow: "SIEM pipeline", + aiFlow: "AI Agent loop", + scope: "Asset scope", + priority: "Priorities", + audit: "Audit trail", + aiTrace: "AI trace", + sameRunMissing: "No same-run closure evidence", + unavailable: "Live readback unavailable", + refresh: "Refresh security control plane", + }, +}; + +const TONE_CLASS = { + healthy: "border-[#8fc39c] bg-[#f2faf4] text-[#17602a]", + warning: "border-[#d9b36f] bg-[#fff8e8] text-[#8a5a08]", + critical: "border-[#e2a29b] bg-[#fff2f0] text-[#9f2f25]", +}; + +function stageCount(stage: SecurityControlPlaneStage) { + return stage.evidence_count_24h ?? stage.receipt_count_24h ?? 0; +} + +async function fetchControlPlane(): Promise { + const base = getRuntimeApiBaseUrl(); + const path = "/api/v1/iwooos/security-asset-control-plane"; + const urls = Array.from(new Set([path, base ? `${base}${path}` : ""].filter(Boolean))); + for (const url of urls) { + try { + const response = await fetch(url, { cache: "no-store" }); + if (!response.ok) continue; + const payload: unknown = await response.json(); + if (isSecurityAssetControlPlanePayload(payload)) return payload; + } catch { + // Try the next same-origin/runtime API candidate. + } + } + return null; +} + +function MetricCell({ + label, + value, + icon: Icon, +}: { + label: string; + value: string | number; + icon: typeof ShieldCheck; +}) { + return ( +
+
+
+
+ {value} +
+
+ ); +} + +function StageStrip({ + title, + stages, +}: { + title: string; + stages: SecurityControlPlaneStage[]; +}) { + return ( +
+
{title}
+
+ {stages.map((stage) => { + const observed = stage.status === "observed"; + return ( +
+
{stage.label}
+
{stageCount(stage)}
+
+ ); + })} +
+
+ ); +} + +export function SecurityAssetControlPlaneCockpit({ + locale = "zh-TW", + className, +}: { + locale?: string; + className?: string; +}) { + const copy = COPY[locale.toLowerCase().startsWith("zh") ? "zh" : "en"]; + const [payload, setPayload] = useState(null); + const [loading, setLoading] = useState(true); + + const load = useCallback(async () => { + setLoading(true); + const next = await fetchControlPlane(); + setPayload(next); + setLoading(false); + }, []); + + useEffect(() => { + void load(); + const timer = window.setInterval(() => void load(), 60_000); + return () => window.clearInterval(timer); + }, [load]); + + const tone = payload ? securityControlPlaneTone(payload) : "critical"; + const priorityItems = useMemo( + () => (payload ? topSecurityControlPlaneWorkItems(payload, 3) : []), + [payload] + ); + + if (!payload && !loading) { + return ( +
+
+
+
+ ); + } + + return ( +
+
+
+
+ +
+ +
+ + + + + + +
+ + {payload ? ( +
+
+
+
{copy.functions}
+
+ {payload.nist_csf_functions.map((item) => ( +
+
{item.label}
+
+ {item.controlled_percent}% +
+
+
= 80 + ? "bg-[#2f8a50]" + : item.controlled_percent > 0 + ? "bg-[#c58a2e]" + : "bg-[#c9584d]" + )} + style={{ width: `${Math.min(100, Math.max(0, item.controlled_percent))}%` }} + /> +
+
+ ))} +
+
+ +
+
+ {copy.scope} + + {payload.summary.controlled_scope_count}/{payload.summary.scope_count} + +
+
+ {payload.asset_scopes.map((scope) => ( +
+ {scope.label} + + {scope.managed_asset_count} + + +
+ ))} +
+
+
+ +
+ + + +
+
+
{copy.audit}
+
+
+
+ {payload.security_audit.automation_operation_count_24h} +
+
OPS
+
+
+
+ {payload.security_audit.mcp_tool_audit_count_24h} +
+
MCP
+
+
+
+ {payload.security_audit.ai_trace_count_24h} +
+
{copy.aiTrace}
+
+
+ {!payload.ai_automation.same_run_closed_loop_proven ? ( +
+
+ ) : null} +
+ +
+
{copy.priority}
+
+ {priorityItems.map((item) => ( +
+ + {item.priority} + + + {item.title} + + {item.gap_count} +
+ ))} +
+
+
+
+
+ ) : ( +
+ )} +
+ ); +} diff --git a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts new file mode 100644 index 000000000..866df2e92 --- /dev/null +++ b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts @@ -0,0 +1,135 @@ +import { describe, expect, it } from "vitest"; + +import { + isSecurityAssetControlPlanePayload, + securityControlPlaneTone, + topSecurityControlPlaneWorkItems, + type SecurityAssetControlPlanePayload, +} from "../security-asset-control-plane"; + +function payload(): SecurityAssetControlPlanePayload { + return { + schema_version: "iwooos_security_asset_control_plane_v1", + generated_at: "2026-07-11T04:00:00+00:00", + status: "ready", + source_status: "live_database_aggregate", + summary: { + managed_asset_count: 24, + expected_asset_type_count: 28, + present_asset_type_count: 20, + asset_type_coverage_percent: 71, + scope_count: 6, + controlled_scope_count: 3, + unowned_asset_count: 0, + stale_asset_count: 0, + orphan_asset_count: 0, + relationship_count: 40, + automation_coverage_green_percent: 85, + automation_coverage_unknown_count: 0, + compliance_violation_count: 0, + compliance_unknown_count: 0, + nist_controlled_percent: 82, + siem_stage_count: 8, + siem_observed_stage_count: 8, + siem_stage_coverage_percent: 100, + ai_loop_stage_count: 7, + ai_loop_observed_stage_count: 7, + ai_loop_stage_coverage_percent: 100, + verified_remediation_receipt_count_24h: 2, + active_work_item_count: 2, + }, + discovery: { + latest_status: "success", + latest_success_at: "2026-07-11T03:50:00+00:00", + age_seconds: 600, + freshness_slo_seconds: 7200, + fresh: true, + }, + asset_scopes: [], + nist_csf_functions: [], + siem: { pipeline: [] }, + ai_automation: { + stages: [], + aggregate_stage_coverage_percent: 100, + accepted_ai_trace_count: 2, + verified_remediation_receipt_count: 2, + rollback_receipt_count: 0, + failed_operation_count: 0, + same_run_closed_loop_proven: false, + same_run_closed_loop_count: 0, + }, + security_audit: { + k8s_execution_audit_count_24h: 2, + k8s_execution_failure_count_24h: 0, + mcp_tool_audit_count_24h: 4, + mcp_tool_failure_count_24h: 0, + asset_change_event_count_24h: 3, + automation_operation_count_24h: 12, + automation_failure_count_24h: 0, + ai_trace_count_24h: 4, + accepted_ai_trace_count_24h: 2, + immutable_external_audit_store_evidenced: false, + }, + work_items: [ + { + work_item_id: "AIA-P1-001-01", + priority: "P1", + control_id: "audit", + title: "audit", + gap_count: 1, + status: "open", + next_action: "audit", + }, + { + work_item_id: "AIA-P0-007-01", + priority: "P0", + control_id: "siem", + title: "siem", + gap_count: 2, + status: "open", + next_action: "siem", + }, + ], + boundaries: { + aggregate_only: true, + raw_asset_identity_returned: false, + raw_event_payload_returned: false, + secret_value_collection_allowed: false, + live_scan_triggered: false, + runtime_action_triggered: false, + completion_requires_same_run_receipts: true, + }, + }; +} + +describe("security asset control plane projection", () => { + it("accepts only the public-safe aggregate contract", () => { + const safe = payload(); + expect(isSecurityAssetControlPlanePayload(safe)).toBe(true); + + const unsafe = { + ...safe, + boundaries: { ...safe.boundaries, raw_asset_identity_returned: true }, + }; + expect(isSecurityAssetControlPlanePayload(unsafe)).toBe(false); + }); + + it("does not show healthy before same-run closure is proven", () => { + const value = payload(); + expect(securityControlPlaneTone(value)).toBe("warning"); + + value.ai_automation.same_run_closed_loop_proven = true; + expect(securityControlPlaneTone(value)).toBe("healthy"); + + value.discovery.fresh = false; + expect(securityControlPlaneTone(value)).toBe("critical"); + }); + + it("keeps P0 work ahead of P1 regardless of API order", () => { + const items = topSecurityControlPlaneWorkItems(payload(), 2); + expect(items.map((item) => item.work_item_id)).toEqual([ + "AIA-P0-007-01", + "AIA-P1-001-01", + ]); + }); +}); diff --git a/apps/web/src/lib/security-asset-control-plane.ts b/apps/web/src/lib/security-asset-control-plane.ts new file mode 100644 index 000000000..ee8d2cf72 --- /dev/null +++ b/apps/web/src/lib/security-asset-control-plane.ts @@ -0,0 +1,190 @@ +export type SecurityControlStatus = "ready" | "degraded"; + +export type SecurityControlPlaneSummary = { + managed_asset_count: number; + expected_asset_type_count: number; + present_asset_type_count: number; + asset_type_coverage_percent: number; + scope_count: number; + controlled_scope_count: number; + unowned_asset_count: number; + stale_asset_count: number; + orphan_asset_count: number; + relationship_count: number; + automation_coverage_green_percent: number; + automation_coverage_unknown_count: number; + compliance_violation_count: number; + compliance_unknown_count: number; + nist_controlled_percent: number; + siem_stage_count: number; + siem_observed_stage_count: number; + siem_stage_coverage_percent: number; + ai_loop_stage_count: number; + ai_loop_observed_stage_count: number; + ai_loop_stage_coverage_percent: number; + verified_remediation_receipt_count_24h: number; + active_work_item_count: number; +}; + +export type SecurityControlPlaneFunction = { + function_id: string; + label: string; + controlled_percent: number; + status: string; +}; + +export type SecurityControlPlaneStage = { + stage_id: string; + label: string; + status: "observed" | "missing"; + evidence_count_24h?: number; + receipt_count_24h?: number; +}; + +export type SecurityControlPlaneScope = { + scope_id: string; + label: string; + managed_asset_count: number; + expected_type_count: number; + present_type_count: number; + missing_type_count: number; + type_coverage_percent: number; + status: string; +}; + +export type SecurityControlPlaneWorkItem = { + work_item_id: string; + priority: string; + control_id: string; + title: string; + gap_count: number; + status: string; + next_action: string; +}; + +export type SecurityAssetControlPlanePayload = { + schema_version: "iwooos_security_asset_control_plane_v1"; + generated_at: string; + status: SecurityControlStatus; + source_status: string; + reason_code?: string; + summary: SecurityControlPlaneSummary; + discovery: { + latest_status: string; + latest_success_at: string | null; + age_seconds: number | null; + freshness_slo_seconds: number; + fresh: boolean; + }; + asset_scopes: SecurityControlPlaneScope[]; + nist_csf_functions: SecurityControlPlaneFunction[]; + siem: { + pipeline: SecurityControlPlaneStage[]; + incident_total_24h?: number; + open_incident_count_24h?: number; + resolved_incident_count_24h?: number; + noisy_rule_count?: number; + mean_time_to_resolve_minutes_24h?: number | null; + }; + ai_automation: { + stages: SecurityControlPlaneStage[]; + aggregate_stage_coverage_percent: number; + accepted_ai_trace_count: number; + verified_remediation_receipt_count: number; + rollback_receipt_count: number; + failed_operation_count: number; + same_run_closed_loop_proven: boolean; + same_run_closed_loop_count: number; + }; + security_audit: { + k8s_execution_audit_count_24h: number; + k8s_execution_failure_count_24h: number; + mcp_tool_audit_count_24h: number; + mcp_tool_failure_count_24h: number; + asset_change_event_count_24h: number; + automation_operation_count_24h: number; + automation_failure_count_24h: number; + ai_trace_count_24h: number; + accepted_ai_trace_count_24h: number; + immutable_external_audit_store_evidenced: boolean; + }; + work_items: SecurityControlPlaneWorkItem[]; + boundaries: { + aggregate_only: boolean; + raw_asset_identity_returned: boolean; + raw_event_payload_returned: boolean; + secret_value_collection_allowed: boolean; + live_scan_triggered: boolean; + runtime_action_triggered: boolean; + completion_requires_same_run_receipts: boolean; + }; +}; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isSecurityAssetControlPlanePayload( + value: unknown +): value is SecurityAssetControlPlanePayload { + if (!isRecord(value)) return false; + if (value.schema_version !== "iwooos_security_asset_control_plane_v1") return false; + if (!isRecord(value.summary) || !isRecord(value.boundaries)) return false; + if (!Array.isArray(value.asset_scopes) || !Array.isArray(value.nist_csf_functions)) { + return false; + } + if (!isRecord(value.siem) || !Array.isArray(value.siem.pipeline)) return false; + if (!isRecord(value.ai_automation) || !Array.isArray(value.ai_automation.stages)) { + return false; + } + return ( + typeof value.summary.managed_asset_count === "number" && + typeof value.summary.nist_controlled_percent === "number" && + value.boundaries.aggregate_only === true && + value.boundaries.raw_asset_identity_returned === false && + value.boundaries.raw_event_payload_returned === false && + value.boundaries.secret_value_collection_allowed === false && + value.boundaries.live_scan_triggered === false && + value.boundaries.runtime_action_triggered === false + ); +} + +export type SecurityControlPlaneTone = "healthy" | "warning" | "critical"; + +export function securityControlPlaneTone( + payload: SecurityAssetControlPlanePayload +): SecurityControlPlaneTone { + if (payload.status !== "ready" || !payload.discovery.fresh) return "critical"; + if ( + payload.summary.compliance_violation_count > 0 || + payload.summary.nist_controlled_percent < 50 || + payload.summary.siem_stage_coverage_percent < 50 || + payload.summary.ai_loop_stage_coverage_percent < 50 + ) { + return "critical"; + } + if ( + payload.summary.unowned_asset_count > 0 || + payload.summary.orphan_asset_count > 0 || + payload.summary.automation_coverage_unknown_count > 0 || + payload.summary.compliance_unknown_count > 0 || + !payload.ai_automation.same_run_closed_loop_proven + ) { + return "warning"; + } + return "healthy"; +} + +export function topSecurityControlPlaneWorkItems( + payload: SecurityAssetControlPlanePayload, + limit = 3 +): SecurityControlPlaneWorkItem[] { + const rank: Record = { P0: 0, P1: 1, P2: 2, P3: 3 }; + return [...payload.work_items] + .sort( + (left, right) => + (rank[left.priority] ?? 99) - (rank[right.priority] ?? 99) || + left.work_item_id.localeCompare(right.work_item_id) + ) + .slice(0, Math.max(0, limit)); +}