From f6dc97348f9a4cfe33ff446e2c3c1e49ef42f1f3 Mon Sep 17 00:00:00 2001 From: kavya Date: Sun, 5 Jul 2026 23:05:05 -0400 Subject: [PATCH 01/10] Added login banner cmdlets and manifest exports --- .../Microsoft.AVS.Management.psd1 | 85 +++--- .../Microsoft.AVS.Management.psm1 | 284 ++++++++++++++++++ 2 files changed, 328 insertions(+), 41 deletions(-) diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psd1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psd1 index 93e4f2a..98e2fa0 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psd1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psd1 @@ -10,131 +10,134 @@ # Script module or binary module file associated with this manifest. RootModule = 'Microsoft.AVS.Management.psm1' - + # Version number of this module. - ModuleVersion = '10.0.0' - + ModuleVersion = '10.1.0' + # Supported PSEditions # CompatiblePSEditions = @() - + # ID used to uniquely identify this module GUID = '84d7f529-e6a8-4b7e-99b4-dafc636ffad2' - + # Author of this module Author = 'Microsoft' - + # Company or vendor of this module CompanyName = 'Microsoft' - + # Copyright statement for this module Copyright = '(c) Microsoft. All rights reserved.' - + # Description of the functionality provided by this module Description = 'Various cmdlets for adminstrator level tasks in managing Azure VMware Solutions' - + # Minimum version of the PowerShell engine required by this module PowerShellVersion = '7.4' - + # Name of the PowerShell host required by this module # PowerShellHostName = '' - + # Minimum version of the PowerShell host required by this module # PowerShellHostVersion = '' - + # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. # DotNetFrameworkVersion = '' - + # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. # ClrVersion = '' - + # Processor architecture (None, X86, Amd64) required by this module # ProcessorArchitecture = '' - + # Modules that must be imported into the global environment prior to importing this module RequiredModules = @( @{ModuleName = 'VMware.vSphere.SsoAdmin'; RequiredVersion = '1.4.0' } @{ModuleName = 'VMware.VimAutomation.Core'; RequiredVersion = '13.3.0.24145081' } @{ModuleName = 'VMware.VimAutomation.Storage'; RequiredVersion = '13.3.0.24145081' } @{ModuleName = 'Posh-SSH'; ModuleVersion = '3.2.7' }) - + # Assemblies that must be loaded prior to importing this module # RequiredAssemblies = @() - + # Script files (.ps1) that are run in the caller's environment prior to importing this module. ScriptsToProcess = 'Classes.ps1' - + # Type files (.ps1xml) to be loaded when importing this module # TypesToProcess = @() - + # Format files (.ps1xml) to be loaded when importing this module # FormatsToProcess = @() - + # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess # NestedModules = @() - + # Functions to export from this module FunctionsToExport = @( 'Set-ToolsRepo' 'Set-CustomDRS' 'Remove-CustomRole' 'Get-EsxtopData' + 'Set-VCLoginBanner' + 'Get-VCLoginBanner' + 'Remove-VCLoginBanner' ) # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. CmdletsToExport = @() - + # Variables to export from this module # VariablesToExport = @() - + # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. AliasesToExport = @() - + # DSC resources to export from this module # DscResourcesToExport = @() - + # List of all modules packaged with this module # ModuleList = @() - + # List of all files packaged with this module # FileList = @() - + # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. PrivateData = @{ - + PSData = @{ - + # Tags applied to this module. These help with module discovery in online galleries. Tags = 'VMware','PowerCLI','Azure','AVS' - + # A URL to the license for this module. LicenseUri = 'https://raw.githubusercontent.com/Azure/Microsoft.AVS.Management/refs/heads/main/LICENSE' - + # A URL to the main website for this project. ProjectUri = 'https://github.com/Azure/Microsoft.AVS.Management' - + # A URL to an icon representing this module. # IconUri = '' - + # ReleaseNotes of this module # ReleaseNotes = '' - + # Prerelease string of this module # Prerelease = '' - + # Flag to indicate whether the module requires explicit user acceptance for install/update/save # RequireLicenseAcceptance = $false - + # External dependent modules of this module # ExternalModuleDependencies = @() - + } # End of PSData hashtable - + } # End of PrivateData hashtable - + # HelpInfo URI of this module # HelpInfoURI = '' - + # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. # DefaultCommandPrefix = '' - + } \ No newline at end of file diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 index 9e14009..d3d22cc 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1002,3 +1002,287 @@ function Get-EsxtopData { Write-Host "Esxtop collection complete. $Iterations samples from $($vmHost.Name)." } +function Set-VCLoginBanner { + <# + .SYNOPSIS + Configures and enables the vCenter login banner via SSH to the VCSA appliance. + + .DESCRIPTION + Sets the vCenter login banner title, message, and optional consent checkbox by + executing sso-config.sh commands on the VCSA via the pre-established SSH session. + This enables both Layer 1 (configuration data) and Layer 2 (activation toggle), + which cannot be achieved through the vCenter API alone. + + .PARAMETER BannerTitle + The title displayed on the vCenter login page (e.g., "Authorized Users Only"). + + .PARAMETER BannerMessage + The full login message or terms text shown to users on the login page. + + .PARAMETER EnableConsent + When True, users must check a consent checkbox before logging in. + + .EXAMPLE + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + #> + [CmdletBinding()] + [AVSAttribute(30, UpdatesSDDC = $true)] + param( + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$BannerTitle, + + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] + [string]$BannerMessage, + + [Parameter(Mandatory = $true)] + [bool]$EnableConsent + ) + begin { + $BannerTitle = Limit-WildcardsandCodeInjectionCharacters -String $BannerTitle + $BannerMessage = Limit-WildcardsandCodeInjectionCharacters -String $BannerMessage + } + process { + # Obtain the pre-established SSH session to vCenter + if ($null -eq $SSH_Sessions -or -not $SSH_Sessions.ContainsKey("VC")) { + throw "SSH session to vCenter is not available. Ensure `$SSH_Sessions['VC'] is pre-established by the AVS platform." + } + $SshSession = $SSH_Sessions["VC"].Value + if ($null -eq $SshSession) { + throw "Failed to initialize SSH session to vCenter." + } + + # POSIX-safe escape for apostrophes inside single-quoted shell text + $posixApostrophe = "'" + '"' + "'" + '"' + "'" + + $escapedTitle = $BannerTitle -replace "'", $posixApostrophe + $escapedMessage = $BannerMessage -replace "'", $posixApostrophe + $consentFlag = if ($EnableConsent) { "true" } else { "false" } + $consentFlagYN = if ($EnableConsent) { "Y" } else { "N" } + + # Reusable command runner for future banner command fallback flows + $InvokeBannerCommand = { + param( + [string]$Command, + [string]$StepName + ) + + $cmdResult = Invoke-SSHCommand -SSHSession $SshSession -Command $Command -ErrorAction Stop + if ($cmdResult.ExitStatus -eq 0) { + Write-Host "$StepName succeeded." + return $true + } + + $errorText = $cmdResult.Error -join ' ' + Write-Warning "$StepName failed: $errorText" + return $false + } + + # Step 1: Set the banner title and message content (Layer 1) + $setContentCmd = "/opt/vmware/bin/sso-config.sh -set_logon_banner -title '$escapedTitle' -content '$escapedMessage'" + Write-Host "Setting login banner content using inline content format..." + $setContentSucceeded = & $InvokeBannerCommand -Command $setContentCmd -StepName "Set login banner content (inline)" + if (-not $setContentSucceeded) { + $bannerTempDir = "/tmp/avs-login-banner-{0}" -f ([guid]::NewGuid().ToString('N')) + $bannerFilePath = "$bannerTempDir/message.txt" + Write-Host "Inline content format failed. Preparing banner file for fallback format..." + $createBannerDirCmd = "/bin/sh -c ""mkdir -p '$bannerTempDir'""" + $createDirSucceeded = & $InvokeBannerCommand -Command $createBannerDirCmd -StepName "Create temp directory for fallback" + + $createdTempDir = $null + if ($createDirSucceeded) { + $createdTempDir = $bannerTempDir + + $createBannerFileCmd = "/bin/sh -c ""printf '%s' '$escapedMessage' > '$bannerFilePath'""" + $createFileSucceeded = & $InvokeBannerCommand -Command $createBannerFileCmd -StepName "Create banner file for fallback" + + if ($createFileSucceeded) { + $setContentCmdFallback = "/opt/vmware/bin/sso-config.sh -set_logon_banner -title '$escapedTitle' '$bannerFilePath'" + Write-Host "Retrying login banner content using file format..." + $setContentSucceeded = & $InvokeBannerCommand -Command $setContentCmdFallback -StepName "Set login banner content (file)" + if ($setContentSucceeded) { + Write-Host "Fallback content format (banner file) worked on this VCSA." + } + } + } + + # Simple strong cleanup guardrail: delete only if created and delete path are exactly same + $deleteTempDir = $bannerTempDir + if ( + -not [string]::IsNullOrEmpty($createdTempDir) -and + -not [string]::IsNullOrEmpty($deleteTempDir) -and + $createdTempDir -eq $deleteTempDir + ) { + $cleanupBannerDirCmd = "/bin/sh -c ""rm -rf -- '$deleteTempDir'""" + $cleanupSucceeded = & $InvokeBannerCommand -Command $cleanupBannerDirCmd -StepName "Cleanup temporary banner directory" + if ($cleanupSucceeded) { + Write-Host "Temporary banner directory was cleaned up." + } else { + Write-Warning "Temporary banner directory cleanup failed (non-blocking): $deleteTempDir" + } + } else { + Write-Warning "Cleanup guardrail failed. Skipping delete for safety." + } + } + + if (-not $setContentSucceeded) { + throw "Failed to set login banner content using supported formats." + } + + # Step 2: Enable the consent checkbox setting + $setConsentCmd = "/opt/vmware/bin/sso-config.sh -set_logon_banner -enable_checkbox $consentFlag" + Write-Host "Setting consent checkbox to $consentFlag..." + $setConsentSucceeded = & $InvokeBannerCommand -Command $setConsentCmd -StepName "Set consent checkbox ($consentFlag)" + if (-not $setConsentSucceeded) { + $setConsentCmdLegacy = "/opt/vmware/bin/sso-config.sh -set_logon_banner -enable_checkbox $consentFlagYN" + Write-Host "Retrying consent checkbox with legacy Y/N format ($consentFlagYN)..." + $setConsentSucceeded = & $InvokeBannerCommand -Command $setConsentCmdLegacy -StepName "Set consent checkbox ($consentFlagYN)" + if ($setConsentSucceeded) { + Write-Host "Legacy checkbox format (Y/N) worked on this VCSA." + } + } + + if (-not $setConsentSucceeded) { + throw "Failed to set consent checkbox using supported formats." + } + + # Step 3: Enable the login banner toggle (Layer 2 — the activation switch) + $enableCmd = "/opt/vmware/bin/sso-config.sh -set_logon_banner -enable true" + Write-Host "Enabling login banner display (Layer 2 toggle)..." + $enableSucceeded = & $InvokeBannerCommand -Command $enableCmd -StepName "Enable login banner toggle (-enable true)" + + if (-not $enableSucceeded) { + Write-Warning "Enable command (-enable true) did not work on this VCSA variant. Checking banner content..." + + $getCmd = "/opt/vmware/bin/sso-config.sh -get_logon_banner" + $printCmd = "/opt/vmware/bin/sso-config.sh -print_logon_banner" + + $checkResult = Invoke-SSHCommand -SSHSession $SshSession -Command $getCmd -ErrorAction SilentlyContinue + if ($checkResult.ExitStatus -ne 0) { + Write-Warning "Primary read command (-get_logon_banner) failed. Retrying with -print_logon_banner..." + $checkResult = Invoke-SSHCommand -SSHSession $SshSession -Command $printCmd -ErrorAction SilentlyContinue + } + + if ($checkResult.ExitStatus -ne 0) { + throw "Failed to verify banner state: both -get_logon_banner and -print_logon_banner failed." + } + + $bannerOutput = $checkResult.Output -join [Environment]::NewLine + $hasBannerContent = $bannerOutput -match "(?i)\btitle\s*:\s*\S" -or $bannerOutput -match "(?i)\bcontent\s*:\s*\S" + + if ($hasBannerContent) { + Write-Host "Banner content found. Treating this as legacy behavior (content implies enabled on this VCSA variant)" + Write-Host "vCenter login banner configured and enabled successfully." + } + else { + throw "Banner enable failed: toggle command failed and banner content is empty. Login banner is not configured/displayed." + } + } + else { + Write-Host "vCenter login banner configured and enabled successfully." + } + } +} + +function Get-VCLoginBanner { + <# + .SYNOPSIS + Retrieves the current vCenter login banner configuration via SSH to the VCSA appliance. + + .DESCRIPTION + Reads the current login banner settings (title, message, consent checkbox, enabled state) + from the VCSA by executing sso-config.sh via the pre-established SSH session. + Returns the configuration via the NamedOutputs hashtable. + + .EXAMPLE + Get-VCLoginBanner + #> + [CmdletBinding()] + [AVSAttribute(10, UpdatesSDDC = $false)] + param() + begin {} + process { + # Obtain the pre-established SSH session to vCenter + if ($null -eq $SSH_Sessions -or -not $SSH_Sessions.ContainsKey("VC")) { + throw "SSH session to vCenter is not available. Ensure `$SSH_Sessions['VC'] is pre-established by the AVS platform." + } + $SshSession = $SSH_Sessions["VC"].Value + if ($null -eq $SshSession) { + throw "Failed to initialize SSH session to vCenter." + } + + $getCmd = "/opt/vmware/bin/sso-config.sh -get_logon_banner" + $printCmd = "/opt/vmware/bin/sso-config.sh -print_logon_banner" + Write-Host "Retrieving login banner configuration..." + + $result = Invoke-SSHCommand -SSHSession $SshSession -Command $getCmd -ErrorAction Stop + if ($result.ExitStatus -ne 0) { + Write-Warning "Primary read command (-get_logon_banner) failed. Retrying with -print_logon_banner..." + $result = Invoke-SSHCommand -SSHSession $SshSession -Command $printCmd -ErrorAction Stop + if ($result.ExitStatus -eq 0) { + Write-Host "Fallback read command (-print_logon_banner) worked on this VCSA." + } + } + + if ($result.ExitStatus -ne 0) { + throw "Failed to retrieve login banner configuration using supported formats: $($result.Error -join ' ')" + } + + $bannerOutput = $result.Output -join "`n" + Write-Host $bannerOutput + + $NamedOutputs = @{ "LoginBannerConfig" = $bannerOutput } + Set-Variable -Name NamedOutputs -Value $NamedOutputs -Scope Global + } +} + +function Remove-VCLoginBanner { + <# + .SYNOPSIS + Disables the vCenter login banner via SSH to the VCSA appliance. + + .DESCRIPTION + Disables the login banner by toggling the "Show login message" switch OFF (Layer 2) + on the VCSA via the pre-established SSH session. The banner configuration data + (title, message) is preserved but no longer displayed on the login page. + + .EXAMPLE + Remove-VCLoginBanner + #> + [CmdletBinding()] + [AVSAttribute(10, UpdatesSDDC = $true)] + param() + begin {} + process { + # Obtain the pre-established SSH session to vCenter + if ($null -eq $SSH_Sessions -or -not $SSH_Sessions.ContainsKey("VC")) { + throw "SSH session to vCenter is not available. Ensure `$SSH_Sessions['VC'] is pre-established by the AVS platform." + } + $SshSession = $SSH_Sessions["VC"].Value + if ($null -eq $SshSession) { + throw "Failed to initialize SSH session to vCenter." + } + + $disableCmd = "/opt/vmware/bin/sso-config.sh -set_logon_banner -enable false" + $disableCmdFallback = "/opt/vmware/bin/sso-config.sh -disable_logon_banner" + Write-Host "Disabling login banner display..." + + $result = Invoke-SSHCommand -SSHSession $SshSession -Command $disableCmd -ErrorAction Stop + if ($result.ExitStatus -ne 0) { + Write-Warning "Primary disable command (-set_logon_banner -enable false) failed. Retrying with -disable_logon_banner..." + $result = Invoke-SSHCommand -SSHSession $SshSession -Command $disableCmdFallback -ErrorAction Stop + if ($result.ExitStatus -eq 0) { + Write-Host "Fallback disable command (-disable_logon_banner) worked on this VCSA." + } + } + + if ($result.ExitStatus -ne 0) { + throw "Failed to disable login banner using supported formats: $($result.Error -join ' ')" + } + + Write-Host "vCenter login banner has been disabled successfully." + Write-Host "Banner configuration data (title, message) is preserved but no longer displayed." + Write-Host "To re-enable, run Set-VCLoginBanner with the desired settings." + } +} \ No newline at end of file From eec621a81658518ee89260f293505cba7d56ef08 Mon Sep 17 00:00:00 2001 From: kavya Date: Mon, 6 Jul 2026 07:37:24 -0400 Subject: [PATCH 02/10] Added test cases for vCenter login banner fallback and cleanup guardrails --- tests/Microsoft.AVS.Management.Tests.ps1 | 575 +++++++++++++++++++++++ 1 file changed, 575 insertions(+) diff --git a/tests/Microsoft.AVS.Management.Tests.ps1 b/tests/Microsoft.AVS.Management.Tests.ps1 index d5c3ede..aaa9528 100644 --- a/tests/Microsoft.AVS.Management.Tests.ps1 +++ b/tests/Microsoft.AVS.Management.Tests.ps1 @@ -1341,3 +1341,578 @@ finally { $output | Should -Contain 'ok' } } +Describe "Set-VCLoginBanner" { + Context "SSH Session Validation" { + It "Should throw when VC SSH session is unavailable" { + $originalSshSessions = $global:SSH_Sessions + try { + $global:SSH_Sessions = $null + Mock Invoke-SSHCommand { + throw "Invoke-SSHCommand should not be called" + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Throw -ExpectedMessage "*SSH session to vCenter is not available*" + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } + Context "Happy Path" { + It "Should configure banner successfully using primary commands" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Limit-WildcardsandCodeInjectionCharacters -ModuleName Microsoft.AVS.Management -Times 2 + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-enable_checkbox true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*mkdir -p*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-enable_checkbox Y*" -or $Command -like "*-enable_checkbox N*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } + Context "Fallback Path" { + It "Should use file fallback when inline banner content command fails" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("inline content format failed") + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Limit-WildcardsandCodeInjectionCharacters -ModuleName Microsoft.AVS.Management -Times 2 + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*mkdir -p*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*printf '%s'*" -and $Command -like "*>*message.txt*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and + $Command -like "*/message.txt*" -and + $Command -notlike "*-content*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-enable_checkbox true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*rm -rf --*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-enable_checkbox Y*" -or $Command -like "*-enable_checkbox N*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } + Context "Consent Fallback Path" { + It "Should retry consent checkbox with Y/N when true/false format fails" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-enable_checkbox true*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("true/false consent format failed") + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Limit-WildcardsandCodeInjectionCharacters -ModuleName Microsoft.AVS.Management -Times 2 + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-enable_checkbox true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-enable_checkbox Y*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*mkdir -p*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } + Context "Enable Failure Path" { + It "Should throw when final enable step fails and banner state cannot be verified" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -enable true*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("enable step failed") + } + } + if ($Command -like "*-get_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("get command not supported") + } + } + if ($Command -like "*-print_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("print command failed") + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Throw -ExpectedMessage "*Failed to verify banner state*" + Should -Invoke Limit-WildcardsandCodeInjectionCharacters -ModuleName Microsoft.AVS.Management -Times 2 + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-enable_checkbox true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-get_logon_banner*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-print_logon_banner*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-enable_checkbox Y*" -or $Command -like "*-enable_checkbox N*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*mkdir -p*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } + Context "Enable Verification Path" { + It "Should succeed when enable command fails but get command returns banner content (legacy fallback)" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -enable true*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("enable step failed") + } + } + if ($Command -like "*-get_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @( + "Title: Notice", + "Content: Authorized use only." + ) + Error = @() + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-get_logon_banner*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-print_logon_banner*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + It "Should succeed when enable and get commands fail but print command returns banner content (legacy fallback)" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -enable true*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("enable step failed") + } + } + if ($Command -like "*-get_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("get command not supported") + } + } + if ($Command -like "*-print_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @( + "Title: Notice", + "Content: Authorized use only." + ) + Error = @() + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-get_logon_banner*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-print_logon_banner*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + It "Should throw when enable command fails and verification output has no banner content" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -enable true*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("enable step failed") + } + } + if ($Command -like "*-get_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("No banner configured") + Error = @() + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Throw -ExpectedMessage "*Banner enable failed: toggle command failed and banner content is empty*" + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-get_logon_banner*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-print_logon_banner*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } + Context "Cleanup Guardrail Path (Optional)" { + It "Should skip cleanup delete when fallback temp directory was not created" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("inline content failed") + } + } + if ($Command -like "*mkdir -p*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("mkdir failed") + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Throw -ExpectedMessage "*Failed to set login banner content using supported formats*" + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*mkdir -p*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*rm -rf --*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*printf '%s'*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and $Command -like "*message.txt*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } +} +Describe "Get-VCLoginBanner" { + Context "Fallback Path" { + It "Should fallback to print command and set NamedOutputs when get command fails" { + $originalSshSessions = $global:SSH_Sessions + $originalNamedOutputs = $global:NamedOutputs + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + $global:NamedOutputs = @{} + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-get_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("get command not supported") + } + } + if ($Command -like "*-print_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @( + "Title: Notice", + "Message: Authorized use only.", + "Checkbox: true", + "Enabled: true" + ) + Error = @() + } + } + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("unexpected command") + } + } -ModuleName Microsoft.AVS.Management + { Get-VCLoginBanner } | Should -Not -Throw + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-get_logon_banner*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-print_logon_banner*" + } + $global:NamedOutputs | Should -Not -BeNullOrEmpty + $global:NamedOutputs.ContainsKey("LoginBannerConfig") | Should -BeTrue + $global:NamedOutputs["LoginBannerConfig"] | Should -BeLike "*Title: Notice*" + } + finally { + $global:SSH_Sessions = $originalSshSessions + $global:NamedOutputs = $originalNamedOutputs + } + } + } +} +Describe "Remove-VCLoginBanner" { + Context "Fallback Path" { + It "Should retry with fallback disable command when primary disable command fails" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -enable false*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("primary disable failed") + } + } + if ($Command -like "*-disable_logon_banner*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("unexpected command") + } + } -ModuleName Microsoft.AVS.Management + { Remove-VCLoginBanner } | Should -Not -Throw + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable false*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-disable_logon_banner*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } +} From f7f3c97dd865b65eaaea4701c83911713b7021a4 Mon Sep 17 00:00:00 2001 From: kavya Date: Tue, 7 Jul 2026 12:44:11 -0400 Subject: [PATCH 03/10] Add Normalize-VCBannerText and apply banner input normalization in Set-VCLoginBanner --- Microsoft.AVS.Management/AVSGenericUtils.ps1 | 49 +++++++++++++++++++ .../Microsoft.AVS.Management.psm1 | 2 + 2 files changed, 51 insertions(+) diff --git a/Microsoft.AVS.Management/AVSGenericUtils.ps1 b/Microsoft.AVS.Management/AVSGenericUtils.ps1 index a5b64fa..4ddae51 100644 --- a/Microsoft.AVS.Management/AVSGenericUtils.ps1 +++ b/Microsoft.AVS.Management/AVSGenericUtils.ps1 @@ -149,3 +149,52 @@ Function Limit-WildcardsandCodeInjectionCharacters { } } + +function Normalize-VCBannerText { + <# + .DESCRIPTION + This function normalizes vCenter login banner text by replacing smart quotes, + cleaning unsupported characters, and preserving paragraph line breaks. + .PARAMETER String + Banner text to normalize. + .EXAMPLE + Normalize-VCBannerText -String "Welcome to \"AVS\"" + Returns normalized text that is safe to pass to banner commands. + #> + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [string] + $String + ) + begin { + # Normalize smart single and double quotes to ASCII quotes. + $String = $String.Replace([char]0x2018, "'").Replace([char]0x2019, "'") + $String = $String.Replace([char]0x201C, '"').Replace([char]0x201D, '"') + + # Normalize non-breaking space to regular space. + $String = $String.Replace([char]0x00A0, ' ') + + # Remove shell-sensitive special characters. + $String = $String.Replace("&", "").Replace("$", "") + $String = $String.Replace([char]0x0060, "") + $String = $String.Replace("(", "").Replace(")", "") + $String = $String.Replace("<", "").Replace(">", "") + + # Remove emojis and unsupported symbols, while preserving letters, numbers, + # punctuation, spaces, and new lines. + $String = $String -replace '[^\p{L}\p{N}\p{P}\p{Zs}\r\n]', '' + + # Collapse repeated spaces and tabs per line, preserving line breaks. + $lines = $String -split "(\r?\n)" + for ($i = 0; $i -lt $lines.Count; $i++) { + if ($lines[$i] -notmatch '^\r?\n$') { + $lines[$i] = ($lines[$i] -replace '[ \t]{2,}', ' ').TrimEnd() + } + } + $String = $lines -join '' + } + process { + return $String + } +} diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 index d3d22cc..37bfc47 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1042,6 +1042,8 @@ function Set-VCLoginBanner { begin { $BannerTitle = Limit-WildcardsandCodeInjectionCharacters -String $BannerTitle $BannerMessage = Limit-WildcardsandCodeInjectionCharacters -String $BannerMessage + $BannerTitle = Normalize-VCBannerText -String $BannerTitle + $BannerMessage = Normalize-VCBannerText -String $BannerMessage } process { # Obtain the pre-established SSH session to vCenter From 660719684173d901a33699e855721cb3d34d5b54 Mon Sep 17 00:00:00 2001 From: kavya Date: Tue, 7 Jul 2026 20:03:39 -0400 Subject: [PATCH 04/10] Added optional cURL login-page source verification after banner setup, with non-blocking warning guidance --- .../Microsoft.AVS.Management.psm1 | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 index 37bfc47..6eee6e1 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1184,6 +1184,36 @@ function Set-VCLoginBanner { else { Write-Host "vCenter login banner configured and enabled successfully." } + + # Step 4: Optional page-source verification (non-blocking) + Write-Host "Checking login page source for banner content (non-blocking)..." + $curlCmd = "/usr/bin/curl -k -s -L https://localhost/ui/login" + $curlResult = Invoke-SSHCommand -SSHSession $SshSession -Command $curlCmd -ErrorAction SilentlyContinue + + if ($curlResult.ExitStatus -eq 0) { + $pageSource = $curlResult.Output -join [Environment]::NewLine + $titleFound = $false + $messageFound = $false + + if (-not [string]::IsNullOrWhiteSpace($BannerTitle)) { + $titleFound = $pageSource -match [regex]::Escape($BannerTitle) + } + + if (-not [string]::IsNullOrWhiteSpace($BannerMessage)) { + $messageToken = if ($BannerMessage.Length -gt 25) { $BannerMessage.Substring(0, 25) } else { $BannerMessage } + $messageFound = $pageSource -match [regex]::Escape($messageToken) + } + + if ($titleFound -or $messageFound) { + Write-Host "Login page source check passed: banner text detected in page content." + } + else { + Write-Warning "Login page source check did not find banner text, but banner configuration commands succeeded. Please refresh the login page (or clear browser cache) and verify the banner in the UI." + } + } + else { + Write-Warning "Login page source check skipped: failed to fetch /ui/login via curl." + } } } From b2db434f3749c1b82e3cbb84343ee5bccb362879 Mon Sep 17 00:00:00 2001 From: kavya Date: Tue, 7 Jul 2026 23:21:39 -0400 Subject: [PATCH 05/10] Added test cases in tets file and helper function for Normalize-VCBannertext --- Microsoft.AVS.Management/AVSGenericUtils.ps1 | 2 +- tests/Microsoft.AVS.Management.Tests.ps1 | 242 +++++++++++++++++++ 2 files changed, 243 insertions(+), 1 deletion(-) diff --git a/Microsoft.AVS.Management/AVSGenericUtils.ps1 b/Microsoft.AVS.Management/AVSGenericUtils.ps1 index 4ddae51..1524a5b 100644 --- a/Microsoft.AVS.Management/AVSGenericUtils.ps1 +++ b/Microsoft.AVS.Management/AVSGenericUtils.ps1 @@ -177,7 +177,7 @@ function Normalize-VCBannerText { # Remove shell-sensitive special characters. $String = $String.Replace("&", "").Replace("$", "") - $String = $String.Replace([char]0x0060, "") + $String = $String.Replace([string][char]0x0060, "") $String = $String.Replace("(", "").Replace(")", "") $String = $String.Replace("<", "").Replace(">", "") diff --git a/tests/Microsoft.AVS.Management.Tests.ps1 b/tests/Microsoft.AVS.Management.Tests.ps1 index aaa9528..e4263c8 100644 --- a/tests/Microsoft.AVS.Management.Tests.ps1 +++ b/tests/Microsoft.AVS.Management.Tests.ps1 @@ -1341,6 +1341,35 @@ finally { $output | Should -Contain 'ok' } } + +Describe "Normalize-VCBannerText" { + Context "Smart quote normalization" { + It "Should replace smart quotes with ASCII quotes" { + InModuleScope 'Microsoft.AVS.Management' { + $input = ([char]0x201C) + "Authorized Users Only" + ([char]0x201D) + " and " + ([char]0x2018) + "AVS" + ([char]0x2019) + $result = Normalize-VCBannerText -String $input + + $result | Should -Be '"Authorized Users Only" and ''AVS''' + } + } + } + + Context "Special character cleanup" { + It "Should remove unsafe symbols, keep line breaks, and normalize spaces" { + InModuleScope 'Microsoft.AVS.Management' { + $line1 = "Line1 with spaces & `$ 😀" + $line2 = "Line2 with tabs" + $input = $line1 + [Environment]::NewLine + $line2 + + $result = Normalize-VCBannerText -String $input + $expected = "Line1 with spaces tag" + [Environment]::NewLine + "Line2 with tabs" + + $result | Should -Be $expected + } + } + } +} + Describe "Set-VCLoginBanner" { Context "SSH Session Validation" { It "Should throw when VC SSH session is unavailable" { @@ -1814,6 +1843,219 @@ Describe "Set-VCLoginBanner" { } } } + Context "Page Source Verification Path" { + It "Should verify banner text in login page source via curl and not throw on success" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*-enable_checkbox true*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*-set_logon_banner -enable true*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*/usr/bin/curl*" -and $Command -like "*-k*" -and $Command -like "*-s*" -and $Command -like "*-L*" -and $Command -like "*https://localhost/ui/login*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @( + "", + "
Notice
", + "
Authorized use only.
", + "" + ) + Error = @() + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-enable_checkbox true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*-set_logon_banner -enable true*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*/usr/bin/curl*" -and $Command -like "*-k*" -and $Command -like "*-s*" -and $Command -like "*-L*" -and $Command -like "*https://localhost/ui/login*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-get_logon_banner*" -or $Command -like "*-print_logon_banner*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + + It "Should warn when login page source does not contain banner text but not throw" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Write-Warning { } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*-enable_checkbox true*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*-set_logon_banner -enable true*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*/usr/bin/curl*" -and $Command -like "*-k*" -and $Command -like "*-s*" -and $Command -like "*-L*" -and $Command -like "*https://localhost/ui/login*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @( + "", + "
No banner text here
", + "" + ) + Error = @() + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*/usr/bin/curl*" -and $Command -like "*https://localhost/ui/login*" + } + Should -Invoke Write-Warning -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Message -like "*Login page source check did not find banner text*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-get_logon_banner*" -or $Command -like "*-print_logon_banner*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + + It "Should warn and not throw when curl check fails" { + $originalSshSessions = $global:SSH_Sessions + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Limit-WildcardsandCodeInjectionCharacters { + param($String) + return $String + } -ModuleName Microsoft.AVS.Management + Mock Write-Warning { } -ModuleName Microsoft.AVS.Management + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + if ($Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*-enable_checkbox true*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*-set_logon_banner -enable true*") { + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } + if ($Command -like "*/usr/bin/curl*" -and $Command -like "*-k*" -and $Command -like "*-s*" -and $Command -like "*-L*" -and $Command -like "*https://localhost/ui/login*") { + return [PSCustomObject]@{ + ExitStatus = 1 + Output = @() + Error = @("curl failed") + } + } + return [PSCustomObject]@{ + ExitStatus = 0 + Output = @("ok") + Error = @() + } + } -ModuleName Microsoft.AVS.Management + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage "Authorized use only." -EnableConsent $true + } | Should -Not -Throw + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Command -like "*/usr/bin/curl*" -and $Command -like "*https://localhost/ui/login*" + } + Should -Invoke Write-Warning -ModuleName Microsoft.AVS.Management -Times 1 -ParameterFilter { + $Message -like "*Login page source check skipped: failed to fetch /ui/login via curl.*" + } + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 -ParameterFilter { + $Command -like "*-get_logon_banner*" -or $Command -like "*-print_logon_banner*" + } + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } } Describe "Get-VCLoginBanner" { Context "Fallback Path" { From 8fcf03a81fbaf06248c048d0ce556ce956693678 Mon Sep 17 00:00:00 2001 From: kavya Date: Wed, 8 Jul 2026 07:01:04 -0400 Subject: [PATCH 06/10] Fixed: preserved tab spaces to collapse in AVSgenericUtils and added test cases of missing SSH for get and remove --- Microsoft.AVS.Management/AVSGenericUtils.ps1 | 2 +- tests/Microsoft.AVS.Management.Tests.ps1 | 34 ++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/Microsoft.AVS.Management/AVSGenericUtils.ps1 b/Microsoft.AVS.Management/AVSGenericUtils.ps1 index 1524a5b..d659093 100644 --- a/Microsoft.AVS.Management/AVSGenericUtils.ps1 +++ b/Microsoft.AVS.Management/AVSGenericUtils.ps1 @@ -183,7 +183,7 @@ function Normalize-VCBannerText { # Remove emojis and unsupported symbols, while preserving letters, numbers, # punctuation, spaces, and new lines. - $String = $String -replace '[^\p{L}\p{N}\p{P}\p{Zs}\r\n]', '' + $String = $String -replace '[^\p{L}\p{N}\p{P}\p{Zs}\t\r\n]', '' # Collapse repeated spaces and tabs per line, preserving line breaks. $lines = $String -split "(\r?\n)" diff --git a/tests/Microsoft.AVS.Management.Tests.ps1 b/tests/Microsoft.AVS.Management.Tests.ps1 index e4263c8..72a72cc 100644 --- a/tests/Microsoft.AVS.Management.Tests.ps1 +++ b/tests/Microsoft.AVS.Management.Tests.ps1 @@ -2112,6 +2112,23 @@ Describe "Get-VCLoginBanner" { } } } + + Context "Session Validation" { + It "Should throw when VC SSH session is missing" { + $originalSshSessions = $global:SSH_Sessions + try { + $global:SSH_Sessions = @{} + Mock Invoke-SSHCommand { } -ModuleName Microsoft.AVS.Management + + { Get-VCLoginBanner } | Should -Throw "*SSH session to vCenter is not available*" + + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } } Describe "Remove-VCLoginBanner" { Context "Fallback Path" { @@ -2157,4 +2174,21 @@ Describe "Remove-VCLoginBanner" { } } } + + Context "Session Validation" { + It "Should throw when VC SSH session is missing" { + $originalSshSessions = $global:SSH_Sessions + try { + $global:SSH_Sessions = @{} + Mock Invoke-SSHCommand { } -ModuleName Microsoft.AVS.Management + + { Remove-VCLoginBanner } | Should -Throw "*SSH session to vCenter is not available*" + + Should -Invoke Invoke-SSHCommand -ModuleName Microsoft.AVS.Management -Times 0 + } + finally { + $global:SSH_Sessions = $originalSshSessions + } + } + } } From 650de6dd7c1206bbf127ec2b444dc141b7048dd6 Mon Sep 17 00:00:00 2001 From: kavya Date: Fri, 17 Jul 2026 09:13:47 -0400 Subject: [PATCH 07/10] Fix: Get-VCLoginBanner to detect unsupported -get_logon_banner option and fallback to -print_logon_banner --- .../Microsoft.AVS.Management.psm1 | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 index 6eee6e1..840d301 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1249,21 +1249,23 @@ function Get-VCLoginBanner { Write-Host "Retrieving login banner configuration..." $result = Invoke-SSHCommand -SSHSession $SshSession -Command $getCmd -ErrorAction Stop - if ($result.ExitStatus -ne 0) { - Write-Warning "Primary read command (-get_logon_banner) failed. Retrying with -print_logon_banner..." + $bannerOutput = $result.Output -join "`n" + + # Check if command failed OR returned help text (unsupported option) + if ($result.ExitStatus -ne 0 -or $bannerOutput -like "*usage:*") { + Write-Warning "Primary read command (-get_logon_banner) failed or unsupported. Retrying with -print_logon_banner..." $result = Invoke-SSHCommand -SSHSession $SshSession -Command $printCmd -ErrorAction Stop + $bannerOutput = $result.Output -join "`n" if ($result.ExitStatus -eq 0) { Write-Host "Fallback read command (-print_logon_banner) worked on this VCSA." } } if ($result.ExitStatus -ne 0) { - throw "Failed to retrieve login banner configuration using supported formats: $($result.Error -join ' ')" + throw "Failed to retrieve login banner configuration: $($result.Error -join ' ')" } - $bannerOutput = $result.Output -join "`n" Write-Host $bannerOutput - $NamedOutputs = @{ "LoginBannerConfig" = $bannerOutput } Set-Variable -Name NamedOutputs -Value $NamedOutputs -Scope Global } From f2b283acab4ea71da185ca02177635421ec197ba Mon Sep 17 00:00:00 2001 From: kavya Date: Fri, 17 Jul 2026 09:31:30 -0400 Subject: [PATCH 08/10] Fixed: set and remove functions to detect fallback options --- .../Microsoft.AVS.Management.psm1 | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 index 840d301..0ade5da 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1161,17 +1161,20 @@ function Set-VCLoginBanner { $printCmd = "/opt/vmware/bin/sso-config.sh -print_logon_banner" $checkResult = Invoke-SSHCommand -SSHSession $SshSession -Command $getCmd -ErrorAction SilentlyContinue - if ($checkResult.ExitStatus -ne 0) { - Write-Warning "Primary read command (-get_logon_banner) failed. Retrying with -print_logon_banner..." + $checkOutput = $checkResult.Output -join [Environment]::NewLine + + # Check if command failed OR returned help text (unsupported option) + if ($checkResult.ExitStatus -ne 0 -or $checkOutput -like "*usage:*") { + Write-Warning "Primary read command (-get_logon_banner) failed or unsupported. Retrying with -print_logon_banner..." $checkResult = Invoke-SSHCommand -SSHSession $SshSession -Command $printCmd -ErrorAction SilentlyContinue + $checkOutput = $checkResult.Output -join [Environment]::NewLine } if ($checkResult.ExitStatus -ne 0) { throw "Failed to verify banner state: both -get_logon_banner and -print_logon_banner failed." } - $bannerOutput = $checkResult.Output -join [Environment]::NewLine - $hasBannerContent = $bannerOutput -match "(?i)\btitle\s*:\s*\S" -or $bannerOutput -match "(?i)\bcontent\s*:\s*\S" + $hasBannerContent = $checkOutput -match "(?i)\btitle\s*:\s*\S" -or $checkOutput -match "(?i)\bcontent\s*:\s*\S" if ($hasBannerContent) { Write-Host "Banner content found. Treating this as legacy behavior (content implies enabled on this VCSA variant)" @@ -1303,8 +1306,11 @@ function Remove-VCLoginBanner { Write-Host "Disabling login banner display..." $result = Invoke-SSHCommand -SSHSession $SshSession -Command $disableCmd -ErrorAction Stop - if ($result.ExitStatus -ne 0) { - Write-Warning "Primary disable command (-set_logon_banner -enable false) failed. Retrying with -disable_logon_banner..." + $disableOutput = $result.Output -join [Environment]::NewLine + + # Check if command failed OR returned help text (unsupported option) + if ($result.ExitStatus -ne 0 -or $disableOutput -like "*usage:*") { + Write-Warning "Primary disable command (-set_logon_banner -enable false) failed or unsupported. Retrying with -disable_logon_banner..." $result = Invoke-SSHCommand -SSHSession $SshSession -Command $disableCmdFallback -ErrorAction Stop if ($result.ExitStatus -eq 0) { Write-Host "Fallback disable command (-disable_logon_banner) worked on this VCSA." From c524f930078edb4ce6b4daf68c81be90b041b837 Mon Sep 17 00:00:00 2001 From: Eugene Tolmachev Date: Fri, 17 Jul 2026 15:24:42 -0400 Subject: [PATCH 09/10] Fix shell injection in Set-VCLoginBanner fallback path The fallback banner-file commands wrapped single-quote-escaped, user-controlled text inside an outer /bin/sh -c "..." (double quotes) with a second shell parse. The POSIX single-quote escape ('"'"') and any literal double quote broke out of the outer double quotes, allowing arbitrary command execution as root on the VCSA. Run mkdir/printf/rm directly through the remote login shell (single parse) so the single-quote escaping the primary path already relies on is sufficient. Add regression tests (structural + real-shell execution) under Set-VCLoginBanner. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: e4e1a2df-e3b1-49f0-833c-04f0ffc8b7b2 --- .../Microsoft.AVS.Management.psm1 | 9 +- tests/Microsoft.AVS.Management.Tests.ps1 | 90 +++++++++++++++++++ 2 files changed, 96 insertions(+), 3 deletions(-) diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 index 0ade5da..3f8a32e 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1089,14 +1089,17 @@ function Set-VCLoginBanner { $bannerTempDir = "/tmp/avs-login-banner-{0}" -f ([guid]::NewGuid().ToString('N')) $bannerFilePath = "$bannerTempDir/message.txt" Write-Host "Inline content format failed. Preparing banner file for fallback format..." - $createBannerDirCmd = "/bin/sh -c ""mkdir -p '$bannerTempDir'""" + # Run the command directly through the remote login shell (single parse). Do NOT wrap + # in /bin/sh -c "...": nesting single-quote-escaped, user-controlled text inside an outer + # double-quoted string lets a literal or escaped double quote break out and inject commands. + $createBannerDirCmd = "mkdir -p '$bannerTempDir'" $createDirSucceeded = & $InvokeBannerCommand -Command $createBannerDirCmd -StepName "Create temp directory for fallback" $createdTempDir = $null if ($createDirSucceeded) { $createdTempDir = $bannerTempDir - $createBannerFileCmd = "/bin/sh -c ""printf '%s' '$escapedMessage' > '$bannerFilePath'""" + $createBannerFileCmd = "printf '%s' '$escapedMessage' > '$bannerFilePath'" $createFileSucceeded = & $InvokeBannerCommand -Command $createBannerFileCmd -StepName "Create banner file for fallback" if ($createFileSucceeded) { @@ -1116,7 +1119,7 @@ function Set-VCLoginBanner { -not [string]::IsNullOrEmpty($deleteTempDir) -and $createdTempDir -eq $deleteTempDir ) { - $cleanupBannerDirCmd = "/bin/sh -c ""rm -rf -- '$deleteTempDir'""" + $cleanupBannerDirCmd = "rm -rf -- '$deleteTempDir'" $cleanupSucceeded = & $InvokeBannerCommand -Command $cleanupBannerDirCmd -StepName "Cleanup temporary banner directory" if ($cleanupSucceeded) { Write-Host "Temporary banner directory was cleaned up." diff --git a/tests/Microsoft.AVS.Management.Tests.ps1 b/tests/Microsoft.AVS.Management.Tests.ps1 index 72a72cc..f2d7c50 100644 --- a/tests/Microsoft.AVS.Management.Tests.ps1 +++ b/tests/Microsoft.AVS.Management.Tests.ps1 @@ -2056,6 +2056,96 @@ Describe "Set-VCLoginBanner" { } } } + Context "Command Injection Safety" { + # Regression guard for the fallback banner-file write. The value is user-controlled and + # is single-quote escaped for a single shell parse. Wrapping it in an outer + # /bin/sh -c "..." (double quotes) re-introduced a shell-injection break-out. + It "Should not wrap fallback commands in a nested double-quoted /bin/sh -c and must keep the message single-quoted" { + $originalSshSessions = $global:SSH_Sessions + $global:VCBannerCapturedCommands = [System.Collections.Generic.List[string]]::new() + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + # Let the real sanitizers (Limit-WildcardsandCodeInjectionCharacters + Normalize-VCBannerText) run. + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + $global:VCBannerCapturedCommands.Add($Command) + if ($Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*") { + # Force the fallback (file) path. + return [PSCustomObject]@{ ExitStatus = 1; Output = @(); Error = @("inline content format failed") } + } + return [PSCustomObject]@{ ExitStatus = 0; Output = @("ok"); Error = @() } + } -ModuleName Microsoft.AVS.Management + + # Message contains both a single quote and a double quote. + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage 'it''s "AVS"' -EnableConsent $true + } | Should -Not -Throw + + $fileCmd = $global:VCBannerCapturedCommands | Where-Object { $_ -like "*printf '%s'*" -and $_ -like "*message.txt*" } | Select-Object -First 1 + $fileCmd | Should -Not -BeNullOrEmpty + # No nested double-quoted shell wrapper. + $global:VCBannerCapturedCommands | ForEach-Object { $_ | Should -Not -BeLike '*/bin/sh -c*' } + # The message must stay inside single quotes. + $fileCmd | Should -BeLike "*printf '%s' '*" + # The user's single quote must be POSIX single-quote escaped ('"'"'). + $posix = "'" + '"' + "'" + '"' + "'" + $fileCmd | Should -BeLike "*it$($posix)s*" + } + finally { + Remove-Variable -Name VCBannerCapturedCommands -Scope Global -ErrorAction SilentlyContinue + $global:SSH_Sessions = $originalSshSessions + } + } + + It "Should not execute injected commands when the fallback file-write runs in a real shell" -Skip:(-not ($IsLinux -or $IsMacOS)) { + $originalSshSessions = $global:SSH_Sessions + $global:VCBannerCapturedCommands = [System.Collections.Generic.List[string]]::new() + $marker = Join-Path ([System.IO.Path]::GetTempPath()) ("avs-banner-inj-{0}" -f ([guid]::NewGuid().ToString('N'))) + try { + $mockSession = [System.Runtime.Serialization.FormatterServices]::GetUninitializedObject([SSH.SshSession]) + $global:SSH_Sessions = @{ + VC = [PSCustomObject]@{ Value = $mockSession } + } + Mock Invoke-SSHCommand { + param($SSHSession, $Command) + $global:VCBannerCapturedCommands.Add($Command) + if ($Command -like "*-set_logon_banner -title*" -and $Command -like "*-content*") { + return [PSCustomObject]@{ ExitStatus = 1; Output = @(); Error = @("inline content format failed") } + } + return [PSCustomObject]@{ ExitStatus = 0; Output = @("ok"); Error = @() } + } -ModuleName Microsoft.AVS.Management + + # Payload uses only characters that survive sanitization (double quote + newline + # + a plain command), attempting to break out and run `touch $marker`. + $payload = "Notice`"`ntouch $marker`nprintf `"" + { + Set-VCLoginBanner -BannerTitle "Notice" -BannerMessage $payload -EnableConsent $true + } | Should -Not -Throw + + $mkdirCmd = $global:VCBannerCapturedCommands | Where-Object { $_ -like "mkdir -p *" } | Select-Object -First 1 + $fileCmd = $global:VCBannerCapturedCommands | Where-Object { $_ -like "*printf '%s'*" -and $_ -like "*message.txt*" } | Select-Object -First 1 + $mkdirCmd | Should -Not -BeNullOrEmpty + $fileCmd | Should -Not -BeNullOrEmpty + + # Reproduce exactly what the remote sshd does: run each captured command with a single shell parse. + & '/bin/sh' '-c' $mkdirCmd + & '/bin/sh' '-c' $fileCmd + + # If the escaping is broken, the injected `touch $marker` would have run. + Test-Path -LiteralPath $marker | Should -BeFalse + } + finally { + Get-ChildItem -Path ([System.IO.Path]::GetTempPath()) -Filter 'avs-login-banner-*' -Directory -ErrorAction SilentlyContinue | + Remove-Item -Recurse -Force -ErrorAction SilentlyContinue + Remove-Item -LiteralPath $marker -Force -ErrorAction SilentlyContinue + Remove-Variable -Name VCBannerCapturedCommands -Scope Global -ErrorAction SilentlyContinue + $global:SSH_Sessions = $originalSshSessions + } + } + } } Describe "Get-VCLoginBanner" { Context "Fallback Path" { From 235221ee668a25b87221c6b79e09fd0f141df859 Mon Sep 17 00:00:00 2001 From: kavya Date: Tue, 21 Jul 2026 18:04:21 -0400 Subject: [PATCH 10/10] Add VC SSH precheck helper and reuse it in login banner cmdlets --- Microsoft.AVS.Management/AVSGenericUtils.ps1 | 50 +++++++++++++++++++ .../Microsoft.AVS.Management.psm1 | 30 +++-------- 2 files changed, 56 insertions(+), 24 deletions(-) diff --git a/Microsoft.AVS.Management/AVSGenericUtils.ps1 b/Microsoft.AVS.Management/AVSGenericUtils.ps1 index d659093..557ea30 100644 --- a/Microsoft.AVS.Management/AVSGenericUtils.ps1 +++ b/Microsoft.AVS.Management/AVSGenericUtils.ps1 @@ -198,3 +198,53 @@ function Normalize-VCBannerText { return $String } } + +function Assert-VCSSHSession { + [CmdletBinding()] + param() + + if ($null -eq $SSH_Sessions -or -not $SSH_Sessions.ContainsKey("VC")) { + throw "SSH session to vCenter is not available. Ensure `$SSH_Sessions['VC'] is pre-established by the AVS platform." + } + + $SshSession = $SSH_Sessions["VC"].Value + if ($null -eq $SshSession) { + throw "Failed to initialize SSH session to vCenter." + } + + return $SshSession +} + +function Write-VCSSHPermissionDiagnostic { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + $SshSession + ) + + try { + $probe = Invoke-SSHCommand -SSHSession $SshSession -Command "whoami; id; sudo -n -l 2>&1; echo SUDO_EXIT_CODE:`$?" + $lines = @($probe.Output | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) + + $userName = if ($lines.Count -gt 0) { $lines[0].Trim() } else { "unknown" } + + $idLine = $lines | Where-Object { $_ -match "^uid=" } | Select-Object -First 1 + $isRoot = $false + if ($idLine -and $idLine -match "uid=0\(") { + $isRoot = $true + } + + $hasPasswordlessSudo = $false + if ($lines -match "SUDO_EXIT_CODE:0") { + $hasPasswordlessSudo = $true + } + elseif ($lines -match "NOPASSWD") { + $hasPasswordlessSudo = $true + } + + Write-Host ("VC SSH precheck: User={0}; IsRoot={1}; HasPasswordlessSudo={2}" -f $userName, $isRoot, $hasPasswordlessSudo) + } + catch { + Write-Warning ("VC SSH precheck could not run: {0}" -f $_.Exception.Message) + } +} diff --git a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 index 3f8a32e..f39aaa3 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1046,14 +1046,8 @@ function Set-VCLoginBanner { $BannerMessage = Normalize-VCBannerText -String $BannerMessage } process { - # Obtain the pre-established SSH session to vCenter - if ($null -eq $SSH_Sessions -or -not $SSH_Sessions.ContainsKey("VC")) { - throw "SSH session to vCenter is not available. Ensure `$SSH_Sessions['VC'] is pre-established by the AVS platform." - } - $SshSession = $SSH_Sessions["VC"].Value - if ($null -eq $SshSession) { - throw "Failed to initialize SSH session to vCenter." - } + $SshSession = Assert-VCSSHSession + Write-VCSSHPermissionDiagnostic -SshSession $SshSession # POSIX-safe escape for apostrophes inside single-quoted shell text $posixApostrophe = "'" + '"' + "'" + '"' + "'" @@ -1241,14 +1235,8 @@ function Get-VCLoginBanner { param() begin {} process { - # Obtain the pre-established SSH session to vCenter - if ($null -eq $SSH_Sessions -or -not $SSH_Sessions.ContainsKey("VC")) { - throw "SSH session to vCenter is not available. Ensure `$SSH_Sessions['VC'] is pre-established by the AVS platform." - } - $SshSession = $SSH_Sessions["VC"].Value - if ($null -eq $SshSession) { - throw "Failed to initialize SSH session to vCenter." - } + $SshSession = Assert-VCSSHSession + Write-VCSSHPermissionDiagnostic -SshSession $SshSession $getCmd = "/opt/vmware/bin/sso-config.sh -get_logon_banner" $printCmd = "/opt/vmware/bin/sso-config.sh -print_logon_banner" @@ -1295,14 +1283,8 @@ function Remove-VCLoginBanner { param() begin {} process { - # Obtain the pre-established SSH session to vCenter - if ($null -eq $SSH_Sessions -or -not $SSH_Sessions.ContainsKey("VC")) { - throw "SSH session to vCenter is not available. Ensure `$SSH_Sessions['VC'] is pre-established by the AVS platform." - } - $SshSession = $SSH_Sessions["VC"].Value - if ($null -eq $SshSession) { - throw "Failed to initialize SSH session to vCenter." - } + $SshSession = Assert-VCSSHSession + Write-VCSSHPermissionDiagnostic -SshSession $SshSession $disableCmd = "/opt/vmware/bin/sso-config.sh -set_logon_banner -enable false" $disableCmdFallback = "/opt/vmware/bin/sso-config.sh -disable_logon_banner"