test harness: Path B (manifest-engine) for Standard-Machine

Initial harness scaffolding per SCOPE.md. Drives the win11 analyzer VM
via qemu-guest-agent (runs as NT AUTHORITY\SYSTEM, same context as
GE-Enforce in production - see reference-vm-qga-as-system memory note
for why this is preferred over WinRM).

Pieces:

- lib/qga.sh - host-side helpers (qga round-trip, snapshot revert,
  share mount via cmdkey + net use, file upload). Source from any
  harness script.
- lib/verify-state.ps1 - VM-side detection runner. Parses matrix.json,
  walks each app's verify block, prints PASS/FAIL with detail, exits
  0 only if every check passes. Methods: Registry, File, FileVersion,
  Hash, FileGrep.
- matrix.json - PC-type matrix data. Currently only Standard/Machine
  rows populated (apps + drift scenarios). Extending to other PC types
  is just adding rows.
- B-enforce/run.sh - 5-phase orchestrator (stage / baseline / tamper /
  heal / idempotent). Defaults to Standard/Machine. SKIP_REVERT=1 for
  faster iteration without burning the snapshot revert.
- B-enforce/tamper.ps1 - applies driftScenarios from matrix.json.
  Methods: RegRemove, RegSet, FileDelete, FileOverwrite, FileGrepDelete.

Path A (imaging-time install) and remaining 8 PC-type rows are next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-02 17:15:37 -04:00
parent 26bc1720af
commit db1cdf7aee
5 changed files with 470 additions and 0 deletions

View File

