diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index 6b458a056..832b4cca7 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -2108,15 +2108,15 @@ jobs: # asyncssh reads /etc/ssh-mcp/known_hosts and requires a non-empty # OpenSSH known_hosts file. Keep hosts unhashed so both asyncssh and # CLI diagnostics can trust the same secret. - # 2026-05-02 ogt + Claude Sonnet 4.6: 加 4 台主機完整性檢查 + # 2026-05-02 ogt + Claude Sonnet 4.6: 加主機完整性檢查 # 根因:partial scan(如 110 timeout、其他成功)會讓 [-s file] 通過、 # 後續 patch 推進缺漏的 known_hosts → asyncssh 拒所有 SSH。 - # 修法:scan 完用 grep -c 驗證 4 台主機都在;缺任何一台就 abort, + # 修法:scan 完驗證所有 allowlisted 主機都在;缺任何一台就 abort, # 不能覆蓋現有 secret,防止 production SSH 自動修復路徑癱瘓。 - ssh-keyscan 192.168.0.110 192.168.0.120 192.168.0.121 192.168.0.188 > /tmp/known_hosts_repair 2>/tmp/known_hosts_scan_err || true - EXPECTED_HOSTS=4 + ssh-keyscan 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 > /tmp/known_hosts_repair 2>/tmp/known_hosts_scan_err || true + EXPECTED_HOSTS=5 PRESENT=0 - for ip in 192.168.0.110 192.168.0.120 192.168.0.121 192.168.0.188; do + for ip in 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188; do if grep -qE "^\${ip}[[:space:]]" /tmp/known_hosts_repair 2>/dev/null; then PRESENT=\$((PRESENT + 1)) else @@ -2133,13 +2133,14 @@ jobs: KNOWN_HOSTS_B64=\$(base64 -w 0 /tmp/known_hosts_repair) \$KUBECTL patch secret ssh-mcp-key -n awoooi-prod --type=merge \ -p="{\"data\":{\"known_hosts\":\"\${KNOWN_HOSTS_B64}\"}}" \ - && echo "✅ ssh-mcp-key known_hosts 已更新(4 台主機完整)" \ - || echo "⚠️ ssh-mcp-key known_hosts 更新失敗 (非致命)" + && echo "✅ ssh-mcp-key known_hosts 已更新(5 台主機完整)" \ + || { echo "❌ ssh-mcp-key known_hosts 更新失敗,停止部署"; exit 1; } rm -f /tmp/known_hosts_repair /tmp/known_hosts_scan_err else echo "❌ ssh-keyscan 只抓到 \${PRESENT}/\${EXPECTED_HOSTS} 台主機,跳過 patch(保留現有 secret)" cat /tmp/known_hosts_scan_err 2>/dev/null | head -10 rm -f /tmp/known_hosts_repair /tmp/known_hosts_scan_err + exit 1 fi echo "✅ 所有 Secrets 注入完成" @@ -2767,7 +2768,7 @@ jobs: container="$2" $KUBECTL exec -i "deployment/$workload" -n awoooi-prod \ -c "$container" -- python - \ - 192.168.0.110 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' + 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' import socket import sys @@ -2787,7 +2788,7 @@ jobs: verify_broker_ssh_allowed() { $KUBECTL exec -i deployment/awoooi-ansible-executor-broker \ -n awoooi-prod -c broker -- python - \ - 192.168.0.110 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' + 192.168.0.110 192.168.0.112 192.168.0.120 192.168.0.121 192.168.0.188 <<'PY' import socket import sys @@ -2801,7 +2802,7 @@ jobs: connection.close() reachable.append(host) print("broker_ssh_reachable=" + ",".join(reachable)) - raise SystemExit(0 if reachable else 1) + raise SystemExit(0 if len(reachable) == len(sys.argv) - 1 else 1) PY } verify_ssh_denied awoooi-api api || { diff --git a/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py b/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py index 62af397ca..3d376f3e2 100644 --- a/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py +++ b/apps/api/src/jobs/awooop_ansible_candidate_backfill_job.py @@ -10,6 +10,7 @@ from __future__ import annotations import asyncio import json +import os import random from collections.abc import Awaitable, Callable from types import SimpleNamespace @@ -350,10 +351,16 @@ async def enqueue_ai_decision_ansible_candidate( def _wazuh_posture_incident( - *, automation_run_id: str, bucket_ref: str + *, + automation_run_id: str, + bucket_ref: str, + attempt_ref: str | None = None, ) -> dict[str, Any]: + receipt_ref = ( + f"{bucket_ref}-{attempt_ref.upper()}" if attempt_ref else bucket_ref + ) return { - "incident_id": f"IWZ-POSTURE-{bucket_ref}", + "incident_id": f"IWZ-POSTURE-{receipt_ref}", "project_id": "awoooi", "status": "INVESTIGATING", "severity": "low", @@ -363,7 +370,7 @@ def _wazuh_posture_incident( "trace_id": automation_run_id, "run_id": automation_run_id, "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, - "source_receipt_ref": f"scheduled-wazuh-manager-posture:{bucket_ref}", + "source_receipt_ref": f"scheduled-wazuh-manager-posture:{receipt_ref}", "asset_scope_aliases": [_WAZUH_POSTURE_PUBLIC_ASSET_ALIAS], "affected_services": ["wazuh-manager", "siem"], "signals": [ @@ -411,14 +418,24 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( existing = await db.execute( text(""" SELECT - op_id::text AS op_id, - status, - input ->> 'automation_run_id' AS automation_run_id - FROM automation_operation_log - WHERE operation_type = 'ansible_candidate_matched' - AND input -> 'executor_candidates' @> CAST(:catalog_match AS jsonb) - AND created_at >= NOW() - (:freshness_hours * INTERVAL '1 hour') - ORDER BY created_at DESC + candidate.op_id::text AS op_id, + candidate.status AS candidate_status, + candidate.input ->> 'automation_run_id' AS automation_run_id, + candidate.input ->> 'source_receipt_ref' AS source_receipt_ref, + check_mode.status AS latest_check_status + FROM automation_operation_log candidate + LEFT JOIN LATERAL ( + SELECT check_receipt.status + FROM automation_operation_log check_receipt + WHERE check_receipt.parent_op_id = candidate.op_id + AND check_receipt.operation_type = 'ansible_check_mode_executed' + ORDER BY check_receipt.created_at DESC + LIMIT 1 + ) check_mode ON TRUE + WHERE candidate.operation_type = 'ansible_candidate_matched' + AND candidate.input -> 'executor_candidates' @> CAST(:catalog_match AS jsonb) + AND candidate.created_at >= NOW() - (:freshness_hours * INTERVAL '1 hour') + ORDER BY candidate.created_at DESC LIMIT 1 """), { @@ -429,7 +446,8 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( }, ) existing_row = existing.mappings().first() - if existing_row: + retry_attempt_ref: str | None = None + if existing_row and existing_row.get("latest_check_status") != "failed": return { "status": "fresh_candidate_already_present", "queued": 0, @@ -443,6 +461,34 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, "active_blockers": [], } + if existing_row: + source_sha = os.getenv("AWOOOI_BUILD_COMMIT_SHA", "").strip().lower() + retry_attempt_ref = ( + source_sha[:12] + if len(source_sha) >= 12 + and all(character in "0123456789abcdef" for character in source_sha) + else "runtime-retry" + ) + existing_source_ref = str( + existing_row.get("source_receipt_ref") or "" + ).lower() + if existing_source_ref.endswith(f"-{retry_attempt_ref.lower()}"): + return { + "status": "failed_check_retry_already_attempted_for_deploy", + "queued": 0, + "existing": 1, + "evidence_ready": 1, + "automation_run_id": str( + existing_row.get("automation_run_id") + or existing_row.get("op_id") + or "" + ), + "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "retry_of_failed_check_mode": True, + "active_blockers": [ + "bounded_retry_already_attempted_for_deploy" + ], + } now = now_taipei() bucket_hour = (now.hour // freshness_hours) * freshness_hours @@ -450,12 +496,14 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( automation_run_id = str( uuid5( NAMESPACE_URL, - f"awoooi:iwooos:wazuh-manager-posture:{bucket.isoformat()}", + "awoooi:iwooos:wazuh-manager-posture:" + f"{bucket.isoformat()}:{retry_attempt_ref or 'initial'}", ) ) incident = _wazuh_posture_incident( automation_run_id=automation_run_id, bucket_ref=bucket.strftime("%Y%m%d%H"), + attempt_ref=retry_attempt_ref, ) try: snapshot = await evidence_collector( @@ -490,10 +538,14 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( ) return { "status": ( - "queued_for_controlled_executor" + "queued_for_controlled_executor_retry" + if queued and evidence_ready and retry_attempt_ref + else "queued_for_controlled_executor" if queued and evidence_ready else ( - "queued_for_controlled_executor_with_degraded_context" + "queued_for_controlled_executor_retry_with_degraded_context" + if queued and retry_attempt_ref + else "queued_for_controlled_executor_with_degraded_context" if queued else "candidate_race_deduplicated" ) @@ -503,6 +555,7 @@ async def enqueue_iwooos_wazuh_manager_posture_candidate_once( "evidence_ready": 1 if evidence_ready else 0, "automation_run_id": automation_run_id, "work_item_id": _WAZUH_POSTURE_WORK_ITEM_ID, + "retry_of_failed_check_mode": bool(retry_attempt_ref), "active_blockers": ( [] if evidence_ready else ["predecision_mcp_or_log_evidence_missing"] ), diff --git a/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py b/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py index 3e8bb692e..d788f1436 100644 --- a/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py +++ b/apps/api/src/services/iwooos_wazuh_controlled_executor_runtime.py @@ -54,6 +54,10 @@ _LIVE_RUNTIME_SQL = """ check_mode.output ->> 'returncode', check_mode.dry_run_result ->> 'returncode' ) AS check_returncode, + coalesce( + check_mode.output ->> 'timed_out', + check_mode.dry_run_result ->> 'timed_out' + ) AS check_timed_out, check_mode.duration_ms AS check_duration_ms, check_mode.created_at AS check_created_at FROM automation_operation_log check_mode @@ -231,6 +235,8 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( data.get("check_status") == "success" and str(data.get("check_returncode") or "") == "0" ) + check_failed = data.get("check_status") == "failed" + check_failure_class = _check_failure_class(data) apply_terminal = data.get("apply_status") in {"success", "failed"} apply_passed = ( data.get("apply_status") == "success" @@ -294,6 +300,8 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( active_blockers.append("wazuh_manager_posture_catalog_missing") if candidate_present and data.get("check_status") == "failed": active_blockers.append("wazuh_manager_posture_check_mode_failed") + if check_failure_class: + active_blockers.append(check_failure_class) if data.get("apply_status") == "failed": active_blockers.append("wazuh_manager_posture_controlled_probe_failed") if apply_passed and not verifier_passed: @@ -311,6 +319,7 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( read_error_type=read_error_type, candidate_present=candidate_present, check_passed=check_passed, + check_failed=check_failed, apply_terminal=apply_terminal, apply_passed=apply_passed, runtime_closed=runtime_closed, @@ -329,6 +338,7 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( "executor_policy_enabled_count": 1 if enabled and catalog_ready else 0, "dispatch_candidate_count": 1 if candidate_present else 0, "check_mode_passed_count": 1 if check_passed else 0, + "check_mode_failed_count": 1 if check_failed else 0, "runtime_execution_performed_count": 1 if apply_terminal else 0, "bounded_posture_execution_passed_count": 1 if apply_passed else 0, "independent_post_verifier_passed_count": 1 if verifier_passed else 0, @@ -359,9 +369,12 @@ def build_iwooos_wazuh_controlled_executor_runtime_readback( for stage_id in required_stages ], "missing_stage_ids": missing_stage_ids, + "check_failure_class": check_failure_class, "next_safe_action": _next_action( candidate_present=candidate_present, check_passed=check_passed, + check_failed=check_failed, + check_failure_class=check_failure_class, apply_terminal=apply_terminal, apply_passed=apply_passed, verifier_passed=verifier_passed, @@ -396,6 +409,7 @@ def _status( read_error_type: str | None, candidate_present: bool, check_passed: bool, + check_failed: bool, apply_terminal: bool, apply_passed: bool, runtime_closed: bool, @@ -410,6 +424,8 @@ def _status( return "wazuh_manager_posture_executed_runtime_writeback_open" if check_passed: return "wazuh_manager_posture_check_passed_waiting_bounded_execution" + if check_failed: + return "wazuh_manager_posture_check_failed_waiting_bounded_retry" if candidate_present: return "wazuh_manager_posture_dispatched_waiting_check_mode" return "waiting_for_scheduled_wazuh_manager_posture_dispatch" @@ -419,6 +435,8 @@ def _next_action( *, candidate_present: bool, check_passed: bool, + check_failed: bool, + check_failure_class: str | None, apply_terminal: bool, apply_passed: bool, verifier_passed: bool, @@ -429,6 +447,11 @@ def _next_action( return "keep_scheduled_wazuh_manager_posture_runtime_receipts_fresh" if not candidate_present: return "internal_worker_enqueue_wazuh_manager_posture_candidate" + if check_failed: + return ( + "repair_and_enqueue_bounded_retry:" + + (check_failure_class or "ansible_check_mode_failed") + ) if not check_passed: return "worker_claim_and_run_allowlisted_ansible_check_mode" if not apply_terminal: @@ -440,6 +463,19 @@ def _next_action( return "backfill_same_run_receipts:" + ",".join(missing_stage_ids[:6]) +def _check_failure_class(data: Mapping[str, Any]) -> str | None: + if data.get("check_status") != "failed": + return None + if str(data.get("check_timed_out") or "").lower() == "true": + return "ansible_check_mode_timeout" + returncode = str(data.get("check_returncode") or "").strip() + if returncode == "4": + return "ansible_target_unreachable" + if returncode.isdigit(): + return f"ansible_check_mode_returncode_{returncode}" + return "ansible_check_mode_failed_unclassified" + + def _latest_receipt_at(data: Mapping[str, Any]) -> str | None: values = [ value diff --git a/apps/api/tests/test_executor_network_policy_boundary.py b/apps/api/tests/test_executor_network_policy_boundary.py index 503cfefd6..99a8bbaeb 100644 --- a/apps/api/tests/test_executor_network_policy_boundary.py +++ b/apps/api/tests/test_executor_network_policy_boundary.py @@ -103,3 +103,12 @@ def test_cd_applies_rolls_back_and_verifies_network_boundary() -> None: assert "legacy_namespace_egress_allow_absent=true" in workflow assert "192.168.0.110/32 192.168.0.112/32 192.168.0.120/32" in workflow assert 'wc -l)" = "5"' in workflow + expected_hosts = ( + "192.168.0.110 192.168.0.112 192.168.0.120 " + "192.168.0.121 192.168.0.188" + ) + assert f"ssh-keyscan {expected_hosts}" in workflow + assert "EXPECTED_HOSTS=5" in workflow + assert workflow.count(expected_hosts) >= 4 + assert "len(reachable) == len(sys.argv) - 1" in workflow + assert "ssh-mcp-key known_hosts 更新失敗,停止部署" in workflow diff --git a/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py b/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py index e2f9303a2..e97fa0cba 100644 --- a/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py +++ b/apps/api/tests/test_iwooos_wazuh_controlled_executor_runtime.py @@ -113,6 +113,37 @@ def test_wazuh_runtime_readback_keeps_partial_execution_open() -> None: assert "same_run_mcp_context_missing" in payload["active_blockers"] +def test_wazuh_runtime_readback_classifies_failed_check_for_bounded_retry() -> None: + row = { + "candidate_op_id": "00000000-0000-0000-0000-000000000311", + "automation_run_id": "00000000-0000-0000-0000-000000000311", + "trace_id": "00000000-0000-0000-0000-000000000311", + "run_id": "00000000-0000-0000-0000-000000000311", + "work_item_id": "P0-03-WAZUH-MANAGER-POSTURE", + "check_status": "failed", + "check_returncode": "4", + "check_timed_out": "false", + "stage_ids": [], + } + + payload = build_iwooos_wazuh_controlled_executor_runtime_readback( + row, + executor_enabled=True, + ) + + assert payload["status"] == ( + "wazuh_manager_posture_check_failed_waiting_bounded_retry" + ) + assert payload["summary"]["check_mode_failed_count"] == 1 + assert payload["summary"]["runtime_execution_performed_count"] == 0 + assert payload["summary"]["runtime_closed_count"] == 0 + assert payload["check_failure_class"] == "ansible_target_unreachable" + assert payload["next_safe_action"] == ( + "repair_and_enqueue_bounded_retry:ansible_target_unreachable" + ) + assert "ansible_target_unreachable" in payload["active_blockers"] + + def test_wazuh_posture_catalog_and_playbook_are_bounded_no_write() -> None: catalog = get_ansible_catalog_item("ansible:wazuh-manager-posture-readback") assert catalog is not None @@ -224,6 +255,192 @@ async def test_wazuh_posture_scheduler_enqueues_after_mcp_and_log_evidence( posture_clock.assert_called_once_with() +@pytest.mark.asyncio +async def test_wazuh_posture_scheduler_retries_failed_check_once_per_deploy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.jobs import awooop_ansible_candidate_backfill_job as job + + existing_row = { + "op_id": "00000000-0000-0000-0000-000000000321", + "candidate_status": "dry_run", + "automation_run_id": "00000000-0000-0000-0000-000000000321", + "latest_check_status": "failed", + } + + class _Mappings: + def first(self): + return existing_row + + class _Result: + def mappings(self): + return _Mappings() + + class _Db: + async def execute(self, _statement, _params): + return _Result() + + @asynccontextmanager + async def fake_db_context(project_id: str): + assert project_id == "awoooi" + yield _Db() + + recorder = AsyncMock(return_value=True) + monkeypatch.setattr(job, "get_db_context", fake_db_context) + monkeypatch.setattr( + job.settings, + "ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR", + True, + ) + monkeypatch.setattr( + job.settings, + "IWOOOS_WAZUH_MANAGER_POSTURE_FRESHNESS_HOURS", + 6, + ) + monkeypatch.setattr( + job, + "now_taipei", + MagicMock(return_value=datetime.fromisoformat("2026-07-11T05:59:59+08:00")), + ) + monkeypatch.setenv( + "AWOOOI_BUILD_COMMIT_SHA", + "abcdef1234567890abcdef1234567890abcdef12", + ) + + result = await job.enqueue_iwooos_wazuh_manager_posture_candidate_once( + recorder=recorder, + evidence_collector=AsyncMock(return_value=MagicMock(snapshot_id="wazuh-2")), + evidence_verifier=AsyncMock(return_value=True), + ) + + assert result["status"] == "queued_for_controlled_executor_retry" + assert result["queued"] == 1 + assert result["retry_of_failed_check_mode"] is True + recorded = recorder.await_args.kwargs + assert recorded["incident"]["incident_id"] == ( + "IWZ-POSTURE-2026071100-ABCDEF123456" + ) + assert recorded["incident"]["source_receipt_ref"] == ( + "scheduled-wazuh-manager-posture:2026071100-ABCDEF123456" + ) + assert recorded["automation_run_id"] == result["automation_run_id"] + + +@pytest.mark.asyncio +async def test_wazuh_posture_scheduler_does_not_duplicate_active_candidate( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.jobs import awooop_ansible_candidate_backfill_job as job + + class _Mappings: + def first(self): + return { + "op_id": "00000000-0000-0000-0000-000000000331", + "candidate_status": "dry_run", + "automation_run_id": "00000000-0000-0000-0000-000000000331", + "latest_check_status": "pending", + } + + class _Result: + def mappings(self): + return _Mappings() + + class _Db: + async def execute(self, _statement, _params): + return _Result() + + @asynccontextmanager + async def fake_db_context(_project_id: str): + yield _Db() + + recorder = AsyncMock(return_value=True) + monkeypatch.setattr(job, "get_db_context", fake_db_context) + monkeypatch.setattr( + job.settings, + "ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR", + True, + ) + monkeypatch.setattr( + job.settings, + "IWOOOS_WAZUH_MANAGER_POSTURE_FRESHNESS_HOURS", + 6, + ) + + result = await job.enqueue_iwooos_wazuh_manager_posture_candidate_once( + recorder=recorder, + ) + + assert result["status"] == "fresh_candidate_already_present" + assert result["queued"] == 0 + recorder.assert_not_awaited() + + +@pytest.mark.asyncio +async def test_wazuh_posture_scheduler_limits_failed_retry_to_once_per_deploy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + from src.jobs import awooop_ansible_candidate_backfill_job as job + + class _Mappings: + def first(self): + return { + "op_id": "00000000-0000-0000-0000-000000000341", + "candidate_status": "failed", + "automation_run_id": "00000000-0000-0000-0000-000000000341", + "source_receipt_ref": ( + "scheduled-wazuh-manager-posture:" + "2026071100-ABCDEF123456" + ), + "latest_check_status": "failed", + } + + class _Result: + def mappings(self): + return _Mappings() + + class _Db: + async def execute(self, _statement, _params): + return _Result() + + @asynccontextmanager + async def fake_db_context(_project_id: str): + yield _Db() + + recorder = AsyncMock(return_value=True) + collector = AsyncMock() + monkeypatch.setattr(job, "get_db_context", fake_db_context) + monkeypatch.setattr( + job.settings, + "ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR", + True, + ) + monkeypatch.setattr( + job.settings, + "IWOOOS_WAZUH_MANAGER_POSTURE_FRESHNESS_HOURS", + 6, + ) + monkeypatch.setenv( + "AWOOOI_BUILD_COMMIT_SHA", + "abcdef1234567890abcdef1234567890abcdef12", + ) + + result = await job.enqueue_iwooos_wazuh_manager_posture_candidate_once( + recorder=recorder, + evidence_collector=collector, + ) + + assert result["status"] == ( + "failed_check_retry_already_attempted_for_deploy" + ) + assert result["queued"] == 0 + assert result["retry_of_failed_check_mode"] is True + assert result["active_blockers"] == [ + "bounded_retry_already_attempted_for_deploy" + ] + recorder.assert_not_awaited() + collector.assert_not_awaited() + + @pytest.mark.asyncio async def test_wazuh_posture_scheduler_queues_bounded_probe_with_degraded_context( monkeypatch: pytest.MonkeyPatch, diff --git a/apps/api/tests/test_signal_worker_ansible_executor_binding.py b/apps/api/tests/test_signal_worker_ansible_executor_binding.py index d7c52c588..b0d61513a 100644 --- a/apps/api/tests/test_signal_worker_ansible_executor_binding.py +++ b/apps/api/tests/test_signal_worker_ansible_executor_binding.py @@ -91,6 +91,7 @@ def test_api_manifest_has_read_only_identity_and_no_ssh_executor_mounts() -> Non assert env["DATABASE_NULL_POOL"] == "true" assert env["ENABLE_AWOOOP_ANSIBLE_CHECK_MODE_WORKER"] == "false" assert env["ENABLE_AWOOOP_ANSIBLE_CANDIDATE_BACKFILL_WORKER"] == "false" + assert env["ENABLE_IWOOOS_WAZUH_MANAGER_POSTURE_EXECUTOR"] == "true" assert {"repair-ssh-key", "repair-known-hosts", "ssh-mcp-key"}.isdisjoint( mount_names | volume_names ) diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index f17ed54b8..1ed7c243c 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -13997,9 +13997,17 @@ "runtime": "Runtime", "ready": "ready", "waiting": "waiting", + "failed": "check failed", "closed": "closed", "blocked": "blocked" }, + "runtimeFailure": { + "title": "Wazuh execution chain", + "targetUnreachable": "Target trust failed; waiting for this release's bounded retry", + "timeout": "Check mode timed out; waiting for this release's bounded retry", + "checkFailed": "Check mode failed; waiting for this release's bounded retry", + "unclassified": "Check failure isolated; waiting for safe classification and bounded retry" + }, "guards": { "runtimeActions": "bounded execution receipt", "criticalSecrets": "sensitive value read", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index ac66a0328..e6e5f27f5 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -13997,9 +13997,17 @@ "runtime": "Runtime", "ready": "ready", "waiting": "waiting", + "failed": "check failed", "closed": "closed", "blocked": "blocked" }, + "runtimeFailure": { + "title": "Wazuh 執行鏈", + "targetUnreachable": "目標連線信任未通過,等待本版受控重試", + "timeout": "check-mode 逾時,等待本版受控重試", + "checkFailed": "check-mode 未通過,等待本版受控重試", + "unclassified": "check-mode 失敗已隔離,等待安全分類與受控重試" + }, "guards": { "runtimeActions": "受控執行收據", "criticalSecrets": "敏感值讀取", diff --git a/apps/web/src/app/[locale]/iwooos/page.tsx b/apps/web/src/app/[locale]/iwooos/page.tsx index 196a6f3eb..81506e846 100644 --- a/apps/web/src/app/[locale]/iwooos/page.tsx +++ b/apps/web/src/app/[locale]/iwooos/page.tsx @@ -16694,6 +16694,7 @@ function IwoooSSecurityToolClosureBoard() { const runtimeClosed = runtimeSummary?.runtime_closed_count ?? summary.wazuh_incident_response_runtime_closed_count ?? 0 const dispatchEnabled = runtimeSummary?.executor_policy_enabled_count ?? summary.wazuh_controlled_executor_dispatch_enabled_count ?? 0 const dispatchCandidate = runtimeSummary?.dispatch_candidate_count ?? 0 + const checkModeFailed = runtimeSummary?.check_mode_failed_count ?? 0 const runtimePresent = runtimeSummary?.same_run_present_stage_count ?? runtimeClosed const runtimeRequired = runtimeSummary?.same_run_required_stage_count ?? 1 const runtimeCompletion = runtimeSummary?.same_run_completion_percent ?? 0 @@ -16701,6 +16702,15 @@ function IwoooSSecurityToolClosureBoard() { runtimeSummary?.runtime_execution_performed_count ?? summary.runtime_execution_performed_count ?? 0 + const runtimeFailureKey = runtimeData?.check_failure_class === 'ansible_target_unreachable' + ? 'targetUnreachable' + : runtimeData?.check_failure_class === 'ansible_check_mode_timeout' + ? 'timeout' + : runtimeData?.check_failure_class?.startsWith('ansible_check_mode_returncode_') + ? 'checkFailed' + : runtimeData?.check_failure_class + ? 'unclassified' + : null const runwayItems = [ { key: 'source', @@ -16725,10 +16735,10 @@ function IwoooSSecurityToolClosureBoard() { }, { key: 'runtime', - icon: ShieldCheck, + icon: checkModeFailed > 0 ? ShieldAlert : ShieldCheck, value: `${runtimePresent}/${runtimeRequired}`, - tone: runtimeClosed > 0 ? 'steady' as const : runtimePresent > 0 ? 'warn' as const : 'locked' as const, - state: runtimeClosed > 0 ? 'closed' : dispatchEnabled > 0 ? 'waiting' : 'blocked', + tone: runtimeClosed > 0 ? 'steady' as const : runtimePresent > 0 || checkModeFailed > 0 ? 'warn' as const : 'locked' as const, + state: runtimeClosed > 0 ? 'closed' : checkModeFailed > 0 ? 'failed' : dispatchEnabled > 0 ? 'waiting' : 'blocked', }, ] const metrics: IwoooSSecurityToolClosureMetric[] = [ @@ -16918,6 +16928,32 @@ function IwoooSSecurityToolClosureBoard() { + {runtimeFailureKey ? ( +