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 @@
-
+
@@ -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 v{version}.ps1' # substitute the version you downloaded
+```
+
+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).