@@ -0,0 +1,117 @@
#!/usr/bin/env bash
# qga.sh - host-side helpers for driving the win11 VM via qemu-guest-agent.
#
# Source this from harness scripts: `source "$(dirname "$0")/../lib/qga.sh"`
#
# All commands run as NT AUTHORITY\SYSTEM inside the VM (qemu-ga's service
# context). See reference-vm-qga-as-system memory note for why this is
# preferred over WinRM for dispatcher / manifest-engine tests.
set -euo pipefail
VM_DOMAIN="${VM_DOMAIN:-win11}"
VM_IP="${VM_IP:-192.168.122.225}"
HOST_SAMBA_USER="${HOST_SAMBA_USER:-camp}"
HOST_SAMBA_PASS="${HOST_SAMBA_PASS:-vos313}"
HOST_SAMBA_HOST="${HOST_SAMBA_HOST:-192.168.122.1}"
# Helper: fail loud
die() { echo "[ERROR] $*" >&2; exit 1; }
log() { printf '[%s] %s\n' "$(date +%H:%M:%S)" "$*"; }
# Send a JSON command to qemu-ga, return parsed return field
qga() {
local payload="$1"
virsh -c qemu:///system qemu-agent-command "$VM_DOMAIN" "$payload" 2>/dev/null \
|| die "qga call failed: $payload"
}
# Run a PowerShell snippet inside the VM as SYSTEM. Stdin = snippet,
# stdout = exit-decorated combined stdout+stderr from the PS process.
qga_run_ps() {
python3 - <<'PY'
import base64, json, subprocess, sys, time, os
DOMAIN = os.environ.get('VM_DOMAIN', 'win11')
TIMEOUT = int(os.environ.get('QGA_TIMEOUT', '300'))
def virsh(cmd):
p = subprocess.run(['virsh','-c','qemu:///system','qemu-agent-command',DOMAIN, json.dumps(cmd)],
capture_output=True, text=True, timeout=120)
if p.returncode != 0:
sys.exit(f"virsh err: {p.stderr.strip()}")
return json.loads(p.stdout)['return']
snippet = sys.stdin.read()
args = ['-NoProfile','-ExecutionPolicy','Bypass','-Command', snippet]
pid = virsh({'execute':'guest-exec','arguments':{'path':'powershell.exe','arg':args,'capture-output':True}})['pid']
deadline = time.time() + TIMEOUT
while time.time() < deadline:
st = virsh({'execute':'guest-exec-status','arguments':{'pid':pid}})
if st.get('exited'):
out = base64.b64decode(st.get('out-data','')).decode('utf-8','replace')
err = base64.b64decode(st.get('err-data','')).decode('utf-8','replace')
rc = st.get('exitcode')
sys.stdout.write(out)
if err:
sys.stdout.write('\n--- STDERR ---\n')
sys.stdout.write(err)
sys.stdout.write(f'\n--- exit {rc} ---\n')
sys.exit(0 if rc == 0 else 1)
time.sleep(0.5)
sys.exit(f"timeout waiting for pid {pid}")
PY
}
# Wait until qemu-ga responds to guest-ping
vm_wait_for_ready() {
local max=${1:-90}
local i=0
while ! virsh -c qemu:///system qemu-agent-command "$VM_DOMAIN" '{"execute":"guest-ping"}' >/dev/null 2>&1; do
((i++))
if (( i > max )); then die "VM never became ready ($max attempts)"; fi
sleep 1
done
}
# Make sure VM is up and qga is alive. Start if not.
vm_start_if_needed() {
local state
state=$(virsh -c qemu:///system domstate "$VM_DOMAIN" 2>/dev/null || echo unknown)
if [[ "$state" != "running" ]]; then
log "VM is $state - starting"
virsh -c qemu:///system start "$VM_DOMAIN" >/dev/null
fi
vm_wait_for_ready 90
}
# Mount the host samba share inside the VM as SYSTEM. Idempotent.
# After this call Z:\ inside the VM points at /home/camp/pxe-images/.
vm_mount_share() {
qga_run_ps <<PS
cmdkey /add:$HOST_SAMBA_HOST /user:$HOST_SAMBA_USER /pass:$HOST_SAMBA_PASS | Out-Null
net use Z: \\\\$HOST_SAMBA_HOST\\pxe-images /persistent:no 2>&1 | Out-Null
"Z reachable: \$(Test-Path 'Z:\\')"
PS
}
# Upload a single file from host to VM by reading it into VM via WriteAllBytes.
# Best for tiny files (config, single ps1). For bigger files use vm_mount_share + Copy-Item.
vm_put_file() {
local local_path="$1" remote_path="$2"
[[ -f "$local_path" ]] || die "no such file: $local_path"
local b64
b64=$(base64 -w0 < "$local_path")
qga_run_ps <<PS
\$bytes = [System.Convert]::FromBase64String('$b64')
\$dst = '$remote_path'
New-Item -ItemType Directory -Force -Path (Split-Path \$dst -Parent) -ErrorAction SilentlyContinue | Out-Null
[System.IO.File]::WriteAllBytes(\$dst, \$bytes)
"wrote \$dst (\$(\$bytes.Length) bytes)"
PS
}
# Revert the VM to the blank-slate snapshot, then start + wait.
vm_revert_to_blank_slate() {
log "reverting to blank-slate snapshot..."
sudo virsh -c qemu:///system snapshot-revert "$VM_DOMAIN" blank-slate \
|| die "snapshot-revert failed (need sudo without password, or run script as root)"
vm_start_if_needed
}

View File

@@ -0,0 +1,96 @@
# verify-state.ps1 - VM-side detection runner. Reads the harness matrix.json
# from the path given via -MatrixPath, runs the verify block of each app under
# the requested -PCType / -PCSubType, prints per-app PASS / FAIL / WARN, and
# exits 0 only if every check passes.
#
# Detection methods supported:
# Registry -> Get-ItemProperty $path[$name] -eq $value
# File -> Test-Path $path
# FileVersion -> (Get-Item $path).VersionInfo.FileVersion -eq $value
# Hash -> Get-FileHash SHA256 -eq $value
# FileGrep -> Get-Content $path -match $pattern (regex)
[CmdletBinding()]
param(
[Parameter(Mandatory=$true)] [string]$MatrixPath,
[Parameter(Mandatory=$true)] [string]$PCType,
[string]$PCSubType
)
$ErrorActionPreference = 'Continue'
if (-not (Test-Path -LiteralPath $MatrixPath)) {
Write-Host "[FAIL] matrix not found at $MatrixPath"
exit 1
}
$matrix = Get-Content -LiteralPath $MatrixPath -Raw | ConvertFrom-Json
$entry = $matrix.pcTypes | Where-Object { $_.PCType -eq $PCType -and ($_.PCSubType -eq $PCSubType -or [string]::IsNullOrEmpty($_.PCSubType)) } | Select-Object -First 1
if (-not $entry) {
Write-Host "[FAIL] no matrix entry for PCType=$PCType PCSubType=$PCSubType"
exit 1
}
function Test-AppState {
param($app)
$v = $app.verify
switch ($v.method) {
'Registry' {
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="reg path missing: $($v.path)" } }
$val = (Get-ItemProperty -LiteralPath $v.path -Name $v.name -ErrorAction SilentlyContinue).$($v.name)
if ($null -eq $val) { return @{ pass=$false; detail="reg name $($v.name) not present" } }
if ($v.value -and $val -ne $v.value) {
return @{ pass=$false; detail="reg $($v.name) = '$val' (expected '$($v.value)')" }
}
return @{ pass=$true; detail="reg $($v.name) = '$val'" }
}
'File' {
if (Test-Path -LiteralPath $v.path) { return @{ pass=$true; detail="exists: $($v.path)" } }
return @{ pass=$false; detail="missing: $($v.path)" }
}
'FileVersion' {
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="missing: $($v.path)" } }
$ver = (Get-Item -LiteralPath $v.path).VersionInfo.FileVersion
if ($v.value -and $ver -ne $v.value) {
return @{ pass=$false; detail="version $ver (expected $($v.value))" }
}
return @{ pass=$true; detail="version $ver" }
}
'Hash' {
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="missing: $($v.path)" } }
$h = (Get-FileHash -LiteralPath $v.path -Algorithm SHA256).Hash
if ($v.value -and $h -ne $v.value) {
return @{ pass=$false; detail="hash $h (expected $($v.value))" }
}
return @{ pass=$true; detail="hash matches" }
}
'FileGrep' {
if (-not (Test-Path -LiteralPath $v.path)) { return @{ pass=$false; detail="missing: $($v.path)" } }
$hit = Get-Content -LiteralPath $v.path | Select-String -Pattern $v.pattern -Quiet
if ($hit) { return @{ pass=$true; detail="pattern matched: $($v.pattern)" } }
return @{ pass=$false; detail="pattern not found: $($v.pattern)" }
}
default { return @{ pass=$false; detail="unknown method: $($v.method)" } }
}
}
$total = 0; $passed = 0; $failed = @()
foreach ($app in $entry.apps) {
$total++
$r = Test-AppState -app $app
if ($r.pass) {
Write-Host " [PASS] $($app.name) - $($r.detail)"
$passed++
} else {
Write-Host " [FAIL] $($app.name) - $($r.detail)"
$failed += $app.name
}
}
Write-Host ""
Write-Host "=== verify summary: $passed/$total passed ==="
if ($failed.Count -gt 0) {
Write-Host "failed: $($failed -join ', ')"
exit 1
}
exit 0