#Requires -Version 5.1 <# .SYNOPSIS Validates full post-Setup state of a template VM (Deploy + Setup). .DESCRIPTION Connects to the VM via WinRM (HTTP/Basic) 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 ) 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) $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" { (Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null } 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 } # 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 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) $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" { (Get-LocalUser -Name $BuildUsername -EA Stop).PasswordExpires -eq $null } 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 } 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 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 }