From ab636a0ec12d128f3ad3aa3d68ff565084426372 Mon Sep 17 00:00:00 2001 From: Simone Date: Sun, 10 May 2026 01:01:30 +0200 Subject: [PATCH] refactor(template): Defender validation-only + auto WU-reboot loop in Prepare Setup-WinBuild2025.ps1 (Step 2): - Defender is now disabled at deploy time by Deploy-WinBuild2025.ps1 via DisableAntiSpyware=1 GPO + RTP off during unattended install. - Step 2 reduced to a single Assert-Step sanity check (GPO key present = 1). Exclusion paths removed: scanner is not running, they would be moot. Prepare-WinBuild2025.ps1 (Setup invocation): - Replace one-shot Invoke-Command + manual 're-run with -SkipWindowsUpdate' message with an automated loop (max 10 iterations): * Invoke-GuestSetup helper runs Setup inside the VM and returns exit code. * Exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) triggers Restart-Computer on the guest, waits for WinRM via Wait-GuestWinRMReady (20 min timeout), reopens PSSession and loops. * Exit 0 breaks the loop; any other code throws immediately. * Hard cap prevents infinite loops if WU never converges. --- template/Prepare-WinBuild2025.ps1 | 116 +++++++++++++++++++++--------- template/Setup-WinBuild2025.ps1 | 80 ++++----------------- 2 files changed, 96 insertions(+), 100 deletions(-) diff --git a/template/Prepare-WinBuild2025.ps1 b/template/Prepare-WinBuild2025.ps1 index c3b50a1..a2c1481 100644 --- a/template/Prepare-WinBuild2025.ps1 +++ b/template/Prepare-WinBuild2025.ps1 @@ -385,43 +385,91 @@ try { if ($SkipCleanup) { $setupArgs['SkipCleanup'] = $true } # ── Run Setup-WinBuild2025.ps1 inside the VM ────────────────────────────── - Write-Host "[Prepare] Running Setup-WinBuild2025.ps1 inside VM (this will take a while)..." - Write-Host "[Prepare] Output from guest:" - Write-Host "------------------------------------------------------------" + # Setup may return exit 3010 (ERROR_SUCCESS_REBOOT_REQUIRED) when Windows + # Update applied patches that need a reboot. We loop: reboot guest, wait for + # WinRM, reopen session, re-invoke Setup. Steps 1-5 are idempotent (~30 s); + # Step 6 picks up where the previous run left off (no leftover updates → + # exit 0). Hard cap on iterations to avoid an infinite loop. + $maxWuIterations = 10 + $rebootDeadlineMin = 20 - $result = Invoke-Command -Session $session -ScriptBlock { - param($scriptPath, $scriptArgs) - Set-ExecutionPolicy Bypass -Scope Process -Force - $exitCode = 0 - try { - & $scriptPath @scriptArgs - $exitCode = $LASTEXITCODE - if ($null -eq $exitCode) { $exitCode = 0 } - } - catch { - Write-Error $_ - $exitCode = 1 - } - return $exitCode - } -ArgumentList $guestScriptPath, $setupArgs - - Write-Host "------------------------------------------------------------" - - # ── Handle reboot required (exit 3010) ─────────────────────────────────── - if ($result -eq 3010) { - Write-Host "" - Write-Warning "*** REBOOT REQUIRED after Windows Update. ***" - Write-Warning " The VM will reboot. Wait for it to come back up, then run:" - Write-Warning " .\Prepare-WinBuild2025.ps1 -VMIPAddress $VMIPAddress -SkipWindowsUpdate" - - # Reboot the VM - Write-Host "[Prepare] Sending reboot command to VM..." - Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } - exit 3010 + function Invoke-GuestSetup { + param([Parameter(Mandatory)] $Session, + [Parameter(Mandatory)] [string] $ScriptPath, + [Parameter(Mandatory)] [hashtable] $ScriptArgs) + Invoke-Command -Session $Session -ScriptBlock { + param($scriptPath, $scriptArgs) + Set-ExecutionPolicy Bypass -Scope Process -Force + $exitCode = 0 + try { + & $scriptPath @scriptArgs + $exitCode = $LASTEXITCODE + if ($null -eq $exitCode) { $exitCode = 0 } + } catch { + Write-Error $_ + $exitCode = 1 + } + return $exitCode + } -ArgumentList $ScriptPath, $ScriptArgs } - if ($result -ne 0) { - throw "Setup-WinBuild2025.ps1 exited with code $result" + function Wait-GuestWinRMReady { + param([Parameter(Mandatory)] [string] $IP, + [Parameter(Mandatory)] $Cred, + [Parameter(Mandatory)] $SessionOptions, + [int] $TimeoutMin = 20) + $deadline = (Get-Date).AddMinutes($TimeoutMin) + while ((Get-Date) -lt $deadline) { + Start-Sleep -Seconds 10 + try { + $r = Test-WSMan -ComputerName $IP -Credential $Cred ` + -Authentication Basic -ErrorAction Stop + if ($r) { return $true } + } catch { } + } + return $false + } + + Write-Host "[Prepare] Running Setup-WinBuild2025.ps1 inside VM (this will take a while)..." + + for ($iter = 1; $iter -le $maxWuIterations; $iter++) { + Write-Host "[Prepare] Setup iteration $iter/$maxWuIterations" + Write-Host "[Prepare] Output from guest:" + Write-Host "------------------------------------------------------------" + + $result = Invoke-GuestSetup -Session $session -ScriptPath $guestScriptPath -ScriptArgs $setupArgs + + Write-Host "------------------------------------------------------------" + Write-Host "[Prepare] Iteration $iter exit code: $result" + + if ($result -eq 0) { break } + + if ($result -ne 3010) { + throw "Setup-WinBuild2025.ps1 exited with code $result" + } + + # exit 3010 → WU applied updates needing reboot. Reboot guest, wait, + # reopen session, loop. -SkipWindowsUpdate must NOT be set on retry — + # we want the next iteration to drain remaining updates. + if ($iter -eq $maxWuIterations) { + throw "Windows Update did not converge after $maxWuIterations iterations (still returning 3010)" + } + + Write-Host "[Prepare] WU applied patches → reboot required. Rebooting guest..." + try { + Invoke-Command -Session $session -ScriptBlock { Restart-Computer -Force } -ErrorAction SilentlyContinue + } catch { } + Remove-PSSession $session -ErrorAction SilentlyContinue + + Start-Sleep -Seconds 30 # let WinRM tear down before polling + Write-Host "[Prepare] Waiting for guest WinRM to come back (max $rebootDeadlineMin min)..." + if (-not (Wait-GuestWinRMReady -IP $VMIPAddress -Cred $credential ` + -SessionOptions $sessionOptions -TimeoutMin $rebootDeadlineMin)) { + throw "Guest did not come back online within $rebootDeadlineMin min after WU reboot" + } + Write-Host "[Prepare] Guest WinRM ready, reopening session..." + $session = New-PSSession -ComputerName $VMIPAddress -Credential $credential ` + -SessionOption $sessionOptions -Authentication Basic -ErrorAction Stop } # ── Post-setup remote validation ────────────────────────────────────────── diff --git a/template/Setup-WinBuild2025.ps1 b/template/Setup-WinBuild2025.ps1 index 55efba5..4dd7949 100644 --- a/template/Setup-WinBuild2025.ps1 +++ b/template/Setup-WinBuild2025.ps1 @@ -11,11 +11,10 @@ Installs and configures (each step followed by Assert-Step validation): Step 1 — Firewall: all profiles disabled first — removes any block on subsequent steps Validation: Domain/Private/Public all Enabled=False - Step 2 — Defender & Antimalware disabled — before WinRM and Windows Update - Set-MpPreference + GPO registry keys; exclusions C:\CI, C:\dotnet, C:\Python - Validation: 6 GPO keys + MpPreference (if WinDefend service present) - Rationale: WU downloads + VS/Python/dotnet installers run unscanned; - saves 5-15 min; WinRM traffic not inspected + Step 2 — Defender state validation only (disabled by Deploy-WinBuild2025 + during unattended install via DisableAntiSpyware GPO + RTP off). + Validation: GPO DisableAntiSpyware=1. No exclusion paths — with + Defender off the scanner isn't running, so exclusions are moot. Step 3 — WinRM: Basic auth, AllowUnencrypted, MaxMemoryPerShellMB=2048, 2h timeout Firewall + Defender already off — no interference during config Validation: service Running+Automatic, AllowUnencrypted, Auth/Basic, memory @@ -190,67 +189,16 @@ foreach ($p in 'Domain','Private','Public') { } } -# ── Step 2: Disable Windows Defender & Antimalware ─────────────────────────── -Write-Step "Disabling Windows Defender & Antimalware" - -# Disabled before WinRM config and Windows Update so that: -# - WU downloads/installs are not scanned (saves 5-15 min on update-heavy runs) -# - WinRM traffic is not intercepted by network inspection -# On an isolated CI template VM this trade-off is acceptable. -$defSvc = Get-Service -Name WinDefend -ErrorAction SilentlyContinue -if ($defSvc) { - Set-MpPreference -DisableRealtimeMonitoring $true -ErrorAction SilentlyContinue 3>$null - Set-MpPreference -DisableBehaviorMonitoring $true -ErrorAction SilentlyContinue 3>$null - Set-MpPreference -DisableIOAVProtection $true -ErrorAction SilentlyContinue 3>$null - Set-MpPreference -DisableScriptScanning $true -ErrorAction SilentlyContinue 3>$null - # Exclude CI build paths from any remaining scanning - Add-MpPreference -ExclusionPath 'C:\CI' -ErrorAction SilentlyContinue 3>$null - Add-MpPreference -ExclusionPath 'C:\dotnet' -ErrorAction SilentlyContinue 3>$null - Add-MpPreference -ExclusionPath 'C:\Python' -ErrorAction SilentlyContinue 3>$null - Write-Host "Windows Defender real-time protection disabled." -} -else { - Write-Host "Windows Defender service not found — skipping." -} - -# Disable via Group Policy registry key — survives Defender auto-updates and reboots. -# This is the same key that SCCM/GPO uses in enterprise environments. -$defenderKey = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' -if (-not (Test-Path $defenderKey)) { - New-Item -Path $defenderKey -Force | Out-Null -} -Set-ItemProperty -Path $defenderKey -Name 'DisableAntiSpyware' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $defenderKey -Name 'DisableAntiVirus' -Value 1 -Type DWord -Force -# Also disable via the Windows Security Center policy subkey -$rtKey = "$defenderKey\Real-Time Protection" -if (-not (Test-Path $rtKey)) { - New-Item -Path $rtKey -Force | Out-Null -} -Set-ItemProperty -Path $rtKey -Name 'DisableRealtimeMonitoring' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $rtKey -Name 'DisableBehaviorMonitoring' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $rtKey -Name 'DisableIOAVProtection' -Value 1 -Type DWord -Force -Set-ItemProperty -Path $rtKey -Name 'DisableScriptScanning' -Value 1 -Type DWord -Force -Write-Host "Defender GPO registry keys written (persistent across updates)." - -# Validation — GPO registry keys (always applicable, survives Defender presence) -Assert-Step 'Defender' "GPO DisableAntiSpyware=1" { - (Get-ItemProperty -Path $defenderKey -Name DisableAntiSpyware -ErrorAction Stop).DisableAntiSpyware -eq 1 -} -Assert-Step 'Defender' "GPO DisableAntiVirus=1" { - (Get-ItemProperty -Path $defenderKey -Name DisableAntiVirus -ErrorAction Stop).DisableAntiVirus -eq 1 -} -foreach ($rtName in 'DisableRealtimeMonitoring','DisableBehaviorMonitoring','DisableIOAVProtection','DisableScriptScanning') { - Assert-Step 'Defender' "GPO Real-Time Protection $rtName=1" { - (Get-ItemProperty -Path $rtKey -Name $rtName -ErrorAction Stop).$rtName -eq 1 - } -} -# Validation — Defender preferences (only if service exists) -if ($defSvc) { - $mp = Get-MpPreference -ErrorAction SilentlyContinue - if ($mp) { - Assert-Step 'Defender' 'Set-MpPreference DisableRealtimeMonitoring=true' { $mp.DisableRealtimeMonitoring } - Assert-Step 'Defender' "ExclusionPath contains 'C:\CI'" { $mp.ExclusionPath -contains 'C:\CI' } - } +# ── Step 2: Defender — already disabled by Deploy-WinBuild2025 ─────────────── +# Deploy-WinBuild2025.ps1 set RTP off + DisableAntiSpyware=1 GPO during the +# unattended install. With Defender fully off, exclusion paths are moot — the +# scanner that would respect them isn't running. Keep a sanity check on the +# tamper-protect GPO key so we throw early if someone re-enabled Defender. +Write-Step "Defender state (validation only — disabled at deploy time)" +Assert-Step 'Defender' 'GPO DisableAntiSpyware=1 (set by Deploy-WinBuild2025)' { + $key = 'HKLM:\SOFTWARE\Policies\Microsoft\Windows Defender' + (Test-Path $key) -and + ((Get-ItemProperty -Path $key -Name DisableAntiSpyware -ErrorAction SilentlyContinue).DisableAntiSpyware -eq 1) } # ── Step 3: WinRM ─────────────────────────────────────────────────────────────