diff --git a/Changelog.md b/Changelog.md index 2499277..1d5a9d6 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,18 @@ # Changelog +## v1.121.13 + +Role/service cleanup and reporting fixes — found by a deep audit of the ADCS, scheduled-task, WSUS, and Remote Desktop modules. + +- **Self-destruct now removes all of the tool's scheduled tasks.** When the tool deleted itself, it left its own scheduled export and update-check tasks registered (running as SYSTEM) pointing at the now-deleted program — leftover privileged tasks that would fire and fail forever. It now unregisters those too, so it leaves nothing behind. +- **A failed RDS licensing change in Dry-Run is reported as failed**, not silently applied. +- **A WSUS setup with no valid update classifications is reported as incomplete.** Previously a misconfigured WSUS (which would sync nothing) still reported "configuration applied" — it now tells you the classifications didn't match and to fix them. +- **The scheduled-task health view stops crying wolf.** Brand-new or on-demand tasks that simply hadn't run yet were shown in red as "FAILED"; they're now shown as "Ready (not yet run)". + +(The audit also confirmed the Certificate Authority setup uses strong crypto — SHA-256, RSA ≥ 2048 — and that the certificate-audit and Defender-onboarding paths are correct.) + +No module or CLI action changes (81 modules, 201 actions). + ## v1.121.12 Backup / undo honesty — fixes found by a deep audit of the security-feature modules (Group Policy, JEA, NPS, SIEM forwarding). Common theme: a safety-net backup that could silently fail and then be trusted anyway. diff --git a/Header.ps1 b/Header.ps1 index 43f477e..8eaaadf 100644 --- a/Header.ps1 +++ b/Header.ps1 @@ -30,7 +30,7 @@ 7h3 4b1d3r .VERSION - 1.121.12 + 1.121.13 .LAST UPDATED 07/01/2026 diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1 index a4965fe..b0e13fc 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.12" +$script:ScriptVersion = "1.121.13" $script:ScriptStartTime = Get-Date # Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe. diff --git a/Modules/47-ExitCleanup.ps1 b/Modules/47-ExitCleanup.ps1 index bfbfb3b..bd9bfee 100644 --- a/Modules/47-ExitCleanup.ps1 +++ b/Modules/47-ExitCleanup.ps1 @@ -212,6 +212,14 @@ function Exit-Script { $cleanupCommands += "Remove-Item -LiteralPath '$escapedPath' -Recurse -Force -ErrorAction SilentlyContinue`n" } $toolNameEsc = $script:ToolName -replace "'", "''" + # Also unregister the OTHER SYSTEM/Highest tasks the tool creates — otherwise the + # self-destruct deletes their target binaries ($script:ScriptPath / the installed exe) + # but leaves the tasks registered and firing on schedule against a missing file: a + # privileged persistence remnant (and, for a portable .ps1 run from a user-writable + # dir, a SYSTEM phantom-task EoP if someone recreates the deleted script). Harmless + # -EA SilentlyContinue when a task doesn't exist. + $cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)-ScheduledExport' -TaskPath '\$($toolNameEsc)\' -Confirm:`$false -ErrorAction SilentlyContinue`n" + $cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)_UpdateCheck' -Confirm:`$false -ErrorAction SilentlyContinue`n" $cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)Cleanup' -Confirm:`$false -ErrorAction SilentlyContinue" $bytes = [System.Text.Encoding]::Unicode.GetBytes($cleanupCommands) diff --git a/Modules/63-ScheduledTasks.ps1 b/Modules/63-ScheduledTasks.ps1 index d03ede5..0a17cd5 100644 --- a/Modules/63-ScheduledTasks.ps1 +++ b/Modules/63-ScheduledTasks.ps1 @@ -27,13 +27,20 @@ function Show-TaskHealthStatus { $color = "Success" $status = "OK" - if ($lastResult -ne 0 -and $lastResult -ne 267009) { # 267009 = task is running + if ($lastResult -eq 267009) { # 0x00041301 = task is currently running + $color = "Warning" + $status = "Running" + } elseif ($lastResult -eq 0x00041300 -or $lastResult -eq 0x00041303) { + # 0x00041300 = ready/never-run at its next time, 0x00041303 = has not yet run. + # These are HEALTHY not-yet-executed states, not failures — the sibling + # Show-FailedTasks excludes them too. Treating them as FAILED made a brand-new + # or on-demand task show a red error before it had ever run. + $color = "Info" + $status = "Ready (not yet run)" + } elseif ($lastResult -ne 0) { $color = "Error" $status = "FAILED (0x$($lastResult.ToString('X')))" $failedCount++ - } elseif ($lastResult -eq 267009) { - $color = "Warning" - $status = "Running" } Write-OutputColor " $taskName Last: $lastRun $status" -color $color diff --git a/Modules/67-WSUS.ps1 b/Modules/67-WSUS.ps1 index ac9f6ea..69a6673 100644 --- a/Modules/67-WSUS.ps1 +++ b/Modules/67-WSUS.ps1 @@ -270,6 +270,7 @@ function Set-WSUSDefaultConfiguration { } else { @('Critical Updates', 'Security Updates', 'Definition Updates', 'Update Rollups') } + $classificationsEnabled = $false try { $allClass = $wsus.GetUpdateClassifications() $selected = @($allClass | Where-Object { $_.Title -in $wantClassifications }) @@ -291,6 +292,7 @@ function Set-WSUSDefaultConfiguration { foreach ($c in $selected) { [void]$coll.Add($c) } $sub.SetUpdateClassifications($coll) $sub.Save() + $classificationsEnabled = $true Write-OutputColor " Enabled classifications: $($matchedTitles -join ', ')" -color "Success" } } @@ -314,6 +316,17 @@ function Set-WSUSDefaultConfiguration { Write-OutputColor " Could not set the sync schedule: $($_.Exception.Message)" -color "Warning" } + # If NO update classification was enabled, the WSUS server will sync nothing — a misconfigured + # server that reports "configured" is the worst outcome (per the comment above). Don't claim + # success: report the config as incomplete and return $false so callers / the CLI exit code + # reflect it. + if (-not $classificationsEnabled) { + Add-SessionChange -Category "Roles" -Description "WSUS configuration incomplete — no update classifications enabled" + Write-OutputColor " WSUS configuration is INCOMPLETE: no update classifications were enabled, so the" -color "Error" + Write-OutputColor " server will sync nothing. Fix WSUS.Classifications in defaults.json and re-run." -color "Warning" + return $false + } + Add-SessionChange -Category "Roles" -Description "Applied default WSUS configuration (language, classifications, sync schedule)" Write-OutputColor " Default configuration applied. First sync will populate the catalog." -color "Success" return $true diff --git a/Modules/80-RemoteDesktopServices.ps1 b/Modules/80-RemoteDesktopServices.ps1 index e051725..79296d9 100644 --- a/Modules/80-RemoteDesktopServices.ps1 +++ b/Modules/80-RemoteDesktopServices.ps1 @@ -147,9 +147,13 @@ function Set-RDSLicenseMode { -Params @{ Mode = $Mode; LicenseServer = $server } ` -Preflight { $true }.GetNewClosure() ` -Apply { - if (-not (Test-Path $path)) { New-Item -Path $path -Force -ErrorAction SilentlyContinue | Out-Null } - Set-ItemProperty -Path $path -Name 'LicensingMode' -Value $mv -Type DWord -ErrorAction SilentlyContinue - Set-ItemProperty -Path $path -Name 'LicenseServers' -Value $sv -Type String -ErrorAction SilentlyContinue + # -EA Stop (matching the immediate path below) so a failed registry write THROWS + # and the Dry-Run commit engine records the step as failed + rolls back. With + # SilentlyContinue the engine (which judges success purely by "did Apply throw?") + # marked a no-op write as "Applied" and printed "all steps applied successfully". + if (-not (Test-Path $path)) { New-Item -Path $path -Force -ErrorAction Stop | Out-Null } + Set-ItemProperty -Path $path -Name 'LicensingMode' -Value $mv -Type DWord -ErrorAction Stop + Set-ItemProperty -Path $path -Name 'LicenseServers' -Value $sv -Type String -ErrorAction Stop }.GetNewClosure() ` -Undo { if ($null -ne $pm) { Set-ItemProperty -Path $path -Name 'LicensingMode' -Value $pm -Type DWord -ErrorAction SilentlyContinue } diff --git a/README.md b/README.md index 4c59e0c..e3e9599 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5341 structural tests + 5348 structural tests Pester 312 tests SLSA Level 3

