diff --git a/.gitea/workflows/cd.yaml b/.gitea/workflows/cd.yaml index a1e7b9572..d2e5efacb 100644 --- a/.gitea/workflows/cd.yaml +++ b/.gitea/workflows/cd.yaml @@ -408,6 +408,8 @@ jobs: ;; apps/api/src/services/reboot_auto_recovery_slo_scorecard.py) ;; + apps/api/src/services/windows99_vmx_source_locator_readback.py) + ;; apps/api/src/services/reboot_auto_recovery_drill_preflight.py) ;; apps/api/src/services/stockplatform_public_api_runtime_readback.py) @@ -550,6 +552,8 @@ jobs: ;; apps/api/tests/test_reboot_auto_recovery_slo_scorecard_api.py) ;; + apps/api/tests/test_windows99_vmx_source_locator_readback_api.py) + ;; apps/api/tests/test_stockplatform_public_api_runtime_readback.py) ;; apps/api/tests/test_stockplatform_public_api_controlled_recovery_preflight.py) @@ -1037,6 +1041,7 @@ jobs: tests/test_gitea_private_inventory_p0_scorecard_api.py \ tests/test_gitea_workflow_runner_owner_attestation_request_api.py \ tests/test_reboot_auto_recovery_slo_scorecard_api.py \ + tests/test_windows99_vmx_source_locator_readback_api.py \ tests/test_stockplatform_public_api_runtime_readback.py \ tests/test_stockplatform_public_api_controlled_recovery_preflight.py \ tests/test_harbor_registry_controlled_recovery_preflight.py \ diff --git a/apps/api/src/api/v1/agents.py b/apps/api/src/api/v1/agents.py index e2214579d..28395190f 100644 --- a/apps/api/src/api/v1/agents.py +++ b/apps/api/src/api/v1/agents.py @@ -506,6 +506,7 @@ from src.services.windows99_vmx_source_backup_search_package import ( ) from src.services.windows99_vmx_source_locator_readback import ( load_latest_windows99_vmx_source_locator_readback, + validate_windows99_vmx_source_candidate_confirmation_packet, ) from src.services.windows99_vmx_source_repair_package import ( load_latest_windows99_vmx_source_repair_package, @@ -1707,6 +1708,39 @@ async def get_windows99_vmx_source_locator_readback() -> dict[str, Any]: ) from exc +@router.post( + "/windows99-vmx-source-locator-readback/validate-candidate-confirmation", + response_model=dict[str, Any], + summary="驗證 Windows99 VMX source candidate confirmation packet", + description=( + "針對 P0-006 Windows99 VMX source locator 的單一 redacted candidate " + "identity evidence packet 做 no-persist validation。此端點只判斷是否" + "足以準備 scoped relink dry-run;不保存 payload、不回傳 raw VMX path、" + "不讀 VMX 內容、不讀 secret、不 SSH、不啟停 VM、不寫 Windows、" + "不改 registry/task、不開 apply gate。" + ), +) +async def validate_windows99_vmx_source_candidate_confirmation( + candidate_confirmation_payload: dict[str, Any], +) -> dict[str, Any]: + """Return no-persist validation for one Windows99 candidate packet.""" + try: + payload = await asyncio.to_thread( + validate_windows99_vmx_source_candidate_confirmation_packet, + candidate_confirmation_payload, + ) + return redact_public_lan_topology(payload) + except (json.JSONDecodeError, ValueError) as exc: + logger.error( + "windows99_vmx_source_candidate_confirmation_invalid", + error=str(exc), + ) + raise HTTPException( + status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, + detail="Windows99 VMX source candidate confirmation 驗證器無效", + ) from exc + + @router.get( "/windows99-vmx-source-backup-search-package", response_model=dict[str, Any], diff --git a/apps/api/src/services/windows99_vmx_source_locator_readback.py b/apps/api/src/services/windows99_vmx_source_locator_readback.py index 60773dca3..71d00900c 100644 --- a/apps/api/src/services/windows99_vmx_source_locator_readback.py +++ b/apps/api/src/services/windows99_vmx_source_locator_readback.py @@ -29,9 +29,67 @@ _RECEIPT_SCHEMA_VERSION = "windows99_vmx_source_locator_receipt_v1" _CANDIDATE_CONFIRMATION_SCHEMA_VERSION = ( "windows99_vmx_source_candidate_confirmation_gate_v1" ) +_CANDIDATE_CONFIRMATION_VALIDATION_SCHEMA_VERSION = ( + "windows99_vmx_source_candidate_confirmation_validation_v1" +) +_CANDIDATE_CONFIRMATION_PACKET_SCHEMA_VERSION = ( + "windows99_vmx_source_candidate_confirmation_packet_v1" +) _RECEIPT_FILE = "windows99-vmx-source-locator-receipt.snapshot.json" _COLLECTOR_SCRIPT_PATH = "scripts/reboot-recovery/locate-windows99-vmx-source.sh" _LOCATOR_SCRIPT_PATH = "scripts/reboot-recovery/windows99-vmx-source-locator.ps1" +_CANDIDATE_CONFIRMATION_REQUIRED_FIELDS = [ + "schema_version", + "target_host_alias", + "target_vm_aliases", + "candidate_identity_kind", + "candidate_identity_evidence_ref", + "candidate_match_count", + "redacted_metadata_only", + "bounded_identity_metadata_only", + "path_fingerprint_only", + "raw_path_output", + "secret_value_read", + "remote_write_performed", + "host_reboot_performed", + "vm_power_change_performed", + "windows_registry_apply_performed", + "scheduled_task_write_performed", + "apply_requested", + "vm_power_change_requested", +] +_CANDIDATE_CONFIRMATION_ALLOWED_KINDS = { + "target_alias_hint", + "authorized_console_identity", + "verified_backup_path_fingerprint", +} +_CANDIDATE_CONFIRMATION_FORBIDDEN_ACTIONS = [ + "raw_vmx_path_output", + "vmx_file_content_read", + "secret_value_read", + "password_or_token_collection", + "remote_write", + "vm_power_change", + "windows_registry_apply", + "scheduled_task_write", + "host_reboot", + "runtime_apply", +] +_CANDIDATE_CONFIRMATION_FORBIDDEN_FRAGMENTS = [ + "192.168.0.", + "D:\\", + "C:\\", + "E:\\", + "/Users/", + "/Volumes/", + "uuid.", + "generatedAddress", + "BEGIN PRIVATE KEY", + "Authorization:", + "password=", + "token=", + "secret_value", +] def _dict(value: Any) -> dict[str, Any]: @@ -60,6 +118,31 @@ def _int(value: Any, default: int | None = None) -> int | None: return default +def _walk_strings(value: Any) -> list[str]: + if isinstance(value, dict): + values: list[str] = [] + for nested in value.values(): + values.extend(_walk_strings(nested)) + return values + if isinstance(value, list): + values = [] + for item in value: + values.extend(_walk_strings(item)) + return values + if isinstance(value, str): + return [value] + return [] + + +def _forbidden_fragments(value: dict[str, Any]) -> list[str]: + text_values = _walk_strings(value) + hits = [] + for fragment in _CANDIDATE_CONFIRMATION_FORBIDDEN_FRAGMENTS: + if any(fragment in text for text in text_values): + hits.append(fragment) + return hits + + def _load_locator_receipt(operations_dir: Path | None = None) -> dict[str, Any]: path = (operations_dir or _DEFAULT_OPERATIONS_DIR) / _RECEIPT_FILE if not path.exists(): @@ -206,6 +289,133 @@ def _build_candidate_confirmation_gate( } +def validate_windows99_vmx_source_candidate_confirmation_packet( + packet: dict[str, Any], + *, + locator_readback: dict[str, Any] | None = None, +) -> dict[str, Any]: + """Validate one redacted Windows99 candidate-confirmation packet. + + This is a no-persist validator. It never reads VMX contents, exposes raw + paths, writes Windows state, starts/stops VMs, reboots hosts, or opens an + apply gate. + """ + locator = locator_readback or load_latest_windows99_vmx_source_locator_readback() + current_target_aliases = _strings(locator.get("target_vm_aliases")) + submitted_aliases = _strings(packet.get("target_vm_aliases")) + missing_fields = [ + field + for field in _CANDIDATE_CONFIRMATION_REQUIRED_FIELDS + if field not in packet + ] + rejected_fields: list[str] = [] + forbidden_fragments = _forbidden_fragments(packet) + + if packet.get("schema_version") != _CANDIDATE_CONFIRMATION_PACKET_SCHEMA_VERSION: + rejected_fields.append("schema_version") + if str(packet.get("target_host_alias") or "") != "99": + rejected_fields.append("target_host_alias") + if not submitted_aliases or submitted_aliases != current_target_aliases: + rejected_fields.append("target_vm_aliases") + if str(packet.get("candidate_identity_kind") or "") not in ( + _CANDIDATE_CONFIRMATION_ALLOWED_KINDS + ): + rejected_fields.append("candidate_identity_kind") + if str(packet.get("candidate_identity_evidence_ref") or "").strip() == "": + rejected_fields.append("candidate_identity_evidence_ref") + if _int(packet.get("candidate_match_count"), 0) != 1: + rejected_fields.append("candidate_match_count") + + required_true_flags = [ + "redacted_metadata_only", + "bounded_identity_metadata_only", + "path_fingerprint_only", + ] + required_false_flags = [ + "raw_path_output", + "secret_value_read", + "remote_write_performed", + "host_reboot_performed", + "vm_power_change_performed", + "windows_registry_apply_performed", + "scheduled_task_write_performed", + "apply_requested", + "vm_power_change_requested", + ] + for field in required_true_flags: + if packet.get(field) is not True: + rejected_fields.append(field) + for field in required_false_flags: + if packet.get(field, False) is not False: + rejected_fields.append(field) + if forbidden_fragments: + rejected_fields.append("forbidden_fragments") + + accepted = not missing_fields and not rejected_fields + status = ( + "accepted_for_scoped_relink_dry_run_preparation_only" + if accepted + else "rejected_candidate_confirmation_packet" + ) + next_safe_step = ( + "build_scoped_relink_dry_run_no_write_then_post_verifier" + if accepted + else "submit_redacted_single_candidate_identity_evidence_without_raw_paths" + ) + + return { + "schema_version": _CANDIDATE_CONFIRMATION_VALIDATION_SCHEMA_VERSION, + "status": status, + "accepted": accepted, + "target_host_alias": "99", + "target_vm_aliases": current_target_aliases, + "current_locator_status": str(locator.get("status") or ""), + "current_candidate_confirmation_status": str( + locator.get("candidate_confirmation_status") or "" + ), + "submitted_candidate_identity_kind": str( + packet.get("candidate_identity_kind") or "" + ), + "submitted_candidate_match_count": _int( + packet.get("candidate_match_count"), 0 + ), + "missing_fields": missing_fields, + "rejected_fields": rejected_fields, + "forbidden_fragment_count": len(forbidden_fragments), + "forbidden_actions": _CANDIDATE_CONFIRMATION_FORBIDDEN_ACTIONS, + "projected_confirmation_gate": { + "schema_version": _CANDIDATE_CONFIRMATION_SCHEMA_VERSION, + "status": ( + "candidate_identity_confirmed_ready_for_scoped_relink_dry_run" + if accepted + else "blocked_candidate_identity_evidence_not_accepted" + ), + "can_prepare_scoped_relink_dry_run": accepted, + "apply_allowed_by_confirmation_gate": False, + "vm_power_change_allowed": False, + "remote_write_performed": False, + "vm_power_change_performed": False, + "secret_value_read": False, + "safe_next_step": next_safe_step, + }, + "operation_boundaries": { + "no_persist_validator": True, + "request_payload_saved": False, + "secret_value_read": False, + "raw_path_output": False, + "file_content_read": False, + "password_prompt_allowed": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + "apply_gate_opened": False, + }, + "next_safe_step": next_safe_step, + } + + def build_windows99_vmx_source_locator_readback( scorecard: dict[str, Any], *, diff --git a/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py b/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py index c99606983..c971ebab3 100644 --- a/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py +++ b/apps/api/tests/test_windows99_vmx_source_locator_readback_api.py @@ -12,6 +12,7 @@ from src.api.v1 import agents from src.api.v1.agents import router from src.services.windows99_vmx_source_locator_readback import ( build_windows99_vmx_source_locator_readback, + validate_windows99_vmx_source_candidate_confirmation_packet, ) from src.services.windows99_vmx_source_repair_package import ( build_windows99_vmx_source_repair_package, @@ -202,6 +203,131 @@ def test_windows99_vmx_source_locator_builder_projects_redacted_receipt() -> Non assert payload["vm_power_change_performed"] is False +def test_windows99_candidate_confirmation_validator_accepts_redacted_packet() -> None: + repair_package = build_windows99_vmx_source_repair_package( + _scorecard(), + generated_at="2026-07-03T11:21:00+08:00", + ) + locator = build_windows99_vmx_source_locator_readback( + _scorecard(), + repair_package=repair_package, + collector_receipt={ + "schema_version": "windows99_vmx_source_locator_receipt_v1", + "collector_readback_present": True, + "collector_collection_status": ( + "collected_windows99_vmx_source_locator_stdout" + ), + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "expected_vmx_path_present_count": 0, + "candidate_source_count": 1, + "candidate_source_fingerprint_count": 1, + "alias_hint_candidate_count": 0, + "unassigned_ubuntu_candidate_count": 0, + "identity_metadata_read": True, + "bounded_identity_metadata_only": True, + "identity_target_hint_count": 1, + "identity_unassigned_ubuntu_candidate_count": 0, + "locate_status": "single_identity_candidate_requires_confirmation", + }, + ) + + payload = validate_windows99_vmx_source_candidate_confirmation_packet( + { + "schema_version": "windows99_vmx_source_candidate_confirmation_packet_v1", + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "candidate_identity_kind": "authorized_console_identity", + "candidate_identity_evidence_ref": ( + "console://awoooi/windows99/candidate-identity-redacted-111" + ), + "candidate_match_count": 1, + "redacted_metadata_only": True, + "bounded_identity_metadata_only": True, + "path_fingerprint_only": True, + "raw_path_output": False, + "secret_value_read": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + "apply_requested": False, + "vm_power_change_requested": False, + }, + locator_readback=locator, + ) + + assert payload["schema_version"] == ( + "windows99_vmx_source_candidate_confirmation_validation_v1" + ) + assert payload["status"] == ( + "accepted_for_scoped_relink_dry_run_preparation_only" + ) + assert payload["accepted"] is True + assert payload["missing_fields"] == [] + assert payload["rejected_fields"] == [] + assert payload["projected_confirmation_gate"][ + "can_prepare_scoped_relink_dry_run" + ] is True + assert payload["projected_confirmation_gate"][ + "apply_allowed_by_confirmation_gate" + ] is False + assert payload["operation_boundaries"]["request_payload_saved"] is False + assert payload["operation_boundaries"]["remote_write_performed"] is False + assert payload["operation_boundaries"]["vm_power_change_performed"] is False + + +def test_windows99_candidate_confirmation_validator_rejects_sensitive_payload() -> None: + locator = build_windows99_vmx_source_locator_readback( + _scorecard(), + collector_receipt={ + "schema_version": "windows99_vmx_source_locator_receipt_v1", + "collector_readback_present": True, + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "candidate_source_count": 1, + "identity_metadata_read": True, + "bounded_identity_metadata_only": True, + "locate_status": "single_identity_candidate_requires_confirmation", + }, + ) + + payload = validate_windows99_vmx_source_candidate_confirmation_packet( + { + "schema_version": "windows99_vmx_source_candidate_confirmation_packet_v1", + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "candidate_identity_kind": "authorized_console_identity", + "candidate_identity_evidence_ref": "D:\\raw\\path\\vm.vmx", + "candidate_match_count": 1, + "redacted_metadata_only": True, + "bounded_identity_metadata_only": True, + "path_fingerprint_only": True, + "raw_path_output": True, + "secret_value_read": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + "apply_requested": False, + "vm_power_change_requested": False, + }, + locator_readback=locator, + ) + + assert payload["accepted"] is False + assert payload["status"] == "rejected_candidate_confirmation_packet" + assert "raw_path_output" in payload["rejected_fields"] + assert "forbidden_fragments" in payload["rejected_fields"] + assert payload["forbidden_fragment_count"] >= 1 + assert payload["projected_confirmation_gate"][ + "can_prepare_scoped_relink_dry_run" + ] is False + assert payload["operation_boundaries"]["apply_gate_opened"] is False + + def test_windows99_vmx_source_locator_endpoint_returns_public_safe_readback( monkeypatch: pytest.MonkeyPatch, ) -> None: @@ -261,3 +387,71 @@ def test_windows99_vmx_source_locator_endpoint_returns_public_safe_readback( assert "D:\\" not in response.text assert "uuid." not in response.text assert "generatedAddress" not in response.text + + +def test_windows99_candidate_confirmation_endpoint_is_no_persist_validator( + monkeypatch: pytest.MonkeyPatch, +) -> None: + repair_package = build_windows99_vmx_source_repair_package(_scorecard()) + locator = build_windows99_vmx_source_locator_readback( + _scorecard(), + repair_package=repair_package, + collector_receipt={ + "schema_version": "windows99_vmx_source_locator_receipt_v1", + "collector_readback_present": True, + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "candidate_source_count": 1, + "identity_metadata_read": True, + "bounded_identity_metadata_only": True, + "locate_status": "single_identity_candidate_requires_confirmation", + }, + ) + monkeypatch.setattr( + agents, + "validate_windows99_vmx_source_candidate_confirmation_packet", + lambda packet: validate_windows99_vmx_source_candidate_confirmation_packet( + packet, + locator_readback=locator, + ), + ) + app = FastAPI() + app.include_router(router, prefix="/api/v1") + client = TestClient(app) + + response = client.post( + "/api/v1/agents/windows99-vmx-source-locator-readback/" + "validate-candidate-confirmation", + json={ + "schema_version": "windows99_vmx_source_candidate_confirmation_packet_v1", + "target_host_alias": "99", + "target_vm_aliases": ["111"], + "candidate_identity_kind": "verified_backup_path_fingerprint", + "candidate_identity_evidence_ref": ( + "backup://awoooi/windows99/redacted-fingerprint-111" + ), + "candidate_match_count": 1, + "redacted_metadata_only": True, + "bounded_identity_metadata_only": True, + "path_fingerprint_only": True, + "raw_path_output": False, + "secret_value_read": False, + "remote_write_performed": False, + "host_reboot_performed": False, + "vm_power_change_performed": False, + "windows_registry_apply_performed": False, + "scheduled_task_write_performed": False, + "apply_requested": False, + "vm_power_change_requested": False, + }, + ) + + assert response.status_code == 200 + payload = response.json() + assert payload["accepted"] is True + assert payload["operation_boundaries"]["no_persist_validator"] is True + assert payload["operation_boundaries"]["request_payload_saved"] is False + assert payload["operation_boundaries"]["apply_gate_opened"] is False + assert payload["operation_boundaries"]["vm_power_change_performed"] is False + assert "D:\\" not in response.text + assert "192.168.0." not in response.text diff --git a/ops/runner/test_cd_controlled_runtime_profile.py b/ops/runner/test_cd_controlled_runtime_profile.py index 54b06e3d6..0c0c14538 100644 --- a/ops/runner/test_cd_controlled_runtime_profile.py +++ b/ops/runner/test_cd_controlled_runtime_profile.py @@ -135,6 +135,17 @@ def test_prod_db_pool_budget_and_non_overlap_rollouts_are_source_controlled() -> assert "maxUnavailable: 1" in canary +def test_windows99_vmx_locator_sources_stay_on_controlled_runtime_profile() -> None: + text = _workflow_text() + expected_sources = [ + "apps/api/src/services/windows99_vmx_source_locator_readback.py)", + "apps/api/tests/test_windows99_vmx_source_locator_readback_api.py)", + "tests/test_windows99_vmx_source_locator_readback_api.py", + ] + for source in expected_sources: + assert source in text + + def test_workflow_secret_transport_sources_stay_on_controlled_runtime_profile() -> None: text = _workflow_text() assert "workflow secret-transport and guard-only" in text