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
12 changes: 12 additions & 0 deletions Changelog.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,17 @@
# Changelog

## v1.121.10

Credential-handling hardening — fixes found by a deep audit of password generation, SecureString hygiene, and secret leakage.

- **A generated password no longer ends up in the session transcript.** The strong-password generator already went out of its way to keep the password off-screen-log by pausing transcription while it displayed — but it then *returned* the password, which the menu echoed back to the console (and into the transcript on disk). It no longer returns the plaintext; it's shown once for you to record and nothing more.
- **The clipboard auto-clear actually runs now.** The 60-second "auto-clears from clipboard" timer could be garbage-collected before it fired, silently leaving the password in the clipboard (and Windows clipboard history). The timer is now held so it reliably clears.
- **The file-server access secret is entered hidden.** Setting the Cloudflare Access client secret in the in-tool settings used a normal prompt that echoed the secret to the screen (and transcript). It's now entered masked and handled without leaving a plaintext copy behind.

(The audit also confirmed the password generator already uses a cryptographic RNG, and that the RADIUS / VPN / SIEM / Azure Arc secrets that must be passed to their native tools are handled as safely as those tools allow.)

No module or CLI action changes (81 modules, 201 actions).

## v1.121.9

Installer download & agent-install hardening — fixes found by a deep audit of the download → verify → execute chain (FileServer, Agent Installer, ISO download).
Expand Down
2 changes: 1 addition & 1 deletion Header.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@
7h3 4b1d3r

.VERSION
1.121.9
1.121.10
.LAST UPDATED
07/01/2026

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.121.9"
$script:ScriptVersion = "1.121.10"
$script:ScriptStartTime = Get-Date

# Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe.
Expand Down
27 changes: 22 additions & 5 deletions Modules/22-Password.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -328,7 +328,11 @@ function Show-LocalAccountAudit {

# Function to generate a strong random password
function New-StrongPassword {
param([int]$Length = 16)
# -PassThru returns the generated plaintext to the caller. It is OFF by default: the
# interactive menu action must NOT receive the value (a bare-statement call would stream it
# to top-level Out-Default, re-echoing it to the console and into the on-disk transcript).
# Only explicit programmatic/test callers that capture and handle the value pass -PassThru.
param([int]$Length = 16, [switch]$PassThru)

if ($Length -lt 12) { $Length = 12 }
if ($Length -gt 128) { $Length = 128 }
Expand Down Expand Up @@ -427,15 +431,28 @@ function New-StrongPassword {
}
} catch { }
}
# Timer is fire-once (period = -1). Reference is intentionally not held;
# GC will reclaim after the callback fires.
$null = New-Object System.Threading.Timer($timerCallback, $expectedClip, ($clipTTL * 1000), [System.Threading.Timeout]::Infinite)
# Timer is fire-once (period = -1). It MUST be kept rooted: an unreferenced
# System.Threading.Timer is GC-eligible immediately and its finalizer CANCELS the
# pending callback, so a collection within the 60s window (ordinary gen0 GC in the menu
# loop, or the explicit [GC]::Collect() in Clear-SecureMemory) would silently skip the
# clipboard clear and leave the plaintext in the clipboard/Win+V history indefinitely.
# Hold it in a script-scope field; dispose the prior one so repeated generation doesn't
# leak timers.
if ($script:_clipClearTimer) { try { $script:_clipClearTimer.Dispose() } catch { } }
$script:_clipClearTimer = New-Object System.Threading.Timer($timerCallback, $expectedClip, ($clipTTL * 1000), [System.Threading.Timeout]::Infinite)
Write-OutputColor " Copied to clipboard (auto-clears in ${clipTTL}s while this window is open)." -color "Success"
}
catch {
Write-OutputColor " Tip: Select and copy the password above." -color "Info"
}

