From b338a7ab5451edd8ca82c4ae2d90a7fa4261b7a4 Mon Sep 17 00:00:00 2001 From: Your Name Date: Fri, 3 Jul 2026 07:30:45 +0800 Subject: [PATCH] fix(reboot): surface windows99 collector readback --- .../awoooi_priority_work_order_readback.py | 66 ++++++ .../reboot_auto_recovery_slo_scorecard.py | 98 +++++++- ...awoooi_priority_work_order_readback_api.py | 30 +++ ..._reboot_auto_recovery_slo_scorecard_api.py | 89 ++++++++ docs/LOGBOOK.md | 12 + .../reboot-auto-recovery-slo-exporter.sh | 3 + .../reboot-auto-recovery-slo-scorecard.py | 216 +++++++++++++++++- ...test_reboot_auto_recovery_slo_scorecard.py | 93 ++++++++ 8 files changed, 605 insertions(+), 2 deletions(-) diff --git a/apps/api/src/services/awoooi_priority_work_order_readback.py b/apps/api/src/services/awoooi_priority_work_order_readback.py index 4ba1f5fee..7260c29e1 100644 --- a/apps/api/src/services/awoooi_priority_work_order_readback.py +++ b/apps/api/src/services/awoooi_priority_work_order_readback.py @@ -3069,6 +3069,25 @@ def _enrich_from_current_readbacks( state["windows99_verify_collection_post_verifier"] = str( windows99_verify_collection.get("post_verifier") or "" ) + state["windows99_no_secret_collector_readback_present"] = bool( + windows99_verify_collection.get("no_secret_collector_readback_present") + is True + ) + state["windows99_no_secret_collector_status"] = str( + windows99_verify_collection.get("no_secret_collector_status") or "unknown" + ) + state["windows99_no_secret_collector_ssh_batchmode_auth_ready"] = bool( + windows99_verify_collection.get( + "no_secret_collector_ssh_batchmode_auth_ready" + ) + is True + ) + state["windows99_no_secret_collector_safe_next_step"] = str( + windows99_verify_collection.get("no_secret_collector_safe_next_step") or "" + ) + state["windows99_available_collection_channels"] = _strings( + windows99_verify_collection.get("available_collection_channels") + ) state["windows99_management_readback_present"] = bool( windows99_management.get("readback_present") is True ) @@ -3292,6 +3311,21 @@ def _enrich_from_current_readbacks( evidence["windows99_verify_collection_host99_uptime_known"] = bool( windows99_verify_collection.get("host99_uptime_known") is True ) + evidence["windows99_no_secret_collector_readback_present"] = state[ + "windows99_no_secret_collector_readback_present" + ] + evidence["windows99_no_secret_collector_status"] = state[ + "windows99_no_secret_collector_status" + ] + evidence["windows99_no_secret_collector_ssh_batchmode_auth_ready"] = state[ + "windows99_no_secret_collector_ssh_batchmode_auth_ready" + ] + evidence["windows99_no_secret_collector_safe_next_step"] = state[ + "windows99_no_secret_collector_safe_next_step" + ] + evidence["windows99_available_collection_channels"] = state[ + "windows99_available_collection_channels" + ] evidence["windows99_management_readback_present"] = state[ "windows99_management_readback_present" ] @@ -3845,6 +3879,22 @@ def _set_rollups_and_summary( "windows99_verify_collection_host99_uptime_known": ( state.get("windows99_verify_collection_host99_uptime_known") is True ), + "windows99_no_secret_collector_readback_present": ( + state.get("windows99_no_secret_collector_readback_present") is True + ), + "windows99_no_secret_collector_status": str( + state.get("windows99_no_secret_collector_status") or "unknown" + ), + "windows99_no_secret_collector_ssh_batchmode_auth_ready": ( + state.get("windows99_no_secret_collector_ssh_batchmode_auth_ready") + is True + ), + "windows99_no_secret_collector_safe_next_step": str( + state.get("windows99_no_secret_collector_safe_next_step") or "" + ), + "windows99_available_collection_channels": _strings( + state.get("windows99_available_collection_channels") + ), "windows99_management_readback_present": ( state.get("windows99_management_readback_present") is True ), @@ -3943,6 +3993,22 @@ def _set_rollups_and_summary( "windows99_verify_collection_post_verifier": str( state.get("windows99_verify_collection_post_verifier") or "" ), + "windows99_no_secret_collector_readback_present": ( + state.get("windows99_no_secret_collector_readback_present") is True + ), + "windows99_no_secret_collector_status": str( + state.get("windows99_no_secret_collector_status") or "unknown" + ), + "windows99_no_secret_collector_ssh_batchmode_auth_ready": ( + state.get("windows99_no_secret_collector_ssh_batchmode_auth_ready") + is True + ), + "windows99_no_secret_collector_safe_next_step": str( + state.get("windows99_no_secret_collector_safe_next_step") or "" + ), + "windows99_available_collection_channels": _strings( + state.get("windows99_available_collection_channels") + ), "windows99_management_readback_present": ( state.get("windows99_management_readback_present") is True ), diff --git a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py index a92a0fe39..de2ae3a24 100644 --- a/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py +++ b/apps/api/src/services/reboot_auto_recovery_slo_scorecard.py @@ -1234,6 +1234,7 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: windows99 = _dict(scorecard.get("windows99_vmware_autostart")) windows99_management = _dict(scorecard.get("windows99_management_channel")) windows99_ssh_batch = _dict(windows99_management.get("ssh_batch")) + windows99_collector = _dict(scorecard.get("windows99_vmware_verify_collector")) source_controls = _dict(scorecard.get("source_controls")) active_blockers = _unique_strings( _strings(scorecard.get("active_blockers")) @@ -1302,6 +1303,8 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: windows99_verify_collection = _build_windows99_verify_collection_packet( windows99=windows99, host_boot_detection=host_boot_detection, + windows99_management=windows99_management, + windows99_collector=windows99_collector, ) blocked_by_fresh_reboot_window_only = active_blockers == [ "host_boot_observation_older_than_target_window" @@ -1436,6 +1439,36 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: "windows99_verify_collection_blocker_count": len( _strings(windows99_verify_collection.get("collection_blockers")) ), + "windows99_no_secret_collector_readback_present": ( + windows99_verify_collection.get("no_secret_collector_readback_present") + is True + ), + "windows99_no_secret_collector_status": str( + windows99_verify_collection.get("no_secret_collector_status") or "unknown" + ), + "windows99_no_secret_collector_ssh_batchmode_auth_ready": ( + windows99_verify_collection.get( + "no_secret_collector_ssh_batchmode_auth_ready" + ) + is True + ), + "windows99_no_secret_collector_port_22_open": ( + windows99_verify_collection.get("no_secret_collector_port_22_open") + is True + ), + "windows99_no_secret_collector_remote_verify_attempted": ( + windows99_verify_collection.get( + "no_secret_collector_remote_verify_attempted" + ) + is True + ), + "windows99_no_secret_collector_safe_next_step": str( + windows99_verify_collection.get("no_secret_collector_safe_next_step") + or "" + ), + "windows99_available_collection_channels": _strings( + windows99_verify_collection.get("available_collection_channels") + ), "windows99_host99_reachable": ( windows99_verify_collection["host99_reachable"] is True ), @@ -1584,6 +1617,21 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: "windows99_verify_collection_can_collect_no_secret": rollups[ "windows99_verify_collection_can_collect_no_secret" ], + "windows99_no_secret_collector_readback_present": rollups[ + "windows99_no_secret_collector_readback_present" + ], + "windows99_no_secret_collector_status": rollups[ + "windows99_no_secret_collector_status" + ], + "windows99_no_secret_collector_ssh_batchmode_auth_ready": rollups[ + "windows99_no_secret_collector_ssh_batchmode_auth_ready" + ], + "windows99_no_secret_collector_safe_next_step": rollups[ + "windows99_no_secret_collector_safe_next_step" + ], + "windows99_available_collection_channels": rollups[ + "windows99_available_collection_channels" + ], "windows99_remote_execution_channel_ready": rollups[ "windows99_remote_execution_channel_ready" ], @@ -1605,6 +1653,7 @@ def _build_payload(scorecard: dict[str, Any], path: Path) -> dict[str, Any]: "stockplatform_data_freshness": stockplatform, "public_maintenance_fallback": public_maintenance, "windows99_vmware_autostart": windows99, + "windows99_vmware_verify_collector": windows99_collector, "windows99_verify_collection": windows99_verify_collection, "windows99_management_channel": windows99_management, "source_controls": source_controls, @@ -1794,12 +1843,26 @@ def _build_windows99_verify_collection_packet( *, windows99: dict[str, Any], host_boot_detection: dict[str, Any], + windows99_management: dict[str, Any] | None = None, + windows99_collector: dict[str, Any] | None = None, ) -> dict[str, Any]: + windows99_management = windows99_management or {} + windows99_collector = windows99_collector or {} host99 = _host_row_by_alias(host_boot_detection, "99") host99_reachable = host99.get("reachable") is True host99_uptime_known = bool(host99) and _int(host99.get("uptime_seconds")) >= 0 readback_present = windows99.get("readback_present") is True verify_ready = windows99.get("verify_ready") is True + local_console_reachable = ( + windows99_management.get("local_console_channel_reachable") is True + ) + console_channels = _strings(windows99_management.get("console_collection_channels")) + collector_present = windows99_collector.get("readback_present") is True + collector_status = str(windows99_collector.get("status") or "unknown") + collector_ssh_ready = ( + windows99_collector.get("ssh_batchmode_auth_ready") is True + or collector_status == "collected_windows99_vmware_verify_stdout" + ) collection_blockers: list[str] = [] if not host99_reachable: collection_blockers.append("windows99_host_not_reachable_for_verify_collection") @@ -1808,8 +1871,19 @@ def _build_windows99_verify_collection_packet( for blocker in _strings(windows99.get("blockers")): if blocker not in collection_blockers: collection_blockers.append(blocker) + for blocker in _strings(windows99_collector.get("blockers")): + if blocker not in collection_blockers: + collection_blockers.append(blocker) if not host99_uptime_known: collection_blockers.append("windows99_uptime_unknown") + available_channels: list[str] = [] + if local_console_reachable: + available_channels.extend(console_channels or ["local_console"]) + if collector_ssh_ready: + available_channels.append("no_secret_ssh_batchmode_verify_collector") + if readback_present: + available_channels.append("committed_no_secret_artifact_file") + available_channels = _unique_strings(available_channels) return { "schema_version": "windows99_vmware_verify_collection_packet_v1", @@ -1828,7 +1902,29 @@ def _build_windows99_verify_collection_packet( "host99_uptime_known": host99_uptime_known, "readback_present": readback_present, "verify_ready": verify_ready, - "can_collect_no_secret_verify": host99_reachable and not verify_ready, + "can_collect_no_secret_verify": ( + host99_reachable + and not verify_ready + and ( + local_console_reachable + or collector_ssh_ready + or not collector_present + ) + ), + "available_collection_channels": available_channels, + "no_secret_collector_readback_present": collector_present, + "no_secret_collector_status": collector_status, + "no_secret_collector_safe_next_step": str( + windows99_collector.get("safe_next_step") or "" + ), + "no_secret_collector_ssh_batchmode_auth_ready": collector_ssh_ready, + "no_secret_collector_port_22_open": ( + windows99_collector.get("port_22_open") is True + ), + "no_secret_collector_remote_verify_attempted": ( + windows99_collector.get("remote_verify_attempted") is True + ), + "no_secret_collector": windows99_collector, "required_vm_aliases": _strings(windows99.get("required_vm_aliases")) or ["111", "112", "120", "121", "188"], "expected_no_secret_output_fields": [ diff --git a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py index 4a68ab046..2d44ae1de 100644 --- a/apps/api/tests/test_awoooi_priority_work_order_readback_api.py +++ b/apps/api/tests/test_awoooi_priority_work_order_readback_api.py @@ -172,6 +172,24 @@ def test_awoooi_priority_work_order_readback_surfaces_gitea_bundle_truth(): "windows99_vmware_autostart_readback_missing", "windows99_uptime_unknown", ] + assert ( + payload["mainline_execution_state"][ + "windows99_no_secret_collector_readback_present" + ] + is False + ) + assert payload["mainline_execution_state"][ + "windows99_no_secret_collector_status" + ] == "unknown" + assert ( + payload["mainline_execution_state"][ + "windows99_no_secret_collector_ssh_batchmode_auth_ready" + ] + is False + ) + assert payload["mainline_execution_state"][ + "windows99_available_collection_channels" + ] == ["rdp_console", "hyperv_vmconnect"] assert ( payload["mainline_execution_state"][ "windows99_verify_collection_host99_reachable" @@ -260,6 +278,12 @@ def test_awoooi_priority_work_order_readback_surfaces_gitea_bundle_truth(): "windows99_vmware_autostart_readback_missing", "windows99_uptime_unknown", ] + assert in_progress["evidence"][ + "windows99_no_secret_collector_status" + ] == "unknown" + assert in_progress["evidence"][ + "windows99_available_collection_channels" + ] == ["rdp_console", "hyperv_vmconnect"] assert ( in_progress["evidence"]["windows99_verify_collection_host99_reachable"] is True @@ -355,7 +379,13 @@ def test_awoooi_priority_work_order_readback_surfaces_gitea_bundle_truth(): "windows99_vmware_autostart_readback_missing", "windows99_uptime_unknown", ] + assert payload["summary"]["windows99_no_secret_collector_status"] == "unknown" + assert payload["summary"]["windows99_available_collection_channels"] == [ + "rdp_console", + "hyperv_vmconnect", + ] assert payload["rollups"]["windows99_verify_collection_blocker_count"] == 2 + assert payload["rollups"]["windows99_no_secret_collector_status"] == "unknown" assert ( payload["rollups"]["windows99_verify_collection_host99_reachable"] is True diff --git a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py index e63ea874a..7b070007c 100644 --- a/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py +++ b/apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py @@ -327,6 +327,74 @@ def test_reboot_auto_recovery_slo_scorecard_uses_latest_runtime_scorecard_from_d ) +def test_reboot_auto_recovery_slo_scorecard_surfaces_runtime_collector_blocker( + tmp_path, +): + runtime_scorecard = json.loads(_SOURCE_SCORECARD.read_text(encoding="utf-8")) + runtime_scorecard["generated_at"] = "2026-07-03T01:42:00+08:00" + runtime_scorecard["windows99_vmware_verify_collector"] = { + "schema_version": "windows99_vmware_verify_collector_v1", + "readback_present": True, + "status": "blocked_ssh_publickey_auth_missing", + "dry_run": "true", + "target_host": "192.168.0.99", + "target_host_alias": "99", + "port_22_open": True, + "port_3389_open": True, + "port_5985_open": False, + "port_5986_open": False, + "port_9182_open": False, + "ssh_candidate_users": [ + "ogt", + "wooo", + "ooo", + "administrator", + "Administrator", + ], + "ssh_auth_probed_users": 2, + "ssh_batchmode_auth_ready": False, + "ssh_authenticated_user": "", + "ssh_auth_probe_exit_status": "255", + "remote_verify_attempted": False, + "remote_verify_exit_status": "not_attempted", + "local_verify_script_present": True, + "safe_next_step": ( + "select_existing_authorized_public_key_user_or_set_WINDOWS99_SSH_USERS_" + "then_rerun_collector_no_password" + ), + "blockers": ["windows99_ssh_publickey_auth_missing"], + "secret_value_read": False, + "password_prompt_allowed": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_update_policy_apply_performed": False, + } + runtime_path = tmp_path / "scorecard.json" + runtime_path.write_text(json.dumps(runtime_scorecard), encoding="utf-8") + + payload = load_latest_reboot_auto_recovery_slo_scorecard( + runtime_scorecard_path=runtime_path + ) + + collection = payload["windows99_verify_collection"] + assert collection["no_secret_collector_readback_present"] is True + assert collection["no_secret_collector_status"] == ( + "blocked_ssh_publickey_auth_missing" + ) + assert collection["no_secret_collector_ssh_batchmode_auth_ready"] is False + assert collection["no_secret_collector_port_22_open"] is True + assert "windows99_ssh_publickey_auth_missing" in collection[ + "collection_blockers" + ] + assert payload["readback"]["windows99_no_secret_collector_status"] == ( + "blocked_ssh_publickey_auth_missing" + ) + assert payload["rollups"]["windows99_no_secret_collector_status"] == ( + "blocked_ssh_publickey_auth_missing" + ) + + def test_reboot_auto_recovery_slo_scorecard_overlays_prometheus_runtime_metrics(): payload = load_latest_reboot_auto_recovery_slo_scorecard( prometheus_metric_readback=PROMETHEUS_RUNTIME_READBACK @@ -847,6 +915,11 @@ def _assert_reboot_slo_payload(payload: dict): payload["readback"]["windows99_verify_collection_can_collect_no_secret"] is True ) + assert payload["readback"]["windows99_no_secret_collector_status"] == "unknown" + assert ( + payload["readback"]["windows99_no_secret_collector_readback_present"] + is False + ) assert payload["readback"]["windows99_remote_execution_channel_ready"] is False assert payload["readback"]["windows99_ssh_batch_status"] == "permission_denied" assert payload["rollups"]["active_blocker_count"] == 11 @@ -893,6 +966,11 @@ def _assert_reboot_slo_payload(payload: dict): is True ) assert payload["rollups"]["windows99_verify_collection_blocker_count"] == 2 + assert payload["rollups"]["windows99_no_secret_collector_status"] == "unknown" + assert ( + payload["rollups"]["windows99_no_secret_collector_readback_present"] + is False + ) assert payload["rollups"]["windows99_host99_reachable"] is True assert payload["rollups"]["windows99_host99_uptime_known"] is False assert payload["rollups"]["windows99_management_readback_present"] is True @@ -910,6 +988,10 @@ def _assert_reboot_slo_payload(payload: dict): "rdp_console", "hyperv_vmconnect", ] + assert payload["rollups"]["windows99_available_collection_channels"] == [ + "rdp_console", + "hyperv_vmconnect", + ] assert payload["rollups"]["stockplatform_final_retry_window_passed"] is False assert ( payload["rollups"]["stockplatform_controlled_recovery_gate_required"] @@ -1014,6 +1096,13 @@ def _assert_reboot_slo_payload(payload: dict): assert collection["readback_present"] is False assert collection["verify_ready"] is False assert collection["can_collect_no_secret_verify"] is True + assert collection["no_secret_collector_readback_present"] is False + assert collection["no_secret_collector_status"] == "unknown" + assert collection["no_secret_collector_ssh_batchmode_auth_ready"] is False + assert collection["available_collection_channels"] == [ + "rdp_console", + "hyperv_vmconnect", + ] assert collection["collection_blockers"] == [ "windows99_vmware_autostart_readback_missing", "windows99_uptime_unknown", diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 8df4aed92..0f1ec0df6 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -20,6 +20,18 @@ - 本輪是 UI / API readback 聚合,不寫 KM / RAG / PlayBook target,不呼叫 MCP tool,不發 Telegram,不做 runtime apply;critical / secret / destructive / reboot / firewall / active scan / paid provider / force push 仍維持 break-glass。 - 未讀 secret / token / `.env` / raw sessions / SQLite / auth;未使用 GitHub / gh;未重啟 host / VM / service;未 Docker / Nginx / K3s / DB / firewall restart;未 DROP / TRUNCATE / restore / prune / delete / force push。 +## 2026-07-03 — 05:18 Windows99 no-secret collector readback 上卷 + +**完成內容**: +- `scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py` 新增 `--windows99-vmware-collector-file`,解析 `collect-windows99-vmware-verify.sh --check/--collect` 的 no-secret stdout,將 `blocked_ssh_publickey_auth_missing`、SSH BatchMode readiness、port readback、remote verify attempted、safe next step 與 forbidden actions 納入 `windows99_vmware_verify_collector`。 +- `windows99_verify_collection` packet 新增 `no_secret_collector_*` 與 `available_collection_channels`,讓 SSH publickey channel blocked、RDP / Hyper-V console channel reachable、committed artifact channel present 三者分開呈現;不再把 collector 執行結果留在 artifact 檔案裡。 +- `reboot-auto-recovery-slo-exporter.sh` 會把 collector 失敗 artifact 也餵給 scorecard;只有 stdout 真的含 `AWOOOI_WINDOWS99_VMWARE_AUTOSTART=1` 時才同時作為 VMware Verify artifact,不會用 failed collector output 假裝 VM verifier 綠燈。 +- `/api/v1/agents/reboot-auto-recovery-slo-scorecard` 與 `awoooi-priority-work-order-readback` 同步上卷 no-secret collector readback 到 readback / rollups / summary / evidence。 + +**仍維持**: +- 這是 P0-006 readback / governance truth 修復;尚未宣稱 Windows99 VMX / guest power 已恢復。 +- 未讀 secret / token / `.env` / raw sessions / SQLite / auth;未使用 GitHub / gh;未 workflow_dispatch;未重啟 host / VM / service;未 Docker / Nginx / K3s / DB / firewall restart;未 DROP / TRUNCATE / restore / prune / delete / force push。 + ## 2026-07-03 — 04:24 Public maintenance fallback location-level hardening **完成內容**: diff --git a/scripts/reboot-recovery/reboot-auto-recovery-slo-exporter.sh b/scripts/reboot-recovery/reboot-auto-recovery-slo-exporter.sh index cbbed7af1..1875a2680 100755 --- a/scripts/reboot-recovery/reboot-auto-recovery-slo-exporter.sh +++ b/scripts/reboot-recovery/reboot-auto-recovery-slo-exporter.sh @@ -306,6 +306,9 @@ fi if [ -s "$windows99_management_file" ]; then scorecard_args+=(--windows99-management-file "$windows99_management_file") fi +if [ -s "$windows99_vmware_file" ]; then + scorecard_args+=(--windows99-vmware-collector-file "$windows99_vmware_file") +fi if [ -s "$windows99_vmware_file" ] && grep -q '^AWOOOI_WINDOWS99_VMWARE_AUTOSTART=1$' "$windows99_vmware_file"; then scorecard_args+=(--windows99-vmware-file "$windows99_vmware_file") fi diff --git a/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py b/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py index 08816f2a0..ec757e3fe 100755 --- a/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py +++ b/scripts/reboot-recovery/reboot-auto-recovery-slo-scorecard.py @@ -54,6 +54,11 @@ def parse_args() -> argparse.Namespace: type=Path, help="Optional windows99-vmware-autostart.ps1 Verify output.", ) + parser.add_argument( + "--windows99-vmware-collector-file", + type=Path, + help="Optional collect-windows99-vmware-verify.sh check/collect output.", + ) parser.add_argument( "--windows99-management-file", type=Path, @@ -351,6 +356,103 @@ def parse_windows99_management_readback(path: Path | None) -> dict[str, Any]: } +def parse_windows99_vmware_collector_readback(text: str) -> dict[str, Any]: + """Parse no-secret Windows99 VMware collector check/collect output.""" + default = { + "schema_version": "windows99_vmware_verify_collector_v1", + "readback_present": False, + "status": "missing", + "ssh_batchmode_auth_ready": False, + "safe_next_step": "run_no_secret_collector_check_or_console_validator", + "blockers": [], + "forbidden_actions": [ + "windows_password_or_secret_collection", + "host_reboot", + "vm_power_change", + "windows_update_policy_apply", + ], + } + if not text.strip(): + return default + + kv = parse_kv(text) + schema = str(kv.get("schema_version") or "") + if schema != "windows99_vmware_verify_collector_v1": + invalid = dict(default) + invalid.update( + { + "readback_present": True, + "schema_version": schema or "unknown", + "status": "blocked_invalid_windows99_vmware_collector_readback", + "blockers": ["windows99_no_secret_collector_readback_invalid"], + } + ) + return invalid + + status = str(kv.get("verify_collection_status") or "unknown") + ssh_ready = truthy(kv.get("ssh_batchmode_auth_ready")) + collector_blockers_by_status = { + "blocked_ssh_publickey_auth_missing": "windows99_ssh_publickey_auth_missing", + "blocked_ssh_port_closed": "windows99_ssh_port_closed", + "blocked_local_verify_script_missing": "windows99_local_verify_script_missing", + "blocked_remote_verify_output_invalid": "windows99_remote_verify_output_invalid", + "blocked_remote_verify_command_failed": "windows99_remote_verify_command_failed", + } + blockers: list[str] = [] + if status.startswith("blocked_"): + blockers.append( + collector_blockers_by_status.get( + status, + "windows99_no_secret_collector_not_ready", + ) + ) + + return { + "schema_version": schema, + "readback_present": True, + "status": status, + "dry_run": str(kv.get("dry_run") or "unknown"), + "target_host": str(kv.get("target_host") or "192.168.0.99"), + "target_host_alias": str(kv.get("target_host_alias") or "99"), + "port_22_open": truthy(kv.get("port_22_open")), + "port_3389_open": truthy(kv.get("port_3389_open")), + "port_5985_open": truthy(kv.get("port_5985_open")), + "port_5986_open": truthy(kv.get("port_5986_open")), + "port_9182_open": truthy(kv.get("port_9182_open")), + "ssh_candidate_users": csv_strings(kv.get("ssh_candidate_users")), + "ssh_auth_probed_users": int_value(kv.get("ssh_auth_probed_users")), + "ssh_batchmode_auth_ready": ssh_ready, + "ssh_authenticated_user": str(kv.get("ssh_authenticated_user") or ""), + "ssh_auth_probe_exit_status": str( + kv.get("ssh_auth_probe_exit_status") or "unknown" + ), + "remote_verify_attempted": truthy(kv.get("remote_verify_attempted")), + "remote_verify_exit_status": str( + kv.get("remote_verify_exit_status") or "unknown" + ), + "local_verify_script_present": truthy(kv.get("local_verify_script_present")), + "safe_next_step": str(kv.get("safe_next_step") or ""), + "blockers": blockers, + "secret_value_read": truthy(kv.get("secret_value_read")), + "password_prompt_allowed": truthy(kv.get("password_prompt_allowed")), + "remote_write_performed": truthy(kv.get("remote_write_performed")), + "host_reboot_performed": truthy(kv.get("host_reboot_performed")), + "vm_power_change_performed": truthy(kv.get("vm_power_change_performed")), + "windows_update_policy_apply_performed": truthy( + kv.get("windows_update_policy_apply_performed") + ), + "forbidden_actions": [ + "windows_password_or_secret_collection", + "host_reboot", + "vm_power_change", + "windows_update_policy_apply", + "manual_registry_edit", + "service_restart", + "github_api", + ], + } + + def read_json_object(path: Path | None) -> dict[str, Any]: if not path: return {} @@ -907,13 +1009,27 @@ def build_windows99_verify_collection_packet( *, windows99: dict[str, Any], host_boot_detection: dict[str, Any], + windows99_management: dict[str, Any] | None = None, + windows99_collector: dict[str, Any] | None = None, ) -> dict[str, Any]: """Describe the next no-secret Windows 99 verifier collection step.""" + windows99_management = windows99_management or {} + windows99_collector = windows99_collector or {} host99 = host_row_by_alias(host_boot_detection, "99") host99_reachable = host99.get("reachable") is True host99_uptime_known = int_value(host99.get("uptime_seconds"), -1) >= 0 readback_present = windows99.get("readback_present") is True verify_ready = windows99.get("verify_ready") is True + local_console_reachable = ( + windows99_management.get("local_console_channel_reachable") is True + ) + console_channels = strings(windows99_management.get("console_collection_channels")) + collector_present = windows99_collector.get("readback_present") is True + collector_status = str(windows99_collector.get("status") or "unknown") + collector_ssh_ready = ( + windows99_collector.get("ssh_batchmode_auth_ready") is True + or collector_status == "collected_windows99_vmware_verify_stdout" + ) blockers = strings(windows99.get("blockers")) collection_blockers: list[str] = [] if not host99_reachable: @@ -923,6 +1039,11 @@ def build_windows99_verify_collection_packet( collection_blockers.extend( blocker for blocker in blockers if blocker not in collection_blockers ) + collection_blockers.extend( + blocker + for blocker in strings(windows99_collector.get("blockers")) + if blocker not in collection_blockers + ) if not host99_uptime_known: collection_blockers.append("windows99_uptime_unknown") @@ -933,6 +1054,14 @@ def build_windows99_verify_collection_packet( if host99_reachable and not readback_present else "blocked_windows99_verify_collection_not_ready" ) + available_channels: list[str] = [] + if local_console_reachable: + available_channels.extend(console_channels or ["local_console"]) + if collector_ssh_ready: + available_channels.append("no_secret_ssh_batchmode_verify_collector") + if readback_present: + available_channels.append("committed_no_secret_artifact_file") + available_channels = unique_strings(available_channels) return { "schema_version": "windows99_vmware_verify_collection_packet_v1", "status": status, @@ -942,7 +1071,29 @@ def build_windows99_verify_collection_packet( "host99_uptime_known": host99_uptime_known, "readback_present": readback_present, "verify_ready": verify_ready, - "can_collect_no_secret_verify": host99_reachable and not verify_ready, + "can_collect_no_secret_verify": ( + host99_reachable + and not verify_ready + and ( + local_console_reachable + or collector_ssh_ready + or not collector_present + ) + ), + "available_collection_channels": available_channels, + "no_secret_collector_readback_present": collector_present, + "no_secret_collector_status": collector_status, + "no_secret_collector_safe_next_step": str( + windows99_collector.get("safe_next_step") or "" + ), + "no_secret_collector_ssh_batchmode_auth_ready": collector_ssh_ready, + "no_secret_collector_port_22_open": ( + windows99_collector.get("port_22_open") is True + ), + "no_secret_collector_remote_verify_attempted": ( + windows99_collector.get("remote_verify_attempted") is True + ), + "no_secret_collector": windows99_collector, "required_vm_aliases": strings(windows99.get("required_vm_aliases")) or sorted(WINDOWS99_REQUIRED_VM_ALIASES), "expected_no_secret_output_fields": [ @@ -1104,6 +1255,9 @@ def build_required_checks(payload: dict[str, Any]) -> dict[str, bool]: windows99_management = payload.get("windows99_management_channel") if not isinstance(windows99_management, dict): windows99_management = {} + windows99_collector = payload.get("windows99_vmware_verify_collector") + if not isinstance(windows99_collector, dict): + windows99_collector = {} ssh_batch = windows99_management.get("ssh_batch") if not isinstance(ssh_batch, dict): ssh_batch = {} @@ -1548,6 +1702,9 @@ def enrich_machine_readback(payload: dict[str, Any]) -> dict[str, Any]: windows99_management = payload.get("windows99_management_channel") if not isinstance(windows99_management, dict): windows99_management = {} + windows99_collector = payload.get("windows99_vmware_verify_collector") + if not isinstance(windows99_collector, dict): + windows99_collector = {} ssh_batch = windows99_management.get("ssh_batch") if not isinstance(ssh_batch, dict): ssh_batch = {} @@ -1587,6 +1744,8 @@ def enrich_machine_readback(payload: dict[str, Any]) -> dict[str, Any]: windows99_verify_collection = build_windows99_verify_collection_packet( windows99=windows99, host_boot_detection=host_boot_detection, + windows99_management=windows99_management, + windows99_collector=windows99_collector, ) rollups = { @@ -1694,6 +1853,36 @@ def enrich_machine_readback(payload: dict[str, Any]) -> dict[str, Any]: "windows99_verify_collection_blocker_count": len( strings(windows99_verify_collection.get("collection_blockers")) ), + "windows99_no_secret_collector_readback_present": ( + windows99_verify_collection.get("no_secret_collector_readback_present") + is True + ), + "windows99_no_secret_collector_status": str( + windows99_verify_collection.get("no_secret_collector_status") or "unknown" + ), + "windows99_no_secret_collector_ssh_batchmode_auth_ready": ( + windows99_verify_collection.get( + "no_secret_collector_ssh_batchmode_auth_ready" + ) + is True + ), + "windows99_no_secret_collector_port_22_open": ( + windows99_verify_collection.get("no_secret_collector_port_22_open") + is True + ), + "windows99_no_secret_collector_remote_verify_attempted": ( + windows99_verify_collection.get( + "no_secret_collector_remote_verify_attempted" + ) + is True + ), + "windows99_no_secret_collector_safe_next_step": str( + windows99_verify_collection.get("no_secret_collector_safe_next_step") + or "" + ), + "windows99_available_collection_channels": strings( + windows99_verify_collection.get("available_collection_channels") + ), "windows99_host99_reachable": ( windows99_verify_collection["host99_reachable"] is True ), @@ -1764,6 +1953,21 @@ def enrich_machine_readback(payload: dict[str, Any]) -> dict[str, Any]: "windows99_verify_collection_can_collect_no_secret": rollups[ "windows99_verify_collection_can_collect_no_secret" ], + "windows99_no_secret_collector_readback_present": rollups[ + "windows99_no_secret_collector_readback_present" + ], + "windows99_no_secret_collector_status": rollups[ + "windows99_no_secret_collector_status" + ], + "windows99_no_secret_collector_ssh_batchmode_auth_ready": rollups[ + "windows99_no_secret_collector_ssh_batchmode_auth_ready" + ], + "windows99_no_secret_collector_safe_next_step": rollups[ + "windows99_no_secret_collector_safe_next_step" + ], + "windows99_available_collection_channels": rollups[ + "windows99_available_collection_channels" + ], "windows99_remote_execution_channel_ready": rollups[ "windows99_remote_execution_channel_ready" ], @@ -1837,6 +2041,12 @@ def enrich_machine_readback(payload: dict[str, Any]) -> dict[str, Any]: "reboot_auto_recovery_windows99_verify_collection_can_collect_no_secret": ( rollups["windows99_verify_collection_can_collect_no_secret"] ), + "reboot_auto_recovery_windows99_no_secret_collector_status": rollups[ + "windows99_no_secret_collector_status" + ], + "reboot_auto_recovery_windows99_no_secret_collector_ssh_batchmode_auth_ready": ( + rollups["windows99_no_secret_collector_ssh_batchmode_auth_ready"] + ), "secret_values_collected": False, "github_api_used": False, "workflow_trigger_performed": False, @@ -1878,6 +2088,9 @@ def build_scorecard(args: argparse.Namespace) -> dict[str, Any]: windows99_management = parse_windows99_management_readback( args.windows99_management_file ) + windows99_collector = parse_windows99_vmware_collector_readback( + read_text(args.windows99_vmware_collector_file) + ) controls = source_controls() free_gib = disk_free_gib(args.disk_path) @@ -2051,6 +2264,7 @@ def build_scorecard(args: argparse.Namespace) -> dict[str, Any]: "public_maintenance_fallback": public_maintenance, "windows99_vmware_autostart": windows99, "windows99_management_channel": windows99_management, + "windows99_vmware_verify_collector": windows99_collector, "capacity": { "checked": free_gib is not None, "free_gib": round(free_gib, 3) if free_gib is not None else None, diff --git a/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py b/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py index 466e4b679..6117523b8 100644 --- a/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py +++ b/scripts/reboot-recovery/tests/test_reboot_auto_recovery_slo_scorecard.py @@ -172,6 +172,43 @@ WINDOWS99_MANAGEMENT_BLOCKED = { } +WINDOWS99_COLLECTOR_PUBLICKEY_BLOCKED = """\ +schema_version=windows99_vmware_verify_collector_v1 +dry_run=true +target_host=192.168.0.99 +target_host_alias=99 +connect_timeout_seconds=3 +ssh_timeout_seconds=3 +remote_verify_timeout_seconds=45 +port_timeout_wrapper=timeout +ssh_auth_probe_user_limit=2 +ssh_timeout_wrapper=timeout +port_22_open=1 +port_3389_open=1 +port_5985_open=0 +port_5986_open=0 +port_9182_open=0 +ssh_candidate_users=ogt,wooo,ooo,administrator,Administrator +ssh_auth_probed_users=2 +ssh_batchmode_auth_ready=0 +ssh_authenticated_user= +ssh_auth_probe_exit_status=255 +ssh_auth_probe_stdout_present=0 +remote_verify_mode=in_memory_stdin_scriptblock +local_verify_script_present=1 +remote_verify_attempted=0 +remote_verify_exit_status=not_attempted +verify_collection_status=blocked_ssh_publickey_auth_missing +safe_next_step=select_existing_authorized_public_key_user_or_set_WINDOWS99_SSH_USERS_then_rerun_collector_no_password +secret_value_read=false +password_prompt_allowed=false +remote_write_performed=false +host_reboot_performed=false +vm_power_change_performed=false +windows_update_policy_apply_performed=false +""" + + def run_scorecard( tmp_path: Path, summary: str, @@ -179,11 +216,13 @@ def run_scorecard( windows99: str = WINDOWS99_VMWARE_GREEN, public_maintenance: dict | None = PUBLIC_MAINTENANCE_GREEN, windows99_management: str | None = None, + windows99_collector: str | None = None, ) -> dict: summary_path = tmp_path / "summary.txt" probe_path = tmp_path / "probe.txt" reboot_event_path = tmp_path / "reboot-event.json" windows99_path = tmp_path / "windows99-vmware.txt" + windows99_collector_path = tmp_path / "windows99-vmware-collector.txt" public_maintenance_path = tmp_path / "public-maintenance.json" summary_path.write_text(summary, encoding="utf-8") probe_path.write_text(probe, encoding="utf-8") @@ -213,6 +252,11 @@ def run_scorecard( if windows99_management is not None: windows99_management_path.write_text(windows99_management, encoding="utf-8") args.extend(["--windows99-management-file", str(windows99_management_path)]) + if windows99_collector is not None: + windows99_collector_path.write_text(windows99_collector, encoding="utf-8") + args.extend( + ["--windows99-vmware-collector-file", str(windows99_collector_path)] + ) result = subprocess.run( args, text=True, @@ -554,6 +598,55 @@ def test_windows99_management_channel_unavailable_is_visible(tmp_path: Path) -> assert payload["readback"]["windows99_remote_execution_channel_ready"] is False +def test_windows99_no_secret_collector_publickey_blocker_is_visible( + tmp_path: Path, +) -> None: + payload = run_scorecard( + tmp_path, + GREEN_SUMMARY, + windows99="", + windows99_management=json.dumps(WINDOWS99_MANAGEMENT_BLOCKED), + windows99_collector=WINDOWS99_COLLECTOR_PUBLICKEY_BLOCKED, + ) + + collection = payload["windows99_verify_collection"] + assert collection["no_secret_collector_readback_present"] is True + assert collection["no_secret_collector_status"] == ( + "blocked_ssh_publickey_auth_missing" + ) + assert collection["no_secret_collector_ssh_batchmode_auth_ready"] is False + assert collection["no_secret_collector_port_22_open"] is True + assert collection["no_secret_collector_remote_verify_attempted"] is False + assert collection["no_secret_collector"]["ssh_auth_probed_users"] == 2 + assert collection["no_secret_collector"]["ssh_candidate_users"] == [ + "ogt", + "wooo", + "ooo", + "administrator", + "Administrator", + ] + assert "windows99_ssh_publickey_auth_missing" in collection[ + "collection_blockers" + ] + assert collection["can_collect_no_secret_verify"] is True + assert collection["available_collection_channels"] == [ + "rdp_console", + "hyperv_vmconnect", + ] + assert payload["rollups"]["windows99_no_secret_collector_status"] == ( + "blocked_ssh_publickey_auth_missing" + ) + assert ( + payload["rollups"][ + "windows99_no_secret_collector_ssh_batchmode_auth_ready" + ] + is False + ) + assert payload["readback"]["windows99_no_secret_collector_status"] == ( + "blocked_ssh_publickey_auth_missing" + ) + + def test_degraded_wazuh_and_old_boot_observation_block_slo(tmp_path: Path) -> None: summary = GREEN_SUMMARY.replace("WAZUH_DASHBOARD_DEGRADED=0", "WAZUH_DASHBOARD_DEGRADED=1") probe = HOST_PROBE_GREEN.replace("uptime_seconds=150", "uptime_seconds=900")