Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -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 `<company>.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 `<company>.defaults.json` names are still read whenever the new-named file is absent, and the tool keeps saving to whichever file it loaded.
Expand Down
4 changes: 2 additions & 2 deletions Header.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Modules/00-Initialization.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
69 changes: 56 additions & 13 deletions Modules/35-Utilities.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -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) {
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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) {
Expand Down
5 changes: 4 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
<a href="https://www.bestpractices.dev/projects/12921"><img alt="OpenSSF Best Practices" src="https://www.bestpractices.dev/projects/12921/badge"></a>
<a href="https://codecov.io/gh/TheAbider/RackStack"><img alt="codecov" src="https://codecov.io/gh/TheAbider/RackStack/branch/master/graph/badge.svg"></a>
<img alt="PSScriptAnalyzer 0 errors" src="https://img.shields.io/badge/PSScriptAnalyzer-0%20errors-brightgreen">
<img alt="5402 structural tests" src="https://img.shields.io/badge/structural%20tests-5402-brightgreen">
<img alt="5417 structural tests" src="https://img.shields.io/badge/structural%20tests-5417-brightgreen">
<img alt="Pester 312 tests" src="https://img.shields.io/badge/Pester-312%20tests-brightgreen">
<img alt="SLSA Level 3" src="https://slsa.dev/images/gh-badge-level3.svg">
</p>
Expand Down Expand Up @@ -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.

<!--
Expand Down Expand Up @@ -181,6 +183,7 @@ In-depth guides live in [`docs/`](docs/):
| Preparing VHD templates | [VHD Preparation](docs/VHD-Preparation.md) |
| File server for ISO / VHD / agent distribution | [File Server Setup](docs/FileServer-Setup.md) |
| Diagnostics and recovery | [Troubleshooting](docs/Troubleshooting.md) |
| Antivirus flags and how to verify a release | [Antivirus Detections](docs/Antivirus-Detections.md) |
| Runbook: HA iSCSI build | [Runbook: HA iSCSI](docs/Runbook-HA-iSCSI.md) |
| Runbook: live host migration | [Runbook: Host Migration](docs/Runbook-Host-Migration.md) |
| Runbook: VM deployment | [Runbook: VM Deployment](docs/Runbook-VM-Deployment.md) |
Expand Down
2 changes: 1 addition & 1 deletion RackStack.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
Environment-specific settings are configured via rackstack.config.json (a legacy defaults.json is still read).

.VERSION
1.122.0
1.122.1
.NOTES
- Requires Windows Server 2012 R2 or later (or Windows 10/11 for testing)
- Must be run as Administrator
Expand Down
21 changes: 21 additions & 0 deletions SECURITY.md
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ The EXE is not Authenticode-signed, so Windows SmartScreen may show an
download reputation. The SHA-256 hash, cosign signature, and SLSA
provenance are the integrity guarantees in the meantime.

## Antivirus detections

Being unsigned, packed by ps2exe, 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.

Please do **not** report an antivirus detection through the vulnerability
channel above. Verify the binary first — the hash, cosign signature, and
build provenance settle the question independently of any AV verdict — and
then open a normal issue. [Antivirus Detections](docs/Antivirus-Detections.md)
walks through the verification steps, how to distinguish a false positive
from a genuinely tampered file, and how to avoid the problem entirely by
running the `.ps1`.

A detection is only a security concern if the hash does **not** match
`release-hashes.txt`, or if `cosign verify-blob` or
`gh attestation verify` fails. That would indicate a file that did not come
from this project's CI, and is worth reporting privately via the channels
at the top of this document.

## rackstack.config.json

The `rackstack.config.json` file (or the legacy `defaults.json` it supersedes)
Expand Down
82 changes: 81 additions & 1 deletion Tests/Run-Tests.ps1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
<#
.SYNOPSIS
Automated Test Runner for RackStack v1.122.0
Automated Test Runner for RackStack v1.122.1

.DESCRIPTION
Comprehensive non-interactive test suite covering:
Expand Down Expand Up @@ -10309,6 +10309,86 @@ catch {
Write-TestResult "Namespaced Config Tests" $false $_.Exception.Message
}

# ============================================================================
# SECTION 204: SELF-UPDATE INTEGRITY VERIFICATION (35-Utilities)
# ============================================================================
# Regression pin for the self-update hash check.
#
# 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 looked the hash up
# by regex-escaping $asset.name, so on the .ps1 path the lookup could never match: the expected
# hash came back null and the code warned and installed anyway — copying an unverified payload
# over the running script and relaunching it elevated. The EXE path was unaffected because
# "RackStack.exe" contains no whitespace to rewrite.
#
# These tests exercise the real Get-ReleaseAssetHash against fixtures shaped like live GitHub
# API responses, so a regression in the normalization fails here rather than shipping.
Write-SectionHeader "SECTION 204: SELF-UPDATE INTEGRITY VERIFICATION (35-Utilities)"

try {
$utilContent204 = Get-Content "$modulesPath\35-Utilities.ps1" -Raw

# -- Structural: the pieces that make the fix hold --
Write-TestResult "Update: Get-ReleaseAssetHash function exists" ($utilContent204 -match 'function\s+Get-ReleaseAssetHash\b')
Write-TestResult "Update: hash lookup is delegated, not inlined" ($utilContent204 -match '\$expectedHash\s*=\s*Get-ReleaseAssetHash\s')
Write-TestResult "Update: asset selection normalizes space/dot" ($utilContent204 -match '\$assetKey\s*=\s*\$assetName\s*-replace\s*''\[\\s\.\]'',\s*''\.''')
# The old fail-open text must never come back.
Write-TestResult "Update: no 'skipping verification' fail-open path" (-not ($utilContent204 -match 'skipping verification'))
Write-TestResult "Update: missing hash refuses the install" ($utilContent204 -match 'refusing to install an unverified update')
# A refusal must actually return, not just print.
Write-TestResult "Update: refusal path returns before install" ($utilContent204 -match 'refusing to install an unverified update[\s\S]{0,400}\breturn\b')

# -- Behavioral: real function, real-shaped fixtures --
# Manifest uses the ORIGINAL spaced name, exactly as ci.yml emits it.
$body204 = @"
## SHA-256

``````
a2145966c1878f26f4825caaafea235f54a5b2cf6146c5a59799fef5803d4808 RackStack.exe
d99c3ebc7ae771f25cccfac278c6b68af70fa1a995fb66cc9b82ec88f04f547b RackStack v1.122.0.ps1
210fd56ca3aeeb8b48b77eee1e5ff33899374ad288ae54f2d209abd1c0fd7e27 rackstack.config.example.json
``````
"@

# THE REGRESSION: asset name as GitHub serves it (dots) vs manifest (spaces).
$psHash204 = Get-ReleaseAssetHash -ReleaseBody $body204 -AssetName 'RackStack.v1.122.0.ps1'
Write-TestResult "Update: dotted .ps1 asset resolves its spaced manifest entry" `
($psHash204 -eq 'D99C3EBC7AE771F25CCCFAC278C6B68AF70FA1A995FB66CC9B82EC88F04F547B')

