From db1cdf7aeec33564edf8e38df2330fb94203c5a3 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Sat, 2 May 2026 17:15:37 -0400 Subject: [PATCH] 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) --- .../common/test/B-enforce/run.sh | 168 ++++++++++++++++++ .../common/test/B-enforce/tamper.ps1 | 59 ++++++ .../shopfloor-setup/common/test/lib/qga.sh | 117 ++++++++++++ .../common/test/lib/verify-state.ps1 | 96 ++++++++++ .../shopfloor-setup/common/test/matrix.json | 30 ++++ 5 files changed, 470 insertions(+) create mode 100755 playbook/shopfloor-setup/common/test/B-enforce/run.sh create mode 100644 playbook/shopfloor-setup/common/test/B-enforce/tamper.ps1 create mode 100755 playbook/shopfloor-setup/common/test/lib/qga.sh create mode 100644 playbook/shopfloor-setup/common/test/lib/verify-state.ps1 create mode 100644 playbook/shopfloor-setup/common/test/matrix.json diff --git a/playbook/shopfloor-setup/common/test/B-enforce/run.sh b/playbook/shopfloor-setup/common/test/B-enforce/run.sh new file mode 100755 index 0000000..ca10832 --- /dev/null +++ b/playbook/shopfloor-setup/common/test/B-enforce/run.sh @@ -0,0 +1,168 @@ +#!/usr/bin/env bash +# B-enforce/run.sh - exercise the manifest engine (GE-Enforce + Install-FromManifest) +# end-to-end on the win11 VM as a synthetic shopfloor PC. +# +# Phases: +# 1. stage: copy enforcer + lib + matrix into the VM, mount the v2 share +# 2. baseline: run dispatcher once, verify expected state +# 3. tamper: apply driftScenarios from matrix.json +# 4. heal: re-run dispatcher, verify drift went away +# 5. idempotent:re-run dispatcher, verify still clean (every entry skipped) +# +# Usage: +# ./run.sh # defaults to Standard / Machine +# ./run.sh CMM "" # CMM (no subtype) +# ./run.sh Standard Timeclock # explicit +# +# Environment: +# VM_DOMAIN override VM name (default win11) +# SKIP_REVERT set to skip blank-slate snapshot revert (faster iteration) + +set -euo pipefail + +PCTYPE="${1:-Standard}" +PCSUBTYPE="${2:-Machine}" + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_ROOT="$(cd "$HERE/.." && pwd)" +REPO_ROOT="$(cd "$TEST_ROOT/../../../.." && pwd)" + +source "$TEST_ROOT/lib/qga.sh" + +# ANSI color (auto-disable if not on a tty) +if [[ -t 1 ]]; then + G='\033[32m'; R='\033[31m'; Y='\033[33m'; D='\033[0m' +else + G=''; R=''; Y=''; D='' +fi + +phase() { printf "\n${Y}=== %s ===${D}\n" "$*"; } +ok() { printf "${G}[ OK ]${D} %s\n" "$*"; } +fail() { printf "${R}[FAIL]${D} %s\n" "$*"; FAILED=1; } + +FAILED=0 + +phase "B-enforce.run for PCType=$PCTYPE PCSubType=$PCSUBTYPE" + +if [[ "${SKIP_REVERT:-}" != "1" ]]; then + vm_revert_to_blank_slate +else + log "skipping snapshot revert (SKIP_REVERT=1)" + vm_start_if_needed +fi + +phase "1. stage enforcer + lib + matrix into VM" + +# Stage the enforcer + lib in a host samba-accessible dir so the VM can pull +# them across via Z:. /home/camp/pxe-images/ is mapped to the share. +ENFORCER_STAGE="/home/camp/pxe-images/test-stage" +mkdir -p "$ENFORCER_STAGE/lib" "$ENFORCER_STAGE/test/B-enforce" +cp "$REPO_ROOT/playbook/shopfloor-setup/common/GE-Enforce.ps1" "$ENFORCER_STAGE/" +cp "$REPO_ROOT/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1" "$ENFORCER_STAGE/lib/" +cp "$TEST_ROOT/matrix.json" "$ENFORCER_STAGE/test/" +cp "$TEST_ROOT/lib/verify-state.ps1" "$ENFORCER_STAGE/test/" +cp "$HERE/tamper.ps1" "$ENFORCER_STAGE/test/B-enforce/" + +vm_mount_share + +# Inside VM: copy from share to local C:\Tools\, prep enrollment stubs + +# fake SFLD creds in HKLM, point shopfloorShareRoot at the v2 mirror. +qga_run_ps <&1 | tail -30 +& 'C:\Program Files\GE\Shopfloor\GE-Enforce.ps1' 2>&1 | Out-Null +"--- last 25 enforce log lines ---" +$f = Get-ChildItem 'C:\Logs\Shopfloor\enforce-*.log' -ErrorAction SilentlyContinue | Sort-Object LastWriteTime -Descending | Select-Object -First 1 +if ($f) { Get-Content $f.FullName -Tail 25 } else { "no enforce log" } +EOF +} + +run_verify() { + local phase_label="$1" + log "verify ($phase_label)..." + if qga_run_ps <<'EOF' | tee /dev/stderr | grep -q '=== verify summary' && qga_run_ps <<'EOF2' | grep -qE '0/[0-9]+ passed|^=== verify summary: ([0-9]+)/\1 passed'; then :; fi +& 'C:\Tools\test-harness\verify-state.ps1' -MatrixPath 'C:\Tools\test-harness\matrix.json' -PCType '$PCTYPE' -PCSubType '$PCSUBTYPE' +EOF +EOF2 +} + +# Simpler verify wrapper: capture output, parse summary line +do_verify() { + local phase_label="$1" expect_pass="$2" + log "verify ($phase_label, expect_pass=$expect_pass)" + local out + out=$(qga_run_ps < remove a registry value +# RegSet -> set a registry value to a wrong value +# FileDelete -> delete a file +# FileOverwrite -> rewrite a file with bogus content +# FileGrepDelete -> drop matching lines from a text file (e.g. hosts) + +[CmdletBinding()] +param( + [Parameter(Mandatory=$true)] [string]$MatrixPath, + [Parameter(Mandatory=$true)] [string]$PCType, + [string]$PCSubType +) + +$ErrorActionPreference = 'Continue' +$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/$PCSubType"; exit 1 } + +foreach ($d in $entry.driftScenarios) { + $t = $d.tamper + try { + switch ($t.method) { + 'RegRemove' { + if (Test-Path -LiteralPath $t.path) { + Remove-ItemProperty -LiteralPath $t.path -Name $t.regName -Force -ErrorAction Stop + } + Write-Host "[TAMPER] $($d.name) - removed reg $($t.path)\$($t.regName)" + } + 'RegSet' { + if (-not (Test-Path -LiteralPath $t.path)) { + New-Item -Path $t.path -Force | Out-Null + } + Set-ItemProperty -LiteralPath $t.path -Name $t.regName -Value $t.value -Force + Write-Host "[TAMPER] $($d.name) - set reg $($t.path)\$($t.regName) = $($t.value)" + } + 'FileDelete' { + if (Test-Path -LiteralPath $t.path) { Remove-Item -LiteralPath $t.path -Force -ErrorAction Stop } + Write-Host "[TAMPER] $($d.name) - deleted $($t.path)" + } + 'FileOverwrite' { + Set-Content -LiteralPath $t.path -Value $t.content -Encoding ascii -Force + Write-Host "[TAMPER] $($d.name) - overwrote $($t.path)" + } + 'FileGrepDelete' { + $kept = Get-Content -LiteralPath $t.path | Where-Object { $_ -notmatch $t.pattern } + Set-Content -LiteralPath $t.path -Value $kept -Encoding ascii -Force + Write-Host "[TAMPER] $($d.name) - dropped lines matching $($t.pattern) from $($t.path)" + } + default { Write-Host "[WARN] unknown tamper method: $($t.method)" } + } + } catch { + Write-Host "[WARN] tamper failed for $($d.name): $_" + } +} diff --git a/playbook/shopfloor-setup/common/test/lib/qga.sh b/playbook/shopfloor-setup/common/test/lib/qga.sh new file mode 100755 index 0000000..182f860 --- /dev/null +++ b/playbook/shopfloor-setup/common/test/lib/qga.sh @@ -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 <&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 < 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 diff --git a/playbook/shopfloor-setup/common/test/matrix.json b/playbook/shopfloor-setup/common/test/matrix.json new file mode 100644 index 0000000..7b4b918 --- /dev/null +++ b/playbook/shopfloor-setup/common/test/matrix.json @@ -0,0 +1,30 @@ +{ + "_comment": "Test matrix for shopfloor harness. Each PC-type entry lists apps to verify + drift scenarios for Path B's tamper+heal phase. Verify methods mirror the v2 manifest's DetectionMethod so harness verification == GE-Enforce detection.", + + "pcTypes": [ + { + "PCType": "Standard", + "PCSubType": "Machine", + "scopes": ["common", "standard-machine"], + "apps": [ + { "name": "Adobe Acrobat Reader DC", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\{AC76BA86-7AD7-1033-7B44-AC0F074E4100}", "name": "DisplayVersion", "value": "25.001.20531" } }, + { "name": "WJF Defect Tracker", "verify": { "method": "File", "path": "C:\\Program Files (x86)\\WJF_Defect_Tracker\\Defect_Tracker.exe" } }, + { "name": "3OF9 barcode font", "verify": { "method": "File", "path": "C:\\Windows\\Fonts\\3OF9.ttf" } }, + { "name": "Edge IE-Mode site list", "verify": { "method": "Hash", "path": "C:\\ProgramData\\Edge\\enterprise-mode-site-list.xml", "value": "16F2A6E45EFA19ED7B1C54B264D6B33597678D3A5303255BC7CEB7E8510C60FC" } }, + { "name": "OpenText HostExplorer", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\GE\\OpenText", "name": "Installed", "value": "15.0.SP1.2" } }, + { "name": "FMS hosts pin", "verify": { "method": "FileGrep", "path": "C:\\Windows\\System32\\drivers\\etc\\hosts", "pattern": "10\\.233\\.112\\.158\\s+wjfms3\\.ae\\.ge\\.com" } }, + { "name": "FMS Primary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostPrimary", "value": "wjfms3.ae.ge.com" } }, + { "name": "FMS Secondary host", "verify": { "method": "Registry", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "name": "FMSHostSecondary", "value": "10.233.112.158" } }, + { "name": "eDNC bundles NTLARS", "verify": { "method": "FileVersion", "path": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe", "value": "6.4.5.0" } } + ], + "driftScenarios": [ + { "name": "OpenText marker reset", "tamper": { "method": "RegRemove", "path": "HKLM:\\SOFTWARE\\GE\\OpenText", "regName": "Installed" }, "expectedHeal": "OpenText HostExplorer" }, + { "name": "Hosts pin removed", "tamper": { "method": "FileGrepDelete", "path": "C:\\Windows\\System32\\drivers\\etc\\hosts", "pattern": "wjfms3\\.ae\\.ge\\.com" }, "expectedHeal": "FMS hosts pin" }, + { "name": "FMS Primary clobbered", "tamper": { "method": "RegSet", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "regName": "FMSHostPrimary", "value": "WJFMS3" }, "expectedHeal": "FMS Primary host" }, + { "name": "FMS Secondary clobbered", "tamper": { "method": "RegSet", "path": "HKLM:\\SOFTWARE\\WOW6432Node\\GE Aircraft Engines\\Dnc\\FMS", "regName": "FMSHostSecondary", "value": "WJFMS4" }, "expectedHeal": "FMS Secondary host" }, + { "name": "Edge IE site list overwrite", "tamper": { "method": "FileOverwrite", "path": "C:\\ProgramData\\Edge\\enterprise-mode-site-list.xml", "content": "" }, "expectedHeal": "Edge IE-Mode site list" }, + { "name": "3OF9 font deleted", "tamper": { "method": "FileDelete", "path": "C:\\Windows\\Fonts\\3OF9.ttf" }, "expectedHeal": "3OF9 barcode font" } + ] + } + ] +}