From 4ab47a4ed0a83855e3864870458a900961973df0 Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Mon, 27 Jul 2026 18:59:54 -0700 Subject: [PATCH 1/2] Fix self-update installing unverified .ps1 payloads GitHub rewrites whitespace to dots in release asset names, so the monolithic published as "RackStack v1.2.3.ps1" is served as "RackStack.v1.2.3.ps1" while the SHA-256 manifest in the release body still lists the original spaced filename. Install-ScriptUpdate resolved the expected hash by regex-escaping $asset.name and matching it against the body, so on the .ps1 path the lookup could never match. $expectedHash came back null, and the code warned "skipping verification" and installed anyway - copying an unverified download over the running script and relaunching it elevated. The exact-name lookup for the .ps1 asset was dead code for the same reason, always falling through to the wildcard, which is what hid it. Updating the .exe was unaffected: "RackStack.exe" has no whitespace to rewrite, so its manifest lookup matched and its staged replace re-verified with certutil before the move. - Extract Get-ReleaseAssetHash, which normalizes space/dot spellings on both sides so either form resolves to the other - Normalize the same way when selecting the asset, so the exact match is no longer dead code - Fail closed: an update whose hash cannot be found is refused rather than installed with a warning Adds Run-Tests section 204, which exercises the real function against fixtures shaped like live GitHub API responses. Verified against the pre-fix logic: it returns null for the dotted asset, so the regression is genuinely caught rather than asserted. Also documents antivirus false positives, which are expected given an unsigned ps2exe binary that manages Defender exclusions. The new guide covers verifying a release via hash, cosign, and SLSA provenance, distinguishing a false positive from a tampered file, restoring from quarantine, and running the .ps1 to avoid the packed binary entirely. --- Changelog.md | 9 ++ Header.ps1 | 4 +- Modules/00-Initialization.ps1 | 2 +- Modules/35-Utilities.ps1 | 69 ++++++++++--- README.md | 5 +- RackStack.ps1 | 2 +- SECURITY.md | 21 ++++ Tests/Run-Tests.ps1 | 82 ++++++++++++++- docs/Antivirus-Detections.md | 189 ++++++++++++++++++++++++++++++++++ docs/Troubleshooting.md | 37 +++++++ 10 files changed, 401 insertions(+), 19 deletions(-) create mode 100644 docs/Antivirus-Detections.md diff --git a/Changelog.md b/Changelog.md index 0cd78f8..9e60296 100644 --- a/Changelog.md +++ b/Changelog.md @@ -1,5 +1,14 @@ # Changelog +## v1.122.1 + +Self-update integrity fix, plus guidance for antivirus false positives. + +- **The self-updater no longer installs an unverified update.** When updating the `.ps1`, the SHA-256 check was silently skipped and the download was installed anyway. GitHub rewrites spaces to dots in release asset names, so the published `RackStack v1.2.3.ps1` is served as `RackStack.v1.2.3.ps1`, while the hash manifest in the release notes still lists the original spaced filename -- the lookup compared the two directly and never matched. Filenames are now normalized before comparison so both spellings resolve, and an update whose hash cannot be found is **refused** rather than installed with a warning. Updating the `.exe` was never affected. +- **New guide: [Antivirus Detections](docs/Antivirus-Detections.md).** Because the EXE is unsigned, packed by ps2exe, and manages Defender exclusions, machine-learning antivirus engines periodically flag it. The guide explains why, walks through verifying that the binary you hold is the genuine published build (SHA-256, Sigstore cosign, SLSA provenance), covers restoring from quarantine, and notes that running the monolithic `.ps1` avoids the packed binary entirely. `SECURITY.md` and the troubleshooting guide now point at it. + +No module or CLI action changes (81 modules, 201 actions). 5417 structural tests. + ## v1.122.0 Namespaced config-file naming. The base config is now `rackstack.config.json` and company overrides are `.rackstack.config.json` -- names that stay unambiguous when the EXE runs from a shared or busy folder. Nothing breaks for existing setups: the legacy `defaults.json` and `.defaults.json` names are still read whenever the new-named file is absent, and the tool keeps saving to whichever file it loaded. diff --git a/Header.ps1 b/Header.ps1 index 09f301d..09ac295 100644 --- a/Header.ps1 +++ b/Header.ps1 @@ -30,9 +30,9 @@ 7h3 4b1d3r .VERSION - 1.122.0 + 1.122.1 .LAST UPDATED - 07/16/2026 + 07/27/2026 .CHANGELOG v1.21.1 ROBUSTNESS, UX, CACHE CONSISTENCY: diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1 index 83d3d58..3e2e4fe 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.122.0" +$script:ScriptVersion = "1.122.1" $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 e1e1341..5ef0c8e 100644 --- a/Modules/35-Utilities.ps1 +++ b/Modules/35-Utilities.ps1 @@ -273,6 +273,42 @@ function Move-ToProtectedStaging { } } +# Resolve the published SHA-256 for a release asset from the hash manifest in the release body. +# +# The manifest lines carry the ORIGINAL filename ("RackStack v1.2.3.ps1"), while GitHub serves +# the asset under a rewritten name with whitespace collapsed to dots ("RackStack.v1.2.3.ps1"). +# Matching the raw asset name against the manifest therefore never hit on the .ps1 path, which +# silently disabled integrity verification for script self-updates. Both sides are normalized to +# one space/dot spelling so either form resolves to the other. +# +# Returns the uppercase hash, or $null when the asset has no manifest entry. Callers MUST treat +# $null as "refuse to install" — the payload this guards is executed with elevation. +function Get-ReleaseAssetHash { + param ( + [Parameter(Mandatory = $true)] + [AllowEmptyString()] + [string]$ReleaseBody, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$AssetName + ) + + if ([string]::IsNullOrWhiteSpace($ReleaseBody)) { return $null } + + $assetHashKey = $AssetName -replace '[\s.]', '.' + foreach ($manifestLine in ($ReleaseBody -split "`n")) { + if ($manifestLine -match '([0-9a-fA-F]{64})\s+(\S.*?)\s*$') { + $regexMatches = $matches + if (($regexMatches[2] -replace '[\s.]', '.') -ieq $assetHashKey) { + return $regexMatches[1].ToUpper() + } + } + } + + return $null +} + # Function to download and install an update from a GitHub release function Install-ScriptUpdate { param ( @@ -286,9 +322,16 @@ function Install-ScriptUpdate { $scriptDir = Split-Path $script:ScriptPath $scriptName = Split-Path $script:ScriptPath -Leaf - # Find the right asset to download + # Find the right asset to download. + # + # GitHub rewrites whitespace to dots in release asset names: the artifact published as + # "RackStack v1.2.3.ps1" is stored and served as "RackStack.v1.2.3.ps1". An exact-string + # match on the spaced name therefore NEVER hit for the .ps1 path — it always fell through + # to the wildcard below, which in turn left $asset.name in the dotted form and broke the + # SHA256 manifest lookup further down. Compare on a space/dot-normalized key instead. $assetName = if ($isExe) { "RackStack.exe" } else { "RackStack v$remoteVersion.ps1" } - $asset = $Release.assets | Where-Object { $_.name -eq $assetName } + $assetKey = $assetName -replace '[\s.]', '.' + $asset = $Release.assets | Where-Object { ($_.name -replace '[\s.]', '.') -ieq $assetKey } # Fallback: try wildcard match if exact name not found if (-not $asset) { @@ -336,16 +379,9 @@ function Install-ScriptUpdate { return } - # SHA256 integrity verification - $expectedHash = $null - if ($Release.body) { - # Parse SHA256 hash from release notes (format: "abcdef123... filename") - $hashPattern = '([0-9a-fA-F]{64})\s+' + [regex]::Escape($asset.name) - if ($Release.body -match $hashPattern) { - $regexMatches = $matches - $expectedHash = $regexMatches[1].ToUpper() - } - } + # SHA256 integrity verification. $null means the asset has no manifest entry, which is + # treated as a hard refusal below — never as "skip the check". + $expectedHash = Get-ReleaseAssetHash -ReleaseBody ([string]$Release.body) -AssetName $asset.name if ($expectedHash) { Write-OutputColor " Verifying SHA256 integrity..." -color "Info" @@ -396,7 +432,14 @@ function Install-ScriptUpdate { } } else { - Write-OutputColor " SHA256 hash not found in release notes — skipping verification." -color "Warning" + # Fail closed. This function copies the payload over the running script (or stages it for + # the EXE replace) and relaunches with elevation, so an update whose integrity cannot be + # established must not be installed. Previously this warned and continued, which meant a + # manifest-lookup failure degraded silently into an unverified self-update. + Write-OutputColor " SHA256 hash not found in release notes — refusing to install an unverified update." -color "Error" + Write-OutputColor " Download and verify manually from: $($Release.html_url)" -color "Info" + Remove-Item -LiteralPath $tempPath -Force -ErrorAction SilentlyContinue + return } if ($isExe) { diff --git a/README.md b/README.md index 6a596c1..4e23fc8 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5402 structural tests + 5417 structural tests Pester 312 tests SLSA Level 3

