diff --git a/Changelog.md b/Changelog.md index f8ccd9e..e6c337b 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,18 @@ # Changelog +## v1.121.9 + +Installer download & agent-install hardening — fixes found by a deep audit of the download → verify → execute chain (FileServer, Agent Installer, ISO download). + +- **Your configured installer hash is actually checked now.** If you set a known-good SHA256 for an agent installer (the recommended path when your file server doesn't publish `.sha256` sidecars), it was being silently ignored — two separate bugs meant the hash never loaded and, even if it had, it was skipped when no sidecar existed. Both are fixed: a configured hash is now enforced, and a mismatch fails the install closed instead of falling through to a weaker size-only prompt. +- **Reinstalling an agent can't falsely report success anymore.** When re-running over an already-installed agent, an install that timed out or was skipped could report "SUCCESS" (and leave a hung installer running) because the *old* service was still present. It now only counts a detected service as success on a fresh install, and a skipped-but-still-running installer is left to finish instead of being killed. +- **The verified installer is run from a protected location.** After its hash is verified, the installer is moved into an Administrators/SYSTEM-only folder before it runs — closing a window where another process could swap the file between verification and execution. +- **The file-server access token is never sent to an unexpected server.** Downloads no longer follow HTTP redirects while carrying the Cloudflare Access token, so the token can't be leaked to a redirect target on another host. +- **Installer arguments with spaces are passed correctly.** A token like `/token "value with spaces"` is no longer re-split into separate arguments. +- **A low-disk-space ISO re-download restores your existing ISO.** Bailing out for insufficient space now puts the previous ISO back and clears its rollback marker, instead of stranding it and confusing the next download. + +No module or CLI action changes (81 modules, 201 actions). + ## v1.121.8 Cleanup and encryption safety — fixes found by a deep audit of the destructive/data-affecting modules. diff --git a/Header.ps1 b/Header.ps1 index b555d47..1b8f0b3 100644 --- a/Header.ps1 +++ b/Header.ps1 @@ -30,7 +30,7 @@ 7h3 4b1d3r .VERSION - 1.121.8 + 1.121.9 .LAST UPDATED 07/01/2026 diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1 index b7e456d..9dbaff3 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.8" +$script:ScriptVersion = "1.121.9" $script:ScriptStartTime = Get-Date # Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe. diff --git a/Modules/35-Utilities.ps1 b/Modules/35-Utilities.ps1 index 17aa559..e1e1341 100644 --- a/Modules/35-Utilities.ps1 +++ b/Modules/35-Utilities.ps1 @@ -225,6 +225,54 @@ function Test-ScriptUpdate { Write-PressEnter } +# Move a just-verified file out of a world-/user-writable temp location into an +# Administrators+SYSTEM-only staging directory BEFORE it is executed, closing the TOCTOU +# window where a lower-privileged process watching the temp path could swap the file between +# the hash verification and the execution/use. Returns the new (protected) path, or $null if +# staging could not be established — in which case the caller keeps using the original path. +# +# The restrictive DACL is (re-)asserted on EVERY call, not just at creation: %ProgramData% lets +# authenticated users create subdirectories, so a lower-privileged actor could pre-create the +# stage dir with weak permissions; re-applying Administrators+SYSTEM-only (no inheritance) each +# time prevents that from defeating the staging. Same SID/DACL pattern as the self-update stage. +function Move-ToProtectedStaging { + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$SourcePath, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$Leaf, + + [string]$SubDir = 'stage' + ) + try { + if (-not (Test-Path -LiteralPath $SourcePath)) { return $null } + $stageDir = Join-Path $env:ProgramData $script:ToolName | Join-Path -ChildPath $SubDir + if (-not (Test-Path -LiteralPath $stageDir)) { + $null = New-Item -Path $stageDir -ItemType Directory -Force -ErrorAction Stop + } + # Re-assert Administrators + SYSTEM only, no inheritance (idempotent hardening). + $stageAcl = Get-Acl -LiteralPath $stageDir + $stageAcl.SetAccessRuleProtection($true, $false) + $stageAcl.Access | ForEach-Object { [void]$stageAcl.RemoveAccessRule($_) } + $adminsSid = New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-32-544' + $systemSid = New-Object System.Security.Principal.SecurityIdentifier 'S-1-5-18' + $stageAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($adminsSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + $stageAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($systemSid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + Set-Acl -LiteralPath $stageDir -AclObject $stageAcl -ErrorAction Stop + + $destPath = Join-Path $stageDir $Leaf + if (Test-Path -LiteralPath $destPath) { Remove-Item -LiteralPath $destPath -Force -ErrorAction SilentlyContinue } + Move-Item -LiteralPath $SourcePath -Destination $destPath -Force -ErrorAction Stop + return $destPath + } + catch { + return $null + } +} + # Function to download and install an update from a GitHub release function Install-ScriptUpdate { param ( diff --git a/Modules/39-FileServer.ps1 b/Modules/39-FileServer.ps1 index c769472..9e5b3cc 100644 --- a/Modules/39-FileServer.ps1 +++ b/Modules/39-FileServer.ps1 @@ -244,7 +244,9 @@ function Find-FileServerFile { } # Reusable function to download files from FileServer via HTTP GET -# Uses WebClient.DownloadFile() to avoid Invoke-WebRequest keep-alive hang. +# Streams via HttpWebRequest (in a background job) with AllowAutoRedirect disabled — avoids +# Invoke-WebRequest keep-alive hang AND keeps the Cloudflare Access token from being resent to +# a redirect target on another host. # Shows rich progress bar with speed/ETA, retries once on failure, and # verifies integrity via size check + SHA256 hash. # Supports HTTP Range-based resume for partial downloads and optional @@ -375,6 +377,11 @@ function Get-FileServerFile { $resumeRequest = [System.Net.HttpWebRequest]::Create($downloadUrl) $resumeRequest.AddRange($existingSize) $resumeRequest.Timeout = 30000 + # Do NOT auto-follow redirects: this request carries the Cloudflare Access + # service token, which .NET would resend verbatim to a redirect target on a + # different host, exfiltrating the secret. A CF-protected origin redirecting an + # authenticated range request elsewhere is anomalous — fail closed (restart). + $resumeRequest.AllowAutoRedirect = $false foreach ($key in $downloadHeaders.Keys) { $resumeRequest.Headers.Add($key, $downloadHeaders[$key]) } @@ -426,31 +433,59 @@ function Get-FileServerFile { } if (-not $resumed) { - # Background job using WebClient (closes connection immediately, no hang). - # Wrapped in try/finally to GUARANTEE job cleanup even if an exception fires - # inside the progress-monitoring loop (Get-Item race, console KeyAvailable on - # a remote session, etc.). Without this, every exception path in the loop - # left the background runspace alive and accumulated across the retry loop. + # Background download job (closes connection immediately, no hang). Wrapped in + # try/finally to GUARANTEE job cleanup even if an exception fires inside the + # progress-monitoring loop (Get-Item race, console KeyAvailable on a remote + # session, etc.). Without this, every exception path in the loop left the + # background runspace alive and accumulated across the retry loop. $downloadJob = $null try { $downloadJob = Start-Job -ScriptBlock { param($url, $headersJson, $destPath) - $webClient = New-Object System.Net.WebClient + # Stream via HttpWebRequest with AllowAutoRedirect DISABLED. The previous + # WebClient.DownloadFile auto-followed 3xx redirects and RESENT caller headers — + # including the Cloudflare Access service token — to the redirect target on ANY + # host, exfiltrating the secret. WebClient exposes no switch for this and + # subclassing needs Add-Type, which can't compile the same source on both hosts + # (WebClient is obsolete under .NET 5+/pwsh7 → warning-as-error, while PS 5.1's + # older compiler rejects the SYSLIB0014 suppression pragma as an invalid number). + # HttpWebRequest is used at RUNTIME only (obsolete = warning, never a compile + # error) so it works on both hosts and lets us refuse redirects outright — a + # CF-protected origin redirecting an authenticated download is anomalous; fail + # closed rather than leak the token. The file grows on disk as CopyTo writes, so + # the parent's size-based progress monitor is unaffected. + $req = [System.Net.HttpWebRequest]::Create($url) + $req.AllowAutoRedirect = $false + $req.Timeout = 100000 + $req.ReadWriteTimeout = 300000 + if ($headersJson) { + $hdrs = $headersJson | ConvertFrom-Json + foreach ($prop in $hdrs.PSObject.Properties) { + $req.Headers.Add($prop.Name, $prop.Value) + } + } + $resp = $null; $inStream = $null; $outStream = $null try { - if ($headersJson) { - $hdrs = $headersJson | ConvertFrom-Json - foreach ($prop in $hdrs.PSObject.Properties) { - $webClient.Headers.Add($prop.Name, $prop.Value) - } + $resp = $req.GetResponse() + # With redirects disabled a 3xx is returned (not thrown) — treat it as a + # hard failure so the secret is never carried to the Location target and we + # don't write the (empty) redirect body over the destination as "success". + $statusCode = [int]$resp.StatusCode + if ($statusCode -ge 300 -and $statusCode -lt 400) { + return @{ Success = $false; Error = "Server returned redirect ($statusCode) to '$($resp.Headers['Location'])'; refusing to follow with auth headers attached." } } - $webClient.DownloadFile($url, $destPath) + $inStream = $resp.GetResponseStream() + $outStream = [System.IO.File]::Create($destPath) + $inStream.CopyTo($outStream) return @{ Success = $true; Error = $null } } catch { return @{ Success = $false; Error = $_.Exception.Message } } finally { - $webClient.Dispose() + if ($outStream) { $outStream.Close() } + if ($inStream) { $inStream.Close() } + if ($resp) { $resp.Close() } } } -ArgumentList $downloadUrl, ($downloadHeaders | ConvertTo-Json -Compress), $destFile @@ -604,6 +639,29 @@ function Get-FileServerFile { # Every other outcome (hash mismatch, compute error) is a genuine fail-closed: # show the error and delete so a tampered/unverifiable file never lingers on disk. if ($integrity.Reason -eq 'NoSidecar') { + # No server-published .sha256 sidecar. But if the CALLER supplied a locally-trusted + # reference hash (e.g. the AgentInstaller HashManifest configured in defaults.json), + # that hash is authoritative and independent of the server — enforce it HERE rather + # than deferring to a weak size-only trust-on-first-use prompt. This is the documented + # RMM primary path (unique per-site installers with no pre-published sidecar). A match + # is full cryptographic verification; a mismatch is a hard fail-closed. + if (-not [string]::IsNullOrWhiteSpace($ExpectedHash)) { + if ([string]::IsNullOrWhiteSpace($integrity.Hash)) { + Write-OutputColor " Integrity check failed: could not compute SHA256 to compare against the expected hash." -color "Error" + Remove-Item -LiteralPath $destFile -Force -ErrorAction SilentlyContinue + return @{ Success = $false; Error = "Could not compute SHA256 for expected-hash comparison"; Reason = 'HashComputeFailed'; FilePath = $null } + } + if ($integrity.Hash -ne $ExpectedHash.ToUpper()) { + Write-OutputColor " Hash verification FAILED (operator-configured reference)" -color "Error" + Write-OutputColor " Expected: $($ExpectedHash.ToUpper())" -color "Error" + Write-OutputColor " Actual: $($integrity.Hash)" -color "Error" + Remove-Item -LiteralPath $destFile -Force -ErrorAction SilentlyContinue + return @{ Success = $false; Error = "SHA256 hash mismatch against expected hash"; FilePath = $null } + } + Write-OutputColor " Hash verified: SHA256 match (operator-configured reference)" -color "Success" + Write-TransferComplete -TotalBytes $fileSize -ElapsedSeconds $totalElapsed -Hash $integrity.Hash -HashMatch $true + return @{ Success = $true; Error = $null; FilePath = $destFile; FileSize = $fileSize; Hash = $integrity.Hash } + } return @{ Success = $false; Error = $integrity.Error; Reason = 'NoSidecar'; FilePath = $destFile; FileSize = $fileSize; Hash = $integrity.Hash } } Write-OutputColor " Integrity check failed: $($integrity.Error)" -color "Error" @@ -659,6 +717,10 @@ function Get-FileServerFileSize { $request = [System.Net.WebRequest]::Create($url) $request.Method = "HEAD" $request.Timeout = 15000 + # Don't auto-follow redirects with the Cloudflare Access token attached — .NET would + # resend the secret to a cross-host redirect target. An unexpected redirect just means + # no size (non-fatal), never a leaked service token. + if ($request -is [System.Net.HttpWebRequest]) { $request.AllowAutoRedirect = $false } $headers = Get-FileServerHeaders foreach ($key in $headers.Keys) { @@ -696,6 +758,10 @@ function Get-FileServerHashFile { $request = [System.Net.WebRequest]::Create($url) $request.Method = "GET" $request.Timeout = 10000 + # Don't auto-follow redirects with the Cloudflare Access token attached — .NET would + # resend the secret to a cross-host redirect target. An unexpected redirect just means + # no sidecar found (handled downstream as NoSidecar), never a leaked service token. + if ($request -is [System.Net.HttpWebRequest]) { $request.AllowAutoRedirect = $false } $headers = Get-FileServerHeaders foreach ($key in $headers.Keys) { diff --git a/Modules/42-ISODownload.ps1 b/Modules/42-ISODownload.ps1 index d67dea2..774e223 100644 --- a/Modules/42-ISODownload.ps1 +++ b/Modules/42-ISODownload.ps1 @@ -171,6 +171,21 @@ function Get-ServerISO { if ($freeGB -lt 10) { Write-OutputColor " ERROR: Only $freeGB GB free on ${isoDriveLetter}: drive." -color "Error" Write-OutputColor " Not enough space for ISO download (4-6 GB typically needed)." -color "Warning" + # Restore the .old fallback we renamed above (if any) before bailing, and clear the + # script-scope rollback pointer. Otherwise the renamed ISO is stranded (Test-CachedISO + # won't match `.iso.old`) AND a later Get-ServerISO for ANY version acts on this stale + # path — deleting it as "cleanup" on success, or Move-Item'ing it onto a different + # version's .iso name on failure, cross-contaminating that version. + if ($script:_isoRollbackPath -and (Test-Path -LiteralPath $script:_isoRollbackPath)) { + try { + $restorePath = $script:_isoRollbackPath -replace '\.old$', '' + Move-Item -LiteralPath $script:_isoRollbackPath -Destination $restorePath -Force -ErrorAction Stop + Write-OutputColor " Restored previous ISO from rollback: $restorePath" -color "Info" + } catch { + Write-OutputColor " Could not restore previous ISO: $($_.Exception.Message)" -color "Warning" + } + } + $script:_isoRollbackPath = $null return $null } } diff --git a/Modules/57-AgentInstaller.ps1 b/Modules/57-AgentInstaller.ps1 index fb5d928..de3c705 100644 --- a/Modules/57-AgentInstaller.ps1 +++ b/Modules/57-AgentInstaller.ps1 @@ -134,7 +134,13 @@ function Get-AgentInstallerList { if ($parsed.Valid) { # Look up optional caller-supplied hash from AgentInstaller.HashManifest (filename → SHA256) $manifestHash = '' - if ($script:AgentInstaller.PSObject.Properties.Name -contains 'HashManifest' -and $script:AgentInstaller.HashManifest) { + # $script:AgentInstaller is a hashtable (00-Initialization) — its existence + # check MUST use .ContainsKey (matching the RequireHash idiom below), not the + # .PSObject.Properties idiom, which on a hashtable returns .NET members and + # never the keys — leaving ExpectedHash silently empty and the operator's + # configured SHA256 gate dead. The HashManifest VALUE is a PSCustomObject + # (nested JSON object), so its per-file lookup at line below is correct as-is. + if ($script:AgentInstaller.ContainsKey('HashManifest') -and $script:AgentInstaller.HashManifest) { $manifest = $script:AgentInstaller.HashManifest if ($manifest.PSObject.Properties.Name -contains $fileName) { $manifestHash = [string]$manifest.$fileName @@ -563,6 +569,15 @@ function Install-SelectedAgent { return } + # Protected staging: move the just-verified installer out of $env:TEMP into an + # Administrators+SYSTEM-only directory BEFORE running it as SYSTEM. This closes the TOCTOU + # window where a lower-privileged process watching $env:TEMP could swap the EXE between the + # SHA256 verification above and the Start-Process below. Mirrors the self-update path's + # staging. Best-effort — if staging can't be set up we fall back to the $env:TEMP path + # (which for an elevated run is already per-user ACL'd) rather than blocking the install. + $stagedPath = Move-ToProtectedStaging -SourcePath $tempPath -Leaf $fn -SubDir 'agentstage' + if ($stagedPath) { $tempPath = $stagedPath } + # Run installer Write-OutputColor "" -color "Info" Write-OutputColor " Running $toolName Agent installer..." -color "Info" @@ -604,7 +619,16 @@ function Install-SelectedAgent { } | Where-Object { $_ -ne "" -and $null -ne $_ }) } if ($argArray.Count -gt 0) { - $installProcess = Start-Process -FilePath $tempPath -ArgumentList $argArray -PassThru -WindowStyle Hidden -ErrorAction Stop + # PS 5.1's Start-Process joins -ArgumentList with single spaces and does NOT re-quote + # array elements that themselves contain whitespace — so a tokenizer-preserved value + # like `GUID with spaces` (from `/token "GUID with spaces"`) would be re-split by the + # installer's CommandLineToArgvW, undoing the careful tokenizing above at the exact + # call site it was meant to protect. Build the command-line string ourselves, wrapping + # any whitespace-bearing token in double quotes, so the installer gets the intended argv. + $argString = ($argArray | ForEach-Object { + if ($_ -match '\s' -and $_ -notmatch '^".*"$') { '"' + $_ + '"' } else { $_ } + }) -join ' ' + $installProcess = Start-Process -FilePath $tempPath -ArgumentList $argString -PassThru -WindowStyle Hidden -ErrorAction Stop } else { $installProcess = Start-Process -FilePath $tempPath -PassThru -WindowStyle Hidden -ErrorAction Stop } @@ -660,10 +684,18 @@ function Install-SelectedAgent { } if ($elapsed -gt $installTimeout) { - $timeoutCheck = Test-AgentInstalled - if ($timeoutCheck.Installed) { - $earlyDetect = $true - break + # Fresh install: a now-present service means the installer overran the timeout + # but the agent is in fact there — accept it. Reinstall/upgrade: the OLD service + # was already present ($preInstalled), so its presence proves NOTHING about THIS + # run; a process still running past the timeout is a genuine hang — fall through + # to kill + honest timeout report instead of falsely claiming success and leaving + # the hung installer running (the finally's leaveRunning would have spared it). + if (-not $preInstalled) { + $timeoutCheck = Test-AgentInstalled + if ($timeoutCheck.Installed) { + $earlyDetect = $true + break + } } Write-OutputColor "" Write-OutputColor " Installation timed out after $installTimeout seconds." -color "Warning" @@ -688,9 +720,12 @@ function Install-SelectedAgent { $exitDesc = "Success (agent detected)" $agentResult = Test-AgentInstalled } elseif ($userSkipped) { - # User pressed Escape — check if agent installed anyway + # User pressed Escape to stop waiting — check if the agent installed anyway. On a + # reinstall/upgrade the OLD service is already present ($preInstalled), so a positive + # Test-AgentInstalled can't be attributed to THIS run; only a FRESH install lets a + # detected agent count as success. Otherwise report an honest SKIPPED, never SUCCESS. $agentResult = Test-AgentInstalled - if ($agentResult.Installed) { + if ($agentResult.Installed -and -not $preInstalled) { $installOK = $true $exitCode = 0 $exitDesc = "Success (agent detected)" @@ -810,11 +845,14 @@ function Install-SelectedAgent { } } finally { - # Only kill a still-running installer if THIS run did NOT succeed. When the agent - # service came up (earlyDetect) or the install otherwise verified, the installer is - # often still finishing enrollment in the background — killing it there leaves the - # agent half-configured. Let it run, and don't yank the EXE it's executing from. - $leaveRunning = $earlyDetect -or $installOK + # Only kill a still-running installer if THIS run did NOT succeed AND the operator + # didn't simply skip watching. When the agent service came up (earlyDetect) or the + # install otherwise verified, the installer is often still finishing enrollment in the + # background — killing it there leaves the agent half-configured. When the operator + # pressed Escape to stop waiting ($userSkipped) the installer may still be legitimately + # enrolling — especially a reinstall over a previously-working agent — so don't yank it + # out from under them either; the result box already reports SKIPPED (never SUCCESS). + $leaveRunning = $earlyDetect -or $installOK -or $userSkipped $stillRunning = $installProcess -and -not $installProcess.HasExited if ($stillRunning -and -not $leaveRunning) { try { $installProcess.Kill() } catch {} diff --git a/README.md b/README.md index 2600bdd..caf45ec 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5291 structural tests + 5308 structural tests Pester 312 tests SLSA Level 3

