From d5f0f90f25b2c8f3799fcfb66d51590a0f86dfac Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Tue, 8 Sep 2026 17:35:00 -0700 Subject: [PATCH] Replace the ps2exe wrapper with a native launcher (v1.123.0) RackStack.exe is now a small C# host that starts Windows PowerShell's own console host and runs the monolithic script from an embedded plain-text resource. It is compiled in CI with the csc.exe that ships inside Windows, so nothing is downloaded or installed to produce the shipped binary. Every release through v1.122.4 was produced by ps2exe. Its script-host wrapper is widely reused by malware droppers, so heuristic engines scored each build as a packed script host regardless of content: a hash Microsoft had cleared was re-flagged within weeks, and the same file drifted from 8 to 19 VirusTotal detections without changing a byte. Also: - The self-destruct cleanup task runs a readable script file from a SYSTEM/Administrators-only directory (owner verified before the task is registered) instead of a base64 -EncodedCommand. - Elevation from the EXE relaunches the executable itself; the old path passed an empty script path to powershell.exe when $PSCommandPath was unset. - Run-Tests Section 209 now pins the launcher build: no ps2exe, in-box compiler by fixed path, nothing downloaded during compile, UAC manifest present, version resource populated and matching RackStack.psd1. Mutation-verified (ps2exe reintroduced, manifest downgraded, company drifted) - each fails exactly the intended assertion. - Docs and comments updated; 5511/5511 structural tests, PSSA 0 errors. --- .github/workflows/ci.yml | 70 ++++++++++------- Changelog.md | 11 +++ GOVERNANCE.md | 5 +- Header.ps1 | 6 +- Modules/00-Initialization.ps1 | 6 +- Modules/47-ExitCleanup.ps1 | 38 ++++++++-- Modules/50-EntryPoint.ps1 | 30 ++++++-- Modules/61-ActiveDirectory.ps1 | 4 +- Modules/75-Compliance.ps1 | 2 +- README.md | 8 +- ROADMAP.md | 2 +- RackStack.ps1 | 2 +- RackStack.psd1 | 2 +- SECURITY.md | 4 +- Tests/Run-Tests.ps1 | 114 +++++++++++++++++++--------- dist/launcher/README.md | 64 ++++++++++++++++ dist/launcher/RackStack.Launcher.cs | 106 ++++++++++++++++++++++++++ dist/launcher/app.manifest | 29 +++++++ dist/winget/README.md | 2 +- docs/ASSURANCE_CASE.md | 8 +- docs/Antivirus-Detections.md | 24 ++++-- docs/Troubleshooting.md | 2 +- 22 files changed, 429 insertions(+), 110 deletions(-) create mode 100644 dist/launcher/README.md create mode 100644 dist/launcher/RackStack.Launcher.cs create mode 100644 dist/launcher/app.manifest diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e9aa2bb..0f02a35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -152,19 +152,14 @@ jobs: "exists=$existsStr" | Out-File -FilePath $env:GITHUB_OUTPUT -Append -Encoding utf8 exit 0 - - name: Install ps2exe - if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' - run: | - # Pinned. This module compiles the binary that ships to users, so an - # unpinned install would let the released artifact change without a - # commit — the same supply-chain exposure the SHA-pinning policy - # closes for actions. Bump deliberately, never implicitly. - $ps2exeVersion = '1.0.18' - if (-not (Get-Module ps2exe -ListAvailable | Where-Object { $_.Version -eq $ps2exeVersion })) { - Install-Module ps2exe -RequiredVersion $ps2exeVersion -Force -Scope CurrentUser -AllowClobber - } - Import-Module ps2exe -RequiredVersion $ps2exeVersion -Force - + # The EXE is a small native host (dist/launcher/RackStack.Launcher.cs) that + # starts Windows PowerShell's own console host and runs the monolithic + # script, embedded as a plain-text resource. It is compiled with the C# + # compiler that ships inside Windows (.NET Framework 4.x), so nothing is + # downloaded or installed to produce the shipped binary. Releases through + # v1.122.4 used ps2exe, whose script-host wrapper is widely reused by + # malware droppers and drew heuristic antivirus detections on every build + # regardless of the script's content. - name: Compile RackStack.exe if: steps.vercheck.outputs.bumped == 'true' && steps.releasecheck.outputs.exists == 'false' run: | @@ -172,24 +167,41 @@ jobs: $mono = "builds\RackStack v$ver.ps1" if (-not (Test-Path $mono)) { throw "Monolithic not found at $mono" } if (-not (Test-Path 'RackStack.ico')) { throw "RackStack.ico missing — required for compile" } - # Populate the full version resource. Company/Product/Description - # were empty in every release through v1.122.3, which is both a mild - # heuristic-AV signal (legitimate software fills these in) and a real - # UX gap: -RequireAdmin raises a UAC prompt, and UAC displays - # FileDescription as the program name — so users were being asked to - # elevate a blank. Values match the identity already published in - # RackStack.psd1, the Chocolatey nuspec, and the Scoop manifest. - Invoke-PS2EXE -InputFile $mono -OutputFile 'builds\RackStack.exe' ` - -Version $ver ` - -RequireAdmin ` - -IconFile 'RackStack.ico' ` - -title 'RackStack - Windows Server configuration toolkit' ` - -product 'RackStack' ` - -company 'TheAbider' ` - -copyright 'Copyright (c) 2026 TheAbider' ` - -description 'Menu-driven configuration and automation for Windows Server hosts.' + + $csc = Join-Path $env:WINDIR 'Microsoft.NET\Framework64\v4.0.30319\csc.exe' + if (-not (Test-Path $csc)) { throw "csc.exe not found at $csc" } + $gac = Join-Path $env:WINDIR 'Microsoft.Net\assembly\GAC_MSIL' + $sma = (Get-ChildItem "$gac\System.Management.Automation" -Recurse -Filter System.Management.Automation.dll | Select-Object -First 1).FullName + $chst = (Get-ChildItem "$gac\Microsoft.PowerShell.ConsoleHost" -Recurse -Filter Microsoft.PowerShell.ConsoleHost.dll | Select-Object -First 1).FullName + if (-not $sma -or -not $chst) { throw "Windows PowerShell 5.1 host assemblies not found in the GAC" } + + # Stamp the release version into the assembly attributes. The version + # resource (Company/Product/Description/Copyright) is declared in the + # launcher source and must agree with RackStack.psd1 — Run-Tests + # Section 209 enforces that. + $stamped = Join-Path $env:RUNNER_TEMP 'RackStack.Launcher.cs' + $src = Get-Content 'dist\launcher\RackStack.Launcher.cs' -Raw + if ($src -notmatch 'Version = "0\.0\.0\.0"') { throw 'Version placeholder missing from launcher source' } + [IO.File]::WriteAllText($stamped, $src.Replace('Version = "0.0.0.0"', "Version = `"$ver.0`"")) + + # The resource name is fixed; the launcher looks it up by this exact string. + $embedded = Join-Path $env:RUNNER_TEMP 'RackStack.ps1' + Copy-Item -LiteralPath $mono -Destination $embedded -Force + + & $csc /nologo /target:exe /platform:anycpu /optimize+ /debug- /warnaserror+ ` + /r:$sma /r:$chst ` + /win32icon:RackStack.ico ` + /win32manifest:dist\launcher\app.manifest ` + /resource:$embedded,RackStack.ps1 ` + /out:builds\RackStack.exe $stamped + if ($LASTEXITCODE -ne 0) { throw "csc.exe exited $LASTEXITCODE" } + $info = Get-Item 'builds\RackStack.exe' + $vi = $info.VersionInfo Write-Host "Compiled: $($info.FullName) ($([math]::Round($info.Length / 1MB, 2)) MB)" + Write-Host "Version resource: $($vi.CompanyName) / $($vi.ProductName) / $($vi.FileVersion) / $($vi.FileDescription)" + if ($vi.FileVersion -ne "$ver.0") { throw "FileVersion '$($vi.FileVersion)' does not match release version $ver" } + if ([string]::IsNullOrWhiteSpace($vi.CompanyName)) { throw 'CompanyName is empty in the compiled EXE' } # Release integrity is provided by SHA-256 hashes, Sigstore cosign # keyless signatures, and SLSA Level 3 build provenance (all below). diff --git a/Changelog.md b/Changelog.md index 6f31268..2376f30 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,16 @@ # Changelog +## v1.123.0 + +Replaces the ps2exe wrapper with a native launcher, so the executable is no longer a packed script host. + +- **`RackStack.exe` is now a small launcher around Windows PowerShell's own console host.** Every release through v1.122.4 was produced by ps2exe, which wraps a script in its own host implementation. That wrapper is widely reused by malware droppers, so antivirus heuristics scored every build as a packed script host no matter what the script did: Microsoft re-flagged a hash it had cleared two weeks earlier, and the same file drifted from 8 to 19 VirusTotal detections without changing a byte. The new executable is compiled with the C# compiler that ships inside Windows, embeds the monolithic script as a plain-text resource, and runs it under the same engine and console as `powershell.exe`. Nothing is downloaded or installed to build it. Behaviour, parameters, elevation, self-update, and package-manager installs are unchanged. +- **The self-destruct cleanup task now runs a readable script file instead of a base64-encoded command.** The file is written to a directory restricted to SYSTEM and Administrators, with the directory's owner verified before the task is registered. What the task will do can now be audited on the host; an encoded command could not be. +- **Elevation from the executable no longer fails when UAC is off.** The relaunch path assumed a script file and passed an empty path to PowerShell; the executable now relaunches itself. +- **The build-integrity tests pin the new arrangement**: no ps2exe, the in-box compiler by its fixed path, nothing downloaded during the compile, the elevation manifest present, and the version resource populated and matching the Gallery manifest. + +No module or CLI action changes (81 modules, 201 actions). + ## v1.122.4 Hardens what the tool will let you exclude from Defender, and fixes an executable that shipped without a name. diff --git a/GOVERNANCE.md b/GOVERNANCE.md index d066c0d..86767be 100644 --- a/GOVERNANCE.md +++ b/GOVERNANCE.md @@ -67,8 +67,9 @@ limit the impact if the maintainer becomes unavailable: - **All source is public** at https://github.com/TheAbider/RackStack under the MIT License. Any forker can pick up development immediately. - **Full release history is reproducible** from any tagged commit via - `.\sync-to-monolithic.ps1` + `Invoke-PS2EXE`. The same source produces - byte-identical output up to ps2exe's PE timestamp. + `.\sync-to-monolithic.ps1` + the `ci.yml` compile step (in-box `csc.exe`, see + `dist/launcher/`). The same source produces byte-identical output up to + the PE timestamp. - **CI is fully automated and GitHub-hosted.** No self-hosted infrastructure is on the critical path; GitHub-hosted `windows-2025` runners are free for public repos. diff --git a/Header.ps1 b/Header.ps1 index add4b85..6069207 100644 --- a/Header.ps1 +++ b/Header.ps1 @@ -5,7 +5,7 @@ .DESCRIPTION This is the MONOLITHIC BUILD -- all 81 modules combined into a single file. Generated by sync-to-monolithic.ps1 from the modular source in Modules/. - The .exe is compiled from this file via ps2exe. + The .exe embeds this file and runs it under the Windows PowerShell console host (see dist/launcher/). For development, use RackStack.ps1 (the modular loader) instead. @@ -30,9 +30,9 @@ 7h3 4b1d3r .VERSION - 1.122.4 + 1.123.0 .LAST UPDATED - 07/28/2026 + 09/08/2026 .CHANGELOG v1.21.1 ROBUSTNESS, UX, CACHE CONSISTENCY: diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1 index 3752548..6f33ee2 100644 --- a/Modules/00-Initialization.ps1 +++ b/Modules/00-Initialization.ps1 @@ -221,11 +221,11 @@ $script:StorageBackendType = "iSCSI" # Store script path at startup (MUST be before functions for Exit-Script to work) $script:ScriptPath = $PSCommandPath if (-not $script:ScriptPath) { - # ps2exe compiled exe: $PSCommandPath is empty, use process path instead + # Compiled exe: $PSCommandPath is empty, use process path instead try { $script:ScriptPath = [System.Diagnostics.Process]::GetCurrentProcess().MainModule.FileName } catch {} } if (-not $script:ModuleRoot) { $script:ModuleRoot = $PSScriptRoot } -# ps2exe: $PSScriptRoot may point to a temp extraction dir, not the EXE folder. +# Compiled exe: $PSScriptRoot is empty or points elsewhere, not at the EXE folder. # Always prefer the EXE directory when running compiled (detected by empty $PSCommandPath). if (-not $PSCommandPath -and $script:ScriptPath) { $script:ModuleRoot = [System.IO.Path]::GetDirectoryName($script:ScriptPath) @@ -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.122.4" +$script:ScriptVersion = "1.123.0" $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 2faf63b..150d41d 100644 --- a/Modules/47-ExitCleanup.ps1 +++ b/Modules/47-ExitCleanup.ps1 @@ -234,7 +234,10 @@ function Exit-Script { try { New-EventLog -LogName Application -Source $toolName -ErrorAction SilentlyContinue } catch { } } - # Schedule deletion after reboot using a scheduled task + # Schedule deletion after reboot using a scheduled task. The task runs a plain-text + # script file, not an encoded command: the file is readable by anyone auditing the + # host (and by the deletion manifest above), whereas an -EncodedCommand blob is the + # signature move of a dropper and reads as one to every antivirus heuristic. try { $cleanupCommands = "Start-Sleep 60`n" foreach ($p in $uniquePaths) { @@ -250,12 +253,37 @@ function Exit-Script { # -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" + $cleanupCommands += "Unregister-ScheduledTask -TaskName '$($toolNameEsc)Cleanup' -Confirm:`$false -ErrorAction SilentlyContinue`n" - $bytes = [System.Text.Encoding]::Unicode.GetBytes($cleanupCommands) - $encoded = [Convert]::ToBase64String($bytes) + # The script runs as SYSTEM at boot, so it must live where only SYSTEM and + # Administrators can write. %ProgramData% lets any user create subfolders, and a + # pre-planted folder would leave its creator as owner with implicit WRITE_DAC — + # so any existing folder is removed, a fresh one is created, inheritance is cut, + # the DACL is reduced to SYSTEM + Administrators, and the owner is verified before + # a SYSTEM task is ever pointed at it. + $cleanupDir = Join-Path $env:ProgramData "$($script:ToolName)-cleanup" + if (Test-Path -LiteralPath $cleanupDir) { Remove-Item -LiteralPath $cleanupDir -Recurse -Force -ErrorAction Stop } + New-Item -Path $cleanupDir -ItemType Directory -Force -ErrorAction Stop | Out-Null + $adminsSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-32-544') + $systemSid = New-Object System.Security.Principal.SecurityIdentifier('S-1-5-18') + $dirAcl = New-Object System.Security.AccessControl.DirectorySecurity + $dirAcl.SetAccessRuleProtection($true, $false) + foreach ($sid in @($systemSid, $adminsSid)) { + $dirAcl.AddAccessRule((New-Object System.Security.AccessControl.FileSystemAccessRule($sid, 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow'))) + } + $dirAcl.SetOwner($adminsSid) + Set-Acl -LiteralPath $cleanupDir -AclObject $dirAcl -ErrorAction Stop + $ownerSid = (Get-Acl -LiteralPath $cleanupDir).GetOwner([System.Security.Principal.SecurityIdentifier]).Value + if ($ownerSid -ne $adminsSid.Value -and $ownerSid -ne $systemSid.Value) { + throw "cleanup directory owner is $ownerSid; refusing to schedule a SYSTEM task against it" + } + + $cleanupScript = Join-Path $cleanupDir 'cleanup.ps1' + $cleanupDirEsc = $cleanupDir -replace "'", "''" + $cleanupCommands += "Remove-Item -LiteralPath '$cleanupDirEsc' -Recurse -Force -ErrorAction SilentlyContinue" + [System.IO.File]::WriteAllText($cleanupScript, $cleanupCommands, (New-Object System.Text.UTF8Encoding $true)) - $action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -EncodedCommand $encoded" + $action = New-ScheduledTaskAction -Execute "PowerShell.exe" -Argument "-NoProfile -NonInteractive -ExecutionPolicy Bypass -File `"$cleanupScript`"" $trigger = New-ScheduledTaskTrigger -AtStartup $principal = New-ScheduledTaskPrincipal -UserId "SYSTEM" -RunLevel Highest Register-ScheduledTask -TaskName "$($script:ToolName)Cleanup" -Action $action -Trigger $trigger -Principal $principal -Force | Out-Null diff --git a/Modules/50-EntryPoint.ps1 b/Modules/50-EntryPoint.ps1 index d0253f1..059ef11 100644 --- a/Modules/50-EntryPoint.ps1 +++ b/Modules/50-EntryPoint.ps1 @@ -160,14 +160,28 @@ function Assert-Elevation { Write-OutputColor " Refusing to elevate: -Config value contains disallowed characters." -color "Error" throw "Invalid -Config value (contains quote/semicolon/backtick/ampersand/pipe)" } - $elevateArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) - if ($script:CLIAction) { $elevateArgs += @('-Action', $script:CLIAction) } - if ($script:CLIProfile -ne 'Standard') { $elevateArgs += @('-Tier', $script:CLIProfile) } - if ($script:CLIConfig) { $elevateArgs += @('-Config', $script:CLIConfig) } - if ($script:CLISilent) { $elevateArgs += '-Silent' } - if ($script:CLIQuiet) { $elevateArgs += '-Quiet' } - if ($script:CLIOutputFormat -ne 'Console') { $elevateArgs += @('-OutputFormat', $script:CLIOutputFormat) } - Start-Process powershell -ArgumentList $elevateArgs -Verb RunAs -ErrorAction Stop + $cliArgs = @() + if ($script:CLIAction) { $cliArgs += @('-Action', $script:CLIAction) } + if ($script:CLIProfile -ne 'Standard') { $cliArgs += @('-Tier', $script:CLIProfile) } + if ($script:CLIConfig) { $cliArgs += @('-Config', $script:CLIConfig) } + if ($script:CLISilent) { $cliArgs += '-Silent' } + if ($script:CLIQuiet) { $cliArgs += '-Quiet' } + if ($script:CLIOutputFormat -ne 'Console') { $cliArgs += @('-OutputFormat', $script:CLIOutputFormat) } + if ($PSCommandPath) { + # Script file: relaunch it under an elevated powershell.exe. + $elevateArgs = @('-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', $PSCommandPath) + $cliArgs + Start-Process powershell -ArgumentList $elevateArgs -Verb RunAs -ErrorAction Stop + } + else { + # Compiled EXE: $PSCommandPath is empty, so relaunch the executable itself. + # Its manifest already requests elevation; this path only runs when UAC is + # off or the caller bypassed the manifest. -ArgumentList rejects an empty + # array, so pass it only when there is something to forward. + $exePath = $script:ScriptPath + if (-not $exePath -or -not (Test-Path -LiteralPath $exePath)) { throw "Cannot locate the running executable to relaunch it elevated." } + if ($cliArgs.Count -gt 0) { Start-Process -FilePath $exePath -ArgumentList $cliArgs -Verb RunAs -ErrorAction Stop } + else { Start-Process -FilePath $exePath -Verb RunAs -ErrorAction Stop } + } } catch { Write-OutputColor " Failed to elevate: $_" -color "Error" diff --git a/Modules/61-ActiveDirectory.ps1 b/Modules/61-ActiveDirectory.ps1 index ca8cd37..f92406e 100644 --- a/Modules/61-ActiveDirectory.ps1 +++ b/Modules/61-ActiveDirectory.ps1 @@ -987,7 +987,7 @@ function Install-AdditionalDC { return } - # Step 4: Domain admin credentials. In console mode (ps2exe-built RackStack) Get-Credential + # Step 4: Domain admin credentials. In console mode (the compiled RackStack.exe) Get-Credential # may return a PSCredential with empty user/password instead of $null on cancel — explicitly # check both halves. A blank password used to make it through to Install-ADDSDomainController # which then blocked for many seconds before Kerberos rejected the empty secret. @@ -1165,7 +1165,7 @@ function Install-ReadOnlyDC { return } - # Step 4: Domain admin credentials. In console mode (ps2exe-built RackStack) Get-Credential + # Step 4: Domain admin credentials. In console mode (the compiled RackStack.exe) Get-Credential # may return a PSCredential with empty user/password instead of $null on cancel — explicitly # check both halves. A blank password used to make it through to Install-ADDSDomainController # which then blocked for many seconds before Kerberos rejected the empty secret. diff --git a/Modules/75-Compliance.ps1 b/Modules/75-Compliance.ps1 index 2aca73d..3629681 100644 --- a/Modules/75-Compliance.ps1 +++ b/Modules/75-Compliance.ps1 @@ -142,7 +142,7 @@ function Get-CISControlTable { # Registry paths are inlined as literals in each Check below — the Check # scriptblocks are invoked locally via `& $c.Check $probe`, where `$using:` # does NOT resolve (it is a remoting/job-scope feature only), and closing - # over loop/function variables is fragile under PS 5.1 + ps2exe. + # over loop/function variables is fragile under PS 5.1 in the compiled EXE. return @( # ---- 1.1 Password Policy (secedit [System Access]) ---- [ordered]@{ Id = "CIS-1.1.1"; Title = "Minimum password length >= 14"; Section = "1.1 Password Policy"; Severity = "High" diff --git a/README.md b/README.md index fcba1e0..269812c 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5495 structural tests + 5495 structural tests Pester 312 tests SLSA Level 3

