diff --git a/apps/api/src/api/v1/iwooos.py b/apps/api/src/api/v1/iwooos.py index 0b9d462bc..9a1124bda 100644 --- a/apps/api/src/api/v1/iwooos.py +++ b/apps/api/src/api/v1/iwooos.py @@ -34,6 +34,9 @@ from src.services.iwooos_security_operating_system import ( from src.services.iwooos_security_control_coverage import ( load_latest_iwooos_security_control_coverage, ) +from src.services.iwooos_security_tool_closure_readback import ( + load_latest_iwooos_security_tool_closure_readback, +) from src.services.iwooos_wazuh_allowlisted_check_mode_dry_run import ( load_latest_iwooos_wazuh_allowlisted_check_mode_dry_run, ) @@ -702,6 +705,38 @@ async def get_iwooos_runtime_security_readback() -> dict[str, Any]: ) from exc +@router.get( + "/api/v1/iwooos/security-tool-closure-readback", + response_model=dict[str, Any], + summary="取得 IwoooS 資安工具閉環讀回", + description=( + "彙整 Wazuh、SBOM / vulnerability、source policy、K8s policy、runtime detection、" + "DAST、Sigma / OCSF 與 AI controlled apply 的 committed readback,形成公開安全的" + "工具閉環控制台。此端點不查 live host、不讀 secret、不執行 scanner、不送 SOAR、" + "不啟用 active response、不開 runtime gate。" + ), +) +async def get_iwooos_security_tool_closure_readback() -> dict[str, Any]: + """回傳 IwoooS 資安工具閉環公開安全只讀總表。""" + try: + alert_receipt_readback = await _alertmanager_receipt_readback() + payload = await asyncio.to_thread( + load_latest_iwooos_security_tool_closure_readback, + alert_receipt_readback=alert_receipt_readback, + ) + return redact_public_lan_topology(payload) + except FileNotFoundError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) from exc + except (json.JSONDecodeError, ValueError) as exc: + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail=f"IwoooS security tool closure readback 無效:{exc}", + ) from exc + + @router.get( "/api/v1/iwooos/security-control-coverage", response_model=dict[str, Any], diff --git a/apps/api/src/services/iwooos_security_tool_closure_readback.py b/apps/api/src/services/iwooos_security_tool_closure_readback.py new file mode 100644 index 000000000..cb0b46420 --- /dev/null +++ b/apps/api/src/services/iwooos_security_tool_closure_readback.py @@ -0,0 +1,511 @@ +""" +IwoooS security tool closure readback. + +This service joins the committed Wazuh, security operating-system and coverage +readbacks into a public-safe tool closure cockpit. It does not query live hosts, +read secrets, run scanners, trigger SOAR, or authorize runtime actions. +""" + +from __future__ import annotations + +from typing import Any + +from src.services.iwooos_security_control_coverage import ( + load_latest_iwooos_security_control_coverage, +) +from src.services.iwooos_security_operating_system import ( + load_latest_iwooos_security_operating_system, +) +from src.services.iwooos_wazuh_allowlisted_check_mode_dry_run import ( + load_latest_iwooos_wazuh_allowlisted_check_mode_dry_run, +) +from src.services.iwooos_wazuh_managed_host_coverage import ( + load_latest_iwooos_wazuh_managed_host_coverage, +) +from src.services.iwooos_wazuh_manager_registry_reviewer_validation import ( + load_latest_iwooos_wazuh_manager_registry_reviewer_validation, +) +from src.services.iwooos_wazuh_runtime_controlled_apply_preflight import ( + load_latest_iwooos_wazuh_runtime_controlled_apply_preflight, +) +from src.services.iwooos_wazuh_runtime_gate_owner_review_readback import ( + load_latest_iwooos_wazuh_runtime_gate_owner_review_readback, +) + +_SCHEMA_VERSION = "iwooos_security_tool_closure_readback_v1" +_REQUIRED_ZERO_SUMMARY_KEYS = { + "runtime_gate_count", + "host_write_authorized_count", + "active_response_authorized_count", + "wazuh_active_response_authorized_count", + "secret_value_collection_allowed_count", + "secret_value_collected_count", +} + + +def load_latest_iwooos_security_tool_closure_readback( + alert_receipt_readback: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Return the public-safe external tool closure rollup.""" + coverage = load_latest_iwooos_security_control_coverage() + security_os = load_latest_iwooos_security_operating_system( + alert_receipt_readback=alert_receipt_readback + ) + managed_hosts = load_latest_iwooos_wazuh_managed_host_coverage() + registry = load_latest_iwooos_wazuh_manager_registry_reviewer_validation() + controlled_apply = load_latest_iwooos_wazuh_runtime_controlled_apply_preflight() + owner_review = load_latest_iwooos_wazuh_runtime_gate_owner_review_readback() + dry_run = load_latest_iwooos_wazuh_allowlisted_check_mode_dry_run() + + source_payloads = [ + coverage, + security_os, + managed_hosts, + registry, + controlled_apply, + owner_review, + dry_run, + ] + _require_runtime_boundaries(source_payloads) + + source_summaries = { + "coverage": _summary(coverage), + "security_os": _summary(security_os), + "managed_hosts": _summary(managed_hosts), + "registry": _summary(registry), + "controlled_apply": _summary(controlled_apply), + "owner_review": _summary(owner_review), + "dry_run": _summary(dry_run), + } + tracks = _tool_tracks(source_summaries) + summary = _summary_rollup(source_summaries, tracks) + + return { + "schema_version": _SCHEMA_VERSION, + "status": "tool_closure_readback_ready_runtime_actions_closed", + "mode": "committed_readback_rollup_no_live_tool_execution", + "summary": summary, + "tool_tracks": tracks, + "next_executable_queue": _next_executable_queue(source_summaries), + "acceptance_definition": [ + "wazuh_registry_fim_vulnerability_alert_receipt_green_with_post_verifier", + "sbom_and_vulnerability_reports_attached_to_package_and_image_artifacts", + "runtime_detection_events_normalized_and_reviewed_without_auto_block", + "web_api_dast_scope_rate_limit_and_baseline_report_closed", + "ai_controlled_apply_has_selector_diff_check_mode_rollback_post_verifier_km_writeback", + ], + "boundary_markers": _boundary_markers(summary), + "boundaries": { + "live_host_query_performed": False, + "live_wazuh_query_performed": False, + "scanner_execution_performed": False, + "payload_persisted": False, + "runtime_execution_authorized": False, + "runtime_gate_open": False, + "host_write_authorized": False, + "wazuh_active_response_authorized": False, + "soar_action_authorized": False, + "secret_value_collection_allowed": False, + "not_authorization": True, + }, + "no_false_green_rules": [ + "tool closure readback is a cockpit rollup, not an active scanner or SOAR switch", + "Wazuh manager registry accepted does not close FIM, vulnerability, alert receipt, or active response", + "SBOM, DAST, runtime detection and policy tracks remain not closed until artifacts and post-verifiers exist", + "runtime gate, host write, active response and secret collection must remain zero until a separate post-verifier gate passes", + ], + "source_refs": [ + *coverage.get("source_refs", []), + *security_os.get("source_refs", []), + *managed_hosts.get("source_refs", []), + *registry.get("source_refs", []), + *controlled_apply.get("source_refs", []), + *owner_review.get("source_refs", []), + *dry_run.get("source_refs", []), + ], + } + + +def _tool_tracks(source_summaries: dict[str, dict[str, Any]]) -> list[dict[str, Any]]: + coverage = source_summaries["coverage"] + security_os = source_summaries["security_os"] + managed_hosts = source_summaries["managed_hosts"] + registry = source_summaries["registry"] + controlled_apply = source_summaries["controlled_apply"] + owner_review = source_summaries["owner_review"] + dry_run = source_summaries["dry_run"] + + expected_hosts = max( + _int(managed_hosts.get("expected_host_scope_count")), + _int(registry.get("expected_scope_alias_count")), + _int(coverage.get("wazuh_expected_host_scope_count")), + ) + accepted_hosts = max( + _int(managed_hosts.get("manager_registry_accepted_count")), + _int(registry.get("manager_registry_accepted_count")), + _int(coverage.get("wazuh_manager_registry_accepted_count")), + _int(security_os.get("wazuh_registry_accepted_count")), + ) + registry_ready = expected_hosts > 0 and accepted_hosts >= expected_hosts + alert_ready = _int(security_os.get("alert_receipt_accepted_count")) > 0 + + controlled_apply_ready_signals = sum( + 1 + for key in ( + "target_selector_count", + "source_of_truth_diff_count", + "check_mode_plan_count", + "rollback_plan_count", + "post_apply_verifier_count", + "km_playbook_writeback_count", + "controlled_apply_preflight_ready_count", + ) + if _int(controlled_apply.get(key)) > 0 + ) + owner_review_ready_signals = sum( + 1 + for key in ( + "target_selector_count", + "source_of_truth_diff_count", + "check_mode_plan_count", + "dry_run_evidence_count", + "rollback_plan_count", + "post_apply_verifier_count", + "km_playbook_writeback_count", + "owner_review_packet_review_ready_count", + ) + if _int(owner_review.get(key)) > 0 + ) + dry_run_ready_signals = sum( + 1 + for key in ( + "allowlisted_target_selector_count", + "check_mode_plan_count", + "dry_run_evidence_ref_count", + "dry_run_result_ref_count", + "post_dry_run_verifier_count", + "rollback_revalidation_count", + "km_playbook_writeback_ready_count", + ) + if _int(dry_run.get(key)) > 0 + ) + + return [ + _track( + track_id="wazuh_detection_response", + priority="P0", + label="Wazuh detection and response", + tool_ids=["wazuh"], + ready_signal_count=(1 if registry_ready else 0) + (1 if alert_ready else 0), + required_signal_count=6, + next_executable_action="close_fim_vulnerability_alert_receipt_then_keep_active_response_gated", + required_verifier="wazuh_registry_fim_vulnerability_alert_receipt_post_verifier", + source_refs=[ + "docs/security/wazuh-managed-host-coverage-gate.snapshot.json", + "docs/security/iwooos-security-operating-system.snapshot.json", + ], + ), + _track( + track_id="supply_chain_sbom_scan", + priority="P0", + label="Supply-chain SBOM and vulnerability scan", + tool_ids=["trivy", "syft", "grype"], + ready_signal_count=0, + required_signal_count=5, + next_executable_action="attach_sbom_vulnerability_report_and_no_secret_output_guard", + required_verifier="sbom_vulnerability_artifact_post_verifier", + source_refs=["docs/security/security-asset-control-ledger.snapshot.json"], + ), + _track( + track_id="source_control_scorecard", + priority="P1", + label="Source-control hygiene scorecard", + tool_ids=["openssf_scorecard", "gitea_source_policy"], + ready_signal_count=0, + required_signal_count=4, + next_executable_action="map_branch_protection_dependency_update_and_build_policy_readback", + required_verifier="source_control_policy_scorecard_verifier", + source_refs=["docs/evaluations/runtime_surface_inventory_latest"], + ), + _track( + track_id="k8s_policy_attestation", + priority="P1", + label="Kubernetes policy and image attestation", + tool_ids=["kyverno", "cosign"], + ready_signal_count=0, + required_signal_count=5, + next_executable_action="stage_policy_check_mode_image_signature_and_exception_register", + required_verifier="k8s_policy_attestation_post_verifier", + source_refs=["docs/security/host-service-config-inventory.snapshot.json"], + ), + _track( + track_id="runtime_threat_detection", + priority="P1", + label="Runtime threat detection", + tool_ids=["falco"], + ready_signal_count=0, + required_signal_count=4, + next_executable_action="stage_rule_dry_run_event_redaction_and_wazuh_correlation", + required_verifier="runtime_detection_event_post_verifier", + source_refs=["docs/security/monitoring-alerting-observability-inventory.snapshot.json"], + ), + _track( + track_id="web_api_dast", + priority="P1", + label="Web and API DAST", + tool_ids=["owasp_zap_or_equivalent_dast"], + ready_signal_count=0, + required_signal_count=5, + next_executable_action="prepare_bounded_target_selector_rate_limit_and_baseline_report", + required_verifier="web_api_dast_scope_and_report_verifier", + source_refs=["docs/security/ssh-network-access-inventory.snapshot.json"], + ), + _track( + track_id="normalized_detection_schema", + priority="P1", + label="Normalized detection schema", + tool_ids=["sigma", "ocsf"], + ready_signal_count=(1 if alert_ready else 0), + required_signal_count=4, + next_executable_action="normalize_wazuh_alertmanager_app_events_into_review_cards", + required_verifier="sigma_ocsf_event_mapping_regression_verifier", + source_refs=["docs/security/iwooos-security-operating-system.snapshot.json"], + ), + _track( + track_id="ai_agent_controlled_apply", + priority="P0", + label="AI Agent controlled apply loop", + tool_ids=["ai_agent", "mcp", "rag", "km_playbook"], + ready_signal_count=min( + controlled_apply_ready_signals + + owner_review_ready_signals + + dry_run_ready_signals, + 9, + ), + required_signal_count=9, + next_executable_action="run_allowlisted_check_mode_with_rollback_post_verifier_and_km_writeback", + required_verifier="ai_controlled_apply_post_verifier_and_learning_writeback", + source_refs=[ + "docs/security/wazuh-runtime-controlled-apply-preflight.snapshot.json", + "docs/security/wazuh-runtime-gate-owner-review-readback.snapshot.json", + "docs/security/wazuh-allowlisted-check-mode-dry-run.snapshot.json", + ], + ), + ] + + +def _track( + *, + track_id: str, + priority: str, + label: str, + tool_ids: list[str], + ready_signal_count: int, + required_signal_count: int, + next_executable_action: str, + required_verifier: str, + source_refs: list[str], +) -> dict[str, Any]: + ready_count = max(min(ready_signal_count, required_signal_count), 0) + missing_count = max(required_signal_count - ready_count, 0) + closure_percent = int(round((ready_count / required_signal_count) * 100)) if required_signal_count else 0 + if missing_count == 0: + closure_state = "readback_ready_runtime_gate_still_closed" + elif ready_count > 0: + closure_state = "partial_readback_missing_verifier" + else: + closure_state = "required_not_closed" + return { + "track_id": track_id, + "priority": priority, + "label": label, + "tool_ids": tool_ids, + "closure_state": closure_state, + "closure_percent": closure_percent, + "ready_signal_count": ready_count, + "required_signal_count": required_signal_count, + "missing_signal_count": missing_count, + "runtime_gate_open": False, + "active_response_authorized": False, + "host_write_authorized": False, + "secret_value_collection_allowed": False, + "next_executable_action": next_executable_action, + "required_verifier": required_verifier, + "source_refs": source_refs, + } + + +def _summary_rollup( + source_summaries: dict[str, dict[str, Any]], + tracks: list[dict[str, Any]], +) -> dict[str, Any]: + coverage = source_summaries["coverage"] + security_os = source_summaries["security_os"] + managed_hosts = source_summaries["managed_hosts"] + registry = source_summaries["registry"] + controlled_apply = source_summaries["controlled_apply"] + owner_review = source_summaries["owner_review"] + dry_run = source_summaries["dry_run"] + + expected_hosts = max( + _int(managed_hosts.get("expected_host_scope_count")), + _int(registry.get("expected_scope_alias_count")), + _int(coverage.get("wazuh_expected_host_scope_count")), + ) + accepted_hosts = max( + _int(managed_hosts.get("manager_registry_accepted_count")), + _int(registry.get("manager_registry_accepted_count")), + _int(coverage.get("wazuh_manager_registry_accepted_count")), + _int(security_os.get("wazuh_registry_accepted_count")), + ) + total_required = sum(_int(track.get("required_signal_count")) for track in tracks) + total_ready = sum(_int(track.get("ready_signal_count")) for track in tracks) + + return { + "source_readback_count": len(source_summaries), + "tool_track_count": len(tracks), + "p0_tool_track_count": sum(1 for track in tracks if track.get("priority") == "P0"), + "closed_tool_track_count": sum( + 1 for track in tracks if _int(track.get("missing_signal_count")) == 0 + ), + "partial_tool_track_count": sum( + 1 + for track in tracks + if _int(track.get("ready_signal_count")) > 0 + and _int(track.get("missing_signal_count")) > 0 + ), + "missing_tool_track_count": sum( + 1 for track in tracks if _int(track.get("ready_signal_count")) == 0 + ), + "total_ready_signal_count": total_ready, + "total_required_signal_count": total_required, + "tool_closure_percent": int(round((total_ready / total_required) * 100)) + if total_required + else 0, + "wazuh_expected_host_scope_count": expected_hosts, + "wazuh_manager_registry_accepted_count": accepted_hosts, + "wazuh_registry_complete_count": 1 if expected_hosts > 0 and accepted_hosts >= expected_hosts else 0, + "wazuh_fim_vulnerability_alert_closed_count": 0, + "alert_receipt_observed_count": _int( + security_os.get("alert_receipt_observed_count") + ), + "alert_receipt_accepted_count": _int( + security_os.get("alert_receipt_accepted_count") + ), + "alert_receipt_source_status": str( + security_os.get("alert_receipt_source_status") or "not_connected" + ), + "controlled_apply_preflight_ready_count": _int( + controlled_apply.get("controlled_apply_preflight_ready_count") + ), + "owner_review_packet_accepted_count": _int( + owner_review.get("owner_review_packet_accepted_count") + ), + "allowlisted_dry_run_result_ref_count": _int( + dry_run.get("dry_run_result_ref_count") + ), + "supply_chain_scan_closed_count": 0, + "runtime_detection_closed_count": 0, + "web_api_dast_closed_count": 0, + "normalized_detection_schema_closed_count": 0, + "runtime_gate_count": 0, + "host_write_authorized_count": 0, + "active_response_authorized_count": 0, + "secret_value_collection_allowed_count": 0, + } + + +def _next_executable_queue( + source_summaries: dict[str, dict[str, Any]], +) -> list[dict[str, Any]]: + managed_hosts = source_summaries["managed_hosts"] + registry = source_summaries["registry"] + controlled_apply = source_summaries["controlled_apply"] + dry_run = source_summaries["dry_run"] + expected_hosts = max( + _int(managed_hosts.get("expected_host_scope_count")), + _int(registry.get("expected_scope_alias_count")), + ) + accepted_hosts = max( + _int(managed_hosts.get("manager_registry_accepted_count")), + _int(registry.get("manager_registry_accepted_count")), + ) + registry_gate = ( + "wazuh_registry_complete_move_to_fim_vulnerability_alert" + if expected_hosts > 0 and accepted_hosts >= expected_hosts + else "complete_wazuh_manager_registry_alias_matrix" + ) + + return [ + { + "queue_id": "P0-03", + "track_id": "wazuh_detection_response", + "state": registry_gate, + "next_action": "attach_fim_vulnerability_alert_receipt_post_verifier", + "runtime_gate_open": False, + }, + { + "queue_id": "P0-07", + "track_id": "supply_chain_sbom_scan", + "state": "sbom_and_scan_artifacts_missing", + "next_action": "stage_trivy_syft_grype_check_mode_reports", + "runtime_gate_open": False, + }, + { + "queue_id": "P0-08", + "track_id": "ai_agent_controlled_apply", + "state": "preflight_ready_waiting_review_and_dry_run_acceptance", + "next_action": "connect_target_selector_diff_check_mode_rollback_post_verifier_km", + "ready_signal_count": _int( + controlled_apply.get("controlled_apply_preflight_ready_count") + ) + + _int(dry_run.get("dry_run_result_ref_count")), + "runtime_gate_open": False, + }, + ] + + +def _summary(payload: dict[str, Any]) -> dict[str, Any]: + summary = payload.get("summary") + return summary if isinstance(summary, dict) else {} + + +def _int(value: Any) -> int: + return value if isinstance(value, int) else 0 + + +def _require_runtime_boundaries(payloads: list[dict[str, Any]]) -> None: + for payload in payloads: + summary = _summary(payload) + for key in _REQUIRED_ZERO_SUMMARY_KEYS: + if key in summary and _int(summary.get(key)) != 0: + raise ValueError(f"{payload.get('schema_version')}: summary.{key} must remain 0") + boundaries = payload.get("boundaries") + if isinstance(boundaries, dict): + for key in ( + "runtime_execution_authorized", + "runtime_gate_open", + "host_write_authorized", + "wazuh_active_response_authorized", + "secret_value_collection_allowed", + ): + if key in boundaries and boundaries.get(key) is not False: + raise ValueError( + f"{payload.get('schema_version')}: boundaries.{key} must remain false" + ) + + +def _boundary_markers(summary: dict[str, Any]) -> list[str]: + return [ + "iwooos_security_tool_closure_readback_visible=true", + f"iwooos_security_tool_closure_tool_track_count={summary['tool_track_count']}", + f"iwooos_security_tool_closure_p0_tool_track_count={summary['p0_tool_track_count']}", + f"iwooos_security_tool_closure_percent={summary['tool_closure_percent']}", + f"iwooos_security_tool_closure_wazuh_manager_registry_accepted_count={summary['wazuh_manager_registry_accepted_count']}", + f"iwooos_security_tool_closure_alert_receipt_accepted_count={summary['alert_receipt_accepted_count']}", + "iwooos_security_tool_closure_runtime_gate_count=0", + "host_write_authorized=false", + "wazuh_active_response_authorized=false", + "secret_value_collection_allowed=false", + "not_authorization=true", + ] diff --git a/apps/api/tests/test_iwooos_security_tool_closure_readback.py b/apps/api/tests/test_iwooos_security_tool_closure_readback.py new file mode 100644 index 000000000..b206949a7 --- /dev/null +++ b/apps/api/tests/test_iwooos_security_tool_closure_readback.py @@ -0,0 +1,129 @@ +from __future__ import annotations + +from fastapi import FastAPI +from fastapi.testclient import TestClient + +from src.api.v1.iwooos import router +from src.services.iwooos_security_tool_closure_readback import ( + load_latest_iwooos_security_tool_closure_readback, +) + + +def _client() -> TestClient: + app = FastAPI() + app.include_router(router) + return TestClient(app) + + +def test_iwooos_security_tool_closure_readback_rolls_up_tool_tracks() -> None: + payload = load_latest_iwooos_security_tool_closure_readback() + + assert payload["schema_version"] == "iwooos_security_tool_closure_readback_v1" + assert payload["status"] == "tool_closure_readback_ready_runtime_actions_closed" + assert payload["summary"]["source_readback_count"] == 7 + assert payload["summary"]["tool_track_count"] == 8 + assert payload["summary"]["p0_tool_track_count"] == 3 + assert payload["summary"]["wazuh_expected_host_scope_count"] == 6 + assert payload["summary"]["wazuh_manager_registry_accepted_count"] == 6 + assert payload["summary"]["wazuh_registry_complete_count"] == 1 + assert payload["summary"]["wazuh_fim_vulnerability_alert_closed_count"] == 0 + assert payload["summary"]["supply_chain_scan_closed_count"] == 0 + assert payload["summary"]["runtime_detection_closed_count"] == 0 + assert payload["summary"]["web_api_dast_closed_count"] == 0 + assert payload["summary"]["runtime_gate_count"] == 0 + + track_ids = {track["track_id"] for track in payload["tool_tracks"]} + assert track_ids == { + "wazuh_detection_response", + "supply_chain_sbom_scan", + "source_control_scorecard", + "k8s_policy_attestation", + "runtime_threat_detection", + "web_api_dast", + "normalized_detection_schema", + "ai_agent_controlled_apply", + } + + +def test_iwooos_security_tool_closure_readback_keeps_dangerous_boundaries_closed() -> None: + payload = load_latest_iwooos_security_tool_closure_readback() + + assert payload["boundaries"]["runtime_execution_authorized"] is False + assert payload["boundaries"]["runtime_gate_open"] is False + assert payload["boundaries"]["host_write_authorized"] is False + assert payload["boundaries"]["wazuh_active_response_authorized"] is False + assert payload["boundaries"]["secret_value_collection_allowed"] is False + assert payload["boundaries"]["not_authorization"] is True + assert all(track["runtime_gate_open"] is False for track in payload["tool_tracks"]) + assert all(track["active_response_authorized"] is False for track in payload["tool_tracks"]) + assert all(track["host_write_authorized"] is False for track in payload["tool_tracks"]) + assert all( + track["secret_value_collection_allowed"] is False + for track in payload["tool_tracks"] + ) + + +def test_iwooos_security_tool_closure_readback_marks_partial_vs_missing_tracks() -> None: + payload = load_latest_iwooos_security_tool_closure_readback() + tracks = {track["track_id"]: track for track in payload["tool_tracks"]} + + assert tracks["wazuh_detection_response"]["closure_state"] == ( + "partial_readback_missing_verifier" + ) + assert tracks["wazuh_detection_response"]["ready_signal_count"] == 1 + assert tracks["wazuh_detection_response"]["missing_signal_count"] == 5 + assert tracks["supply_chain_sbom_scan"]["closure_state"] == "required_not_closed" + assert tracks["runtime_threat_detection"]["tool_ids"] == ["falco"] + assert tracks["web_api_dast"]["tool_ids"] == ["owasp_zap_or_equivalent_dast"] + assert tracks["ai_agent_controlled_apply"]["ready_signal_count"] > 0 + assert tracks["ai_agent_controlled_apply"]["missing_signal_count"] >= 0 + + +def test_iwooos_security_tool_closure_readback_accepts_alert_receipt_signal() -> None: + payload = load_latest_iwooos_security_tool_closure_readback( + alert_receipt_readback={ + "events": [{"provider_event_id": "alertmanager:received:tool-closure"}], + "total": 1, + "limit": 20, + "source_status": "ready", + } + ) + tracks = {track["track_id"]: track for track in payload["tool_tracks"]} + + assert payload["summary"]["alert_receipt_accepted_count"] == 1 + assert payload["summary"]["alert_receipt_observed_count"] == 1 + assert tracks["wazuh_detection_response"]["ready_signal_count"] == 2 + assert tracks["normalized_detection_schema"]["ready_signal_count"] == 1 + assert payload["summary"]["runtime_gate_count"] == 0 + + +def test_iwooos_security_tool_closure_readback_api_is_public_safe(monkeypatch) -> None: + async def fake_recent_events(**_: object) -> dict[str, object]: + return { + "events": [{"provider_event_id": "alertmanager:received:tool-closure"}], + "total": 1, + "limit": 20, + "source_status": "ready", + } + + monkeypatch.setattr( + "src.api.v1.iwooos.list_recent_channel_events", + fake_recent_events, + ) + + response = _client().get("/api/v1/iwooos/security-tool-closure-readback") + + assert response.status_code == 200 + data = response.json() + assert data["schema_version"] == "iwooos_security_tool_closure_readback_v1" + assert data["summary"]["tool_track_count"] == 8 + assert data["summary"]["alert_receipt_accepted_count"] == 1 + assert data["summary"]["runtime_gate_count"] == 0 + assert any( + item["track_id"] == "supply_chain_sbom_scan" + for item in data["tool_tracks"] + ) + assert "192.168.0." not in response.text + assert "工作視窗" not in response.text + assert "批准!繼續" not in response.text + assert "WAZUH_API_PASSWORD" not in response.text diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 76b2c4cae..1edcf1c86 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -13962,6 +13962,81 @@ } } }, + "securityToolClosure": { + "eyebrow": "Tool closure readback", + "title": "Wazuh, SBOM, runtime detection, DAST and AI apply in one panel", + "subtitle": "This panel only reads committed readback and shows which tool tracks have signals, which still miss verifiers, and why runtime action stays closed.", + "statusLabel": "Tool closure state", + "statusDetail": "{ready}/{required} closure signals are readable; runtime gate remains 0.", + "statusDetailFallback": "Waiting for tool closure readback; runtime gate remains closed.", + "queueLabel": "Next executable P0", + "boundaryTitle": "Show 0 / false boundaries", + "status": { + "loading": "Reading tool closure", + "failed": "Tool closure readback is not deployed or failed to load", + "ready": "Tool closure readback is visible; verifiers are still required" + }, + "metrics": { + "closure": "Closure", + "tracks": "Partial", + "wazuh": "Wazuh registry", + "alerts": "Alert receipt", + "runtime": "Runtime gate" + }, + "states": { + "readback_ready_runtime_gate_still_closed": "readback ready", + "partial_readback_missing_verifier": "missing verifier", + "required_not_closed": "not closed" + }, + "tracks": { + "wazuh_detection_response": { + "label": "Wazuh detect / respond", + "next": "Add FIM, vulnerability rollup, alert receipt and post-verifier; active response stays gated." + }, + "supply_chain_sbom_scan": { + "label": "SBOM / vulnerability", + "next": "Add Trivy, Syft and Grype reports with no-secret output guard." + }, + "source_control_scorecard": { + "label": "Source policy", + "next": "Add Gitea/source-side hygiene, branch-protection equivalent and dependency policy." + }, + "k8s_policy_attestation": { + "label": "K8s policy / attestation", + "next": "Add Kyverno check-mode, Cosign verification, exception register and rollback." + }, + "runtime_threat_detection": { + "label": "Runtime detection", + "next": "Add Falco rule dry-run, event redaction and Wazuh / OCSF correlation." + }, + "web_api_dast": { + "label": "Web / API DAST", + "next": "Add target selector, rate limit, test window and baseline report." + }, + "normalized_detection_schema": { + "label": "Sigma / OCSF", + "next": "Normalize Wazuh, Alertmanager and app events into reviewable cards." + }, + "ai_agent_controlled_apply": { + "label": "AI controlled apply", + "next": "Connect selector, diff, check-mode, rollback, post-verifier and KM writeback." + } + }, + "queue": { + "P0-03": { + "title": "Wazuh detection closure", + "next": "Registry is readable; next close FIM / vuln / alert verifiers." + }, + "P0-07": { + "title": "SBOM and vuln artifacts", + "next": "Start with check-mode reports; do not touch runtime." + }, + "P0-08": { + "title": "AI apply closure", + "next": "Join dry-run, rollback, postcheck and KM writeback into one chain." + } + } + }, "commandRail": { "eyebrow": "控制面", "title": "AI SOC 工作台", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 2e82ae560..5f728d858 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -13962,6 +13962,81 @@ } } }, + "securityToolClosure": { + "eyebrow": "工具閉環 readback", + "title": "Wazuh、SBOM、runtime detection、DAST 與 AI apply 一屏收斂", + "subtitle": "這張面板只讀 committed readback,集中顯示哪些工具軌已有訊號、哪些仍缺 verifier;不執行掃描、不寫主機、不啟用主動回應。", + "statusLabel": "工具閉環狀態", + "statusDetail": "已讀回 {ready}/{required} 個 closure 訊號;runtime gate 仍維持 0。", + "statusDetailFallback": "等待工具閉環 readback,不開 runtime gate。", + "queueLabel": "下一個可執行 P0", + "boundaryTitle": "展開 0 / false 邊界", + "status": { + "loading": "正在讀取工具閉環", + "failed": "工具閉環 readback 尚未部署或讀取失敗", + "ready": "工具閉環已讀回,仍需補 verifier 才能真正防護" + }, + "metrics": { + "closure": "閉環率", + "tracks": "部分可讀", + "wazuh": "Wazuh registry", + "alerts": "Alert receipt", + "runtime": "Runtime gate" + }, + "states": { + "readback_ready_runtime_gate_still_closed": "readback ready", + "partial_readback_missing_verifier": "缺 verifier", + "required_not_closed": "未閉環" + }, + "tracks": { + "wazuh_detection_response": { + "label": "Wazuh 偵測 / 回應", + "next": "補 FIM、弱點 rollup、alert receipt 與 post-verifier;active response 仍 gated。" + }, + "supply_chain_sbom_scan": { + "label": "SBOM / 弱點掃描", + "next": "補 Trivy、Syft、Grype 報告與 no-secret output guard。" + }, + "source_control_scorecard": { + "label": "Source policy", + "next": "補 Gitea/source-side hygiene、分支保護等效與 dependency policy。" + }, + "k8s_policy_attestation": { + "label": "K8s policy / attestation", + "next": "補 Kyverno check-mode、Cosign 驗證、exception register 與 rollback。" + }, + "runtime_threat_detection": { + "label": "Runtime detection", + "next": "補 Falco rule dry-run、事件脫敏與 Wazuh / OCSF correlation。" + }, + "web_api_dast": { + "label": "Web / API DAST", + "next": "補 target selector、rate limit、test window 與 baseline report。" + }, + "normalized_detection_schema": { + "label": "Sigma / OCSF", + "next": "把 Wazuh、Alertmanager、app events 正規化成可審查事件卡。" + }, + "ai_agent_controlled_apply": { + "label": "AI controlled apply", + "next": "接 selector、diff、check-mode、rollback、post-verifier 與 KM writeback。" + } + }, + "queue": { + "P0-03": { + "title": "Wazuh 偵測閉環", + "next": "registry 已可讀,下一步補 FIM / vuln / alert verifier。" + }, + "P0-07": { + "title": "SBOM 與弱點 artifact", + "next": "先做 check-mode 報告,不直接打 runtime。" + }, + "P0-08": { + "title": "AI apply 閉環", + "next": "把 dry-run、rollback、postcheck 與 KM 回寫接成一條鏈。" + } + } + }, "commandRail": { "eyebrow": "控制面", "title": "AI SOC 工作台", diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index 93668bf5c..afeddbfd2 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -46,6 +46,8 @@ import { type IwoooSRuntimeSecurityReadbackResponse, type IwoooSSecurityControlCoverageDomain, type IwoooSSecurityControlCoverageResponse, + type IwoooSSecurityToolClosureReadbackResponse, + type IwoooSSecurityToolClosureTrack, type IwoooSWazuhLiveMetadataGateItem, type IwoooSWazuhLiveMetadataGateResponse, type IwoooSWazuhAllowlistedCheckModeDryRunResponse, @@ -518,6 +520,13 @@ type IwoooSManagerCockpitMetric = { tone: 'steady' | 'warn' | 'locked' } +type IwoooSSecurityToolClosureMetric = { + key: string + value: string + icon: typeof ShieldCheck + tone: 'steady' | 'warn' | 'locked' +} + type IwoooSImmediateVisualMeshNode = { key: string metric: string @@ -16248,6 +16257,301 @@ function IwoooSManagerCockpit() { ) } +function securityToolClosureTone(track: IwoooSSecurityToolClosureTrack): 'steady' | 'warn' | 'locked' { + if (track.missing_signal_count === 0) return 'steady' + if (track.ready_signal_count > 0) return 'warn' + return 'locked' +} + +function IwoooSSecurityToolClosureBoard() { + const t = useTranslations('iwooos.securityToolClosure') + const textWrap = { overflowWrap: 'anywhere' as const, wordBreak: 'break-word' as const } + const [data, setData] = useState(null) + const [loading, setLoading] = useState(true) + const [failed, setFailed] = useState(false) + + useEffect(() => { + let mounted = true + + async function fetchToolClosure() { + setLoading(true) + setFailed(false) + try { + const payload = await apiClient.getIwoooSSecurityToolClosureReadback() + if (mounted) setData(payload) + } catch { + if (mounted) { + setData(null) + setFailed(true) + } + } finally { + if (mounted) setLoading(false) + } + } + + fetchToolClosure() + return () => { + mounted = false + } + }, []) + + const summary = data?.summary + const statusKey = loading ? 'loading' : failed || !data ? 'failed' : 'ready' + const tracks = data?.tool_tracks ?? [] + const queue = data?.next_executable_queue ?? [] + const metrics: IwoooSSecurityToolClosureMetric[] = [ + { + key: 'closure', + value: summary ? `${summary.tool_closure_percent}%` : '...', + icon: ShieldCheck, + tone: summary && summary.tool_closure_percent > 0 ? 'warn' : 'locked', + }, + { + key: 'tracks', + value: summary ? `${summary.partial_tool_track_count}/${summary.tool_track_count}` : '...', + icon: SearchCheck, + tone: summary && summary.partial_tool_track_count > 0 ? 'warn' : 'locked', + }, + { + key: 'wazuh', + value: summary ? `${summary.wazuh_manager_registry_accepted_count}/${summary.wazuh_expected_host_scope_count}` : '...', + icon: Radar, + tone: summary && summary.wazuh_registry_complete_count > 0 ? 'steady' : 'warn', + }, + { + key: 'alerts', + value: summary ? `${summary.alert_receipt_accepted_count}/${summary.alert_receipt_observed_count}` : '...', + icon: Bell, + tone: summary && summary.alert_receipt_accepted_count > 0 ? 'steady' : 'warn', + }, + { + key: 'runtime', + value: summary ? String(summary.runtime_gate_count) : '...', + icon: Lock, + tone: 'locked', + }, + ] + + return ( +
+
+
+
+
+ + {t('eyebrow')} +
+

+ {t('title')} +

+

+ {t('subtitle')} +

+
+ +
+
+ {t('statusLabel')} + +
+
+ {t(`status.${statusKey}` as never)} +
+
+ {summary ? t('statusDetail', { ready: summary.total_ready_signal_count, required: summary.total_required_signal_count }) : t('statusDetailFallback')} +
+
+
+ +
+ {metrics.map(item => { + const Icon = item.icon + return ( +
+
+ + {t(`metrics.${item.key}` as never)} + + +
+
+ {item.value} +
+
+ ) + })} +
+ +
+ {tracks.map(track => { + const tone = securityToolClosureTone(track) + const width = `${Math.min(Math.max(track.closure_percent, 0), 100)}%` + return ( +
+
+
+
{track.priority}
+
+ {t(`tracks.${track.track_id}.label` as never)} +
+
+ +
+
+
+
+
+
+ {track.ready_signal_count}/{track.required_signal_count} + {t(`states.${track.closure_state}` as never)} +
+
+
+ {track.tool_ids.slice(0, 4).map(tool => ( + + {tool} + + ))} +
+
+ {t(`tracks.${track.track_id}.next` as never)} +
+
+ ) + })} +
+ +
+
+ + {t('queueLabel')} +
+
+ {queue.map(item => ( +
+
+ {item.queue_id} + +
+
+ {t(`queue.${item.queue_id}.title` as never)} +
+
+ {t(`queue.${item.queue_id}.next` as never)} +
+
+ ))} +
+
+ +
+ + {t('boundaryTitle')} + +
+ {(data?.boundary_markers ?? []).slice(0, 10).map(marker => ( + + {marker} + + ))} +
+
+
+
+ ) +} + function IwoooSCommandRail() { const t = useTranslations('iwooos.commandRail') @@ -24200,6 +24504,7 @@ export default function IwoooSPage({ params }: { params: { locale: string } }) { + diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index cc6aa1126..914b60e0f 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -742,6 +742,87 @@ export interface IwoooSSecurityControlCoverageResponse { source_refs: string[] } +export interface IwoooSSecurityToolClosureTrack { + track_id: + | 'wazuh_detection_response' + | 'supply_chain_sbom_scan' + | 'source_control_scorecard' + | 'k8s_policy_attestation' + | 'runtime_threat_detection' + | 'web_api_dast' + | 'normalized_detection_schema' + | 'ai_agent_controlled_apply' + priority: 'P0' | 'P1' + label: string + tool_ids: string[] + closure_state: + | 'readback_ready_runtime_gate_still_closed' + | 'partial_readback_missing_verifier' + | 'required_not_closed' + closure_percent: number + ready_signal_count: number + required_signal_count: number + missing_signal_count: number + runtime_gate_open: boolean + active_response_authorized: boolean + host_write_authorized: boolean + secret_value_collection_allowed: boolean + next_executable_action: string + required_verifier: string + source_refs: string[] +} + +export interface IwoooSSecurityToolClosureQueueItem { + queue_id: string + track_id: IwoooSSecurityToolClosureTrack['track_id'] + state: string + next_action: string + ready_signal_count?: number + runtime_gate_open: boolean +} + +export interface IwoooSSecurityToolClosureReadbackResponse { + schema_version: 'iwooos_security_tool_closure_readback_v1' + status: string + mode: string + summary: { + source_readback_count: number + tool_track_count: number + p0_tool_track_count: number + closed_tool_track_count: number + partial_tool_track_count: number + missing_tool_track_count: number + total_ready_signal_count: number + total_required_signal_count: number + tool_closure_percent: number + wazuh_expected_host_scope_count: number + wazuh_manager_registry_accepted_count: number + wazuh_registry_complete_count: number + wazuh_fim_vulnerability_alert_closed_count: number + alert_receipt_observed_count: number + alert_receipt_accepted_count: number + alert_receipt_source_status: string + controlled_apply_preflight_ready_count: number + owner_review_packet_accepted_count: number + allowlisted_dry_run_result_ref_count: number + supply_chain_scan_closed_count: number + runtime_detection_closed_count: number + web_api_dast_closed_count: number + normalized_detection_schema_closed_count: number + runtime_gate_count: number + host_write_authorized_count: number + active_response_authorized_count: number + secret_value_collection_allowed_count: number + } + tool_tracks: IwoooSSecurityToolClosureTrack[] + next_executable_queue: IwoooSSecurityToolClosureQueueItem[] + acceptance_definition: string[] + boundary_markers: string[] + boundaries: Record + no_false_green_rules: string[] + source_refs: string[] +} + export interface IwoooSHighValueConfigControlCoverageCategory { category_id: string label: string @@ -945,6 +1026,11 @@ export const apiClient = { return handleResponse(res) }, + async getIwoooSSecurityToolClosureReadback() { + const res = await fetch(`${API_BASE_URL}/iwooos/security-tool-closure-readback`, { cache: 'no-store' }) + return handleResponse(res) + }, + async getIwoooSHighValueConfigControlCoverage() { const res = await fetch(`${API_BASE_URL}/iwooos/high-value-config-control-coverage`, { cache: 'no-store' }) return handleResponse(res)