diff --git a/RackStack.ps1 b/RackStack.ps1 index a33656c..a130bd2 100644 --- a/RackStack.ps1 +++ b/RackStack.ps1 @@ -13,7 +13,7 @@ Environment-specific settings are configured via defaults.json. .VERSION - 1.121.12 + 1.121.13 .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 b923ada..2ced864 100644 --- a/RackStack.psd1 +++ b/RackStack.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'RackStack.psm1' - ModuleVersion = '1.121.12' + ModuleVersion = '1.121.13' GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91' Author = 'TheAbider' CompanyName = 'TheAbider' diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index 7ae400b..5148419 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Automated Test Runner for RackStack v1.121.12 + Automated Test Runner for RackStack v1.121.13 .DESCRIPTION Comprehensive non-interactive test suite covering: @@ -9484,7 +9484,7 @@ try { Write-TestResult "80-RDS: role install is reversible (undo)" ($rdsC -match 'function\s+Install-RDSRoles[\s\S]{0,3000}Add-UndoAction[\s\S]{0,400}Uninstall-WindowsFeature') Write-TestResult "80-RDS: role install is server-SKU gated" ($rdsC -match 'Test-WindowsServer') # Licensing mode is reversible (captures prior + undo). - Write-TestResult "80-RDS: license-mode change is reversible (undo)" ($rdsC -match 'function\s+Set-RDSLicenseMode[\s\S]{0,3500}Add-UndoAction[\s\S]{0,400}LicensingMode') + Write-TestResult "80-RDS: license-mode change is reversible (undo)" ($rdsC -match 'function\s+Set-RDSLicenseMode[\s\S]{0,4000}Add-UndoAction[\s\S]{0,400}LicensingMode') Write-TestResult "80-RDS: license-mode change is Dry-Run aware (reversible)" ($rdsC -match 'Set-RDSLicenseMode[\s\S]{0,2000}Push-DryRunStep[\s\S]{0,400}-OneWay \$false') # Validates the license-server name before writing it. Write-TestResult "80-RDS: validates license server name" ($rdsC -match 'LicenseServer[\s\S]{0,200}-notmatch') @@ -10056,6 +10056,39 @@ catch { Write-TestResult "Backup/Undo Safety-Net Tests" $false $_.Exception.Message } +# ============================================================================ +# SECTION 200: ROLE/SERVICE MODULE HARDENING (v1.121.13) +# ============================================================================ +# Guards the fixes from the role/service-module audit (ADCS/cert/scheduled-task/Defender/WSUS/RDS): +# [0] Self-destruct also unregisters the tool's other SYSTEM/Highest tasks (ScheduledExport, +# UpdateCheck) so it doesn't leave privileged tasks orphaned against deleted binaries. +# [1] RDS licensing Dry-Run apply uses -EA Stop so a failed registry write isn't reported applied. +# [2] WSUS default-config returns $false when no update classification was enabled (syncs nothing). +# [3] Task Health no longer flags healthy never-run tasks (0x00041300 / 0x00041303) as FAILED. +Write-SectionHeader "SECTION 200: ROLE/SERVICE MODULE HARDENING" + +try { + $ecRaw = Get-Content "$modulesPath\47-ExitCleanup.ps1" -Raw + $rdsRaw = Get-Content "$modulesPath\80-RemoteDesktopServices.ps1" -Raw + $wsusRaw = Get-Content "$modulesPath\67-WSUS.ps1" -Raw + $schRaw = Get-Content "$modulesPath\63-ScheduledTasks.ps1" -Raw + + # [0] + Write-TestResult "47-ExitCleanup: self-destruct unregisters the ScheduledExport task [0]" ($ecRaw -match "Unregister-ScheduledTask -TaskName '\`$\(\`$toolNameEsc\)-ScheduledExport'") + Write-TestResult "47-ExitCleanup: self-destruct unregisters the UpdateCheck task [0]" ($ecRaw -match "Unregister-ScheduledTask -TaskName '\`$\(\`$toolNameEsc\)_UpdateCheck'") + # [1] + Write-TestResult "80-RDS: licensing Dry-Run apply uses -EA Stop [1]" ($rdsRaw -match "Set-ItemProperty -Path \`$path -Name 'LicensingMode' -Value \`$mv -Type DWord -ErrorAction Stop") + Write-TestResult "80-RDS: licensing Dry-Run apply no longer swallows write errors [1]" (-not ($rdsRaw -match "Name 'LicensingMode' -Value \`$mv -Type DWord -ErrorAction SilentlyContinue")) + # [2] + Write-TestResult "67-WSUS: tracks whether classifications were enabled [2]" (($wsusRaw -match '\$classificationsEnabled = \$false') -and ($wsusRaw -match '\$classificationsEnabled = \$true')) + Write-TestResult "67-WSUS: returns false when no classification enabled [2]" ($wsusRaw -match 'if \(-not \$classificationsEnabled\)[\s\S]{0,400}return \$false') + # [3] + Write-TestResult "63-ScheduledTasks: never-run tasks are not flagged FAILED [3]" ($schRaw -match '\$lastResult -eq 0x00041300 -or \$lastResult -eq 0x00041303') +} +catch { + Write-TestResult "Role/Service Module Hardening Tests" $false $_.Exception.Message +} + # ============================================================================ # SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase) # ============================================================================