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 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).