Add integration tests based on VM
This commit is contained in:
@@ -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())
|
||||
Reference in New Issue
Block a user