Add integration tests based on VM

This commit is contained in:
2026-10-26 22:34:00 +02:00
parent f4a6540df9
commit 7d0f006601
22 changed files with 3826 additions and 69 deletions
+385
View File
@@ -0,0 +1,385 @@
#!/usr/bin/env python3
"""Fail-closed host guard for the single M9 VirtualBox target.
This is deliberately not a general VirtualBox wrapper. Its only possible
state-changing action is starting the one named lab VM after the full host
identity check. Guest privileged actions remain an explicit later step and
must be preceded by ``authorize-guest`` after the guest marker is installed.
"""
from __future__ import annotations
import argparse
import json
import os
from pathlib import Path
import re
import selectors
import shlex
import stat
import subprocess
import sys
import time
from typing import Callable, Sequence
import uuid
LAB_ROOT = Path("/media/data/wm/Virtualbox/wga-m9-lab")
TARGET_NAME = "wga-m9-debian137-20261025"
IDENTITY_FILE = "target-identity.json"
EXPECTED_ISO = "debian-13.7.0-amd64-netinst.iso"
EXPECTED_ISO_SHA256 = "a7ef94ac2fb9a7fec454552abd629b7cc9d5155c886165a45649f5ce6167e355"
EXPECTED_GUEST_MARKER = "wga-m9-debian137-20261025"
GUEST_MARKER_PATH = "/etc/wga-m9-lab-marker"
MAX_CAPTURE = 1024 * 1024
class GuardError(Exception):
"""A target failed one of the explicit identity/isolation checks."""
Runner = Callable[[Sequence[str]], subprocess.CompletedProcess[str]]
def run_command(argv: Sequence[str]) -> subprocess.CompletedProcess[str]:
process = subprocess.Popen(
list(argv),
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env={"PATH": os.environ.get("PATH", "/usr/bin:/bin"), "LANG": "C"},
)
assert process.stdout is not None and process.stderr is not None
output = {process.stdout: bytearray(), process.stderr: bytearray()}
selector = selectors.DefaultSelector()
for stream in output:
os.set_blocking(stream.fileno(), False)
selector.register(stream, selectors.EVENT_READ)
deadline = time.monotonic() + 15
try:
while selector.get_map():
remaining = deadline - time.monotonic()
if remaining <= 0:
raise subprocess.TimeoutExpired(argv, 15)
for key, _ in selector.select(remaining):
stream = key.fileobj
chunk = os.read(stream.fileno(), min(65536, MAX_CAPTURE + 1 - len(output[stream])))
if not chunk:
selector.unregister(stream)
continue
output[stream].extend(chunk)
if len(output[stream]) > MAX_CAPTURE:
raise GuardError("VBoxManage output exceeded the guard limit")
returncode = process.wait(timeout=max(0.0, deadline - time.monotonic()))
except (OSError, subprocess.TimeoutExpired, GuardError):
process.kill()
try:
process.wait(timeout=1)
except subprocess.TimeoutExpired:
pass
raise
finally:
selector.close()
process.stdout.close()
process.stderr.close()
return subprocess.CompletedProcess(
argv,
returncode,
bytes(output[process.stdout]).decode("utf-8", errors="replace"),
bytes(output[process.stderr]).decode("utf-8", errors="replace"),
)
def _machine_values(output: str) -> dict[str, str]:
"""Parse VBoxManage key=value output without evaluating its values."""
values: dict[str, str] = {}
for line in output.splitlines():
if not line.strip():
continue
# VBoxManage groups recording settings under section markers in
# machine-readable output. These are the only non-key/value rows we
# accept; keep rejecting all other unexpected text.
if re.fullmatch(r" rec_screen[0-9]+", line):
continue
if "=" not in line:
raise GuardError("malformed VBoxManage machine-readable line")
raw_key, raw = line.split("=", 1)
raw_key = raw_key.strip()
if raw_key.startswith('"') and raw_key.endswith('"'):
key = raw_key[1:-1]
elif '"' in raw_key:
raise GuardError("unrecognized VBoxManage machine-readable key")
else:
key = raw_key
if not re.fullmatch(r"[A-Za-z][A-Za-z0-9_()\-]*", key):
raise GuardError("unrecognized VBoxManage machine-readable key")
if key in values:
raise GuardError(f"duplicate VBoxManage machine-readable key: {key}")
try:
value = shlex.split(raw, posix=True)
except ValueError as exc:
raise GuardError(f"malformed VBoxManage value for {key}") from exc
if len(value) != 1:
raise GuardError(f"ambiguous VBoxManage value for {key}")
values[key] = value[0]
return values
def _owned_private_directory(path: Path, owner_uid: int) -> None:
try:
info = path.lstat()
except OSError as exc:
raise GuardError(f"lab directory unavailable: {path}") from exc
if not stat.S_ISDIR(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise GuardError("lab root must be a real directory")
if info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o700:
raise GuardError("lab root must be owned by the current user and mode 0700")
def _read_manifest(path: Path, owner_uid: int) -> dict[str, object]:
try:
info = path.lstat()
except OSError as exc:
raise GuardError("target identity manifest is missing") from exc
if not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode):
raise GuardError("target identity manifest must be a regular file")
if info.st_uid != owner_uid or stat.S_IMODE(info.st_mode) != 0o600:
raise GuardError("target identity manifest must be current-user-owned mode 0600")
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
raise GuardError("target identity manifest is unreadable or invalid") from exc
if not isinstance(raw, dict) or raw.get("schema") != 1:
raise GuardError("unsupported target identity manifest")
if raw.get("name") != TARGET_NAME or raw.get("owner_uid") != owner_uid:
raise GuardError("manifest owner or exact VM name does not match")
try:
target_uuid = str(uuid.UUID(str(raw.get("uuid"))))
except (ValueError, AttributeError) as exc:
raise GuardError("manifest UUID is missing or invalid") from exc
expected_marker = f"{TARGET_NAME}:{target_uuid}"
if raw.get("host_marker") != expected_marker:
raise GuardError("manifest host marker does not match its UUID")
return {"uuid": target_uuid, "host_marker": expected_marker}
def _invoke(runner: Runner, argv: Sequence[str]) -> str:
try:
result = runner(argv)
except (OSError, subprocess.TimeoutExpired) as exc:
raise GuardError(f"bounded VBoxManage call failed: {argv[1]}") from exc
if result.returncode != 0:
raise GuardError(f"VBoxManage {argv[1]} failed with status {result.returncode}")
if len(result.stdout) > MAX_CAPTURE or len(result.stderr) > MAX_CAPTURE:
raise GuardError("VBoxManage output exceeded the guard limit")
return result.stdout
def _check_attachment_path(raw_path: str, lab_root: Path, suffix: str) -> Path:
if raw_path in {"", "emptydrive", "none"}:
return Path()
path = Path(raw_path)
if not path.is_absolute() or path.is_symlink():
raise GuardError("attached storage path must be absolute and not a symlink")
try:
resolved_root = lab_root.resolve(strict=True)
resolved = path.resolve(strict=True)
except OSError as exc:
raise GuardError("attached storage path is missing or unreadable") from exc
try:
resolved.relative_to(resolved_root)
except ValueError as exc:
raise GuardError("attached storage is outside the exact lab root") from exc
if resolved.suffix.lower() != suffix:
raise GuardError("attached storage is outside the exact lab root or has wrong type")
if resolved.stat().st_uid != os.getuid() or not resolved.is_file():
raise GuardError("attached storage must be a current-user-owned regular file")
return resolved
def inspect_target(
*,
lab_root: Path = LAB_ROOT,
owner_uid: int | None = None,
runner: Runner = run_command,
) -> tuple[str, dict[str, str]]:
owner_uid = os.getuid() if owner_uid is None else owner_uid
if lab_root != LAB_ROOT and owner_uid == os.getuid():
# The alternate root is only for unit tests; the CLI never accepts it.
pass
_owned_private_directory(lab_root, owner_uid)
manifest = _read_manifest(lab_root / IDENTITY_FILE, owner_uid)
target_uuid = str(manifest["uuid"])
listed = _invoke(runner, ("VBoxManage", "list", "vms"))
rows: list[tuple[str, str]] = []
for line in listed.splitlines():
if not line.strip():
continue
match = re.fullmatch(r'"([^"\r\n]+)" \{([0-9A-Fa-f-]{36})\}', line)
if match is None:
raise GuardError("malformed VBoxManage VM inventory row")
name, raw_uuid = match.groups()
try:
listed_uuid = str(uuid.UUID(raw_uuid))
except ValueError as exc:
raise GuardError("malformed VBoxManage VM inventory UUID") from exc
rows.append((name, listed_uuid))
if len({name for name, _ in rows}) != len(rows) or len({uid for _, uid in rows}) != len(rows):
raise GuardError("duplicate VBoxManage VM inventory name or UUID")
matching = [(name, listed_uuid) for name, listed_uuid in rows if name == TARGET_NAME]
uuid_matches = [(name, listed_uuid) for name, listed_uuid in rows if listed_uuid.lower() == target_uuid]
if matching != [(TARGET_NAME, target_uuid)] or uuid_matches != [(TARGET_NAME, target_uuid)]:
raise GuardError("exact VM name is missing, duplicated, or has a different UUID")
output = _invoke(runner, ("VBoxManage", "showvminfo", target_uuid, "--machinereadable"))
info = _machine_values(output)
if info.get("name") != TARGET_NAME or info.get("UUID", "").lower() != target_uuid:
raise GuardError("showvminfo identity does not match the selected target")
marker = str(manifest["host_marker"])
if info.get("description") != marker:
raise GuardError("VirtualBox host marker is missing or mismatched")
cfg_file = Path(info.get("CfgFile", ""))
if cfg_file.is_symlink() or cfg_file.name != f"{TARGET_NAME}.vbox":
raise GuardError("VM configuration file is missing or unexpected")
target_folder = lab_root / TARGET_NAME
try:
resolved_lab_root = lab_root.resolve(strict=True)
target_folder_info = target_folder.lstat()
if not stat.S_ISDIR(target_folder_info.st_mode) or stat.S_ISLNK(target_folder_info.st_mode):
raise GuardError("exact target folder must be a real directory")
resolved_target_folder = target_folder.resolve(strict=True)
vm_folder = cfg_file.parent.resolve(strict=True)
config_file = cfg_file.resolve(strict=True)
except OSError as exc:
raise GuardError("VM configuration file or folder is missing") from exc
try:
resolved_target_folder.relative_to(resolved_lab_root)
config_file.relative_to(resolved_lab_root)
except ValueError as exc:
raise GuardError("VM configuration is outside the exact target folder") from exc
if (resolved_target_folder != resolved_lab_root / TARGET_NAME
or vm_folder != resolved_target_folder
or config_file.parent != resolved_target_folder):
raise GuardError("VM configuration is outside the exact target folder")
config_info = config_file.stat()
if not stat.S_ISREG(config_info.st_mode) or config_info.st_uid != owner_uid:
raise GuardError("VM configuration must be a current-user-owned regular file")
controllers = {
int(match.group(1)): value
for key, value in info.items()
if (match := re.fullmatch(r"storagecontrollername([0-9]+)", key))
}
if controllers != {0: "SATA"}:
raise GuardError("unexpected storage controller inventory")
disks: list[Path] = []
isos: list[Path] = []
# VirtualBox emits attachment rows as Controller-port-device. Its other
# per-port rows (for example SATA-ImageUUID-0-0 and
# SATA-hot-pluggable-0-0) look similar but are metadata, not media.
metadata_fields = {
"ImageUUID",
"IsEjected",
"hot-pluggable",
"nonrotational",
"discard",
"tempeject",
}
attachment_keys: set[str] = set()
for key, value in info.items():
if key.startswith("SATA-"):
suffix = key[len("SATA-"):]
if re.fullmatch(r"[0-9]+-[0-9]+", suffix):
attachment_keys.add(key)
continue
metadata = re.fullmatch(r"([A-Za-z][A-Za-z0-9_-]*)-[0-9]+-[0-9]+", suffix)
if metadata and metadata.group(1) in metadata_fields:
continue
# Do not let a disk on an undeclared controller (including a name
# containing hyphens) disappear from the attachment inventory.
if re.fullmatch(r".+-[0-9]+-[0-9]+", key):
raise GuardError("unrecognized attached storage key or controller")
for key in attachment_keys:
value = info[key]
if value.lower().endswith(".vdi"):
disks.append(_check_attachment_path(value, lab_root, ".vdi"))
elif value.lower().endswith(".iso"):
isos.append(_check_attachment_path(value, lab_root, ".iso"))
elif value not in {"emptydrive", "none"}:
raise GuardError("unexpected attached storage type")
expected_disk = (lab_root / TARGET_NAME / f"{TARGET_NAME}.vdi").resolve(strict=True)
if len(disks) != 1 or disks[0] != expected_disk:
raise GuardError("expected exactly one target VDI in the lab root")
expected_iso = (lab_root / EXPECTED_ISO).resolve(strict=True)
if len(isos) > 1 or (isos and isos[0] != expected_iso):
raise GuardError("unexpected attached ISO")
nics = {int(match.group(1)): value for key, value in info.items()
if (match := re.fullmatch(r"nic([1-8])", key))}
if nics.get(1) != "nat" or any(mode not in {"none", "null"} for slot, mode in nics.items() if slot != 1):
raise GuardError("target must have only NIC 1 in NAT mode")
if len(nics) != 8:
raise GuardError("NIC inventory is incomplete; refusing ambiguous network state")
if any((key.lower().startswith("natpf") or key.lower().startswith("forwarding(")) and value
for key, value in info.items()):
raise GuardError("NAT port forwarding is prohibited")
if any(key.startswith("SharedFolder") and value for key, value in info.items()):
raise GuardError("shared folders are prohibited")
return target_uuid, info
def start_target(*, lab_root: Path = LAB_ROOT, runner: Runner = run_command) -> str:
target_uuid, _ = inspect_target(lab_root=lab_root, runner=runner)
_invoke(runner, ("VBoxManage", "startvm", target_uuid, "--type=gui"))
return target_uuid
def authorize_guest(*, password_file: Path, lab_root: Path = LAB_ROOT, runner: Runner = run_command) -> str:
target_uuid, _ = inspect_target(lab_root=lab_root, runner=runner)
try:
info = password_file.lstat()
except OSError as exc:
raise GuardError("guest password file is unavailable") from exc
if (not stat.S_ISREG(info.st_mode) or stat.S_ISLNK(info.st_mode)
or info.st_uid != os.getuid() or stat.S_IMODE(info.st_mode) != 0o600):
raise GuardError("guest password file must be current-user-owned mode 0600")
output = _invoke(runner, (
"VBoxManage", "guestcontrol", target_uuid, "run", "--username", "wga",
"--timeout", "15000", "--wait-stdout",
"--passwordfile", str(password_file), "--exe", "/usr/bin/cat", "--",
GUEST_MARKER_PATH,
))
if output.strip() != EXPECTED_GUEST_MARKER:
raise GuardError("guest-local marker is missing or mismatched")
return target_uuid
def main(argv: Sequence[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
sub = parser.add_subparsers(dest="action", required=True)
sub.add_parser("check", help="inspect the selected VM without changing it")
sub.add_parser("start", help="start only the selected VM after host identity checks")
guest = sub.add_parser("authorize-guest", help="check the guest marker using a password file")
guest.add_argument("--password-file", required=True, type=Path)
args = parser.parse_args(argv)
try:
if args.action == "check":
target_uuid, _ = inspect_target()
elif args.action == "start":
target_uuid = start_target()
else:
target_uuid = authorize_guest(password_file=args.password_file)
except GuardError as exc:
print(f"VM guard refused: {exc}", file=sys.stderr)
return 2
print(f"{args.action} authorized for {TARGET_NAME} ({target_uuid})")
return 0
if __name__ == "__main__":
raise SystemExit(main())