diff --git a/RackStack.ps1 b/RackStack.ps1 index 3e73ba3..4b11057 100644 --- a/RackStack.ps1 +++ b/RackStack.ps1 @@ -13,7 +13,7 @@ Environment-specific settings are configured via defaults.json. .VERSION - 1.121.8 + 1.121.9 .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 bcd4724..b03361c 100644 --- a/RackStack.psd1 +++ b/RackStack.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'RackStack.psm1' - ModuleVersion = '1.121.8' + ModuleVersion = '1.121.9' GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91' Author = 'TheAbider' CompanyName = 'TheAbider' diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index bf559fc..dcbb86c 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Automated Test Runner for RackStack v1.121.8 + Automated Test Runner for RackStack v1.121.9 .DESCRIPTION Comprehensive non-interactive test suite covering: @@ -3054,10 +3054,12 @@ try { $acMod = Get-Content (Join-Path $modulesPath "39-FileServer.ps1") -Raw $hasRetry = $acMod -match 'Retrying download' $hasIntegrity = $acMod -match 'Test-FileIntegrity' - $hasWebClient = $acMod -match 'System\.Net\.WebClient' + # v1.121.9: the download moved off WebClient (auto-followed redirects and resent the CF + # Access token) to a redirect-safe HttpWebRequest stream with AllowAutoRedirect disabled. + $hasStreamingDownload = ($acMod -match '\[System\.Net\.HttpWebRequest\]::Create') -and ($acMod -match 'AllowAutoRedirect = \$false') Write-TestResult "Get-FileServerFile: has retry logic on download failure" $hasRetry Write-TestResult "Get-FileServerFile: calls Test-FileIntegrity for validation" $hasIntegrity - Write-TestResult "Get-FileServerFile: uses WebClient for downloads" $hasWebClient + Write-TestResult "Get-FileServerFile: streams downloads via redirect-safe HttpWebRequest" $hasStreamingDownload } catch { Write-TestResult "Get-FileServerFile retry logic" $false $_.Exception.Message } @@ -7762,7 +7764,10 @@ try { Write-TestResult "39-FS: integrity gate tags missing sidecar as NoSidecar" ($fsContent2 -match "Reason\s*=\s*'NoSidecar'") Write-TestResult "39-FS: integrity gate tags tamper as HashMismatch" ($fsContent2 -match "Reason\s*=\s*'HashMismatch'") # NoSidecar is recoverable (file kept for caller); everything else fails closed (deleted) - Write-TestResult "39-FS: Get-FileServerFile preserves file on NoSidecar" ($fsContent2 -match "Reason\s*-eq\s*'NoSidecar'[\s\S]{0,200}FilePath\s*=\s*\`$destFile") + # The recoverable NoSidecar return keeps the downloaded file (FilePath = $destFile) so a + # caller can offer trust-on-first-use. (v1.121.9 added an ExpectedHash gate ahead of this + # return; the recoverable return still preserves the file.) + Write-TestResult "39-FS: Get-FileServerFile preserves file on NoSidecar" ($fsContent2 -match "Reason = 'NoSidecar'; FilePath = \`$destFile") # Agent installer honors the configurable policy and never auto-trusts a mismatch Write-TestResult "57-Agent: reads AgentInstaller.RequireHash policy" ($aiContent2 -match '\$requireHash\s*=' -and $aiContent2 -match "ContainsKey\('RequireHash'\)") @@ -7800,9 +7805,12 @@ try { Write-TestResult "35-Util: self-update certutil loop reads tokens=* (no skip)" ` ($utilContent3 -match 'for /f "tokens=\*" %%H in \(.certutil -hashfile') - # Fix 2: recoverable NoSidecar must NOT print the red "Integrity check failed" before returning + # Fix 2: the recoverable NoSidecar return must come BEFORE the genuine-fail red + # "Integrity check failed: $($integrity.Error)" print (only real tamper/compute failures show + # that). v1.121.9 added an ExpectedHash gate inside the NoSidecar branch, so key on the + # recoverable return preceding the $integrity.Error print rather than a fixed-distance return. Write-TestResult "39-FS: NoSidecar returns before the red 'Integrity check failed' print" ` - ($fsContent3 -match "Reason\s*-eq\s*'NoSidecar'[\s\S]{0,260}return[\s\S]{0,260}Write-OutputColor\s+`"\s*Integrity check failed") + ($fsContent3 -match "Reason = 'NoSidecar'; FilePath = \`$destFile[\s\S]{0,700}Integrity check failed: \`$\(\`$integrity\.Error\)") # Fix 4: no-Content-Length + no-sidecar is recoverable, not a hard 'NoSignal' delete Write-TestResult "39-FS: 'NoSignal' hard-fail reason removed" ($fsContent3 -notmatch "'NoSignal'") @@ -9849,6 +9857,63 @@ catch { Write-TestResult "Destructive-Module Hardening Tests" $false $_.Exception.Message } +# ============================================================================ +# SECTION 196: DOWNLOAD → VERIFY → EXECUTE HARDENING (v1.121.9) +# ============================================================================ +# Guards the seven fixes from the adversarial audit of the download/execute chain (FileServer, +# AgentInstaller, ISO download, Utilities staging): +# A config/caller ExpectedHash is enforced even when the FileServer has no .sha256 sidecar +# B HashManifest lookup uses .ContainsKey (hashtable), so ExpectedHash is actually populated +# C verified agent EXE is moved to an Admin/SYSTEM-only stage before execution (TOCTOU) +# D reinstall/upgrade timeout & skip no longer falsely report SUCCESS from the pre-existing svc +# E the Cloudflare Access token is never resent on a redirect (all four request paths) +# F space-bearing InstallArgs tokens are quoted so Start-Process doesn't re-split them +# G the ISO re-download free-space bail restores the .old fallback and clears the rollback ptr +Write-SectionHeader "SECTION 196: DOWNLOAD -> VERIFY -> EXECUTE HARDENING" + +try { + $fsRaw = Get-Content "$modulesPath\39-FileServer.ps1" -Raw + $aiRaw = Get-Content "$modulesPath\57-AgentInstaller.ps1" -Raw + $utRaw = Get-Content "$modulesPath\35-Utilities.ps1" -Raw + $isoRaw = Get-Content "$modulesPath\42-ISODownload.ps1" -Raw + + # A — ExpectedHash enforced inside the NoSidecar branch, before returning NoSidecar. + Write-TestResult "39-FileServer: ExpectedHash enforced even without a sidecar (A)" ` + ($fsRaw -match 'Reason -eq ''NoSidecar''[\s\S]{0,800}IsNullOrWhiteSpace\(\$ExpectedHash\)[\s\S]{0,900}integrity\.Hash -ne \$ExpectedHash') + + # B — HashManifest existence check uses the hashtable idiom, and the dead PSObject idiom is gone. + Write-TestResult "57-AgentInstaller: HashManifest lookup uses .ContainsKey (B)" ($aiRaw -match 'ContainsKey\(''HashManifest''\)') + Write-TestResult "57-AgentInstaller: dead PSObject HashManifest idiom removed (B)" (-not ($aiRaw -match 'PSObject\.Properties\.Name -contains ''HashManifest''')) + + # C — protected-staging helper exists (Admin+SYSTEM DACL) and is called before running the EXE. + Write-TestResult "35-Utilities: Move-ToProtectedStaging helper exists (C)" ($utRaw -match 'function Move-ToProtectedStaging') + Write-TestResult "35-Utilities: staging DACL is Administrators + SYSTEM only (C)" (($utRaw -match 'S-1-5-32-544') -and ($utRaw -match 'S-1-5-18')) + Write-TestResult "57-AgentInstaller: stages the EXE before executing it (C)" ($aiRaw -match 'Move-ToProtectedStaging[\s\S]{0,500}Running \$toolName Agent installer') + + # D — reinstall guards on timeout & skip; a skipped-but-running installer isn't killed. + Write-TestResult "57-AgentInstaller: >=3 reinstall (-not preInstalled) guards (D)" ((([regex]::Matches($aiRaw, '-not \$preInstalled')).Count) -ge 3) + Write-TestResult "57-AgentInstaller: skip result requires fresh install to claim success (D)" ($aiRaw -match 'agentResult\.Installed -and -not \$preInstalled') + Write-TestResult "57-AgentInstaller: userSkipped leaves a running installer alone (D)" ($aiRaw -match '\$leaveRunning = \$earlyDetect -or \$installOK -or \$userSkipped') + + # E — no CF token resent on redirect: all four request paths refuse auto-redirect; the main + # download streams via HttpWebRequest (no WebClient) and treats a 3xx as a hard failure. + Write-TestResult "39-FileServer: >=4 AllowAutoRedirect=false request paths (E)" ((([regex]::Matches($fsRaw, 'AllowAutoRedirect = \$false')).Count) -ge 4) + Write-TestResult "39-FileServer: main download streams via HttpWebRequest (E)" ($fsRaw -match 'GetResponseStream\(\)[\s\S]{0,200}CopyTo') + Write-TestResult "39-FileServer: a 3xx redirect is refused as failure (E)" ($fsRaw -match 'statusCode -ge 300 -and \$statusCode -lt 400') + Write-TestResult "39-FileServer: redirect-following WebClient removed (E)" ((([regex]::Matches($fsRaw, 'New-Object System\.Net\.WebClient')).Count) -eq 0) + + # F — InstallArgs are built into a quoted string and passed as a string (not the raw array). + Write-TestResult "57-AgentInstaller: builds a quoted arg string (F)" ($aiRaw -match '\$argString = \(\$argArray \| ForEach-Object') + Write-TestResult "57-AgentInstaller: passes the built arg string to Start-Process (F)" ($aiRaw -match '-ArgumentList \$argString -PassThru') + Write-TestResult "57-AgentInstaller: no longer passes the raw arg array (F)" ((([regex]::Matches($aiRaw, '-ArgumentList \$argArray')).Count) -eq 0) + + # G — ISO free-space bail restores the .old fallback and clears the script-scope rollback ptr. + Write-TestResult "42-ISODownload: free-space bail restores + clears rollback (G)" ($isoRaw -match 'Not enough space for ISO download[\s\S]{0,1500}_isoRollbackPath = \$null[\s\S]{0,80}return \$null') +} +catch { + Write-TestResult "Download-Execute Hardening Tests" $false $_.Exception.Message +} + # ============================================================================ # SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase) # ============================================================================