#Requires -Version 5.1 <# .SYNOPSIS Validates full post-Setup state of a template VM (Deploy + Setup). .DESCRIPTION Connects to the VM via WinRM (HTTPS/Basic, port 5986) and runs all checks for the final BaseClean snapshot state: Deploy OS hardening + Setup CI toolchain. Run this after Prepare-WinBuild2025.ps1 completes (exit 0) to confirm the VM is ready for snapshot and production use. .PARAMETER VMIPAddress IP address of the guest VM on VMnet8 (e.g. 192.168.79.129). .PARAMETER AdminUsername Admin account inside the VM (default: Administrator). .PARAMETER AdminPassword Password for the admin account. Prompted if omitted. .PARAMETER BuildUsername CI build account created by Setup (default: ci_build). .PARAMETER DotNetChannel Expected .NET SDK channel prefix (default: 10.0). .PARAMETER PythonVersion Expected exact Python version string (default: 3.13.3). .PARAMETER Remediate If set, automatically applies fixes for known remediable failures (AutoAdminLogon, wuauserv/UsoSvc StartType=Disabled) then re-runs all checks. .EXAMPLE .\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129 .EXAMPLE .\Validate-SetupState.ps1 -VMIPAddress 192.168.79.129 -Remediate #> [CmdletBinding()] param( [Parameter(Mandatory)] [string] $VMIPAddress, [string] $AdminUsername = 'Administrator', [string] $AdminPassword, [string] $BuildUsername = 'ci_build', [string] $DotNetChannel = '10.0', [string] $PythonVersion = '3.13.3', [switch] $Remediate, # Skip Tier-2 Toolchain checks (mirrors -SkipTier2 in Install-CIToolchain-WinBuild2025.ps1) [switch] $SkipTier2 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' if (-not $AdminPassword) { $secure = Read-Host "Password for $AdminUsername@$VMIPAddress" -AsSecureString $AdminPassword = [Runtime.InteropServices.Marshal]::PtrToStringAuto( [Runtime.InteropServices.Marshal]::SecureStringToBSTR($secure)) } $cred = New-Object System.Management.Automation.PSCredential( $AdminUsername, (ConvertTo-SecureString $AdminPassword -AsPlainText -Force) ) $so = New-PSSessionOption -SkipCACheck -SkipCNCheck -SkipRevocationCheck $prevTrustedHosts = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value try { $cur = (Get-Item WSMan:\localhost\Client\TrustedHosts).Value if ($cur -ne '*' -and $cur -notmatch [regex]::Escape($VMIPAddress)) { $newHosts = if ($cur) { "$cur,$VMIPAddress" } else { $VMIPAddress } Set-Item WSMan:\localhost\Client\TrustedHosts -Value $newHosts -Force } Write-Host "`nValidating full Setup state on $VMIPAddress..." -ForegroundColor Cyan $checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred ` -Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock { param($BuildUsername, $DotNetChannel, $PythonVersion, $SkipTier2) $results = [System.Collections.Generic.List[PSCustomObject]]::new() function Chk { param([string]$Name, [scriptblock]$Test) try { $ok = [bool](& $Test); $err = '' } catch { $ok = $false; $err = $_.Exception.Message } $results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err }) } # ════════════════════════════════════════════════════════════════════ # DEPLOY checks (same as Validate-DeployState.ps1) # ════════════════════════════════════════════════════════════════════ # Firewall foreach ($p in Get-NetFirewallProfile) { $n = $p.Name; $e = $p.Enabled Chk "Firewall $n disabled" { $e -eq $false } } # Defender Chk 'Defender GPO DisableAntiSpyware=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' ` -Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1 } # WinRM Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' } Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' } Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' { (Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false' } Chk 'WinRM Auth/Basic=true' { (Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true' } Chk 'WinRM MaxMemoryPerShellMB >= 2048' { [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048 } # UAC $polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' Chk 'UAC EnableLUA=0' { (Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0 } Chk 'UAC LocalAccountTokenFilterPolicy=1' { (Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1 } # Explorer Chk 'Explorer LaunchTo=1 (HKCU)' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' ` -Name LaunchTo -EA Stop).LaunchTo -eq 1 } # UX hardening Chk 'NoLockScreen=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' ` -Name NoLockScreen -EA Stop).NoLockScreen -eq 1 } Chk 'Server Manager policy DoNotOpenAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' ` -Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1 } Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' ` -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 } Chk 'Server Manager task Disabled' { $t = Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' ` -TaskName 'ServerManager' -EA SilentlyContinue (-not $t) -or ($t.State -eq 'Disabled') } Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' ` -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 } Chk 'DisableCAD=1 (Policies\System)' { (Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1 } Chk 'OOBE DisablePrivacyExperience=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' ` -Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1 } Chk 'DataCollection AllowTelemetry=0' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' ` -Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0 } Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' { $wl = Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' } # ════════════════════════════════════════════════════════════════════ # SETUP checks (post-Prepare state) # ════════════════════════════════════════════════════════════════════ # CI build user Chk "user $BuildUsername exists" { $null -ne (Get-LocalUser -Name $BuildUsername -EA Stop) } Chk "user $BuildUsername enabled" { (Get-LocalUser -Name $BuildUsername -EA Stop).Enabled } Chk "user $BuildUsername PasswordNeverExpires" { $u = Get-LocalUser -Name $BuildUsername -EA Stop (-not $u.PasswordExpires) -or ($u.PasswordExpires -eq [datetime]::MaxValue) } Chk "user $BuildUsername member of Administrators" { [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) } # CI directories foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') { Chk "$dir exists" { Test-Path $dir -PathType Container } } # Windows Update permanently disabled Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' } Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' } Chk 'WU GPO NoAutoUpdate=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' ` -Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1 } Chk 'WU GPO DisableWindowsUpdateAccess=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' ` -Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1 } # Toolchain Chk ".NET SDK channel $DotNetChannel" { $v = (& dotnet --version 2>&1) -as [string] $v -and $v.StartsWith($DotNetChannel) } Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' } Chk "Python version $PythonVersion" { $v = (& 'C:\Python\python.exe' --version 2>&1) -as [string] $v -and $v.Trim() -eq "Python $PythonVersion" } Chk 'MSBuild.exe present' { $msb = Get-Command msbuild -ErrorAction SilentlyContinue $msb -and (Test-Path $msb.Source) } # Tier-1 Toolchain (§6.6) Chk 'Git for Windows (git.exe in PATH)' { Get-Command git -ErrorAction SilentlyContinue } Chk '7-Zip (7z.exe in PATH)' { Get-Command 7z -ErrorAction SilentlyContinue } # Tier-2 Toolchain (§6.6) if (-not $SkipTier2) { Chk 'PowerShell 7 (pwsh.exe in PATH)' { Get-Command pwsh -ErrorAction SilentlyContinue } Chk 'NSIS (makensis.exe in PATH)' { Get-Command makensis -ErrorAction SilentlyContinue } Chk 'CMake (cmake.exe in PATH)' { Get-Command cmake -ErrorAction SilentlyContinue } Chk 'Node.js (node.exe in PATH)' { Get-Command node -ErrorAction SilentlyContinue } Chk 'WiX Toolset (wix.exe in PATH)' { Get-Command wix -ErrorAction SilentlyContinue } Chk 'GitHub CLI (gh.exe in PATH)' { Get-Command gh -ErrorAction SilentlyContinue } Chk 'Sysinternals PsExec.exe present' { Test-Path 'C:\BuildTools\Sysinternals\PsExec.exe' -PathType Leaf } Chk 'vcpkg.exe present' { Test-Path 'C:\BuildTools\vcpkg\vcpkg.exe' -PathType Leaf } } # end if (-not $SkipTier2) # No leftover installer files in C:\CI root Chk 'no leftover installer scripts in C:\CI' { $leftovers = Get-ChildItem 'C:\CI' -File -EA SilentlyContinue | Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' } $leftovers.Count -eq 0 } return $results.ToArray() } -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion, $SkipTier2 Write-Host '' $failed = 0 $deployCnt = 0 $setupCnt = 0 $inSetup = $false foreach ($r in $checks) { if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") { Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan $inSetup = $true } if ($r.Pass) { Write-Host " [OK] $($r.Name)" -ForegroundColor Green } else { $detail = if ($r.Err) { " ($($r.Err))" } else { '' } Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red $failed++ } } Write-Host '' if ($failed -eq 0) { Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green exit 0 } if (-not $Remediate) { Write-Host "$failed / $($checks.Count) checks FAILED." -ForegroundColor Red Write-Host "Re-run with -Remediate to auto-fix known issues." -ForegroundColor Yellow exit 1 } # ── Remediation pass ───────────────────────────────────────────────────── $failedNames = @($checks | Where-Object { -not $_.Pass } | Select-Object -ExpandProperty Name) Write-Host "Applying remediation for $($failedNames.Count) failed check(s)..." -ForegroundColor Yellow Invoke-Command -ComputerName $VMIPAddress -Credential $cred ` -Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock { param([string[]] $FailedNames, [string] $AdminPassword) if ($FailedNames -contains 'AutoAdminLogon=1, DefaultUserName=Administrator') { $wl = 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' Set-ItemProperty $wl AutoAdminLogon '1' -Type String Set-ItemProperty $wl DefaultUserName 'Administrator' -Type String Set-ItemProperty $wl DefaultDomainName '.' -Type String Set-ItemProperty $wl DefaultPassword $AdminPassword -Type String Write-Host " [FIX] AutoAdminLogon keys written." } if ($FailedNames -contains 'wuauserv StartType=Disabled') { Set-Service -Name wuauserv -StartupType Disabled -ErrorAction SilentlyContinue Write-Host " [FIX] wuauserv set to Disabled." } if ($FailedNames -contains 'UsoSvc StartType=Disabled') { Set-Service -Name UsoSvc -StartupType Disabled -ErrorAction SilentlyContinue Write-Host " [FIX] UsoSvc set to Disabled." } } -ArgumentList $failedNames, $AdminPassword # ── Re-run checks ───────────────────────────────────────────────────────── Write-Host "`nRe-validating after remediation..." -ForegroundColor Cyan $checks = Invoke-Command -ComputerName $VMIPAddress -Credential $cred ` -Authentication Basic -UseSSL -Port 5986 -SessionOption $so -ScriptBlock { param($BuildUsername, $DotNetChannel, $PythonVersion, $SkipTier2) $results = [System.Collections.Generic.List[PSCustomObject]]::new() function Chk { param([string]$Name, [scriptblock]$Test) try { $ok = [bool](& $Test); $err = '' } catch { $ok = $false; $err = $_.Exception.Message } $results.Add([PSCustomObject]@{ Name=$Name; Pass=$ok; Err=$err }) } foreach ($p in Get-NetFirewallProfile) { $n=$p.Name;$e=$p.Enabled; Chk "Firewall $n disabled" { $e -eq $false } } Chk 'Defender GPO DisableAntiSpyware=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' -Name DisableAntiSpyware -EA Stop).DisableAntiSpyware -eq 1 } Chk 'WinRM service Running' { (Get-Service WinRM -EA Stop).Status -eq 'Running' } Chk 'WinRM service Automatic' { (Get-Service WinRM -EA Stop).StartType -eq 'Automatic' } Chk 'WinRM AllowUnencrypted=false (HTTPS-only)' { (Get-Item WSMan:\localhost\Service\AllowUnencrypted -EA Stop).Value -eq 'false' } Chk 'WinRM Auth/Basic=true' { (Get-Item WSMan:\localhost\Service\Auth\Basic -EA Stop).Value -eq 'true' } Chk 'WinRM MaxMemoryPerShellMB >= 2048' { [int](Get-Item WSMan:\localhost\Shell\MaxMemoryPerShellMB -EA Stop).Value -ge 2048 } $polSys = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System' Chk 'UAC EnableLUA=0' { (Get-ItemProperty $polSys -Name EnableLUA -EA Stop).EnableLUA -eq 0 } Chk 'UAC LocalAccountTokenFilterPolicy=1' { (Get-ItemProperty $polSys -Name LocalAccountTokenFilterPolicy -EA Stop).LocalAccountTokenFilterPolicy -eq 1 } Chk 'Explorer LaunchTo=1 (HKCU)' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\Windows\CurrentVersion\Explorer\Advanced' -Name LaunchTo -EA Stop).LaunchTo -eq 1 } Chk 'NoLockScreen=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\Personalization' -Name NoLockScreen -EA Stop).NoLockScreen -eq 1 } Chk 'Server Manager policy DoNotOpenAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows NT\CurrentVersion\ServerManager' -Name DoNotOpenAtLogon -EA Stop).DoNotOpenAtLogon -eq 1 } Chk 'Server Manager HKLM DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 } Chk 'Server Manager task Disabled' { $t=Get-ScheduledTask -TaskPath '\Microsoft\Windows\Server Manager\' -TaskName 'ServerManager' -EA SilentlyContinue; (-not $t) -or ($t.State -eq 'Disabled') } Chk 'Server Manager HKCU DoNotOpenServerManagerAtLogon=1' { (Get-ItemProperty 'HKCU:\SOFTWARE\Microsoft\ServerManager' -Name DoNotOpenServerManagerAtLogon -EA Stop).DoNotOpenServerManagerAtLogon -eq 1 } Chk 'DisableCAD=1 (Policies\System)' { (Get-ItemProperty $polSys -Name DisableCAD -EA Stop).DisableCAD -eq 1 } Chk 'OOBE DisablePrivacyExperience=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\OOBE' -Name DisablePrivacyExperience -EA Stop).DisablePrivacyExperience -eq 1 } Chk 'DataCollection AllowTelemetry=0' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\DataCollection' -Name AllowTelemetry -EA Stop).AllowTelemetry -eq 0 } Chk 'AutoAdminLogon=1, DefaultUserName=Administrator' { $wl=Get-ItemProperty 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon' -EA Stop; $wl.AutoAdminLogon -eq '1' -and $wl.DefaultUserName -eq 'Administrator' } Chk 'wuauserv StartType=Disabled' { (Get-Service wuauserv -EA Stop).StartType -eq 'Disabled' } Chk 'UsoSvc StartType=Disabled' { (Get-Service UsoSvc -EA Stop).StartType -eq 'Disabled' } Chk 'WU GPO NoAutoUpdate=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU' -Name NoAutoUpdate -EA Stop).NoAutoUpdate -eq 1 } Chk 'WU GPO DisableWindowsUpdateAccess=1' { (Get-ItemProperty 'HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate' -Name DisableWindowsUpdateAccess -EA Stop).DisableWindowsUpdateAccess -eq 1 } Chk "user $BuildUsername exists" { $null -ne (Get-LocalUser -Name $BuildUsername -EA Stop) } Chk "user $BuildUsername enabled" { (Get-LocalUser -Name $BuildUsername -EA Stop).Enabled } Chk "user $BuildUsername PasswordNeverExpires" { $u = Get-LocalUser -Name $BuildUsername -EA Stop; (-not $u.PasswordExpires) -or ($u.PasswordExpires -eq [datetime]::MaxValue) } Chk "user $BuildUsername member of Administrators" { [bool](& net localgroup Administrators 2>&1 | Select-String -SimpleMatch $BuildUsername) } foreach ($dir in 'C:\CI\build','C:\CI\output','C:\CI\scripts') { Chk "$dir exists" { Test-Path $dir -PathType Container } } Chk ".NET SDK channel $DotNetChannel" { $v=(& dotnet --version 2>&1) -as [string]; $v -and $v.StartsWith($DotNetChannel) } Chk 'Python C:\Python\python.exe present' { Test-Path 'C:\Python\python.exe' } Chk "Python version $PythonVersion" { $v=(& 'C:\Python\python.exe' --version 2>&1) -as [string]; $v -and $v.Trim() -eq "Python $PythonVersion" } Chk 'MSBuild.exe present' { $msb=Get-Command msbuild -EA SilentlyContinue; $msb -and (Test-Path $msb.Source) } Chk 'Git for Windows (git.exe in PATH)' { Get-Command git -ErrorAction SilentlyContinue } Chk '7-Zip (7z.exe in PATH)' { Get-Command 7z -ErrorAction SilentlyContinue } if (-not $SkipTier2) { Chk 'PowerShell 7 (pwsh.exe in PATH)' { Get-Command pwsh -ErrorAction SilentlyContinue } Chk 'NSIS (makensis.exe in PATH)' { Get-Command makensis -ErrorAction SilentlyContinue } Chk 'CMake (cmake.exe in PATH)' { Get-Command cmake -ErrorAction SilentlyContinue } Chk 'Node.js (node.exe in PATH)' { Get-Command node -ErrorAction SilentlyContinue } Chk 'WiX Toolset (wix.exe in PATH)' { Get-Command wix -ErrorAction SilentlyContinue } Chk 'GitHub CLI (gh.exe in PATH)' { Get-Command gh -ErrorAction SilentlyContinue } Chk 'Sysinternals PsExec.exe present' { Test-Path 'C:\BuildTools\Sysinternals\PsExec.exe' -PathType Leaf } Chk 'vcpkg.exe present' { Test-Path 'C:\BuildTools\vcpkg\vcpkg.exe' -PathType Leaf } } # end if (-not $SkipTier2) Chk 'no leftover installer scripts in C:\CI' { $l=Get-ChildItem 'C:\CI' -File -EA SilentlyContinue | Where-Object { $_.Name -match 'wu_worker|wu_result|vs_buildtools|dotnet-install' }; $l.Count -eq 0 } return $results.ToArray() } -ArgumentList $BuildUsername, $DotNetChannel, $PythonVersion, $SkipTier2 Write-Host '' $failed = 0 $inSetup = $false foreach ($r in $checks) { if (-not $inSetup -and $r.Name -match "^user $BuildUsername|^C:\\CI\\|wuauserv Start|UsoSvc Start|WU GPO|\.NET SDK|Python|MSBuild|leftover") { Write-Host "`n --- Setup checks ---" -ForegroundColor DarkCyan $inSetup = $true } if ($r.Pass) { Write-Host " [OK] $($r.Name)" -ForegroundColor Green } else { $detail = if ($r.Err) { " ($($r.Err))" } else { '' } Write-Host " [FAIL] $($r.Name)$detail" -ForegroundColor Red $failed++ } } Write-Host '' if ($failed -eq 0) { Write-Host "All $($checks.Count) checks passed — VM ready for BaseClean snapshot." -ForegroundColor Green exit 0 } else { Write-Host "$failed / $($checks.Count) checks still FAILED after remediation." -ForegroundColor Red exit 1 } } finally { Set-Item WSMan:\localhost\Client\TrustedHosts $prevTrustedHosts -Force -EA SilentlyContinue }