diff --git a/Microsoft.AVS.Management/AVSGenericUtils.ps1 b/Microsoft.AVS.Management/AVSGenericUtils.ps1 index a5b64fa..557ea30 100644 --- a/Microsoft.AVS.Management/AVSGenericUtils.ps1 +++ b/Microsoft.AVS.Management/AVSGenericUtils.ps1 @@ -149,3 +149,102 @@ 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([string][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}\t\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 + } +} + +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.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..f39aaa3 100644 --- a/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 +++ b/Microsoft.AVS.Management/Microsoft.AVS.Management.psm1 @@ -1002,3 +1002,312 @@ 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 + $BannerTitle = Normalize-VCBannerText -String $BannerTitle + $BannerMessage = Normalize-VCBannerText -String $BannerMessage + } + process { + $SshSession = Assert-VCSSHSession + Write-VCSSHPermissionDiagnostic -SshSession $SshSession + + # 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..." + # 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 = "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 = "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 + $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." + } + + $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)" + 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." + } + + # 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." + } + } +} + +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 { + $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" + Write-Host "Retrieving login banner configuration..." + + $result = Invoke-SSHCommand -SSHSession $SshSession -Command $getCmd -ErrorAction Stop + $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: $($result.Error -join ' ')" + } + + 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 { + $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" + Write-Host "Disabling login banner display..." + + $result = Invoke-SSHCommand -SSHSession $SshSession -Command $disableCmd -ErrorAction Stop + $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." + } + } + + 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 diff --git a/tests/Microsoft.AVS.Management.Tests.ps1 b/tests/Microsoft.AVS.Management.Tests.ps1 index d5c3ede..f2d7c50 100644 --- a/tests/Microsoft.AVS.Management.Tests.ps1 +++ b/tests/Microsoft.AVS.Management.Tests.ps1 @@ -1341,3 +1341,944 @@ 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" { + $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 + } + } + } + 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 + } + } + } + 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" { + 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 + } + } + } + + 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" { + 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 + } + } + } + + 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 + } + } + } +}