CIS490/vm/targets
Max Gorog 4d29b7236d PIPELINE §5 step 3: target VM build infrastructure + containment posture
§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>
2026-05-04 01:31:40 -05:00
..
shellshock PIPELINE §5 step 3: target VM build infrastructure + containment posture 2026-05-04 01:31:40 -05:00
README.md PIPELINE §5 step 3: target VM build infrastructure + containment posture 2026-05-04 01:31:40 -05:00

Target VM build specs (PIPELINE.md §4.2 / §4.13)

Every Tier-3 module in manifest.toml [catalog] MUST land its session against a target VM that this directory defines. Targets are NOT fetched from third-party blob stores (no Metasploitable2 from SourceForge — that was the §3 evidence); they are built locally from declarative specs, sha256-pinned, and re-verified at every release.

Layout

vm/targets/<name>/
├── spec.toml      ← what this target promises (verified at load time)
├── build.sh       ← declarative build steps; produces $OUT_PATH
└── verify.sh      ← boots the produced image, asserts every promise

Adding a target

  1. Create vm/targets/<name>/.

  2. Write spec.toml. Every field is required; containment fields all have ONE safe value (no knobs):

    name = "<name>"
    description = "<short prose>"
    base_image = "<e.g. alpine-3.21-virt>"
    
    [promises]
    cve = "CVE-YYYY-NNNN"
    service_name = "samba"           # what the module catalog talks to
    service_port = 445
    service_proto = "tcp"
    vulnerable_software = "samba"    # the actual vulnerable component
    vulnerable_version = "3.0.20"
    
    [containment]
    upstream_egress = false           # MUST
    shared_filesystem = false         # MUST
    unprivileged_qemu = true          # MUST
    fresh_snapshot_per_episode = true # MUST
    
  3. Write build.sh. The orchestrator invokes it with OUT_PATH=<staging>.qcow2 and BASE_IMAGE_NAME=<base>. The script should produce a valid qcow2 at $OUT_PATH and exit 0.

  4. Write verify.sh. The orchestrator invokes it with IMAGE_PATH=<staging>.qcow2 and EXPECTED_* env vars matching spec.promises. Boot the image in a containment-correct configuration (see "Verification harness" below), wait for the service to come up, assert the promised port + version. Exit 0 only if every promise verifies.

  5. Run the build:

    sudo python tools/build_target.py <name>
    

    On success the script prints the sha256 + the manifest.toml stanza to add. Build artifacts go to /var/lib/cis490/vm/images/ by default.

  6. Operator amends manifest.toml:

    [[targets.images]]
    image_name = "<name>"
    sha256 = "<from build_target output>"
    build_script = "vm/targets/<name>/build.sh"
    

    This is a substantive amendment per §16 — operator sign-off required. Lands in the same merge as any modules that depend on the target.

Verification harness

verify.sh MUST boot the image with the §4.13 containment posture:

  • -netdev user,...,restrict=on — no upstream egress
  • No -virtfs / -fsdev / -9pfs host-shared mounts
  • Run QEMU as the unprivileged service user (no sudo qemu-system-*)
  • snapshot=on so the build artifact isn't mutated by verification

A tests/test_containment.py regression asserts every spec on disk declares the correct containment posture. A spec that asserts weakened containment is a containment regression and load_target_spec rejects it before build_target.py even invokes build.sh.

Why this exists

Targets we don't build, we don't trust. PIPELINE.md §3 surfaced 0/67 session_open against the SourceForge Metasploitable2 image — and we couldn't even tell whether that was a payload bug, a hostfwd bug, a SLIRP timing race, or just the image being modified somewhere along the supply chain. With locally-built declarative targets:

  • The vulnerable service is verified up at the promised port + version BEFORE the image is admitted.
  • The image's sha256 is recorded in manifest.toml; tampering is visible.
  • Build is reproducible: same spec.toml + same build.sh on a fresh base produces the same image.

This is non-negotiable per §4.2 / §4.3. Tier-3 modules that target unverified images stay out of the catalog.