@@ -120,6 +120,8 @@ 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. + 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. B[Compute the SHA-256 hash] + B --> C{Does it match release-hashes.txt
for that version?} + C -->|Yes| D[Genuine published build] + C -->|No| E[STOP - do not run it] + D --> F{Do the cosign signature and
SLSA provenance verify?} + F -->|Yes| G[Confirmed authentic
Treat the alert as a false positive] + F -->|No| E + E --> H[Re-download from the GitHub release
and report it] + G --> I[Restore from quarantine, or
run the .ps1 instead] +``` + +### 1. Hash + +```powershell +(Get-FileHash RackStack.exe -Algorithm SHA256).Hash.ToLower() +``` + +Compare against `release-hashes.txt`, attached to +[every release](https://github.com/TheAbider/RackStack/releases). The same hash also appears as +`InstallerSha256` in the winget manifest under [`dist/winget/`](../dist/winget/). + +### 2. Build provenance + +This proves the binary was produced by a specific public CI run, from a specific commit, in this +repository — something malware repackaged by a third party cannot reproduce. + +```powershell +gh attestation verify RackStack.exe --owner TheAbider +``` + +### 3. Sigstore signature + +```powershell +cosign verify-blob ` + --certificate RackStack.exe.pem ` + --signature RackStack.exe.sig ` + --certificate-identity-regexp "^https://github.com/TheAbider/RackStack/.github/workflows/ci.yml@refs/heads/master$" ` + --certificate-oidc-issuer https://token.actions.githubusercontent.com ` + RackStack.exe +``` + +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 +so you can read exactly what the EXE does. + +--- + +## Telling a false positive from a real problem + +If the hash matches and the attestations verify, the binary is authentic and any detection is a +false positive by definition — whatever it does, it is what the public source does. + +When reading a VirusTotal result, the *pattern* of detections matters more than the count: + +**Consistent with a false positive** +- Verdicts are generic ML labels — `*.!ml`, `ML.Attribute.*`, `Static AI`, `*.ml.score`, + `Malicious (high Confidence)`, `Artemis!`, `Trojan.MSIL.Gen.*` +- Major engines are silent — Kaspersky, ESET, BitDefender, Sophos, Avast/AVG, Malwarebytes, + Fortinet, Google, CrowdStrike +- The "popular threat label" names a specific malware family that does not match the file's + actual architecture. This label is chosen by clustering the family strings that detecting + engines report, so a handful of low-tier engines bucketing every unsigned .NET binary into one + generic family is enough to produce a frightening headline. + +**Worth taking seriously** +- Multiple major engines agree on a specific named family +- The hash does not match `release-hashes.txt` +- `gh attestation verify` or `cosign verify-blob` fails +- The file did not come from the GitHub release, PowerShell Gallery, Scoop, Chocolatey, or winget + +--- + +## Restoring from quarantine + +Only after you have verified the hash and attestations above. + +If the tool was quarantined **mid-run**, some configuration may have been applied and some not. +Check the action history and undo state before continuing: + +``` +%ProgramData%\RackStack\state\ +``` + +Then restore and exclude, from an elevated PowerShell session: + +```powershell +# See what was quarantined +Get-MpThreat | Select-Object ThreatName, Resources + +# Restore it (substitute the ThreatName reported above) +& "$env:ProgramFiles\Windows Defender\MpCmdRun.exe" -Restore -Name "Behavior:Win32/DefenseEvasion.A!ml" + +# Exclude the location you run it from +Add-MpPreference -ExclusionPath 'C:\Path\To\RackStack.exe' +``` + +> Adding an antivirus exclusion reduces your security posture. Scope it to the specific file +> path, not a whole drive, and remove it when you are finished. + +--- + +## 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 +is never scored by the PE classifiers that produce these detections. + +```powershell +Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process +& '.\RackStack v1.122.0.ps1' +``` + +The PowerShell Gallery module (`Install-Module RackStack`) is another script-based route that +avoids the packed binary. + +If antivirus alerts are a recurring problem in your environment, prefer one of these. + +--- + +## Reporting a new detection + +False-positive clearances granted by antivirus vendors are **per file hash**, so a detection can +reappear on a new release even after a previous one was cleared. Reports are genuinely useful. + +Please [open an issue](https://github.com/TheAbider/RackStack/issues) with: + +- The RackStack version and the SHA-256 you computed +- The engine and the exact detection name +- Whether it was a static scan or fired while the tool was running +- A VirusTotal link if you have one + +Detections are disputed with the vendors as they are reported, with the build provenance and +public source attached as evidence. + +--- + +## Related + +- [Security Policy](../SECURITY.md) — release verification and vulnerability reporting +- [Troubleshooting](Troubleshooting.md) — operational issues diff --git a/docs/Troubleshooting.md b/docs/Troubleshooting.md index ad40b11..e8f7a49 100644 --- a/docs/Troubleshooting.md +++ b/docs/Troubleshooting.md @@ -17,6 +17,7 @@ Common issues encountered during server configuration with RackStack, organized - [NIC Auto-Select for SET and iSCSI](#nic-auto-select-for-set-and-iscsi) - [Common Errors](#common-errors) - [Network Diagnostics Walkthrough](#network-diagnostics-walkthrough) +- [Antivirus Blocked or Quarantined RackStack](#antivirus-blocked-or-quarantined-rackstack) --- @@ -652,3 +653,39 @@ The sweep tool uses parallel background jobs for speed: - Custom start/end octets can be specified. - Results show IP, reverse DNS hostname (if available), and total hosts alive. - 30-second timeout for the entire sweep. + +--- + +## Antivirus Blocked or Quarantined RackStack + +### Symptoms + +- The EXE disappears mid-run, or Defender reports `Behavior:Win32/DefenseEvasion.A!ml` +- A static scan flags `Trojan:Win32/Sabsik.EN.A!ml` or a generic `MSIL` / `Ransom` label +- VirusTotal shows detections from several machine-learning engines + +### Cause + +`RackStack.exe` is unsigned, packed by ps2exe into a .NET assembly, and manages Windows Defender +exclusions as a documented feature. That combination scores as evasion behaviour to ML +classifiers. These are false positives. + +### Resolution + +Verify the binary first — the SHA-256 hash, cosign signature, and SLSA build provenance settle +the question regardless of what any engine says: + +```powershell +(Get-FileHash RackStack.exe -Algorithm SHA256).Hash.ToLower() # compare to release-hashes.txt +gh attestation verify RackStack.exe --owner TheAbider +``` + +If the tool was quarantined **mid-run**, check `%ProgramData%\RackStack\state\` for +partially-applied configuration before continuing. + +To avoid the problem entirely, run the monolithic `.ps1` from the same release instead of the +EXE — identical code, unpacked, and not scored by the PE classifiers that produce these +detections. + +**Full detail, including how to tell a false positive from a genuinely tampered file and how to +restore from quarantine:** [Antivirus Detections](Antivirus-Detections.md). From 422fb1c97c5e827fdf2cc2c3cf33d67417f0bf4b Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Mon, 27 Jul 2026 19:01:33 -0700 Subject: [PATCH 2/2] Use a version placeholder in the antivirus guide example --- docs/Antivirus-Detections.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/Antivirus-Detections.md b/docs/Antivirus-Detections.md index 53207fd..7ea7af4 100644 --- a/docs/Antivirus-Detections.md +++ b/docs/Antivirus-Detections.md @@ -156,7 +156,7 @@ is never scored by the PE classifiers that produce these detections. ```powershell Set-ExecutionPolicy -ExecutionPolicy Bypass -Scope Process -& '.\RackStack v1.122.0.ps1' +& '.\RackStack v{version}.ps1' # substitute the version you downloaded ``` The PowerShell Gallery module (`Install-Module RackStack`) is another script-based route that