§4.2 calls for target VMs we BUILD, not VMs we fetch. §4.13 demands
every target ship the same isolation posture (no upstream egress, no
host-shared FS, unprivileged QEMU, fresh snapshot per episode). This
commit lands the infrastructure for both.
New surface:
* orchestrator/target_spec.py
Loads + validates `vm/targets/<name>/spec.toml`. Containment
fields are not knobs — each has exactly ONE safe value, and a
spec asserting the unsafe value is rejected at load time. There's
no `--containment-override`; weakening §4.13 requires amending
PIPELINE.md and operator sign-off.
* tools/build_target.py
Orchestrates build → verify → publish for a single target. Spec
invalid → exit 78 (sysadmin error). build.sh failure → image not
published. verify.sh failure → image discarded; that's the §4.2
acceptance gate. Publishes sha256 + the manifest.toml stanza the
operator copies in to admit the image (§16 substantive amendment
with sign-off per §15).
* vm/targets/<name>/{spec.toml,build.sh,verify.sh}
Template structure. spec.toml is the contract; build.sh produces
$OUT_PATH; verify.sh boots the produced image under the §4.13
containment posture and asserts every promise.
* vm/targets/shellshock/
First real working target. CVE-2014-6271 (Apache mod_cgi + bash
4.2 mis-parsing function-export environment values). Replaces
the SourceForge Metasploitable2 path that §3 evidence proved
unverifiable. Bash 4.2 is built from sha256-pinned GNU source
inside an Alpine 3.21 cloudinit guest; the build script asserts
the produced bash actually triggers shellshock; the verifier
re-asserts it under restrict=on with a real CVE-2014-6271 probe.
* vm/targets/README.md
How operators add a target. Walks the spec → build → verify →
manifest amendment loop.
Containment regression tests (tests/test_containment.py) — 20 new
assertions, parameterized over every target with a build/verify trio:
* verify.sh MUST contain `restrict=on` on its netdev (§4.13)
* verify.sh MUST contain `snapshot=on` on the boot drive (§4.13)
* verify.sh + build.sh MUST NOT contain -virtfs / -fsdev / 9pfs
* verify.sh + build.sh MUST NOT wrap qemu-system in `sudo`
* Every target must ship the complete spec.toml + build.sh + verify.sh
trio — no half-built targets (§1 default-to-removal)
Spec validation tests (tests/test_target_spec.py): 13 new tests over
spec parse, name/dir mismatch, missing fields, out-of-range port, and
the §4.13 containment field validators (each unsafe value rejected
with a clear error).
The shellshock target's image is NOT yet published to manifest.toml's
[[targets.images]] — that's the §15 sign-off amendment that lands
after a successful operator-driven build_target.py run on a lab host
with KVM. Building takes ~10 min on x86_64; cannot run on the Pi
under TCG. Operator drives the first build, verifies the sha256, then
amends manifest.toml in a follow-up commit.
261 tests passing.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
168 lines
6.1 KiB
Python
168 lines
6.1 KiB
Python
"""Tests for orchestrator/target_spec.py — target VM spec loader
|
|
(PIPELINE.md §4.2 / §4.13)."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from orchestrator.target_spec import (
|
|
TargetSpecError, list_target_specs, load_target_spec,
|
|
)
|
|
|
|
|
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
|
|
VALID_SPEC = """
|
|
name = "fixture-target"
|
|
description = "fixture for spec validation"
|
|
base_image = "alpine-3.21-virt"
|
|
|
|
[promises]
|
|
cve = "CVE-2014-6271"
|
|
service_name = "apache"
|
|
service_port = 80
|
|
service_proto = "tcp"
|
|
vulnerable_software = "bash"
|
|
vulnerable_version = "4.2"
|
|
|
|
[containment]
|
|
upstream_egress = false
|
|
shared_filesystem = false
|
|
unprivileged_qemu = true
|
|
fresh_snapshot_per_episode = true
|
|
"""
|
|
|
|
|
|
def _write_spec(repo: Path, name: str, body: str) -> None:
|
|
target = repo / "vm" / "targets" / name
|
|
target.mkdir(parents=True, exist_ok=True)
|
|
(target / "spec.toml").write_text(body)
|
|
|
|
|
|
def test_valid_spec_loads(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC)
|
|
s = load_target_spec(tmp_path, "fixture-target")
|
|
assert s.name == "fixture-target"
|
|
assert s.promises.cve == "CVE-2014-6271"
|
|
assert s.promises.service_port == 80
|
|
assert s.containment.upstream_egress is False
|
|
assert s.containment.shared_filesystem is False
|
|
assert s.containment.unprivileged_qemu is True
|
|
assert s.containment.fresh_snapshot_per_episode is True
|
|
|
|
|
|
def test_missing_spec_raises(tmp_path: Path) -> None:
|
|
with pytest.raises(TargetSpecError, match="not found"):
|
|
load_target_spec(tmp_path, "no-such-target")
|
|
|
|
|
|
def test_name_must_match_directory(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC.replace(
|
|
'name = "fixture-target"', 'name = "different-name"'
|
|
))
|
|
with pytest.raises(TargetSpecError, match="doesn't match directory"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_upstream_egress_must_be_false(tmp_path: Path) -> None:
|
|
"""§4.13: containment regression rejected at spec load."""
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC.replace(
|
|
"upstream_egress = false", "upstream_egress = true"
|
|
))
|
|
with pytest.raises(TargetSpecError, match="upstream_egress.*must be false"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_shared_filesystem_must_be_false(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC.replace(
|
|
"shared_filesystem = false", "shared_filesystem = true"
|
|
))
|
|
with pytest.raises(TargetSpecError, match="shared_filesystem.*must be false"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_unprivileged_qemu_must_be_true(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC.replace(
|
|
"unprivileged_qemu = true", "unprivileged_qemu = false"
|
|
))
|
|
with pytest.raises(TargetSpecError, match="unprivileged_qemu.*must be true"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_fresh_snapshot_must_be_true(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC.replace(
|
|
"fresh_snapshot_per_episode = true",
|
|
"fresh_snapshot_per_episode = false",
|
|
))
|
|
with pytest.raises(TargetSpecError,
|
|
match="fresh_snapshot_per_episode.*must be true"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_invalid_proto_rejected(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC.replace(
|
|
'service_proto = "tcp"', 'service_proto = "icmp"'
|
|
))
|
|
with pytest.raises(TargetSpecError, match="service_proto"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_out_of_range_port_rejected(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC.replace(
|
|
"service_port = 80", "service_port = 99999"
|
|
))
|
|
with pytest.raises(TargetSpecError, match="service_port out of range"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_missing_required_field_rejected(tmp_path: Path) -> None:
|
|
body = VALID_SPEC.replace('cve = "CVE-2014-6271"\n', "")
|
|
_write_spec(tmp_path, "fixture-target", body)
|
|
with pytest.raises(TargetSpecError, match="cve"):
|
|
load_target_spec(tmp_path, "fixture-target")
|
|
|
|
|
|
def test_to_meta_round_trips_to_json(tmp_path: Path) -> None:
|
|
import json
|
|
_write_spec(tmp_path, "fixture-target", VALID_SPEC)
|
|
s = load_target_spec(tmp_path, "fixture-target")
|
|
decoded = json.loads(json.dumps(s.to_meta()))
|
|
assert decoded["name"] == "fixture-target"
|
|
assert decoded["containment"]["upstream_egress"] is False
|
|
|
|
|
|
def test_list_target_specs_finds_all_valid(tmp_path: Path) -> None:
|
|
_write_spec(tmp_path, "fixture-a", VALID_SPEC.replace(
|
|
'name = "fixture-target"', 'name = "fixture-a"'
|
|
))
|
|
_write_spec(tmp_path, "fixture-b", VALID_SPEC.replace(
|
|
'name = "fixture-target"', 'name = "fixture-b"'
|
|
))
|
|
specs = list_target_specs(tmp_path)
|
|
assert {s.name for s in specs} == {"fixture-a", "fixture-b"}
|
|
|
|
|
|
# ---------------------------------------------------------------------
|
|
# Repo-level invariant: every spec in vm/targets/ must validate.
|
|
# This is the production tripwire — adding a target with broken
|
|
# containment fails this test before any build runs.
|
|
# ---------------------------------------------------------------------
|
|
|
|
|
|
def test_every_repo_target_spec_loads_cleanly() -> None:
|
|
"""All shipped target specs must validate. A new target that
|
|
weakens §4.13 containment fails this assertion before its
|
|
image even gets built (PIPELINE.md §4.2 / §4.13)."""
|
|
specs = list_target_specs(REPO_ROOT)
|
|
# No assertion on count — empty is fine (shipping no targets is
|
|
# the honest interim state). What matters: every spec that IS
|
|
# there validates.
|
|
for s in specs:
|
|
# Containment posture re-asserted explicitly so a future drift
|
|
# in the validator doesn't silently accept a regression.
|
|
assert s.containment.upstream_egress is False, s.name
|
|
assert s.containment.shared_filesystem is False, s.name
|
|
assert s.containment.unprivileged_qemu is True, s.name
|
|
assert s.containment.fresh_snapshot_per_episode is True, s.name
|