From ec4210e6bfec4bc72b6ab1f0f63428944e21a62b Mon Sep 17 00:00:00 2001 From: ogt Date: Tue, 14 Jul 2026 17:59:04 +0800 Subject: [PATCH] feat(security): expose scanner failures in focused cockpit --- .../iwooos_security_asset_control_plane.py | 89 ++- ...est_iwooos_security_asset_control_plane.py | 61 +- apps/web/src/app/[locale]/iwooos/page.tsx | 52 +- .../security-asset-control-plane-cockpit.tsx | 612 +++++++++++++----- .../security-asset-control-plane.test.ts | 36 +- .../src/lib/security-asset-control-plane.ts | 72 ++- 6 files changed, 668 insertions(+), 254 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 863efab7a..b317dc7fd 100644 --- a/apps/api/src/services/iwooos_security_asset_control_plane.py +++ b/apps/api/src/services/iwooos_security_asset_control_plane.py @@ -150,6 +150,28 @@ _SIEM_STAGES = ( "verify_learn", ) +_PUBLIC_DISCOVERY_COLLECTOR_IDS = frozenset( + { + "k8s_replicasets", + "k8s_nodes", + "k8s_pods", + "k8s_deployments", + "k8s_statefulsets", + "k8s_daemonsets", + "k8s_services", + "k8s_configmaps", + "k8s_persistentvolumeclaims", + "k8s_ingresses", + "k8s_cronjobs", + "prometheus_targets", + "database_catalog", + "ai_catalog", + "gitea_bundle_backup", + "gitea_ci", + "domain_tls_inventory", + } +) + def _value(row: Any, name: str, default: Any = None) -> Any: if row is None: @@ -162,6 +184,28 @@ def _value(row: Any, name: str, default: Any = None) -> Any: return getattr(row, name, default) +def _public_discovery_failure(error: Any) -> tuple[str | None, list[str]]: + """Project only fixed collector identities, never raw scanner errors.""" + if not isinstance(error, str) or not error: + return None, [] + + marker = "asset_collector_failures=" + if marker not in error: + return "asset_scan_failed_public_details_unavailable", [] + + candidates = error.split(marker, 1)[1].split(",") + collector_ids = sorted( + { + candidate.strip() + for candidate in candidates + if candidate.strip() in _PUBLIC_DISCOVERY_COLLECTOR_IDS + } + ) + if not collector_ids: + return "asset_scan_failed_public_details_unavailable", [] + return "asset_collector_failures", collector_ids + + def _count(rows: Iterable[Any], key: str, value: str, count_key: str = "cnt") -> int: return sum( int(_value(row, count_key, 0) or 0) @@ -684,8 +728,7 @@ def build_iwooos_security_asset_control_plane( for row in inventory_source ) package_digest_unpinned_count = sum( - int(_value(row, "unpinned_package_count", 0) or 0) - for row in inventory_source + int(_value(row, "unpinned_package_count", 0) or 0) for row in inventory_source ) certificate_live_unverified_count = sum( int(_value(row, "unverified_certificate_count", 0) or 0) @@ -726,10 +769,14 @@ def build_iwooos_security_asset_control_plane( discovery_fresh = ( discovery_age is not None and discovery_age <= _DISCOVERY_FRESHNESS_SECONDS ) + discovery_status = str( + _value(discovery_row, "latest_status", "missing") or "missing" + ) + failure_reason_code, failed_collector_ids = _public_discovery_failure( + _value(discovery_row, "latest_error") if discovery_status == "failed" else None + ) discovery = { - "latest_status": str( - _value(discovery_row, "latest_status", "missing") or "missing" - ), + "latest_status": discovery_status, "latest_scan_depth": str( _value(discovery_row, "latest_scan_depth", "unknown") or "unknown" ), @@ -747,6 +794,10 @@ def build_iwooos_security_asset_control_plane( "age_seconds": discovery_age, "freshness_slo_seconds": _DISCOVERY_FRESHNESS_SECONDS, "fresh": discovery_fresh, + "failure_reason_code": failure_reason_code, + "failed_collector_count": len(failed_collector_ids), + "failed_collector_ids": failed_collector_ids, + "raw_error_returned": False, } coverage = _rows_by_dimension(coverage_source, _COVERAGE_DIMENSIONS) @@ -897,8 +948,7 @@ def build_iwooos_security_asset_control_plane( github_supply_chain_compliant_percent=_percent( max( 0, - by_type.get("package", 0) - - forbidden_github_supply_chain_asset_count, + by_type.get("package", 0) - forbidden_github_supply_chain_asset_count, ), by_type.get("package", 0), ), @@ -909,8 +959,7 @@ def build_iwooos_security_asset_control_plane( certificate_live_verified_percent=_percent( max( 0, - by_type.get("certificate", 0) - - certificate_live_unverified_count, + by_type.get("certificate", 0) - certificate_live_unverified_count, ), by_type.get("certificate", 0), ), @@ -963,8 +1012,7 @@ def build_iwooos_security_asset_control_plane( ) ) supply_chain_policy_gap_count = ( - forbidden_github_supply_chain_asset_count - + package_digest_unpinned_count + forbidden_github_supply_chain_asset_count + package_digest_unpinned_count ) if supply_chain_policy_gap_count > 0: work_items.append( @@ -1151,9 +1199,7 @@ def build_iwooos_security_asset_control_plane( "forbidden_github_supply_chain_asset_count": ( forbidden_github_supply_chain_asset_count ), - "certificate_live_unverified_count": ( - certificate_live_unverified_count - ), + "certificate_live_unverified_count": (certificate_live_unverified_count), "certificate_live_unhealthy_count": certificate_live_unhealthy_count, "automation_coverage_green_percent": _percent( coverage_green, coverage_total @@ -1241,13 +1287,11 @@ def build_iwooos_security_asset_control_plane( "certificate_asset_count": by_type.get("certificate", 0), "live_tls_verified_count": max( 0, - by_type.get("certificate", 0) - - certificate_live_unverified_count, + by_type.get("certificate", 0) - certificate_live_unverified_count, ), "live_tls_evidenced_count": max( 0, - by_type.get("certificate", 0) - - certificate_live_unverified_count, + by_type.get("certificate", 0) - certificate_live_unverified_count, ), "live_tls_healthy_count": max( 0, @@ -1272,6 +1316,7 @@ def build_iwooos_security_asset_control_plane( "aggregate_only": True, "raw_asset_identity_returned": False, "raw_event_payload_returned": False, + "raw_discovery_error_returned": False, "secret_value_collection_allowed": False, "live_scan_triggered": False, "runtime_action_triggered": False, @@ -1340,6 +1385,10 @@ def build_unavailable_iwooos_security_asset_control_plane( "age_seconds": None, "freshness_slo_seconds": _DISCOVERY_FRESHNESS_SECONDS, "fresh": False, + "failure_reason_code": reason_code, + "failed_collector_count": 0, + "failed_collector_ids": [], + "raw_error_returned": False, }, "asset_scopes": [ { @@ -1532,6 +1581,7 @@ def build_unavailable_iwooos_security_asset_control_plane( "aggregate_only": True, "raw_asset_identity_returned": False, "raw_event_payload_returned": False, + "raw_discovery_error_returned": False, "secret_value_collection_allowed": False, "live_scan_triggered": False, "runtime_action_triggered": False, @@ -1656,11 +1706,12 @@ class IwoooSSecurityAssetControlPlaneService: latest.new_assets AS latest_new_assets, latest.modified_assets AS latest_modified_assets, latest.disappeared_assets AS latest_disappeared_assets, + latest.error AS latest_error, 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 + new_assets, modified_assets, disappeared_assets, error FROM asset_discovery_run ORDER BY started_at DESC LIMIT 1 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 347a4a652..c1250852d 100644 --- a/apps/api/tests/test_iwooos_security_asset_control_plane.py +++ b/apps/api/tests/test_iwooos_security_asset_control_plane.py @@ -26,10 +26,15 @@ def _row(**values): return SimpleNamespace(**values) -def _ready_payload(source_health: list[dict] | None = None) -> dict: +def _ready_payload( + source_health: list[dict] | None = None, + *, + discovery_status: str = "success", + discovery_error: str | None = None, +) -> dict: now = datetime(2026, 7, 11, 4, 0, tzinfo=UTC) discovery = _row( - latest_status="success", + latest_status=discovery_status, latest_scan_depth="full", latest_started_at=now - timedelta(minutes=31), latest_ended_at=now - timedelta(minutes=30), @@ -38,6 +43,7 @@ def _ready_payload(source_health: list[dict] | None = None) -> dict: latest_new_assets=2, latest_modified_assets=1, latest_disappeared_assets=0, + latest_error=discovery_error, ) inventory = [ _row(asset_type="host", cnt=3, unowned_count=1, stale_count=0), @@ -150,6 +156,10 @@ def test_builds_live_asset_siem_audit_and_ai_control_plane_without_false_closure assert payload["completion"]["strict_runtime_closure_percent"] == 0 assert payload["completion"]["dimension_count"] == 6 assert payload["discovery"]["fresh"] is True + assert payload["discovery"]["failure_reason_code"] is None + assert payload["discovery"]["failed_collector_count"] == 0 + assert payload["discovery"]["failed_collector_ids"] == [] + assert payload["discovery"]["raw_error_returned"] is False 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 @@ -187,6 +197,45 @@ 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_discovery_failure_exposes_only_allowlisted_collector_ids() -> None: + raw_error = ( + "RuntimeError: asset_collector_failures=domain_tls_inventory," + "prometheus_targets,postgresql://operator:raw-secret@private-host/runtime" + ) + payload = _ready_payload( + discovery_status="failed", + discovery_error=raw_error, + ) + + assert payload["discovery"]["failure_reason_code"] == "asset_collector_failures" + assert payload["discovery"]["failed_collector_count"] == 2 + assert payload["discovery"]["failed_collector_ids"] == [ + "domain_tls_inventory", + "prometheus_targets", + ] + assert payload["discovery"]["raw_error_returned"] is False + assert payload["boundaries"]["raw_discovery_error_returned"] is False + + public_text = json.dumps(payload, ensure_ascii=False) + assert "raw-secret" not in public_text + assert "private-host" not in public_text + assert "postgresql://" not in public_text + + +def test_discovery_failure_fails_closed_for_unknown_raw_error() -> None: + payload = _ready_payload( + discovery_status="failed", + discovery_error="RuntimeError: /private/path leaked provider detail", + ) + + assert payload["discovery"]["failure_reason_code"] == ( + "asset_scan_failed_public_details_unavailable" + ) + assert payload["discovery"]["failed_collector_count"] == 0 + assert payload["discovery"]["failed_collector_ids"] == [] + assert "/private/path" not in json.dumps(payload) + + def test_runtime_image_policy_gaps_are_visible_and_never_false_green() -> None: now = datetime(2026, 7, 11, 4, 0, tzinfo=UTC) payload = build_iwooos_security_asset_control_plane( @@ -398,9 +447,7 @@ def test_unhealthy_live_tls_evidence_remains_a_p0_gap() -> None: domains = { domain["domain_id"]: domain for domain in payload["security_program_domains"] } - assert domains["network_dns_tls"]["gap_code"] == ( - "certificate_live_tls_unhealthy" - ) + assert domains["network_dns_tls"]["gap_code"] == ("certificate_live_tls_unhealthy") def test_fully_evidenced_healthy_tls_inventory_proves_health() -> None: @@ -438,8 +485,7 @@ def test_fully_evidenced_healthy_tls_inventory_proves_health() -> None: assert payload["certificate_inventory"]["live_tls_unhealthy_count"] == 0 assert payload["certificate_inventory"]["live_tls_health_proven"] is True assert all( - item["work_item_id"] != "AIA-P0-006-02B" - for item in payload["work_items"] + item["work_item_id"] != "AIA-P0-006-02B" for item in payload["work_items"] ) @@ -517,6 +563,7 @@ def test_unavailable_payload_is_fixed_degraded_contract_without_raw_error() -> N assert payload["discovery"]["fresh"] is False assert payload["boundaries"]["raw_asset_identity_returned"] is False assert payload["boundaries"]["raw_event_payload_returned"] is False + assert payload["boundaries"]["raw_discovery_error_returned"] is False def test_service_redacts_database_exception_details(monkeypatch) -> None: diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index 9689cf0d7..717406809 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -6826,14 +6826,7 @@ function IwoooSWorkspaceSwitcher({ return (
-
+
@@ -25126,47 +25119,32 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) { return (
-
-
-
- - {t('eyebrow')} +
+
+
-
-
- - {t('boundary.label')} +
+
- - - - - - - + + @@ -25203,6 +25181,8 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) { + + 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 d75d99fb4..4217208e4 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 @@ -27,6 +27,9 @@ import { cn } from "@/lib/utils"; type Copy = { title: string; + commandView: string; + assetView: string; + automationView: string; live: string; degraded: string; overall: string; @@ -64,6 +67,8 @@ type Copy = { stale: string; success: string; failed: string; + failedCollectors: string; + failedCollectorsUnavailable: string; sameRunMissing: string; unavailable: string; refresh: string; @@ -72,6 +77,9 @@ type Copy = { const COPY: Record<"zh" | "en", Copy> = { zh: { title: "AI 資安控制平面", + commandView: "指揮總覽", + assetView: "資產與供應鏈", + automationView: "SIEM 與 AI", live: "LIVE", degraded: "DEGRADED", overall: "整體完成度", @@ -109,12 +117,17 @@ const COPY: Record<"zh" | "en", Copy> = { stale: "逾期資產", success: "成功", failed: "失敗", + failedCollectors: "失敗 Collector", + failedCollectorsUnavailable: "失敗原因待安全分類", sameRunMissing: "尚無同 run 閉環證據", unavailable: "Live readback 無法取得", refresh: "重新整理資安控制平面", }, en: { title: "AI Security Control Plane", + commandView: "Command", + assetView: "Assets & supply chain", + automationView: "SIEM & AI", live: "LIVE", degraded: "DEGRADED", overall: "Overall completion", @@ -152,12 +165,26 @@ const COPY: Record<"zh" | "en", Copy> = { stale: "Stale assets", success: "Success", failed: "Failed", + failedCollectors: "Failed collectors", + failedCollectorsUnavailable: "Failure detail awaiting safe classification", sameRunMissing: "No same-run closure evidence", unavailable: "Live readback unavailable", refresh: "Refresh security control plane", }, }; +type CockpitView = "command" | "assets" | "automation"; + +const COCKPIT_VIEWS: Array<{ + key: CockpitView; + icon: typeof ShieldCheck; + label: keyof Pick; +}> = [ + { key: "command", icon: Radar, label: "commandView" }, + { key: "assets", icon: Boxes, label: "assetView" }, + { key: "automation", icon: Bot, label: "automationView" }, +]; + const TONE_CLASS = { healthy: "border-[#8fc39c] bg-[#f2faf4] text-[#17602a]", warning: "border-[#d9b36f] bg-[#fff8e8] text-[#8a5a08]", @@ -177,7 +204,9 @@ function freshnessLabel(ageSeconds: number | null) { 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))); + 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" }); @@ -230,7 +259,7 @@ function TruthCell({