# The spaced spelling must resolve too (defence against the normalization inverting).
$psHashSpaced204 = Get-ReleaseAssetHash -ReleaseBody $body204 -AssetName 'RackStack v1.122.0.ps1'
Write-TestResult "Update: spaced .ps1 asset resolves the same entry" ($psHashSpaced204 -eq $psHash204)

# EXE path must keep working — it was never broken.
$exeHash204 = Get-ReleaseAssetHash -ReleaseBody $body204 -AssetName 'RackStack.exe'
Write-TestResult "Update: EXE asset resolves its manifest entry" `
($exeHash204 -eq 'A2145966C1878F26F4825CAAAFEA235F54A5B2CF6146C5A59799FEF5803D4808')

# Must not cross-match a different asset just because the names normalize similarly.
Write-TestResult "Update: config example resolves its own hash, not another's" `
((Get-ReleaseAssetHash -ReleaseBody $body204 -AssetName 'rackstack.config.example.json') -eq '210FD56CA3AEEB8B48B77EEE1E5FF33899374AD288AE54F2D209ABD1C0FD7E27')

# Unknown asset, empty body, and hashless body must all return $null so the caller refuses.
Write-TestResult "Update: unknown asset returns null" `
($null -eq (Get-ReleaseAssetHash -ReleaseBody $body204 -AssetName 'NotShipped.ps1'))
Write-TestResult "Update: empty release body returns null" `
($null -eq (Get-ReleaseAssetHash -ReleaseBody '' -AssetName 'RackStack.exe'))
Write-TestResult "Update: body with no manifest returns null" `
($null -eq (Get-ReleaseAssetHash -ReleaseBody "Just release notes.`nNo hashes here." -AssetName 'RackStack.exe'))

# A short hex string must not be mistaken for a SHA-256.
Write-TestResult "Update: sub-64-char hex is not accepted as a hash" `
($null -eq (Get-ReleaseAssetHash -ReleaseBody "deadbeef RackStack.exe" -AssetName 'RackStack.exe'))

# Hash comparison downstream is uppercase; a lowercase manifest must still normalize.
Write-TestResult "Update: lowercase manifest hash is upper-cased" `
((Get-ReleaseAssetHash -ReleaseBody "d99c3ebc7ae771f25cccfac278c6b68af70fa1a995fb66cc9b82ec88f04f547b RackStack.exe" -AssetName 'RackStack.exe') -cmatch '^[0-9A-F]{64}$')
}
catch {
Write-TestResult "Self-Update Integrity Tests" $false $_.Exception.Message
}

# ============================================================================
# SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase)
# ============================================================================
Expand Down
Loading