From f984bec0e34ccdfb4ba00db8ca776f93b7e5900b Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:07:36 -0700 Subject: [PATCH 1/2] Harden credential & secret handling (v1.121.10) Fixes from an adversarial audit of password generation, SecureString hygiene, and secret-leak sinks. 22-Password.ps1: - New-StrongPassword no longer returns the generated plaintext. The sole caller used it as a bare statement, streaming the password to top-level Out-Default, which re-echoed it to the console AND into the just-resumed on-disk transcript -- defeating the Stop-Transcript display mitigation the function performs. It's shown once via [Console]::WriteLine and never leaves the function. - Keep the clipboard auto-clear Timer rooted (script scope). An unreferenced System.Threading.Timer is GC-eligible immediately and its finalizer cancels the pending callback, so the advertised 60s clipboard clear could silently never run, leaving the plaintext in the clipboard / Win+V history. 56-OperationsMenu.ps1: - Collect the FileServer CF-Access-Client-Secret with Read-Host -AsSecureString and marshal it with a zero-freed BSTR, instead of a plain Read-Host that echoes the secret to the console (and thus the transcript). Audit also confirmed the generator already uses RNGCryptoServiceProvider, and that the RADIUS/VPN/SIEM/Azure-Arc secrets required in plaintext by their native tools (netsh, azcmagent, agent config) are handled as safely as those tools permit. Tests: Section 197 (7 asserts); 5315/5315 passing. PSSA 0. Monolithic + version stamps -> 1.121.10. --- Changelog.md | 12 ++++++++++++ Header.ps1 | 2 +- Modules/00-Initialization.ps1 | 2 +- Modules/22-Password.ps1 | 20 +++++++++++++++---- Modules/56-OperationsMenu.ps1 | 18 ++++++++++++++--- README.md | 2 +- RackStack.ps1 | 2 +- RackStack.psd1 | 2 +- Tests/Run-Tests.ps1 | 37 ++++++++++++++++++++++++++++++++++- 9 files changed, 84 insertions(+), 13 deletions(-) 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..c2a5c6c 100644 --- a/Modules/22-Password.ps1 +++ b/Modules/22-Password.ps1 @@ -427,15 +427,27 @@ 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 + # Deliberately DO NOT return the plaintext. The only caller (menu option 11) discards the + # value, and returning it emits the password on the success stream to top-level Out-Default, + # which both re-echoes it to the console AND writes it into the (just-resumed) on-disk + # transcript — defeating the Stop-Transcript dance above that kept it out of the log. The + # password is shown once via [Console]::WriteLine for the operator to record; it must not + # leave this function. + 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..220bb39 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5308 structural tests + 5315 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/Run-Tests.ps1 b/Tests/Run-Tests.ps1 index dcbb86c..63e3732 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,41 @@ 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 + + # H — no plaintext return of the generated password (case-sensitive: the generated var is + # lowercase $password; an unrelated entry-helper legitimately returns $Password1). + Write-TestResult "22-Password: New-StrongPassword does not return the plaintext (H)" (-not ($pwRaw -cmatch 'return \$password')) + 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) # ============================================================================ From dd6c4fb33b0e81801c5b3c624dfb8712759d8ad8 Mon Sep 17 00:00:00 2001 From: TheAbider <51920546+TheAbider@users.noreply.github.com> Date: Wed, 1 Jul 2026 06:24:28 -0700 Subject: [PATCH 2/2] Return generated password only under -PassThru (fix Pester unit tests) The initial fix removed New-StrongPassword's plaintext return outright, which broke the Password.Tests.ps1 Pester tests that capture the value to verify length/complexity/ uniqueness. Gate the return behind an explicit -PassThru switch instead: the interactive menu action calls it WITHOUT -PassThru (so the password still never streams to Out-Default / the transcript), while tests and programmatic callers opt in and handle the value. - 22-Password.ps1: add [switch]$PassThru; return the plaintext only when set. - Password.Tests.ps1: pass -PassThru at the call sites that assert on the value. - Run-Tests.ps1 Section 197: assert the return is -PassThru-guarded and the menu caller does not opt in. Structural 5317/5317; full Pester 312/312; monolithic re-synced. --- Modules/22-Password.ps1 | 19 +++++++++++------- README.md | 2 +- Tests/Pester/Password.Tests.ps1 | 34 ++++++++++++++++----------------- Tests/Run-Tests.ps1 | 13 +++++++++---- 4 files changed, 39 insertions(+), 29 deletions(-) diff --git a/Modules/22-Password.ps1 b/Modules/22-Password.ps1 index c2a5c6c..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 } @@ -442,12 +446,13 @@ function New-StrongPassword { Write-OutputColor " Tip: Select and copy the password above." -color "Info" } - # Deliberately DO NOT return the plaintext. The only caller (menu option 11) discards the - # value, and returning it emits the password on the success stream to top-level Out-Default, - # which both re-echoes it to the console AND writes it into the (just-resumed) on-disk - # transcript — defeating the Stop-Transcript dance above that kept it out of the log. The - # password is shown once via [Console]::WriteLine for the operator to record; it must not - # leave this function. + # 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/README.md b/README.md index 220bb39..b2df9dc 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ OpenSSF Best Practices codecov PSScriptAnalyzer 0 errors - 5315 structural tests + 5317 structural tests Pester 312 tests SLSA Level 3

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 63e3732..b1c8bb9 100644 --- a/Tests/Run-Tests.ps1 +++ b/Tests/Run-Tests.ps1 @@ -9930,10 +9930,15 @@ Write-SectionHeader "SECTION 197: CREDENTIAL & SECRET HANDLING" try { $pwRaw = Get-Content "$modulesPath\22-Password.ps1" -Raw $opsRaw = Get-Content "$modulesPath\56-OperationsMenu.ps1" -Raw - - # H — no plaintext return of the generated password (case-sensitive: the generated var is - # lowercase $password; an unrelated entry-helper legitimately returns $Password1). - Write-TestResult "22-Password: New-StrongPassword does not return the plaintext (H)" (-not ($pwRaw -cmatch 'return \$password')) + $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.