From 8a777495337aac04390532f8d986cd52aa7b7a96 Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Wed, 1 Jul 2026 07:14:03 -0700 Subject: [PATCH] Harden state integrity: Dry-Run rollback, profile import, batch apply (v1.121.11) Fixes from an adversarial audit of the Dry-Run engine, config-profile import/export, and the batch (scripted) apply. 70-DryRun.ps1: - Atomic rollback now resets each reverted step to Queued. It was left "Applied", so a retry's pending filter skipped it (and the queue view showed it green) -- silently dropping rolled-back changes while reporting "all steps applied". 45-ConfigExport.ps1: - Profile-import + drift-remediation network apply capture the prior IP/gateway and restore them if New-NetIPAddress fails, instead of leaving the host with no IPv4 address / default route (remote box drops off the network, no in-band recovery). - Declining the network sub-step (or an invalid domain name) skips only that step and continues; a bare return previously aborted the entire profile import. - Export leaves DNS null when the source has none rather than fabricating 8.8.8.8; import skips DNS when null. - Interactive text export write uses -ErrorAction Stop (no truncated-file success). 50-EntryPoint.ps1 (batch) + 14-WindowsUpdates.ps1: - Batch shared-storage honors Initialize-StorageBackendBatch's boolean result. - Batch Windows-Updates checks a status flag (Install-WindowsUpdates returns, not throws, on no-network/gallery/scan failure) instead of always counting success. - Batch power-plan failure counts as an error, not a skip. - Batch DNS-only fast path registers a DNS-restore undo entry. Tests: Section 198 (14 asserts) + widened one distance-tight remediation assertion. Structural 5332/5332; full Pester 312/312; PSSA 0. Version stamps -> 1.121.11. --- Changelog.md | 13 ++++++ Header.ps1 | 2 +- Modules/00-Initialization.ps1 | 2 +- Modules/14-WindowsUpdates.ps1 | 9 ++++ Modules/45-ConfigExport.ps1 | 86 ++++++++++++++++++++++++++++------- Modules/50-EntryPoint.ps1 | 46 ++++++++++++++++--- Modules/70-DryRun.ps1 | 8 ++++ README.md | 2 +- RackStack.ps1 | 2 +- RackStack.psd1 | 2 +- Tests/Run-Tests.ps1 | 65 +++++++++++++++++++++++++- 11 files changed, 208 insertions(+), 29 deletions(-) diff --git a/Changelog.md b/Changelog.md index cd303b0..35dac4e 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,18 @@ # Changelog +## v1.121.11 + +State-integrity hardening — fixes found by a deep audit of the Dry-Run engine, configuration profile import/export, and the batch (scripted) apply. + +- **A rolled-back Dry-Run commit no longer silently drops changes.** If an atomic commit failed partway and rolled the applied steps back, those steps were still marked "Applied" — so a retry skipped them and reported "all steps applied" while their changes were actually missing. Reverted steps are now re-applied on retry. +- **Applying a saved profile can't strand a server with no IP.** The network step removed the current address before adding the new one; if the new address failed (duplicate IP, bad gateway) the box was left with no IPv4 and no gateway. It now restores the previous address on failure. (Same fix applied to the drift-remediation path.) +- **The batch report tells the truth about failures.** A shared-storage step that was refused or failed, a Windows Update run with no connectivity, and a failed power-plan change were all being counted as successful applied changes. Each now reports the real result. +- **Cancelling one import step no longer aborts the whole import.** Declining the network reconfiguration (or hitting an invalid domain name) used to silently skip every remaining step and the summary; it now skips just that step and continues. +- **Export no longer invents a DNS server.** When a machine had no DNS configured, export wrote a public resolver (8.8.8.8) into the profile; it now leaves DNS blank so import doesn't apply a value the source never had. +- **'Undo all' restores DNS after a DNS-only batch change**, and the interactive text export reports a real error instead of silently writing a truncated file. + +No module or CLI action changes (81 modules, 201 actions). + ## v1.121.10 Credential-handling hardening — fixes found by a deep audit of password generation, SecureString hygiene, and secret leakage. diff --git a/Header.ps1 b/Header.ps1 index 4c0b0b8..6ddc69c 100644 --- a/Header.ps1 +++ b/Header.ps1 @@ -30,7 +30,7 @@ 7h3 4b1d3r .VERSION - 1.121.10 + 1.121.11 .LAST UPDATED 07/01/2026 diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1 index 6517ab3..3626eed 100644 --- a/Modules/00-Initialization.ps1 +++ b/Modules/00-Initialization.ps1 @@ -233,7 +233,7 @@ if (-not $PSCommandPath -and $script:ScriptPath) { if (-not $script:ModuleRoot -and $script:ScriptPath) { $script:ModuleRoot = [System.IO.Path]::GetDirectoryName($script:ScriptPath) } -$script:ScriptVersion = "1.121.10" +$script:ScriptVersion = "1.121.11" $script:ScriptStartTime = Get-Date # Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe. diff --git a/Modules/14-WindowsUpdates.ps1 b/Modules/14-WindowsUpdates.ps1 index c267455..8526e80 100644 --- a/Modules/14-WindowsUpdates.ps1 +++ b/Modules/14-WindowsUpdates.ps1 @@ -88,6 +88,13 @@ function Install-WindowsUpdates { Clear-Host Write-CenteredOutput "Windows Updates" -color "Info" + # Pessimistic status flag for non-interactive callers (batch config) to detect a genuine + # no-op/failure. The function signals its failures by early `return` (no throw), so a caller + # can't tell success from "no network / gallery unreachable / scan failed" without this. + # Defaults to 'Failed'; set to 'Success' only at the two real success points (up-to-date / + # install completed). Interactive callers ignore it. + $script:LastWindowsUpdateStatus = 'Failed' + # Check network connectivity Write-OutputColor " Checking network connectivity..." -color "Info" if (-not (Test-NetworkConnectivity)) { @@ -210,6 +217,7 @@ function Install-WindowsUpdates { if ($null -eq $updates -or @($updates).Count -eq 0) { Write-OutputColor " No updates available. System is up to date!" -color "Success" + $script:LastWindowsUpdateStatus = 'Success' # up-to-date is a successful check, not a failure return } @@ -403,6 +411,7 @@ function Install-WindowsUpdates { Write-OutputColor "`nWindows updates installation complete!" -color "Success" Write-OutputColor " A reboot may be required to complete the installation." -color "Warning" $script:RebootNeeded = $true + $script:LastWindowsUpdateStatus = 'Success' Add-SessionChange -Category "System" -Description "Installed $updateCount Windows update(s)" Clear-MenuCache } diff --git a/Modules/45-ConfigExport.ps1 b/Modules/45-ConfigExport.ps1 index e2ba151..4883e92 100644 --- a/Modules/45-ConfigExport.ps1 +++ b/Modules/45-ConfigExport.ps1 @@ -462,7 +462,10 @@ function Export-ServerConfiguration { # was missing it. A kill mid-write would leave a truncated config file next to the operator # with no warning. $tmpExportPath = "$exportPath.tmp" - $config | Out-File -LiteralPath $tmpExportPath -Encoding UTF8 -Force + # -ErrorAction Stop so a non-terminating write failure (disk full / quota) doesn't fall + # through to a successful same-volume rename of a TRUNCATED file and report success. + # Matches the guarded writes in Save-ConfigurationProfile / Save-DriftBaseline. + $config | Out-File -LiteralPath $tmpExportPath -Encoding UTF8 -Force -ErrorAction Stop Move-Item -LiteralPath $tmpExportPath -Destination $exportPath -Force -ErrorAction Stop Write-OutputColor "`nConfiguration exported successfully!" -color "Success" @@ -517,8 +520,11 @@ function Save-ConfigurationProfile { "IPAddress" = $null # Intentionally null - user should set for new server "SubnetCIDR" = if ($primaryIP) { $primaryIP.PrefixLength } else { 24 } "Gateway" = $null # Intentionally null - user should set for new server - "DnS1" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 1) { $primaryDnS.ServerAddresses[0] } else { $script:DnSPresets["Google DnS"][0] } - "DnS2" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 2) { $primaryDnS.ServerAddresses[1] } else { $script:DnSPresets["Google DnS"][1] } + # Null when the source has no DNS configured — do NOT fabricate a public resolver + # (8.8.8.8). Fabricating a value the source never had breaks round-trip fidelity and + # injects an unexpected external resolver on import. Import skips DNS when DnS1 is null. + "DnS1" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 1) { $primaryDnS.ServerAddresses[0] } else { $null } + "DnS2" = if ($primaryDnS -and $primaryDnS.ServerAddresses.Count -ge 2) { $primaryDnS.ServerAddresses[1] } else { $null } } "Domain" = [ordered]@{ "JoinDomain" = if ($null -ne $computerSystem) { $computerSystem.PartOfDomain } else { $false } @@ -805,36 +811,69 @@ function Import-ConfigurationProfile { $remoteSession = $true } } catch { } + $proceedNetwork = $true if ($remoteSession) { Write-OutputColor " WARNING: applying network changes over a remote session may disconnect you." -color "Warning" if (-not (Confirm-UserAction -Message "Continue with network reconfiguration?")) { - Write-OutputColor " Network step cancelled." -color "Info" + # Skip ONLY the network step and continue the rest of the import. A bare + # `return` here previously aborted the ENTIRE profile import — every later + # step (timezone, RDP, WinRM, firewall, domain join...), the summary, the + # reboot notice and the session-change log were all silently skipped. + Write-OutputColor " Network step cancelled — continuing with the remaining steps." -color "Info" $errors++ - return + $proceedNetwork = $false } } + if ($proceedNetwork) { # Targeted removal: only delete the existing static IP that matches the saved # adapter, not every IP on the NIC. Loop in case multiple primary IPs exist. $existingIPs = @(Get-NetIPAddress -InterfaceAlias $adaptername -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.PrefixOrigin -eq 'Manual' }) + # Capture the current IP(s) + default gateway BEFORE removing them, so a failed + # New-NetIPAddress can be rolled back instead of stranding the host with no IPv4 + # address and no default route — a remote box would drop off the network entirely + # with no in-band recovery. + $rollbackIPs = @($existingIPs | ForEach-Object { @{ IPAddress = $_.IPAddress; PrefixLength = $_.PrefixLength } }) + $oldGateway = (Get-NetRoute -InterfaceAlias $adaptername -DestinationPrefix '0.0.0.0/0' -ErrorAction SilentlyContinue | + Select-Object -First 1).NextHop foreach ($ip in $existingIPs) { Remove-NetIPAddress -InterfaceAlias $adaptername -IPAddress $ip.IPAddress -Confirm:$false -ErrorAction SilentlyContinue } # Only remove the default route on this adapter (not every route) Remove-NetRoute -InterfaceAlias $adaptername -DestinationPrefix '0.0.0.0/0' -Confirm:$false -ErrorAction SilentlyContinue - new-netIPAddress -InterfaceAlias $adaptername -IPAddress $configProfile.network.IPAddress ` - -PrefixLength $configProfile.network.SubnetCIDR -DefaultGateway $configProfile.network.Gateway -ErrorAction Stop + try { + new-netIPAddress -InterfaceAlias $adaptername -IPAddress $configProfile.network.IPAddress ` + -PrefixLength $configProfile.network.SubnetCIDR -DefaultGateway $configProfile.network.Gateway -ErrorAction Stop + } + catch { + # New IP couldn't be set — restore the previous address(es) + gateway so the + # host keeps a reachable IPv4 config, then re-throw to the outer handler. + Write-OutputColor " New IP assignment failed; restoring previous network config..." -color "Warning" + foreach ($rb in $rollbackIPs) { + New-NetIPAddress -InterfaceAlias $adaptername -IPAddress $rb.IPAddress -PrefixLength $rb.PrefixLength -ErrorAction SilentlyContinue | Out-Null + } + if ($oldGateway) { + New-NetRoute -InterfaceAlias $adaptername -DestinationPrefix '0.0.0.0/0' -NextHop $oldGateway -Confirm:$false -ErrorAction SilentlyContinue | Out-Null + } + throw + } - $dnsServers = @($configProfile.network.DnS1) - if ($configProfile.network.DnS2) { $dnsServers += $configProfile.network.DnS2 } - Set-DnsClientServerAddress -InterfaceAlias $adaptername -ServerAddresses $dnsServers -ErrorAction Stop + # Only set DNS when the profile actually carries a primary server. A null DnS1 + # (source had none, no longer fabricated) must NOT be pushed as @($null), which + # would error or wipe the adapter's DNS. + if (-not [string]::IsNullOrWhiteSpace($configProfile.network.DnS1)) { + $dnsServers = @($configProfile.network.DnS1) + if ($configProfile.network.DnS2) { $dnsServers += $configProfile.network.DnS2 } + Set-DnsClientServerAddress -InterfaceAlias $adaptername -ServerAddresses $dnsServers -ErrorAction Stop + } $changesApplied++ Write-OutputColor " network configured." -color "Success" Add-SessionChange -Category "network" -Description "Set IP $($configProfile.network.IPAddress)/$($configProfile.network.SubnetCIDR) on $adaptername" Clear-MenuCache + } } catch { Write-OutputColor " Failed: $_" -color "Error" @@ -1062,11 +1101,13 @@ function Import-ConfigurationProfile { Write-OutputColor " [13/13] Joining domain '$($configProfile.Domain.Domainname)'..." -color "Info" # Validate before Add-Computer (mirrors the gate in 50-EntryPoint:13280). if (-not (Test-ValidDomainName -DomainName $configProfile.Domain.Domainname)) { + # Skip ONLY the domain-join step and fall through to the summary. A bare `return` + # here previously aborted the function before the "Profile applied" summary, the + # reboot notice and the Install-Updates prompt were ever reached. Write-OutputColor " ERROR: '$($configProfile.Domain.Domainname)' is not a valid domain name. Skipping." -color "Error" $errors++ - Write-OutputColor "" -color "Info" - return } + else { Write-OutputColor " Enter domain credentials:" -color "Info" try { $domainCred = Get-Credential -Message "Enter credentials to join $($configProfile.Domain.Domainname)" @@ -1083,6 +1124,7 @@ function Import-ConfigurationProfile { Write-OutputColor " Failed: $_" -color "Error" $errors++ } + } } else { Write-OutputColor " [13/13] Domain join: skipped" -color "Debug" } @@ -1520,14 +1562,26 @@ function Invoke-Remediation { $currentIPObj = Get-NetIPAddress -InterfaceAlias $adaptername -AddressFamily IPv4 -ErrorAction SilentlyContinue | Where-Object { $_.IPAddress -eq $current -or $_.PrefixOrigin -ne 'Dhcp' } | Select-Object -First 1 + # Capture prior IP + gateway so a failed New-NetIPAddress can be rolled back + # rather than stranding the host with no IPv4 address / default route. + $rbIP = if ($currentIPObj) { @{ IPAddress = $currentIPObj.IPAddress; PrefixLength = $currentIPObj.PrefixLength } } else { $null } + $rbGW = (Get-NetRoute -InterfaceAlias $adaptername -DestinationPrefix "0.0.0.0/0" -ErrorAction SilentlyContinue | Select-Object -First 1).NextHop if ($currentIPObj) { Remove-NetIPAddress -InterfaceAlias $adaptername -IPAddress $currentIPObj.IPAddress -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue } Remove-NetRoute -InterfaceAlias $adaptername -DestinationPrefix "0.0.0.0/0" -AddressFamily IPv4 -Confirm:$false -ErrorAction SilentlyContinue - if ($newGW) { - new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -DefaultGateway $newGW -ErrorAction Stop | Out-null - } else { - new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -ErrorAction Stop | Out-null + try { + if ($newGW) { + new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -DefaultGateway $newGW -ErrorAction Stop | Out-null + } else { + new-netIPAddress -InterfaceAlias $adaptername -IPAddress $newIP -PrefixLength $cidr -ErrorAction Stop | Out-null + } + } + catch { + Write-OutputColor " New IP assignment failed; restoring previous network config..." -color "Warning" + if ($rbIP) { New-NetIPAddress -InterfaceAlias $adaptername -IPAddress $rbIP.IPAddress -PrefixLength $rbIP.PrefixLength -ErrorAction SilentlyContinue | Out-Null } + if ($rbGW) { New-NetRoute -InterfaceAlias $adaptername -DestinationPrefix "0.0.0.0/0" -NextHop $rbGW -Confirm:$false -ErrorAction SilentlyContinue | Out-Null } + throw } $detail = "Set IP to $newIP/$cidr$(if ($newGW) { " GW $newGW" })" Add-SessionChange -Category "Remediation" -Description $detail diff --git a/Modules/50-EntryPoint.ps1 b/Modules/50-EntryPoint.ps1 index e1d800f..86ed80f 100644 --- a/Modules/50-EntryPoint.ps1 +++ b/Modules/50-EntryPoint.ps1 @@ -13439,6 +13439,19 @@ function Start-BatchMode { $changesApplied++ Add-SessionChange -Category "Network" -Description "DNS servers updated on $adapterName" Clear-MenuCache + + # Register a DNS-only undo so 'Undo all reversible changes' restores the prior + # DNS. This fast path changed DNS but previously pushed nothing onto the undo + # stack, so the rollback silently left DNS pointed at the new values (the full + # reconfigure path below correctly captures + restores DNS). + $dnsUndoAdapterEsc = $adapterName -replace "'", "''" + $dnsUndoOld = @($currentDNS) | Where-Object { $_ } + $dnsUndoOldLiteral = if ($dnsUndoOld -and $dnsUndoOld.Count -gt 0) { + "@('" + (($dnsUndoOld | ForEach-Object { $_ -replace "'", "''" }) -join "','") + "')" + } else { '@()' } + $dnsOnlyUndo = "`$dnsRestore = $dnsUndoOldLiteral; if (`$dnsRestore.Count -gt 0) { Set-DnsClientServerAddress -InterfaceAlias '$dnsUndoAdapterEsc' -ServerAddresses `$dnsRestore -ErrorAction SilentlyContinue } else { Set-DnsClientServerAddress -InterfaceAlias '$dnsUndoAdapterEsc' -ResetServerAddresses -ErrorAction SilentlyContinue }" + $script:BatchUndoStack.Add(@{ Step = $stepNum; Description = "Restore DNS servers on $adapterName"; Reversible = $true; UndoScript = [scriptblock]::Create($dnsOnlyUndo) }) + Save-BatchUndoState } catch { Write-OutputColor " Failed to update DNS: $_" -color "Error" @@ -13704,8 +13717,11 @@ function Start-BatchMode { $oldPlanGuid = $currentPlan.Guid powercfg /setactive $script:PowerPlanGUID[$Config.SetPowerPlan] 2>&1 | Out-Null if ($LASTEXITCODE -ne 0) { + # A real powercfg failure is an error, not a skip. The batch summary and the + # JSON/exit-code derive Success from $errors only, so bucketing this as + # $skipped let a genuine power-plan failure report overall success (exit 0). Write-OutputColor " Failed to set power plan (exit code $LASTEXITCODE)." -color "Warning" - $skipped++ + $errors++ } else { Write-OutputColor " Power plan set." -color "Success" $changesApplied++ @@ -14126,7 +14142,16 @@ function Start-BatchMode { else { try { Install-WindowsUpdates - $changesApplied++ + # Install-WindowsUpdates signals failure by early `return` (no throw), so the catch + # alone can't detect a no-network / gallery-unreachable / scan-failed run. Check the + # status flag it sets instead of unconditionally counting an applied change. + if ($script:LastWindowsUpdateStatus -eq 'Success') { + $changesApplied++ + } + else { + Write-OutputColor " Windows Updates did not complete successfully." -color "Warning" + $errors++ + } } catch { Write-OutputColor " Failed: $_" -color "Error" @@ -14449,10 +14474,19 @@ function Start-BatchMode { # Non-iSCSI backends: use the generalized initializer $configHash = @{} if ($Config.SMB3SharePath) { $configHash["SMB3SharePath"] = $Config.SMB3SharePath } - $null = Initialize-StorageBackendBatch -Config $configHash -BackendType $storageBackend - $changesApplied++ - Add-SessionChange -Category "Storage" -Description "Configured $storageBackend storage backend" - Clear-MenuCache + # Honor the boolean result: Initialize-StorageBackendBatch returns $false on real + # failure/refusal paths (e.g. S2D consent missing, Enable-ClusterS2D threw and was + # caught internally). Discarding it with $null = and unconditionally counting the + # change reported a storage step that did nothing as an applied success. + $storageOk = Initialize-StorageBackendBatch -Config $configHash -BackendType $storageBackend + if ($storageOk) { + $changesApplied++ + Add-SessionChange -Category "Storage" -Description "Configured $storageBackend storage backend" + Clear-MenuCache + } else { + Write-OutputColor " Storage backend '$storageBackend' was not configured (refused or failed)." -color "Warning" + $errors++ + } } } catch { diff --git a/Modules/70-DryRun.ps1 b/Modules/70-DryRun.ps1 index 048d9c2..7a02a06 100644 --- a/Modules/70-DryRun.ps1 +++ b/Modules/70-DryRun.ps1 @@ -391,6 +391,14 @@ function Invoke-DryRunCommitAtomic { } try { & $s.Undo + # Reset to Queued so a later retry RE-APPLIES this reverted step. Leaving it + # "Applied" would make the pending filter (Status -ne "Applied", line ~314) skip + # it on the next commit AND render it as [A] Applied in the queue view — silently + # dropping a rolled-back change while reporting "all steps applied successfully". + # Only steps whose Undo actually RAN are reset; OneWay / no-Undo / Undo-threw + # steps keep "Applied" because their change may still be live on the box. + $s.Status = "Queued" + $s.AppliedAt = $null Write-OutputColor " [revert] $($s.Label)" -color "Success" } catch { diff --git a/README.md b/README.md index b2df9dc..de52a81 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5317 structural tests + 5332 structural tests Pester 312 tests SLSA Level 3

