From 2babb1b1557ac29b07ac091828dc1bb9d011530d Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 22 Jul 2026 12:47:43 +0800 Subject: [PATCH] fix(iwooos): expose security runtime gaps truthfully --- .../iwooos_security_asset_control_plane.py | 246 +++++++++++++++--- ...est_iwooos_security_asset_control_plane.py | 115 +++++--- .../security-asset-control-plane-cockpit.tsx | 38 ++- .../security-asset-control-plane.test.ts | 41 +++ .../src/lib/security-asset-control-plane.ts | 3 + 5 files changed, 358 insertions(+), 85 deletions(-) diff --git a/apps/api/src/services/iwooos_security_asset_control_plane.py b/apps/api/src/services/iwooos_security_asset_control_plane.py index 24396cd77..aaac11fd1 100644 --- a/apps/api/src/services/iwooos_security_asset_control_plane.py +++ b/apps/api/src/services/iwooos_security_asset_control_plane.py @@ -33,6 +33,104 @@ _WINDOW_HOURS = 24 _STRICT_RUNTIME_SCHEMA_VERSION = "ai_agent_strict_runtime_completion_v2" _STRICT_RUNTIME_SOURCE_ID = "autonomous_strict_runtime" +_WORK_ITEM_ORDER = { + "source_health": 0, + "asset_discovery": 10, + "asset_scope": 20, + "asset_ownership": 30, + "automation_coverage": 40, + "telemetry_detection": 50, + "siem_pipeline": 60, + "endpoint_xdr_wazuh": 70, + "incident_response_soar": 80, + "vulnerability_exposure": 90, + "identity_zero_trust": 100, + "data_secrets_privacy": 110, + "backup_dr_resilience": 120, + "siem_soc_threat_intel": 130, + "security_audit": 140, + "ai_security_automation": 150, + "supply_chain_policy": 160, + "certificate_live_verification": 170, + "network_dns_tls": 180, + "appsec_api": 190, + "asset_relationship": 200, + "audit_integrity": 210, +} + +_SECURITY_DOMAIN_WORK_ITEMS: tuple[tuple[str, str, str, str, str], ...] = ( + ( + "endpoint_xdr_wazuh", + "AIA-P0-006-07", + "P0", + "接通 Wazuh / XDR 全資產 runtime 證據", + "把 manager、agent、host、service 與事件 identity 接入資產圖,驗證偵測、隔離候選、postcheck 與復原證據。", + ), + ( + "incident_response_soar", + "AIA-P0-007-02", + "P0", + "封閉事件應變與 SOAR 處置", + "將 open incident 串接 owner、decision、bounded apply、獨立 verifier、rollback 與 durable closure receipt。", + ), + ( + "vulnerability_exposure", + "AIA-P0-006-08", + "P0", + "接入 CVE 與外部暴露面驗證", + "以 SBOM、映像掃描、DAST 與 public exposure verifier 產生可追溯 finding、修復與複驗。", + ), + ( + "identity_zero_trust", + "AIA-P0-006-09", + "P0", + "建立 IAM 與 Zero Trust runtime 稽核", + "盤點人員、服務與 Agent identity,驗證最小權限、存取審查、失效權限與 break-glass 使用紀錄。", + ), + ( + "data_secrets_privacy", + "AIA-P0-006-10", + "P0", + "建立資料、Secret 與隱私控制證據", + "驗證 secret rotation、at-rest/in-transit encryption、敏感資料流與資料保留;不得讀取 secret value。", + ), + ( + "telemetry_detection", + "AIA-P0-006-11", + "P0", + "補齊全資產監控與偵測覆蓋", + "將每項資產綁定 telemetry、alert rule、owner、SLO 與可驗證的失效告警 receipt。", + ), + ( + "backup_dr_resilience", + "AIA-P0-006-12", + "P0", + "完成備份、還原演練與 DR 證據", + "把 backup target、freshness、checksum、restore drill、RPO/RTO 與獨立 verifier 接入資產圖。", + ), + ( + "siem_soc_threat_intel", + "AIA-P0-007-03", + "P0", + "完成 SIEM / SOC 威脅情報閉環", + "將 threat intel、規則命中、case、處置、驗證與 learning receipt 綁在同一事件與同一 run。", + ), + ( + "network_dns_tls", + "AIA-P1-006-13", + "P1", + "封閉網路、DNS 與 TLS 控制缺口", + "對 public route、DNS、certificate chain、expiry 與 network policy 建立持續驗證與受控修復。", + ), + ( + "appsec_api", + "AIA-P1-006-14", + "P1", + "完成 AppSec、API 與 DAST 證據鏈", + "把 SAST、DAST、API contract、dependency finding、修復 commit 與 production verifier 串成同一證據鏈。", + ), +) + _ASSET_TYPES = ( "host", "container", @@ -684,8 +782,10 @@ def _work_item( title: str, gap_count: int, next_action: str, + *, + gap_percent: int | None = None, ) -> dict[str, Any]: - return { + item = { "work_item_id": work_item_id, "priority": priority, "control_id": control_id, @@ -693,7 +793,11 @@ def _work_item( "gap_count": gap_count, "status": "open", "next_action": next_action, + "order": _WORK_ITEM_ORDER.get(control_id, 999), } + if gap_percent is not None: + item["gap_percent"] = max(0, min(100, gap_percent)) + return item def _strict_runtime_completion_projection(row: Any) -> dict[str, Any]: @@ -1283,7 +1387,38 @@ def build_iwooos_security_asset_control_plane( ) ) - work_items.sort(key=lambda item: (item["priority"], item["work_item_id"])) + domain_by_id = { + str(domain["domain_id"]): domain for domain in security_program_domains + } + active_control_ids = {str(item["control_id"]) for item in work_items} + for ( + domain_id, + work_item_id, + priority, + title, + next_action, + ) in _SECURITY_DOMAIN_WORK_ITEMS: + domain = domain_by_id.get(domain_id) + if not domain or int(domain.get("controlled_percent", 0)) >= 100: + continue + if domain_id in active_control_ids: + continue + work_items.append( + _work_item( + work_item_id, + priority, + domain_id, + title, + 1, + next_action, + gap_percent=max(0, 100 - int(domain.get("controlled_percent", 0))), + ) + ) + active_control_ids.add(domain_id) + + work_items.sort( + key=lambda item: (item["priority"], item["order"], item["work_item_id"]) + ) overall_control_percent = round( sum(function["controlled_percent"] for function in control_functions) / len(control_functions) @@ -1829,8 +1964,7 @@ async def _read_strict_runtime_source() -> tuple[dict[str, Any], dict[str, Any]] readback ) if ( - strict_runtime.get("schema_version") - != _STRICT_RUNTIME_SCHEMA_VERSION + strict_runtime.get("schema_version") != _STRICT_RUNTIME_SCHEMA_VERSION or strict_runtime.get("runtime_contract_schema_version") != AI_AUTOMATION_RUNTIME_CONTRACT_SCHEMA_VERSION ): @@ -2056,21 +2190,45 @@ class IwoooSSecurityAssetControlPlaneService: source_id="security_compliance", statement=_sql( """ - WITH recent AS MATERIALIZED ( - SELECT snapshot_id, asset_id, dimension, status, detected_at - FROM asset_compliance_snapshot - ORDER BY snapshot_id DESC - LIMIT 100000 - ), latest AS ( - SELECT DISTINCT ON (asset_id, dimension) - asset_id, dimension, status - FROM recent - ORDER BY asset_id, dimension, detected_at DESC + WITH dimensions(dimension) AS ( + SELECT unnest(ARRAY[ + 'ssl_cert_valid', 'cve_scan', 'secret_rotated', + 'backup_tested', 'audit_log_enabled', + 'access_reviewed', 'encryption_at_rest' + ]::text[]) ) - SELECT dimension, status, count(*) AS cnt - FROM latest - GROUP BY dimension, status - ORDER BY dimension, status + SELECT + dimensions.dimension, + CASE + WHEN latest.detected_at IS NULL + OR latest.detected_at < NOW() - INTERVAL '36 hours' + THEN 'unknown' + ELSE latest.status + END AS status, + count(*) AS cnt + FROM asset_inventory asset + CROSS JOIN dimensions + LEFT JOIN LATERAL ( + SELECT snapshot.status, snapshot.detected_at + FROM asset_compliance_snapshot snapshot + WHERE snapshot.asset_id = asset.asset_id + AND snapshot.dimension = dimensions.dimension + ORDER BY snapshot.detected_at DESC, snapshot.snapshot_id DESC + LIMIT 1 + ) latest ON true + WHERE asset.lifecycle_state = 'active' + GROUP BY dimensions.dimension, CASE + WHEN latest.detected_at IS NULL + OR latest.detected_at < NOW() - INTERVAL '36 hours' + THEN 'unknown' + ELSE latest.status + END + ORDER BY dimensions.dimension, CASE + WHEN latest.detected_at IS NULL + OR latest.detected_at < NOW() - INTERVAL '36 hours' + THEN 'unknown' + ELSE latest.status + END """ ), result_mode="all", @@ -2225,30 +2383,36 @@ class IwoooSSecurityAssetControlPlaneService: source_id="audit_runtime", statement=_sql( """ + WITH k8s AS ( + SELECT + count(*) AS total, + count(*) FILTER (WHERE success IS FALSE) AS failed + FROM audit_logs + WHERE project_id = 'awoooi' + AND created_at >= NOW() - INTERVAL '24 hours' + ), mcp AS ( + SELECT + count(*) AS total, + count(*) FILTER (WHERE success IS FALSE) AS failed + FROM mcp_audit_log + WHERE created_at >= NOW() - INTERVAL '24 hours' + ), asset_changes AS ( + SELECT + count(*) AS total, + count(*) FILTER (WHERE ai_analysis IS NOT NULL) AS analyzed + FROM asset_change_event + WHERE detected_at >= NOW() - INTERVAL '24 hours' + ) 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 + k8s.total AS k8s_audit_count_24h, + k8s.failed AS k8s_audit_failure_count_24h, + mcp.total AS mcp_audit_count_24h, + mcp.failed AS mcp_audit_failure_count_24h, + asset_changes.total AS asset_change_count_24h, + asset_changes.analyzed AS asset_change_ai_analysis_count_24h + FROM k8s + CROSS JOIN mcp + CROSS JOIN asset_changes """ ), result_mode="one", diff --git a/apps/api/tests/test_iwooos_security_asset_control_plane.py b/apps/api/tests/test_iwooos_security_asset_control_plane.py index 12fa999fa..74ad6541b 100644 --- a/apps/api/tests/test_iwooos_security_asset_control_plane.py +++ b/apps/api/tests/test_iwooos_security_asset_control_plane.py @@ -71,6 +71,8 @@ def _ready_payload( *, discovery_status: str = "success", discovery_error: str | None = None, + coverage_rows: list[SimpleNamespace] | None = None, + compliance_rows: list[SimpleNamespace] | None = None, automation_rows: list[SimpleNamespace] | None = None, runtime_receipt_counts: dict[str, int] | None = None, strict_runtime: dict | None = None, @@ -98,30 +100,38 @@ def _ready_payload( _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", - ) - ] + coverage = ( + coverage_rows + if coverage_rows is not None + else [ + _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 = ( + compliance_rows + if compliance_rows is not None + else [ + _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 = ( automation_rows if automation_rows is not None @@ -226,6 +236,11 @@ def test_builds_live_asset_siem_audit_and_ai_control_plane_without_false_closure } assert domain_by_id["endpoint_xdr_wazuh"]["status"] == "not_evidenced" assert domain_by_id["siem_soc_threat_intel"]["controlled_percent"] == 50 + work_item_by_control = {item["control_id"]: item for item in payload["work_items"]} + assert work_item_by_control["endpoint_xdr_wazuh"]["priority"] == "P0" + assert work_item_by_control["endpoint_xdr_wazuh"]["gap_count"] == 1 + assert work_item_by_control["endpoint_xdr_wazuh"]["gap_percent"] == 100 + assert payload["work_items"][0]["order"] <= payload["work_items"][1]["order"] assert len(payload["siem"]["pipeline"]) == 8 assert payload["security_audit"]["mcp_tool_audit_count_24h"] == 11 assert payload["summary"]["package_digest_unpinned_count"] == 0 @@ -256,6 +271,36 @@ def test_builds_live_asset_siem_audit_and_ai_control_plane_without_false_closure assert 'secret_value_collection_allowed": true' not in public_text.lower() +def test_uncovered_security_domains_are_explicit_ordered_p0_work_items() -> None: + payload = _ready_payload(coverage_rows=[], compliance_rows=[]) + + work_item_by_control = {item["control_id"]: item for item in payload["work_items"]} + for control_id in ( + "endpoint_xdr_wazuh", + "incident_response_soar", + "identity_zero_trust", + "data_secrets_privacy", + "vulnerability_exposure", + "telemetry_detection", + "backup_dr_resilience", + "siem_soc_threat_intel", + ): + assert work_item_by_control[control_id]["priority"] == "P0" + + p0_orders = [ + item["order"] for item in payload["work_items"] if item["priority"] == "P0" + ] + assert p0_orders == sorted(p0_orders) + assert all(item["order"] < 999 for item in payload["work_items"]) + assert [item["control_id"] for item in payload["work_items"][:5]] == [ + "asset_scope", + "asset_ownership", + "automation_coverage", + "telemetry_detection", + "endpoint_xdr_wazuh", + ] + + def test_projects_canonical_runtime_receipts_without_false_same_run_closure() -> None: payload = _ready_payload( automation_rows=[ @@ -318,8 +363,7 @@ def test_projects_exact_authoritative_same_run_runtime_closure() -> None: assert payload["ai_automation"]["same_run_closed_loop_proven"] is True assert payload["ai_automation"]["same_run_closed_loop_count"] == 1 assert all( - item["work_item_id"] != "AIA-P0-008-01" - for item in payload["work_items"] + item["work_item_id"] != "AIA-P0-008-01" for item in payload["work_items"] ) assert payload["completion"]["overall_percent"] == round( sum( @@ -372,8 +416,7 @@ def test_strict_runtime_projection_fails_closed_on_contract_or_receipt_drift() - assert payload["completion"]["strict_runtime_closure_percent"] == 0 assert payload["ai_automation"]["same_run_closed_loop_proven"] is False assert any( - item["work_item_id"] == "AIA-P0-008-01" - for item in payload["work_items"] + item["work_item_id"] == "AIA-P0-008-01" for item in payload["work_items"] ) @@ -886,8 +929,14 @@ def test_service_bounds_source_concurrency_for_database_budget(monkeypatch) -> N for statement in statements if "asset_compliance_snapshot" in statement ) - assert "ORDER BY snapshot_id DESC" in compliance_query - assert "LIMIT 100000" in compliance_query + assert "CROSS JOIN dimensions" in compliance_query + assert "LEFT JOIN LATERAL" in compliance_query + assert ( + "ORDER BY snapshot.detected_at DESC, snapshot.snapshot_id DESC" + in compliance_query + ) + assert "latest.detected_at < NOW() - INTERVAL '36 hours'" in compliance_query + assert "THEN 'unknown'" in compliance_query inventory_query = next( statement for statement in statements if "FROM asset_inventory" in statement ) @@ -908,6 +957,12 @@ def test_service_bounds_source_concurrency_for_database_budget(monkeypatch) -> N assert "executor_returncode_trusted" in siem_query assert "learning_writeback_count_24h" in siem_query assert "ansible_learning_writeback_recorded" in siem_query + audit_query = next( + statement for statement in statements if "WITH k8s AS" in statement + ) + assert "count(*) FILTER (WHERE success IS FALSE)" in audit_query + assert "CROSS JOIN mcp" in audit_query + assert "CROSS JOIN asset_changes" in audit_query assert "repository_readback_verified" in siem_query 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 index feeca669d..0d1db45bf 100644 --- a/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx +++ b/apps/web/src/components/iwooos/security-asset-control-plane-cockpit.tsx @@ -88,11 +88,11 @@ const COPY: Record<"zh" | "en", Copy> = { automationView: "SIEM 與 AI", live: "LIVE", degraded: "DEGRADED", - overall: "整體完成度", + overall: "控制面建置", assetTypes: "資產類型", nist: "NIST 控制", siem: "SIEM 閉環", - strictRun: "全域 AI run", + strictRun: "階段收據", aggregateVerified: "全域已驗證", aggregateOpen: "全域未閉環", aggregateScopeNote: "不代表目前 Wazuh lane", @@ -142,11 +142,11 @@ const COPY: Record<"zh" | "en", Copy> = { automationView: "SIEM & AI", live: "LIVE", degraded: "DEGRADED", - overall: "Overall completion", + overall: "Control-plane build", assetTypes: "Asset types", nist: "NIST control", siem: "SIEM closure", - strictRun: "Aggregate AI run", + strictRun: "Stage receipts", aggregateVerified: "Aggregate verified", aggregateOpen: "Aggregate open", aggregateScopeNote: "Does not represent the current Wazuh lane", @@ -183,8 +183,10 @@ const COPY: Record<"zh" | "en", Copy> = { failed: "Failed", failedCollectors: "Failed collectors", failedCollectorsUnavailable: "Failure detail awaiting safe classification", - sameRunMissing: "Aggregate AI run is open; this is not the current Wazuh lane", - sameRunVerified: "Aggregate AI run is verified; this is not the current Wazuh lane", + sameRunMissing: + "Aggregate AI run is open; this is not the current Wazuh lane", + sameRunVerified: + "Aggregate AI run is verified; this is not the current Wazuh lane", loading: "Loading security control plane", unavailable: "Live readback unavailable", refresh: "Refresh security control plane", @@ -464,7 +466,7 @@ export function SecurityAssetControlPlaneCockpit({ const tone = payload ? securityControlPlaneTone(payload) : "critical"; const priorityItems = useMemo( - () => (payload ? topSecurityControlPlaneWorkItems(payload, 3) : []), + () => (payload ? topSecurityControlPlaneWorkItems(payload, 5) : []), [payload], ); @@ -597,13 +599,17 @@ export function SecurityAssetControlPlaneCockpit({ label={copy.strictRun} value={ payload - ? payload.ai_automation.strict_runtime_completion.closed - ? copy.aggregateVerified - : copy.aggregateOpen + ? `${payload.ai_automation.strict_runtime_completion.present_stage_count}/${payload.ai_automation.strict_runtime_completion.required_stage_count}` : "--" } icon={Bot} - detail={payload ? copy.aggregateScopeNote : undefined} + detail={ + payload + ? payload.ai_automation.strict_runtime_completion.closed + ? copy.aggregateVerified + : copy.aggregateOpen + : undefined + } testId="security-aggregate-ai-run-status" title={ payload @@ -815,7 +821,7 @@ export function SecurityAssetControlPlaneCockpit({ {priorityItems.length} -
+
{priorityItems.map((item) => (
- {item.gap_count} + {item.gap_percent === undefined + ? item.gap_count + : `${item.gap_percent}%`}
))} @@ -1063,7 +1071,9 @@ export function SecurityAssetControlPlaneCockpit({ {item.title} - {item.gap_count} + {item.gap_percent === undefined + ? item.gap_count + : `${item.gap_percent}%`}
))} 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 index b660b8b0d..f9cbe1d4c 100644 --- a/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts +++ b/apps/web/src/lib/__tests__/security-asset-control-plane.test.ts @@ -302,4 +302,45 @@ describe("security asset control plane projection", () => { "AIA-P1-001-01", ]); }); + + it("uses explicit security dependency order within P0", () => { + const value = payload(); + value.work_items = [ + { + work_item_id: "wazuh", + priority: "P0", + control_id: "endpoint_xdr_wazuh", + title: "wazuh", + gap_count: 1, + gap_percent: 100, + order: 70, + status: "open", + next_action: "wazuh", + }, + { + work_item_id: "inventory", + priority: "P0", + control_id: "asset_discovery", + title: "inventory", + gap_count: 1, + order: 10, + status: "open", + next_action: "inventory", + }, + { + work_item_id: "audit", + priority: "P1", + control_id: "audit_integrity", + title: "audit", + gap_count: 1, + order: 210, + status: "open", + next_action: "audit", + }, + ]; + + expect( + topSecurityControlPlaneWorkItems(value, 3).map((item) => item.control_id), + ).toEqual(["asset_discovery", "endpoint_xdr_wazuh", "audit_integrity"]); + }); }); diff --git a/apps/web/src/lib/security-asset-control-plane.ts b/apps/web/src/lib/security-asset-control-plane.ts index d80a570d4..e64e1c870 100644 --- a/apps/web/src/lib/security-asset-control-plane.ts +++ b/apps/web/src/lib/security-asset-control-plane.ts @@ -91,6 +91,8 @@ export type SecurityControlPlaneWorkItem = { gap_count: number; status: string; next_action: string; + order?: number; + gap_percent?: number; }; export type SecurityControlPlaneSupplyChain = { @@ -426,6 +428,7 @@ export function topSecurityControlPlaneWorkItems( .sort( (left, right) => (rank[left.priority] ?? 99) - (rank[right.priority] ?? 99) || + (left.order ?? 999) - (right.order ?? 999) || left.work_item_id.localeCompare(right.work_item_id), ) .slice(0, Math.max(0, limit));