fix(windows99): validate vmx candidate confirmation
All checks were successful
CD Pipeline / workflow-shape (push) Successful in 1s
CD Pipeline / cancel-stale-cd (push) Has been skipped
CD Pipeline / tests (push) Successful in 3m16s
AWOOOI Harbor 110 Local Repair / workflow-shape (push) Successful in 1s
AWOOOI Harbor 110 Local Repair / harbor-110-local-repair (push) Successful in 35s
CD Pipeline / build-and-deploy (push) Successful in 5m3s
CD Pipeline / post-deploy-checks (push) Successful in 2m0s

This commit is contained in:
ogt
2026-07-10 00:15:31 +08:00
parent a1ec18b9ba
commit e150a85a6d
5 changed files with 454 additions and 0 deletions

View File

@@ -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],

View File

@@ -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],
*,

View File

@@ -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