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
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:
@@ -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],
|
||||
|
||||
@@ -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],
|
||||
*,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user