diff --git a/RackStack.ps1 b/RackStack.ps1 index fd4c407..3e42c24 100644 --- a/RackStack.ps1 +++ b/RackStack.ps1 @@ -13,7 +13,7 @@ Environment-specific settings are configured via defaults.json. .VERSION - 1.121.10 + 1.121.11 .NOTES - Requires Windows Server 2012 R2 or later (or Windows 10/11 for testing) - Must be run as Administrator diff --git a/RackStack.psd1 b/RackStack.psd1 index 8f8cf9e..c212d6e 100644 --- a/RackStack.psd1 +++ b/RackStack.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'RackStack.psm1' - ModuleVersion = '1.121.10' + ModuleVersion = '1.121.11' GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91' Author = 'TheAbider' CompanyName = 'TheAbider' diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index b1c8bb9..3e85e96 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Automated Test Runner for RackStack v1.121.10 + Automated Test Runner for RackStack v1.121.11 .DESCRIPTION Comprehensive non-interactive test suite covering: @@ -9954,6 +9954,67 @@ catch { Write-TestResult "Credential & Secret Handling Tests" $false $_.Exception.Message } +# ============================================================================ +# SECTION 198: STATE-INTEGRITY HARDENING (v1.121.11) +# ============================================================================ +# Guards the fixes from the Dry-Run / config-export / batch-config state-integrity audit: +# [0] Dry-Run atomic rollback resets a reverted step to Queued (so a retry re-applies it +# instead of silently skipping it and reporting success). +# [1] Config-profile network apply (import + remediation) captures the prior IP/gateway and +# restores it if New-NetIPAddress fails, instead of leaving the host with no IPv4. +# [2] Batch shared-storage honors Initialize-StorageBackendBatch's boolean (no false "applied"). +# [3] Declining the network sub-step skips only that step (was aborting the whole import); +# an invalid domain name likewise falls through to the summary. +# [4] Export leaves DNS null when the source has none (no fabricated 8.8.8.8); import skips null. +# [5] Batch Windows-Updates checks a status flag (Install-WindowsUpdates returns, not throws). +# [6] Interactive text export write uses -ErrorAction Stop (no truncated-file "success"). +# [7] Batch power-plan failure counts as an error, not a skip. +# [8] Batch DNS-only fast path registers an undo so 'Undo all' restores DNS. +Write-SectionHeader "SECTION 198: STATE-INTEGRITY HARDENING" + +try { + $drRaw = Get-Content "$modulesPath\70-DryRun.ps1" -Raw + $ceRaw = Get-Content "$modulesPath\45-ConfigExport.ps1" -Raw + $epRaw = Get-Content "$modulesPath\50-EntryPoint.ps1" -Raw + $wuRaw = Get-Content "$modulesPath\14-WindowsUpdates.ps1" -Raw + + # [0] rollback resets status + Write-TestResult "70-DryRun: atomic rollback resets reverted step to Queued [0]" ($drRaw -match '& \$s\.Undo[\s\S]{0,750}\$s\.Status = "Queued"') + + # [1] network apply rollback (both sites) + Write-TestResult "45-ConfigExport: import captures IP/gateway for rollback [1]" ($ceRaw -match '\$rollbackIPs = @\(\$existingIPs') + Write-TestResult "45-ConfigExport: import restores network + re-throws on new-IP failure [1]" ($ceRaw -match 'restoring previous network config[\s\S]{0,260}New-NetIPAddress[\s\S]{0,600}throw') + Write-TestResult "45-ConfigExport: remediation captures IP/gateway for rollback [1]" ($ceRaw -match '\$rbIP = if \(\$currentIPObj\)') + + # [2] shared-storage boolean honored + Write-TestResult "50-EntryPoint: batch shared-storage honors the boolean result [2]" ($epRaw -match '\$storageOk = Initialize-StorageBackendBatch[\s\S]{0,120}if \(\$storageOk\)') + + # [3] decline-network / invalid-domain skip only their step + Write-TestResult "45-ConfigExport: decline-network skips only that step (not the import) [3]" (($ceRaw -match '\$proceedNetwork = \$false') -and ($ceRaw -match 'if \(\$proceedNetwork\)')) + Write-TestResult "45-ConfigExport: invalid domain falls through to summary [3]" ($ceRaw -match 'is not a valid domain name\. Skipping[\s\S]{0,200}\$errors\+\+[\s\S]{0,120}else \{') + + # [4] export leaves DNS null (no fabricated 8.8.8.8); import guards null + Write-TestResult "45-ConfigExport: export does not fabricate Google DNS [4]" (-not ($ceRaw -match 'DnS1" = if[\s\S]{0,120}Google DnS')) + Write-TestResult "45-ConfigExport: import skips DNS when profile DnS1 is null [4]" ($ceRaw -match 'IsNullOrWhiteSpace\(\$configProfile\.network\.DnS1\)') + + # [5] Windows Updates status flag + Write-TestResult "14-WindowsUpdates: sets a pessimistic status flag [5]" ($wuRaw -match "LastWindowsUpdateStatus = 'Failed'") + Write-TestResult "14-WindowsUpdates: marks success at up-to-date + installed [5]" ((([regex]::Matches($wuRaw, "LastWindowsUpdateStatus = 'Success'")).Count) -ge 2) + Write-TestResult "50-EntryPoint: batch checks the update status flag [5]" ($epRaw -match "LastWindowsUpdateStatus -eq 'Success'") + + # [6] text export -ErrorAction Stop + Write-TestResult "45-ConfigExport: text export write uses -ErrorAction Stop [6]" ($ceRaw -match 'Out-File -LiteralPath \$tmpExportPath -Encoding UTF8 -Force -ErrorAction Stop') + + # [7] power-plan failure is an error + Write-TestResult "50-EntryPoint: batch power-plan failure counts as error [7]" ($epRaw -match 'Failed to set power plan \(exit code \$LASTEXITCODE\)\.[\s\S]{0,80}\$errors\+\+') + + # [8] batch DNS-only path registers undo + Write-TestResult "50-EntryPoint: batch DNS-only fast path registers an undo [8]" ($epRaw -match 'Restore DNS servers on \$adapterName[\s\S]{0,140}Save-BatchUndoState') +} +catch { + Write-TestResult "State-Integrity Hardening Tests" $false $_.Exception.Message +} + # ============================================================================ # SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase) # ============================================================================ @@ -12080,7 +12141,7 @@ try { Write-TestResult "45-ConfigExport: Invoke-Remediation handles Timezone" ($ceContentCLI -match "Invoke-Remediation[\s\S]{0,5000}Set-TimeZone") Write-TestResult "45-ConfigExport: Invoke-Remediation handles RDP" ($ceContentCLI -match "Invoke-Remediation[\s\S]{0,6000}fDenyTSConnections") Write-TestResult "45-ConfigExport: Invoke-Remediation handles DNS" ($ceContentCLI -match "Invoke-Remediation[\s\S]{0,8000}Set-DnsClientServerAddress") - Write-TestResult "45-ConfigExport: Invoke-Remediation handles Hostname" ($ceContentCLI -match "Invoke-Remediation[\s\S]{0,14000}Rename-Computer") + Write-TestResult "45-ConfigExport: Invoke-Remediation handles Hostname" ($ceContentCLI -match "Invoke-Remediation[\s\S]{0,16000}Rename-Computer") Write-TestResult "45-ConfigExport: Invoke-Remediation tracks reboot" ($ceContentCLI -match "Invoke-Remediation[\s\S]{0,3000}RebootRequired") Write-TestResult "45-ConfigExport: Invoke-Remediation calls Add-SessionChange" ($ceContentCLI -match "Invoke-Remediation[\s\S]{0,5000}Add-SessionChange.*Remediation") Write-TestResult "45-ConfigExport: Show-RemediationReport function exists" ($ceContentCLI -match "function Show-RemediationReport")