@@ -120,7 +120,7 @@ Grab `RackStack.exe` from the [latest release](https://github.com/TheAbider/Rack Every release artifact is signed with [Sigstore](https://www.sigstore.dev/) cosign (keyless) and carries [SLSA Level 3](https://slsa.dev/) build provenance; each release page lists SHA-256 hashes and the verification commands. The EXE is not Authenticode-signed, so Windows SmartScreen may show an "Unknown publisher" prompt on first run. -> **Antivirus false positives:** because the EXE is unsigned, packed by ps2exe, and manages Defender exclusions, ML-based engines sometimes flag it. See [Antivirus Detections](docs/Antivirus-Detections.md) for why it happens and how to verify the binary you hold is the genuine published build. If AV alerts are a problem in your environment, run the `.ps1` from the same release instead — it is the same code, unpacked. +> **Antivirus false positives:** because the EXE is unsigned and manages Defender exclusions, ML-based engines sometimes flag it. See [Antivirus Detections](docs/Antivirus-Detections.md) for why it happens and how to verify the binary you hold is the genuine published build. If AV alerts are a problem in your environment, run the `.ps1` from the same release instead — it is the same code, unpacked. On first launch, a setup wizard walks you through configuring your environment (domain, DNS, admin account, iSCSI subnet). Your settings are saved to `rackstack.config.json` next to the exe. To pre-configure, download `rackstack.config.example.json` from the release, rename it to `rackstack.config.json`, fill in your values, and place it alongside the exe. A legacy `defaults.json` from an earlier version is still read automatically when no `rackstack.config.json` exists -- no migration needed. @@ -480,7 +480,7 @@ Run `RackStack.exe -ListActions` or `RackStack.exe -ListActions -OutputFormat JS RackStack/ ├── RackStack.ps1 # Modular loader -- dot-sources 81 modules (dev use) ├── RackStack v{version}.ps1 # Monolithic build -- all modules in one file (deploy/compile) -├── RackStack.exe # Compiled from the monolithic .ps1 via ps2exe +├── RackStack.exe # Native launcher with the monolithic .ps1 embedded (built in CI) ├── rackstack.config.json # Your environment config (gitignored; legacy defaults.json still read) ├── rackstack.config.example.json # Config template with examples ├── sync-to-monolithic.ps1 # Builds monolithic from Header.ps1 + Modules/ @@ -546,7 +546,7 @@ Tests cover parsing, module loading, function existence (615 functions), version 2. Test with `.\RackStack.ps1` (modular loader -- fast iteration, no build step) 3. Sync: `.\sync-to-monolithic.ps1` (builds `RackStack v{version}.ps1` monolithic) 4. Test: `.\Tests\Run-Tests.ps1` -5. Compile: `Invoke-PS2EXE -InputFile 'RackStack v{ver}.ps1' -OutputFile 'RackStack.exe'` +5. Compile: see [`dist/launcher/README.md`](dist/launcher/README.md) -- CI does this on release; a local build is only needed to test the EXE itself The sync script matches `#region`/`#endregion` markers between modules and the monolithic file. All 77 region pairs are flat (non-nested). Use `-DryRun` to preview. diff --git a/ROADMAP.md b/ROADMAP.md index 494245a..b62531a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -65,7 +65,7 @@ of the next feature releases once they can be validated safely. | Item | Why | |---|---| -| Optional: ARM64 EXE | If demand emerges. ps2exe + .NET on ARM64 is straightforward; CI matrix expansion only. | +| Optional: ARM64 EXE | If demand emerges. The launcher is AnyCPU .NET Framework and already runs on ARM64 Windows; a native ARM64 build would be a CI matrix expansion only. | | Optional: PowerShell 7 module path | The thin-wrapper module already supports both editions via the `.psd1` `CompatiblePSEditions = @('Desktop', 'Core')`. A PS7-only feature track is not currently planned. | | `RackStack.exe -Action FleetScan` improvements (PSRemoting over WinRM HTTPS, parallel host limits) | Adoption-driven — only if a real multi-host operator surfaces concrete asks. | diff --git a/RackStack.ps1 b/RackStack.ps1 index c537a44..fa42a79 100644 --- a/RackStack.ps1 +++ b/RackStack.ps1 @@ -13,7 +13,7 @@ Environment-specific settings are configured via rackstack.config.json (a legacy defaults.json is still read). .VERSION - 1.122.4 + 1.123.0 .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 2309036..7fc7425 100644 --- a/RackStack.psd1 +++ b/RackStack.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'RackStack.psm1' - ModuleVersion = '1.122.4' + ModuleVersion = '1.123.0' GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91' Author = 'TheAbider' CompanyName = 'TheAbider' diff --git a/SECURITY.md b/SECURITY.md index 31ef363..427878c 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -72,7 +72,7 @@ Issues of particular concern: Out of scope: - Vulnerabilities in Windows itself or third-party modules (`Pester`, - `PSScriptAnalyzer`, `ps2exe`) — please report those upstream + `PSScriptAnalyzer`) — please report those upstream - Operator misconfiguration where the documented default is safe - Findings that require an attacker who is already Administrator on the same machine (RackStack runs as Administrator by design) @@ -113,7 +113,7 @@ provenance are the integrity guarantees in the meantime. ## Antivirus detections -Being unsigned, packed by ps2exe, and capable of managing Defender +Being unsigned and capable of managing Defender exclusions makes `RackStack.exe` score badly with machine-learning and heuristic antivirus engines. Detections are reported periodically and are false positives; they are disputed with vendors as they come in. diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index 77d3ac5..bd01e19 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -1,6 +1,6 @@ <# .SYNOPSIS - Automated Test Runner for RackStack v1.122.4 + Automated Test Runner for RackStack v1.123.0 .DESCRIPTION Comprehensive non-interactive test suite covering: @@ -10773,24 +10773,27 @@ catch { } # ============================================================================ -# SECTION 209: BUILD METADATA INTEGRITY (what ps2exe stamps into the EXE) +# SECTION 209: BUILD INTEGRITY (how RackStack.exe is produced, and what it says it is) # ============================================================================ -# Every release through v1.122.3 shipped a binary whose CompanyName, -# ProductName, FileDescription and LegalCopyright were EMPTY — verified by -# reading the version resource out of the published v1.122.3 artifact. Two -# costs: an empty version resource is a mild heuristic-AV signal because -# legitimate software populates it, and -RequireAdmin raises a UAC prompt that -# displays FileDescription as the program name, so users were asked to elevate -# a blank. +# Releases through v1.122.4 were produced by ps2exe. Its script-host wrapper +# is widely reused by malware droppers, so heuristic antivirus engines scored +# every build as a packed script host no matter what the script contained — +# the same file drifted from 8 to 19 VirusTotal detections in four weeks +# without changing a byte, and Microsoft re-flagged a hash it had cleared. # -# The compiler version is pinned here too. ps2exe builds the binary that ships -# to users, so an unpinned Install-Module let the released artifact change -# without a commit — the exposure the SHA-pinning policy already closes for -# actions, including transitive ones. -Write-SectionHeader "SECTION 209: BUILD METADATA INTEGRITY" +# The EXE is now a small launcher (dist/launcher/RackStack.Launcher.cs) that +# starts Windows PowerShell's own console host and runs the monolithic script +# from an embedded plain-text resource. It is compiled with the C# compiler +# that ships inside Windows, so no compiler or wrapper is downloaded to build +# the binary that ships to users. These checks pin that arrangement: a return +# to ps2exe, an unpinned/downloaded compiler, a lost elevation manifest, or an +# empty version resource all fail here rather than in a VirusTotal result. +Write-SectionHeader "SECTION 209: BUILD INTEGRITY" try { $ciPath209 = Join-Path $script:ModuleRoot '.github\workflows\ci.yml' + $launcherPath209 = Join-Path $script:ModuleRoot 'dist\launcher\RackStack.Launcher.cs' + $manifestPath209 = Join-Path $script:ModuleRoot 'dist\launcher\app.manifest' if (Test-Path -LiteralPath $ciPath209) { # -Encoding UTF8 is load-bearing. ci.yml has no BOM, and Windows PowerShell # 5.1 decodes BOM-less files as ANSI, so its em-dashes and box-drawing @@ -10800,31 +10803,74 @@ try { # non-ASCII text in this file pass in one host and fail in the other. $ci209 = Get-Content -LiteralPath $ciPath209 -Raw -Encoding UTF8 - # Compiler must be pinned to an exact version, never floating. - Write-TestResult "Build: ps2exe is pinned to an explicit version" ` - ([bool]($ci209 -match "\`$ps2exeVersion\s*=\s*'\d+\.\d+\.\d+'")) - Write-TestResult "Build: ps2exe install uses -RequiredVersion" ` - ([bool]($ci209 -match 'Install-Module ps2exe -RequiredVersion')) - Write-TestResult "Build: ps2exe install is not unpinned" ` - ([bool]($ci209 -notmatch 'Install-Module ps2exe -Force')) - - # The version resource must actually be populated. - $p2e209 = [regex]::Match($ci209, '(?s)Invoke-PS2EXE.*?(?=\r?\n\s*\$info\s*=)') - Write-TestResult "Build: Invoke-PS2EXE call is locatable" $p2e209.Success ` - "regex found no ps2exe invocation — the checks below would pass vacuously" - $call209 = $p2e209.Value - foreach ($flag in @('title', 'product', 'company', 'copyright', 'description')) { - Write-TestResult "Build: EXE metadata sets -$flag" ` - ($p2e209.Success -and $call209 -match "-$flag\s+'") + # No ps2exe anywhere in the release path — not installed, not invoked. + Write-TestResult "Build: ci.yml no longer installs ps2exe" ` + ([bool]($ci209 -notmatch 'Install-Module\s+ps2exe')) + Write-TestResult "Build: ci.yml no longer invokes ps2exe" ` + ([bool]($ci209 -notmatch '(?i)Invoke-PS2EXE|Import-Module\s+ps2exe')) + + # The compile step must be locatable, or every check on its body passes vacuously. + $compile209 = [regex]::Match($ci209, '(?s)- name: Compile RackStack\.exe.*?(?=\r?\n\s{6}- name:)') + Write-TestResult "Build: compile step is locatable" $compile209.Success ` + "regex found no 'Compile RackStack.exe' step — the checks below would pass vacuously" + $cbody209 = $compile209.Value + + # The compiler is the one inside Windows, referenced by its fixed path — + # nothing fetched from a gallery or the network decides what ships. + Write-TestResult "Build: compiles with the in-box .NET Framework csc.exe" ` + ($compile209.Success -and $cbody209 -match 'Microsoft\.NET\\Framework64\\v4\.0\.30319\\csc\.exe') + Write-TestResult "Build: compile step downloads nothing" ` + ($compile209.Success -and $cbody209 -notmatch '(?i)Invoke-WebRequest|Install-Module|Invoke-RestMethod|DownloadFile') + Write-TestResult "Build: compile step warns as errors" ` + ($compile209.Success -and $cbody209 -match '/warnaserror\+') + + # Inputs to the compile: the tracked launcher source, the tracked UAC + # manifest, the icon, and the monolithic embedded under its fixed name. + Write-TestResult "Build: compiles dist/launcher/RackStack.Launcher.cs" ` + ($compile209.Success -and $cbody209 -match 'dist\\launcher\\RackStack\.Launcher\.cs') + Write-TestResult "Build: applies dist/launcher/app.manifest" ` + ($compile209.Success -and $cbody209 -match '/win32manifest:dist\\launcher\\app\.manifest') + Write-TestResult "Build: embeds the icon" ` + ($compile209.Success -and $cbody209 -match '/win32icon:RackStack\.ico') + Write-TestResult "Build: embeds the monolithic as resource 'RackStack.ps1'" ` + ($compile209.Success -and $cbody209 -match '/resource:\$embedded,RackStack\.ps1') + Write-TestResult "Build: stamps the release version into the launcher" ` + ($compile209.Success -and $cbody209 -match 'Replace\(''Version = "0\.0\.0\.0"''') + Write-TestResult "Build: verifies the compiled FileVersion equals the release version" ` + ($compile209.Success -and $cbody209 -match 'FileVersion -ne "\$ver\.0"') + Write-TestResult "Build: refuses an EXE with an empty CompanyName" ` + ($compile209.Success -and $cbody209 -match 'IsNullOrWhiteSpace\(\$vi\.CompanyName\)') + + # The launcher source itself: version placeholder present (so stamping + # has something to replace), the embedded-resource name matches what the + # build embeds, and the version resource is fully populated. + $lsrc209 = if (Test-Path -LiteralPath $launcherPath209) { Get-Content -LiteralPath $launcherPath209 -Raw -Encoding UTF8 } else { '' } + Write-TestResult "Build: launcher source exists" ([bool]$lsrc209) + Write-TestResult "Build: launcher carries the version placeholder the build stamps" ` + ([bool]($lsrc209 -match 'const string Version = "0\.0\.0\.0";')) + Write-TestResult "Build: launcher reads the resource the build embeds" ` + ([bool]($lsrc209 -match 'ScriptName = "RackStack\.ps1";') -and [bool]($lsrc209 -match 'GetManifestResourceStream\(ScriptName\)')) + Write-TestResult "Build: launcher hosts PowerShell's own console host" ` + ([bool]($lsrc209 -match 'ConsoleShell\.Start\(')) + Write-TestResult "Build: launcher writes nothing to disk" ` + ([bool]($lsrc209 -notmatch '(?i)File\.Write|WriteAllText|WriteAllBytes|FileStream\(|Process\.Start|Path\.GetTempPath')) + foreach ($attr in @('AssemblyTitle', 'AssemblyProduct', 'AssemblyCompany', 'AssemblyCopyright', 'AssemblyDescription', 'AssemblyFileVersion')) { + Write-TestResult "Build: launcher declares $attr" ` + ([bool]($lsrc209 -match "\[assembly:\s*$attr\(`"?[^`")]")) } - Write-TestResult "Build: EXE still stamps -Version" ` - ($p2e209.Success -and $call209 -match '-Version\s+\$ver') + + # The UAC manifest is what makes the EXE elevate; losing it silently + # produces a binary that fails at the first admin cmdlet. + $lman209 = if (Test-Path -LiteralPath $manifestPath209) { Get-Content -LiteralPath $manifestPath209 -Raw -Encoding UTF8 } else { '' } + Write-TestResult "Build: UAC manifest exists" ([bool]$lman209) + Write-TestResult "Build: UAC manifest requests requireAdministrator" ` + ([bool]($lman209 -match 'requestedExecutionLevel\s+level="requireAdministrator"')) # One identity across every published surface. The EXE's CompanyName # must agree with the Gallery manifest rather than drifting on its own. $psd209 = Get-Content (Join-Path $script:ModuleRoot 'RackStack.psd1') -Raw $psdCompany209 = [regex]::Match($psd209, "CompanyName\s*=\s*'([^']+)'").Groups[1].Value - $exeCompany209 = [regex]::Match($call209, "-company\s+'([^']+)'").Groups[1].Value + $exeCompany209 = [regex]::Match($lsrc209, 'AssemblyCompany\("([^"]+)"\)').Groups[1].Value Write-TestResult "Build: EXE CompanyName matches RackStack.psd1 ('$psdCompany209')" ` ($psdCompany209 -and $exeCompany209 -and $psdCompany209 -eq $exeCompany209) ` "psd1='$psdCompany209' exe='$exeCompany209'" @@ -12778,7 +12824,7 @@ try { Write-TestResult "50-EntryPoint: JSON includes Tool field" ($mod50 -match 'Tool\s*=\s*\$script:ToolFullName') Write-TestResult "50-EntryPoint: JSON includes Version field" ($mod50 -match 'Version\s*=\s*\$script:ScriptVersion') Write-TestResult "50-EntryPoint: JSON includes Action field" ($mod50 -match "Action\s*=\s*'HealthCheck'") - Write-TestResult "50-EntryPoint: OutputFormat in re-elevation" ($mod50 -match 'CLIOutputFormat.*elevateArgs.*OutputFormat') + Write-TestResult "50-EntryPoint: OutputFormat in re-elevation" ($mod50 -match 'CLIOutputFormat.*(cliArgs|elevateArgs).*OutputFormat') # HealthCheck structured report tests Write-TestResult "37-HealthCheck: builds report hashtable" ($mod37 -match '\$report\s*=\s*@\{') diff --git a/dist/launcher/README.md b/dist/launcher/README.md new file mode 100644 index 0000000..a43a279 --- /dev/null +++ b/dist/launcher/README.md @@ -0,0 +1,64 @@ +# RackStack.exe launcher + +`RackStack.exe` is a small native host for the monolithic script, not a packed +or converted copy of it. + +| File | Purpose | +|---|---| +| `RackStack.Launcher.cs` | The whole program. Starts Windows PowerShell's own console host in-process and runs the embedded script in it. | +| `app.manifest` | Requests elevation (`requireAdministrator`), declares supported Windows versions, opts into long paths. | + +## How the executable is built + +CI compiles the launcher with `csc.exe` from the .NET Framework that is part of +Windows, and embeds `RackStack v{version}.ps1` as a plain-text resource named +`RackStack.ps1`. There is no third-party compiler, wrapper, or packer in the +path from source to binary. The exact command is in +[`.github/workflows/ci.yml`](../../.github/workflows/ci.yml), step +"Compile RackStack.exe". + +The embedded script is byte-identical to the monolithic `.ps1` published in the +same release, which is Sigstore-signed like every other release artifact. + +## Why the launcher exists + +Earlier releases were produced with ps2exe, which wraps a script in its own +PowerShell host implementation. That wrapper is widely reused by malware +droppers, so antivirus heuristics scored every RackStack build as a packed +script host regardless of what the script did. Running the script under the +genuine console host removes the wrapper entirely and gives users the same +console behaviour as `powershell.exe`. + +## Local build + +From a Windows PowerShell 5.1 machine, with the monolithic already generated by +`sync-to-monolithic.ps1`: + +```powershell +$ver = '1.123.0' +$csc = "$env:WINDIR\Microsoft.NET\Framework64\v4.0.30319\csc.exe" +$gac = "$env:WINDIR\Microsoft.Net\assembly\GAC_MSIL" +$sma = (Get-ChildItem "$gac\System.Management.Automation" -Recurse -Filter System.Management.Automation.dll | Select-Object -First 1).FullName +$chst = (Get-ChildItem "$gac\Microsoft.PowerShell.ConsoleHost" -Recurse -Filter Microsoft.PowerShell.ConsoleHost.dll | Select-Object -First 1).FullName + +$src = (Get-Content dist\launcher\RackStack.Launcher.cs -Raw).Replace('Version = "0.0.0.0"', "Version = `"$ver.0`"") +[IO.File]::WriteAllText("$env:TEMP\RackStack.Launcher.cs", $src) +Copy-Item "builds\RackStack v$ver.ps1" "$env:TEMP\RackStack.ps1" -Force + +& $csc /nologo /target:exe /platform:anycpu /optimize+ /debug- /warnaserror+ ` + /r:$sma /r:$chst ` + /win32icon:RackStack.ico /win32manifest:dist\launcher\app.manifest ` + /resource:"$env:TEMP\RackStack.ps1",RackStack.ps1 ` + /out:builds\RackStack.exe "$env:TEMP\RackStack.Launcher.cs" +``` + +## Runtime behaviour + +- Arguments pass straight through: `RackStack.exe -Action HealthCheck -Silent` + behaves exactly like running the script with those parameters. +- `$PSCommandPath` is empty inside the script (as it was under ps2exe); the + script already resolves its own location from the process path. +- If the embedded resource is missing (a development build compiled without + `/resource:`), the launcher falls back to a `RackStack.ps1` beside the + executable and otherwise exits with code 2. +- Exit code 3 means Windows PowerShell 5.1 is not installed. diff --git a/dist/launcher/RackStack.Launcher.cs b/dist/launcher/RackStack.Launcher.cs new file mode 100644 index 0000000..af765ad --- /dev/null +++ b/dist/launcher/RackStack.Launcher.cs @@ -0,0 +1,106 @@ +// RackStack.exe — the native host for the RackStack PowerShell toolkit. +// +// This program does one thing: it starts Windows PowerShell's own console host +// (the same engine and console UI that powershell.exe uses) and runs RackStack +// in it. It contains no PowerShell of its own, performs no work beyond locating +// the script and handing over, and writes nothing to disk. +// +// The script is embedded as a plain-text resource; it is byte-identical to the +// monolithic .ps1 published (and Sigstore-signed) in the same release, so what +// the EXE does can be read there. +// +// Build: csc.exe from the .NET Framework 4.x that ships inside Windows — no +// third-party compiler, wrapper, or packer. See ci.yml "Compile RackStack.exe". + +using System; +using System.Collections.Generic; +using System.IO; +using System.Reflection; +using System.Management.Automation.Runspaces; +using Microsoft.PowerShell; + +[assembly: AssemblyTitle("RackStack - Windows Server configuration toolkit")] +[assembly: AssemblyDescription("Menu-driven configuration and automation for Windows Server hosts.")] +[assembly: AssemblyProduct("RackStack")] +[assembly: AssemblyCompany("TheAbider")] +[assembly: AssemblyCopyright("Copyright (c) 2026 TheAbider")] +[assembly: AssemblyVersion(RackStack.Launcher.Version)] +[assembly: AssemblyFileVersion(RackStack.Launcher.Version)] +[assembly: AssemblyInformationalVersion(RackStack.Launcher.Version)] + +namespace RackStack +{ + internal static class Launcher + { + // Stamped by the build from Header.ps1's .VERSION; the placeholder never ships. + internal const string Version = "0.0.0.0"; + + // Name of the embedded resource AND of the optional sibling file used when + // the resource is absent (development builds). + private const string ScriptName = "RackStack.ps1"; + + private static int Main(string[] args) + { + var psArgs = new List + { + "-NoLogo", + "-NoProfile", + "-ExecutionPolicy", "Bypass" + }; + + string script = ReadEmbeddedScript(); + if (script != null) + { + // The host joins everything after -Command with spaces, so the script + // becomes one anonymous script block and the user's arguments follow it + // exactly as they would after "& { ... }" at a PowerShell prompt. + psArgs.Add("-Command"); + psArgs.Add("& {" + Environment.NewLine + script + Environment.NewLine + "}"); + foreach (string a in args) psArgs.Add(QuoteForCommand(a)); + } + else + { + string scriptPath = Path.Combine(AppDomain.CurrentDomain.BaseDirectory, ScriptName); + if (!File.Exists(scriptPath)) + { + Console.Error.WriteLine("RackStack: this executable carries no embedded script and no " + ScriptName + " sits next to it."); + Console.Error.WriteLine("Re-download it from https://github.com/TheAbider/RackStack/releases"); + return 2; + } + psArgs.Add("-File"); + psArgs.Add(scriptPath); + psArgs.AddRange(args); + } + + try + { + return ConsoleShell.Start(RunspaceConfiguration.Create(), string.Empty, string.Empty, psArgs.ToArray()); + } + catch (FileNotFoundException ex) + { + Console.Error.WriteLine("RackStack: Windows PowerShell 5.1 (Windows Management Framework 5.1) is required but was not found."); + Console.Error.WriteLine(ex.Message); + return 3; + } + } + + private static string ReadEmbeddedScript() + { + using (Stream s = Assembly.GetExecutingAssembly().GetManifestResourceStream(ScriptName)) + { + if (s == null) return null; + using (var r = new StreamReader(s, true)) { return r.ReadToEnd(); } + } + } + + // Parameter names (-Action, -Silent) must stay bare so PowerShell binds them; + // everything else is single-quoted so spaces and special characters survive. + private static string QuoteForCommand(string a) + { + bool looksLikeParameter = a.Length > 1 && a[0] == '-' + && a.IndexOfAny(new[] { ' ', '\t', '\'', '"', '`', '$', ';', '&', '|', '(', ')', '{', '}' }) < 0; + if (looksLikeParameter) return a; + return "'" + a.Replace("'", "''") + "'"; + } + } +} diff --git a/dist/launcher/app.manifest b/dist/launcher/app.manifest new file mode 100644 index 0000000..487503d --- /dev/null +++ b/dist/launcher/app.manifest @@ -0,0 +1,29 @@ + + + + RackStack - Windows Server configuration toolkit + + + + + + + + + + + + + + + + + + + + + + true + + + diff --git a/dist/winget/README.md b/dist/winget/README.md index 558a6a6..0a89ef3 100644 --- a/dist/winget/README.md +++ b/dist/winget/README.md @@ -18,7 +18,7 @@ ci.yml step -- do not hand-maintain these files after that. ## Notes on the manifest choices - **`InstallerType: portable`** -- `RackStack.exe` is a standalone - ps2exe-compiled executable, not an installer. winget installs it as a + self-contained executable (a native launcher with the script embedded), not an installer. winget installs it as a portable package: it places the EXE and registers a PATH alias. - **`Commands: [rackstack]`** -- so `rackstack` works from any shell after install. The EXE auto-elevates itself when run. diff --git a/docs/ASSURANCE_CASE.md b/docs/ASSURANCE_CASE.md index 37c5ca4..015652a 100644 --- a/docs/ASSURANCE_CASE.md +++ b/docs/ASSURANCE_CASE.md @@ -186,7 +186,7 @@ added as part of the patch that introduces them, by policy. | SHA-256 hash of every artifact published in `release-hashes.txt` | Every release. | | Sigstore cosign keyless signature on every artifact (`.sig` + `.pem`) | Every release since v1.98.54; verification command in release notes. | | SLSA Level 3 build provenance attestation | `actions/attest-build-provenance@v2` on every release; verifiable via `gh attestation verify`. | -| Reproducible build from source | `.\sync-to-monolithic.ps1` produces deterministic monolithic; `Invoke-PS2EXE` output is byte-identical given the same source + version arguments. | +| Reproducible build from source | `.\sync-to-monolithic.ps1` produces deterministic monolithic; the launcher compile (`ci.yml`, in-box `csc.exe`) is byte-identical given the same source + version, up to the PE timestamp. | | SHA-pinned GitHub Actions enforced at the repo policy level | `gh api repos/TheAbider/RackStack/actions/permissions` shows `"sha_pinning_required": true`. | **Counter-argument considered.** The maintainer's GitHub account could @@ -248,14 +248,14 @@ following are acknowledged and tracked: whole-codebase 96%; readers should weight the regex harness's 4990-pattern coverage alongside. -### CR-4: ps2exe PE timestamp non-determinism -- The compiled EXE has a PE COFF timestamp field that's set by ps2exe +### CR-4: PE timestamp non-determinism +- The compiled EXE has a PE COFF timestamp field that the in-box C# compiler sets to "now" at build time. Two builds from the same source produce different SHA-256 hashes for that reason alone. - Mitigation: `release-hashes.txt` is signed per-build; downstream verifiers check the cosign signature, not bit-for-bit reproducibility with their own rebuild. -- Tracking: a ps2exe patch upstream could fix this; not currently +- Tracking: the Roslyn compiler's `/deterministic` switch would fix this; the in-box compiler predates it. Not currently planned to fork. ### CR-5: Operator can disable defenses diff --git a/docs/Antivirus-Detections.md b/docs/Antivirus-Detections.md index 7ea7af4..693abd8 100644 --- a/docs/Antivirus-Detections.md +++ b/docs/Antivirus-Detections.md @@ -22,18 +22,26 @@ If you arrived here from a VirusTotal result or a quarantine notification, start ## Why it happens -Three properties of RackStack combine to score badly with behavioral and static ML classifiers. -All three are inherent to what the tool is and does. +Two properties of RackStack combine to score badly with behavioral and static ML classifiers. +Both are inherent to what the tool is and does. | Property | Why a classifier dislikes it | |---|---| | **The EXE is not Authenticode-signed** | No publisher reputation exists to offset a heuristic score. Code-signing certificates that would fix this require a validated legal entity, which this project does not have. | -| **It is a packed script host** | The EXE is a PowerShell script compiled by [ps2exe](https://github.com/MScholtes/PS2EXE) into a .NET assembly. Self-extracting script hosts are strongly associated with malware droppers, which is why detections usually carry `MSIL`, `assembly`, or generic packer labels. | -| **It manages Defender exclusions and services** | RackStack applies Microsoft's own published antivirus exclusion recommendations for Hyper-V, Failover Clustering, and iSCSI/SAN workloads, and can disable optional Windows services. An unsigned packed binary adding its own antivirus exclusions is, behaviorally, the textbook opening move of a dropper. | +| **It manages Defender exclusions and services** | RackStack applies Microsoft's own published antivirus exclusion recommendations for Hyper-V, Failover Clustering, and iSCSI/SAN workloads, and can disable optional Windows services. An unsigned binary whose contents mention antivirus exclusions is, to a classifier, the textbook opening move of a dropper. | + +A third property was removed in v1.123.0. Releases through v1.122.4 were produced by +[ps2exe](https://github.com/MScholtes/PS2EXE), which wraps a script in its own PowerShell host +implementation. That wrapper is widely reused by malware droppers, so every build scored as a +packed script host regardless of the script's content: detections carried `MSIL`, `assembly`, and +generic packer labels, a cleared hash was re-flagged within weeks, and the same file drifted from 8 +to 19 VirusTotal detections without changing a byte. The EXE is now a small launcher, compiled with +the C# compiler that ships inside Windows, that starts Windows PowerShell's own console host and +runs the script from an embedded plain-text resource. See [`dist/launcher/`](../dist/launcher/). The most common result is a **behavioral** detection such as `Behavior:Win32/DefenseEvasion.A!ml`, which fires on what the running process *does* — not on the file matching anything known. Static -ML verdicts such as `Trojan:Win32/Sabsik.EN.A!ml` come from the same combination of features. +ML verdicts such as `Trojan:Win32/Wacatac.B!ml` come from the same combination of features. New releases are also **low-prevalence** files, which raises heuristic scores until download history accumulates. @@ -89,7 +97,7 @@ cosign verify-blob ` Every release also ships a CycloneDX SBOM. There is no manual or local step anywhere in the release path — the published EXE is built entirely in GitHub-hosted CI from the public source in -this repository, and the monolithic `.ps1` it was compiled from is published in the same release +this repository, and the monolithic `.ps1` it embeds is published in the same release so you can read exactly what the EXE does. --- @@ -151,7 +159,7 @@ Add-MpPreference -ExclusionPath 'C:\Path\To\RackStack.exe' ## Avoiding it entirely: run the script The monolithic `RackStack v{version}.ps1` published in every release is the *same code* the EXE -is compiled from. It is unpacked, it is cosign-signed like every other release artifact, and it +embeds. It is unpacked, it is cosign-signed like every other release artifact, and it is never scored by the PE classifiers that produce these detections. ```powershell @@ -160,7 +168,7 @@ Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process ``` The PowerShell Gallery module (`Install-Module RackStack`) is another script-based route that -avoids the packed binary. +avoids the executable entirely. If antivirus alerts are a recurring problem in your environment, prefer one of these. diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index e8f7a49..63a1349 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -666,7 +666,7 @@ The sweep tool uses parallel background jobs for speed: ### Cause -`RackStack.exe` is unsigned, packed by ps2exe into a .NET assembly, and manages Windows Defender +`RackStack.exe` is unsigned and manages Windows Defender exclusions as a documented feature. That combination scores as evasion behaviour to ML classifiers. These are false positives.