diff --git a/Changelog.md b/Changelog.md index e6c337b..cd303b0 100644 --- a/Changelog.md +++ b/Changelog.md @@ -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). diff --git a/Header.ps1 b/Header.ps1 index 1b8f0b3..4c0b0b8 100644 --- a/Header.ps1 +++ b/Header.ps1 @@ -30,7 +30,7 @@ 7h3 4b1d3r .VERSION - 1.121.9 + 1.121.10 .LAST UPDATED 07/01/2026 diff --git a/Modules/00-Initialization.ps1 b/Modules/00-Initialization.ps1 index 9dbaff3..6517ab3 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.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. diff --git a/Modules/22-Password.ps1 b/Modules/22-Password.ps1 index 6cf2e60..977839b 100644 --- a/Modules/22-Password.ps1 +++ b/Modules/22-Password.ps1 @@ -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 } @@ -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 \ No newline at end of file diff --git a/Modules/56-OperationsMenu.ps1 b/Modules/56-OperationsMenu.ps1 index c2aaf32..b73f672 100644 --- a/Modules/56-OperationsMenu.ps1 +++ b/Modules/56-OperationsMenu.ps1 @@ -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 diff --git a/README.md b/README.md index caf45ec..b2df9dc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5308 structural tests + 5317 structural tests Pester 312 tests SLSA Level 3

diff --git a/RackStack.ps1 b/RackStack.ps1 index 4b11057..fd4c407 100644 --- a/RackStack.ps1 +++ b/RackStack.ps1 @@ -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 diff --git a/RackStack.psd1 b/RackStack.psd1 index b03361c..8f8cf9e 100644 --- a/RackStack.psd1 +++ b/RackStack.psd1 @@ -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' diff --git a/Tests/Pester/Password.Tests.ps1 b/Tests/Pester/Password.Tests.ps1 index a73519c..639d536 100644 --- a/Tests/Pester/Password.Tests.ps1 +++ b/Tests/Pester/Password.Tests.ps1 @@ -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 @@ -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 @@ -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 } @@ -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 } } @@ -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 } @@ -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 } @@ -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)' { @@ -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 } diff --git a/Tests/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index dcbb86c..b1c8bb9 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -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: @@ -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) # ============================================================================