return $password
# Return the plaintext ONLY when the caller explicitly opts in via -PassThru (tests /
# programmatic use, which capture and handle it). The interactive menu action (option 11)
# calls this WITHOUT -PassThru, so the password never emits on the success stream to
# top-level Out-Default — which would re-echo it to the console AND write it into the
# just-resumed on-disk transcript, defeating the Stop-Transcript dance above. On the default
# path it is shown once via [Console]::WriteLine for the operator to record and nothing more.
if ($PassThru) { return $password }
return
}
#endregion
18 changes: 15 additions & 3 deletions Modules/56-OperationsMenu.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -1627,9 +1627,21 @@ function Show-EditDefaults {
$val = Read-Host
if (-not [string]::IsNullOrWhiteSpace($val)) { $script:FileServer.ClientId = $val }

Write-OutputColor " Enter CF-Access-Client-Secret:" -color "Info"
$val = Read-Host
if (-not [string]::IsNullOrWhiteSpace($val)) { $script:FileServer.ClientSecret = $val }
# Collect the CF-Access-Client-Secret hidden: a plain Read-Host echoes the typed
# secret to the console, which Start-Transcript then persists to the on-disk log.
# Marshal the SecureString to the plaintext string the FileServer needs as an HTTP
# header, zero-freeing the BSTR in a finally. (ClientId above is not a secret.)
Write-OutputColor " Enter CF-Access-Client-Secret (input hidden, leave empty to keep current):" -color "Info"
$secVal = Read-Host -AsSecureString
if ($secVal -and $secVal.Length -gt 0) {
$secBstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($secVal)
try {
$script:FileServer.ClientSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringBSTR($secBstr)
}
finally {
[System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($secBstr)
}
}

Write-OutputColor " Enter ISOs subfolder name (default: ISOs):" -color "Info"
$val = Read-Host
Expand Down
2 changes: 1 addition & 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="5308 structural tests" src="https://img.shields.io/badge/structural%20tests-5308-brightgreen">
<img alt="5317 structural tests" src="https://img.shields.io/badge/structural%20tests-5317-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
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 defaults.json.

.VERSION
1.121.9
1.121.10
.NOTES
- Requires Windows Server 2012 R2 or later (or Windows 10/11 for testing)
- Must be run as Administrator
Expand Down
2 changes: 1 addition & 1 deletion RackStack.psd1
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
@{
RootModule = 'RackStack.psm1'
ModuleVersion = '1.121.9'
ModuleVersion = '1.121.10'
GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91'
Author = 'TheAbider'
CompanyName = 'TheAbider'
Expand Down
34 changes: 17 additions & 17 deletions Tests/Pester/Password.Tests.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -89,49 +89,49 @@ Describe 'New-StrongPassword' {

Context 'Length boundaries' {
It 'defaults to length 16' {
$pw = New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null
$pw.Length | Should -Be 16
}

It 'respects an explicit length parameter (24)' {
$pw = New-StrongPassword -Length 24 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru -Length 24 6>$null 4>$null 5>$null 3>$null 2>$null
$pw.Length | Should -Be 24
}

It 'floors length to 12 when caller asks for less' {
$pw = New-StrongPassword -Length 4 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru -Length 4 6>$null 4>$null 5>$null 3>$null 2>$null
$pw.Length | Should -Be 12
}

It 'caps length at 128 when caller asks for more' {
$pw = New-StrongPassword -Length 1000 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru -Length 1000 6>$null 4>$null 5>$null 3>$null 2>$null
$pw.Length | Should -Be 128
}
}

Context 'Generated complexity' {
It 'contains at least one uppercase letter' {
$pw = New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null
$pw | Should -Match '[A-Z]'
}

It 'contains at least one lowercase letter' {
$pw = New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null
$pw | Should -Match '[a-z]'
}

It 'contains at least one digit (excluding ambiguous 0/1)' {
$pw = New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null
$pw | Should -Match '[2-9]'
}

It 'contains at least one special char from the safe set !@#%^&*-_=+' {
$pw = New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null
$pw | Should -Match '[!@#%^&\*\-_=+]'
}

It 'excludes ambiguous characters 0, 1, I, O, i, l, o (case-sensitive check)' {
$pw = New-StrongPassword -Length 64 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru -Length 64 6>$null 4>$null 5>$null 3>$null 2>$null
# PowerShell -match (and Pester Should -Match) is case-insensitive by default,
# which would falsely flag uppercase L. Use -cnotmatch for a literal case-sensitive check.
($pw -cmatch '[01IOilo]') | Should -BeFalse
Expand All @@ -144,7 +144,7 @@ Describe 'New-StrongPassword' {
# includes # and -, and the Fisher-Yates shuffle can land one of those
# first — that's an existing generator/validator coherence gap, not
# something this test should pin. Check the per-class rules directly.
$pw = New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null
$pw = New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null
$pw.Length | Should -BeGreaterOrEqual 12
($pw -cmatch '[A-Z]') | Should -BeTrue
($pw -cmatch '[a-z]') | Should -BeTrue
Expand All @@ -156,7 +156,7 @@ Describe 'New-StrongPassword' {
Context 'Cryptographic randomness (statistical)' {
It 'produces distinct passwords across calls' {
$samples = 1..10 | ForEach-Object {
New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null
New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null
}
($samples | Select-Object -Unique).Count | Should -Be 10
}
Expand All @@ -166,12 +166,12 @@ Describe 'New-StrongPassword' {
It 'exercises the clipboard-success branch when Set-Clipboard works' {
Mock Set-Clipboard { } # silent success
Mock Get-Clipboard { 'whatever' } # for the timer callback path
{ New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
{ New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
}

It 'exercises the clipboard-failure branch when Set-Clipboard throws' {
Mock Set-Clipboard { throw 'clipboard locked' }
{ New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
{ New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
}
}

Expand All @@ -187,7 +187,7 @@ Describe 'New-StrongPassword' {
Mock Stop-Transcript { 'fake-transcript-path' } # truthy = was running
Mock Start-Transcript { } # no-op success
Mock Set-Clipboard { }
{ New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
{ New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
} finally {
$script:TranscriptPath = $oldTp
}
Expand All @@ -200,7 +200,7 @@ Describe 'New-StrongPassword' {
Mock Stop-Transcript { 'something-truthy' }
Mock Start-Transcript { } # the else branch path
Mock Set-Clipboard { }
{ New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
{ New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
} finally {
$script:TranscriptPath = $oldTp
}
Expand All @@ -209,7 +209,7 @@ Describe 'New-StrongPassword' {
It 'exercises the no-transcript-running path (Stop-Transcript throws)' {
Mock Stop-Transcript { throw 'no transcript running' } # catch -> $transcriptWasRunning stays false
Mock Set-Clipboard { }
{ New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
{ New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
}

It 'exercises the transcript-restart-fails path (Start-Transcript throws)' {
Expand All @@ -219,7 +219,7 @@ Describe 'New-StrongPassword' {
Mock Stop-Transcript { 'truthy' }
Mock Start-Transcript { throw 'restart failed' } # outer catch swallows it
Mock Set-Clipboard { }
{ New-StrongPassword 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
{ New-StrongPassword -PassThru 6>$null 4>$null 5>$null 3>$null 2>$null } | Should -Not -Throw
} finally {
$script:TranscriptPath = $oldTp
}
Expand Down
42 changes: 41 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.121.9
Automated Test Runner for RackStack v1.121.10

.DESCRIPTION
Comprehensive non-interactive test suite covering:
Expand Down Expand Up @@ -9914,6 +9914,46 @@ catch {
Write-TestResult "Download-Execute Hardening Tests" $false $_.Exception.Message
}

# ============================================================================
# SECTION 197: CREDENTIAL & SECRET HANDLING (v1.121.10)
# ============================================================================
# Guards three fixes from the credential/secret-handling audit:
# H New-StrongPassword must not RETURN the generated plaintext — a bare-statement caller would
# stream it to top-level Out-Default, re-echoing it to the console AND into the on-disk
# transcript, defeating the module's own Stop-Transcript display mitigation.
# L1 the clipboard auto-clear Timer must be kept rooted (script-scope), or GC cancels it and the
# plaintext lingers in the clipboard / Win+V history.
# L2 the FileServer CF-Access-Client-Secret must be collected hidden (-AsSecureString) and marshalled
# with a zero-freed BSTR, so a plain Read-Host doesn't echo it to console/transcript.
Write-SectionHeader "SECTION 197: CREDENTIAL & SECRET HANDLING"

try {
$pwRaw = Get-Content "$modulesPath\22-Password.ps1" -Raw
$opsRaw = Get-Content "$modulesPath\56-OperationsMenu.ps1" -Raw
$runnerRaw197 = Get-Content "$modulesPath\49-MenuRunner.ps1" -Raw

# H — the generated plaintext is returned ONLY under an explicit -PassThru opt-in (tests /
# programmatic callers that capture it). The interactive menu action calls New-StrongPassword
# WITHOUT -PassThru, so on the default path the password never streams to top-level
# Out-Default (which would re-echo it to the console and into the on-disk transcript).
Write-TestResult "22-Password: New-StrongPassword has a -PassThru opt-in switch (H)" ($pwRaw -match '\[switch\]\$PassThru')
Write-TestResult "22-Password: plaintext return is guarded by -PassThru (H)" ($pwRaw -match 'if \(\$PassThru\) \{ return \$password \}')
Write-TestResult "22-Password: interactive menu caller does NOT pass -PassThru (H)" ($runnerRaw197 -match '"11"\s*\{\s*New-StrongPassword;')
Write-TestResult "22-Password: still shows the password via [Console]::WriteLine (H)" ($pwRaw -match '\[Console\]::WriteLine\([\s\S]{0,20}\$\(\$password')

# L1 — clipboard auto-clear timer rooted in script scope; old unrooted form gone.
Write-TestResult "22-Password: clipboard clear timer is kept rooted (L1)" ($pwRaw -match '\$script:_clipClearTimer = New-Object System\.Threading\.Timer')
Write-TestResult "22-Password: prior clipboard timer disposed on regen (L1)" ($pwRaw -match 'if \(\$script:_clipClearTimer\) \{ try \{ \$script:_clipClearTimer\.Dispose')
Write-TestResult "22-Password: no unrooted (`$null =) timer (L1)" (-not ($pwRaw -match '\$null = New-Object System\.Threading\.Timer'))

# L2 — CF client secret collected hidden and zero-freed.
Write-TestResult "56-Ops: CF client secret uses Read-Host -AsSecureString (L2)" ($opsRaw -match 'Enter CF-Access-Client-Secret[\s\S]{0,140}Read-Host -AsSecureString')
Write-TestResult "56-Ops: CF client secret BSTR is zero-freed (L2)" ($opsRaw -match 'ClientSecret = \[System\.Runtime\.InteropServices\.Marshal\]::PtrToStringBSTR[\s\S]{0,200}ZeroFreeBSTR')
}
catch {
Write-TestResult "Credential & Secret Handling Tests" $false $_.Exception.Message
}

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