diff --git a/src/powershell/ZeroTrustAssessment.psd1 b/src/powershell/ZeroTrustAssessment.psd1 index d094966273..cb0d11d3aa 100644 --- a/src/powershell/ZeroTrustAssessment.psd1 +++ b/src/powershell/ZeroTrustAssessment.psd1 @@ -76,7 +76,7 @@ FunctionsToExport = 'Connect-ZtAssessment', 'Disconnect-ZtAssessment', 'Get-ZtTestStatistics', 'Invoke-ZtAssessment', 'Invoke-ZtGraphRequest', 'Invoke-ZtAzureRequest', 'Invoke-ZtAzureResourceGraphRequest', 'Clear-ZtRequiredModule', - 'Get-ZtCurrentLicense' + 'Get-ZtCurrentLicense', 'Stop-ZtReportServer' # 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 = @() diff --git a/src/powershell/private/utility/browser/Install-ZtBrowserUrlCapture.ps1 b/src/powershell/private/utility/browser/Install-ZtBrowserUrlCapture.ps1 new file mode 100644 index 0000000000..cdc6625115 --- /dev/null +++ b/src/powershell/private/utility/browser/Install-ZtBrowserUrlCapture.ps1 @@ -0,0 +1,54 @@ +function Install-ZtBrowserUrlCapture { + <# + .SYNOPSIS + Installs a temporary xdg-open/open wrapper that captures the auth URL MSAL passes to the browser. + .DESCRIPTION + In container environments, MSAL opens the browser via xdg-open (Linux) or open (macOS) with a + URL containing PKCE code_challenge, state, and a dynamic localhost port. This function creates + a thin wrapper script that logs the URL to a temp file before forwarding to the real browser + opener, enabling a clickable fallback link to be displayed in the terminal. + .OUTPUTS + [hashtable] State needed by Start-ZtBrowserUrlWatcher and Remove-ZtBrowserUrlCapture, + or $null if the wrapper could not be installed. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param () + + if (-not ($IsLinux -or $IsMacOS)) { return $null } + + $browserCmd = if ($IsLinux) { 'xdg-open' } else { 'open' } + $resolvedCmd = Get-Command -Name $browserCmd -ErrorAction Ignore + if (-not $resolvedCmd) { + Write-PSFMessage -Message "Cannot install URL capture: '$browserCmd' not found on PATH." -Level Debug + return $null + } + $realBrowserPath = $resolvedCmd.Source + + $authUrlFile = [System.IO.Path]::GetTempFileName() + $wrapperDirName = "zt-browser-wrapper-$([System.Guid]::NewGuid().ToString('N'))" + $wrapperDir = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath $wrapperDirName + $null = New-Item -Path $wrapperDir -ItemType Directory -Force + $wrapperPath = Join-Path -Path $wrapperDir -ChildPath $browserCmd + + # Wrapper: save the URL to the temp file, then call the real browser opener + $wrapperContent = @" +#!/bin/bash +echo "`$1" > "$authUrlFile" +exec "$realBrowserPath" "`$@" +"@ + Set-Content -Path $wrapperPath -Value $wrapperContent -Force + chmod +x $wrapperPath + + # Prepend wrapper dir to PATH so MSAL finds our wrapper first + $env:PATH = "${wrapperDir}:$($env:PATH)" + + Write-PSFMessage -Message "Installed browser URL capture wrapper at $wrapperPath (real: $realBrowserPath)." -Level Debug + + return @{ + AuthUrlFile = $authUrlFile + WrapperPath = $wrapperPath + WrapperDir = $wrapperDir + RealBrowserPath = $realBrowserPath + } +} diff --git a/src/powershell/private/utility/browser/Open-ZtFile.ps1 b/src/powershell/private/utility/browser/Open-ZtFile.ps1 new file mode 100644 index 0000000000..3401c94f20 --- /dev/null +++ b/src/powershell/private/utility/browser/Open-ZtFile.ps1 @@ -0,0 +1,43 @@ +function Open-ZtFile { + <# + .SYNOPSIS + Opens a file or URL in the default application, with container-aware fallback. + .DESCRIPTION + On Linux, uses xdg-open. On macOS, uses open. On Windows, uses Invoke-Item. + Returns $true if the open command ran without error. + .PARAMETER Path + The file path or URL to open. + .OUTPUTS + [bool] True if the open command ran successfully. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] $Path + ) + + if ($IsLinux -or $IsMacOS) { + $openCmd = if ($IsLinux) { 'xdg-open' } else { 'open' } + if (Get-Command -Name $openCmd -ErrorAction Ignore) { + try { + & $openCmd $Path 2>&1 | Out-Null + return $true + } + catch { + Write-PSFMessage -Message "Failed to open with ${openCmd}: $_" -Level Debug + } + } + } + else { + try { + Invoke-Item $Path | Out-Null + return $true + } + catch { + Write-PSFMessage -Message "Failed to open with Invoke-Item: $_" -Level Debug + } + } + + return $false +} diff --git a/src/powershell/private/utility/browser/Remove-ZtBrowserUrlCapture.ps1 b/src/powershell/private/utility/browser/Remove-ZtBrowserUrlCapture.ps1 new file mode 100644 index 0000000000..b07c7cd11e --- /dev/null +++ b/src/powershell/private/utility/browser/Remove-ZtBrowserUrlCapture.ps1 @@ -0,0 +1,37 @@ +function Remove-ZtBrowserUrlCapture { + <# + .SYNOPSIS + Cleans up the browser URL capture wrapper, temp file, and background watcher. + .DESCRIPTION + Reverses the PATH modification, removes the wrapper directory and temp file, + and disposes the background PowerShell watcher. Call this in a finally block + after Connect-MgGraph completes. + .PARAMETER CaptureState + The hashtable returned by Install-ZtBrowserUrlCapture. May be $null (no-op). + .PARAMETER Watcher + The PowerShell instance returned by Start-ZtBrowserUrlWatcher. May be $null (no-op). + #> + [CmdletBinding()] + param ( + [AllowNull()] + [hashtable] $CaptureState, + + [AllowNull()] + [powershell] $Watcher + ) + + if ($CaptureState) { + if ($CaptureState.WrapperDir) { + $env:PATH = ($env:PATH -split ':' | Where-Object { $_ -ne $CaptureState.WrapperDir }) -join ':' + Remove-Item -Path $CaptureState.WrapperDir -Recurse -Force -ErrorAction Ignore + } + if ($CaptureState.AuthUrlFile) { + Remove-Item -Path $CaptureState.AuthUrlFile -Force -ErrorAction Ignore + } + Write-PSFMessage -Message "Removed browser URL capture wrapper." -Level Debug + } + + if ($Watcher) { + $Watcher.Dispose() + } +} diff --git a/src/powershell/private/utility/browser/Start-ZtBrowserUrlWatcher.ps1 b/src/powershell/private/utility/browser/Start-ZtBrowserUrlWatcher.ps1 new file mode 100644 index 0000000000..c79d024740 --- /dev/null +++ b/src/powershell/private/utility/browser/Start-ZtBrowserUrlWatcher.ps1 @@ -0,0 +1,40 @@ +function Start-ZtBrowserUrlWatcher { + <# + .SYNOPSIS + Starts a background PowerShell instance that watches for the captured auth URL and prints it. + .DESCRIPTION + After Install-ZtBrowserUrlCapture sets up the wrapper, call this function before + Connect-MgGraph to start a background watcher. When MSAL invokes xdg-open, the wrapper + writes the URL to a temp file, and the watcher detects it and prints it to the console + as a clickable fallback link. + .PARAMETER CaptureState + The hashtable returned by Install-ZtBrowserUrlCapture. + .OUTPUTS + [powershell] The background PowerShell instance (dispose it during cleanup). + #> + [CmdletBinding()] + [OutputType([powershell])] + param ( + [Parameter(Mandatory)] + [hashtable] $CaptureState + ) + + $watcher = [powershell]::Create() + $null = $watcher.AddScript({ + param($authUrlFile) + for ($i = 0; $i -lt 100; $i++) { + Start-Sleep -Milliseconds 100 + if ((Test-Path $authUrlFile) -and (Get-Item $authUrlFile).Length -gt 0) { + $capturedUrl = (Get-Content -Path $authUrlFile -Raw).Trim() + if ($capturedUrl -match '^https://') { + [Console]::WriteLine(" If the browser did not open, copy and paste this link:") + [Console]::WriteLine(" `u{1f517} $capturedUrl") + } + break + } + } + }).AddArgument($CaptureState.AuthUrlFile) + $null = $watcher.BeginInvoke() + + return $watcher +} diff --git a/src/powershell/private/utility/container/Get-ZtContainerAuthFailureHint.ps1 b/src/powershell/private/utility/container/Get-ZtContainerAuthFailureHint.ps1 new file mode 100644 index 0000000000..e17f8602c6 --- /dev/null +++ b/src/powershell/private/utility/container/Get-ZtContainerAuthFailureHint.ps1 @@ -0,0 +1,25 @@ +function Get-ZtContainerAuthFailureHint { + <# + .SYNOPSIS + Returns a user-friendly hint when Graph auth succeeds but no context is established. + .DESCRIPTION + When Connect-MgGraph completes without error but Get-MgContext returns null, + this typically means the MSAL callback (browser redirect to localhost) did not + reach the container. This function returns an appropriate message based on + whether the session is running in a container. + .OUTPUTS + [string] A hint message suggesting next steps. + #> + [CmdletBinding()] + [OutputType([string])] + param () + + $msg = "Connect-MgGraph completed but no auth context was established." + + $authReadiness = $script:ZtContainerAuthReadiness + if ($authReadiness -and $authReadiness.IsContainer) { + $msg += " The browser authentication callback may not have reached this container. Try using device code flow: Connect-ZtAssessment -UseDeviceCode" + } + + return $msg +} diff --git a/src/powershell/private/utility/container/Initialize-ZtContainerBrowserShim.ps1 b/src/powershell/private/utility/container/Initialize-ZtContainerBrowserShim.ps1 new file mode 100644 index 0000000000..acf3d71151 --- /dev/null +++ b/src/powershell/private/utility/container/Initialize-ZtContainerBrowserShim.ps1 @@ -0,0 +1,109 @@ +function Initialize-ZtContainerBrowserShim { + <# + .SYNOPSIS + Ensures xdg-open is available for MSAL's browser launch in container environments. + .OUTPUTS + [bool] True if a browser helper is available. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + # Reset shim tracking state + $script:ZtBrowserShimState = $null + + $xdgOpen = Get-Command -Name 'xdg-open' -ErrorAction Ignore + + if ($xdgOpen) { + Write-Host -Object ' ✅ Browser helper: xdg-open available.' -ForegroundColor Green + Write-PSFMessage -Message "xdg-open found at '$($xdgOpen.Source)'." -Level Debug + return $true + } + + # Try $VSCODE_BROWSER first (set by VS Code remote), then $BROWSER + $browserHelper = $null + foreach ($envVar in @('VSCODE_BROWSER', 'BROWSER')) { + $candidate = [System.Environment]::GetEnvironmentVariable($envVar) + if (-not $candidate) { continue } + + # The variable may be a full path, a bare command name, or include arguments. + # Extract the first token (the executable) for resolution. + $tokens = $candidate -split '\s+', 2 + $exePath = $tokens[0] + + # Resolve via Get-Command (handles both paths and command names on PATH) + $resolved = Get-Command -Name $exePath -ErrorAction Ignore + if ($resolved) { + $remainder = if ($tokens.Count -gt 1) { ' ' + $tokens[1] } else { '' } + $browserHelper = "$($resolved.Source)$remainder" + break + } + else { + Write-PSFMessage -Message "Browser helper from `$$envVar ('$exePath') not found on PATH." -Level Debug + } + } + + if ($browserHelper) { + Write-PSFMessage -Message "xdg-open not found. Creating shim from browser helper '$browserHelper'." -Level Verbose + + # Split the browser helper into executable and arguments so the shim + # works correctly when $browserHelper contains spaces or extra args. + $helperTokens = $browserHelper -split '\s+', 2 + $helperExe = $helperTokens[0] + $helperArgs = if ($helperTokens.Count -gt 1) { $helperTokens[1] } else { '' } + + $shimContent = @" +#!/bin/sh +exec "$helperExe" $helperArgs "`$@" +"@ + + try { + $shimPath = '/usr/local/bin/xdg-open' + $shimContent | & sudo -n tee $shimPath > $null 2>&1 + & sudo -n chmod +x $shimPath 2>&1 > $null + } + catch { + Write-PSFMessage -Message "Failed to create system xdg-open shim: $_" -Level Debug + } + + if (Get-Command -Name 'xdg-open' -ErrorAction Ignore) { + Write-Host -Object ' ✅ Browser helper: created xdg-open shim.' -ForegroundColor Green + $script:ZtBrowserShimState = @{ ShimPath = $shimPath; AddedPathEntry = $null } + return $true + } + + try { + $userBinDir = Join-Path -Path $HOME -ChildPath '.local/bin' + $userShimPath = Join-Path -Path $userBinDir -ChildPath 'xdg-open' + + if (-not (Test-Path -Path $userBinDir)) { + $null = New-Item -Path $userBinDir -ItemType Directory -Force + } + + Set-Content -Path $userShimPath -Value $shimContent -NoNewline + & chmod +x $userShimPath 2>&1 > $null + + $addedPathEntry = $null + $pathEntries = $env:PATH -split ':' + if ($pathEntries -notcontains $userBinDir) { + $env:PATH = "${userBinDir}:$($env:PATH)" + $addedPathEntry = $userBinDir + } + } + catch { + Write-PSFMessage -Message "Failed to create user xdg-open shim: $_" -Level Debug + } + + if (Get-Command -Name 'xdg-open' -ErrorAction Ignore) { + Write-Host -Object ' ✅ Browser helper: created xdg-open shim.' -ForegroundColor Green + $script:ZtBrowserShimState = @{ ShimPath = $userShimPath; AddedPathEntry = $addedPathEntry } + return $true + } + + Write-Host -Object " ⚠️ Browser helper: could not create xdg-open shim, and `$BROWSER alone may not be sufficient." -ForegroundColor Yellow + return $false + } + + Write-Host -Object ' ❌ Browser helper: no xdg-open, $VSCODE_BROWSER, or $BROWSER available.' -ForegroundColor Red + return $false +} diff --git a/src/powershell/private/utility/container/Initialize-ZtContainerEnvironment.ps1 b/src/powershell/private/utility/container/Initialize-ZtContainerEnvironment.ps1 new file mode 100644 index 0000000000..52bcfeb389 --- /dev/null +++ b/src/powershell/private/utility/container/Initialize-ZtContainerEnvironment.ps1 @@ -0,0 +1,111 @@ +function Initialize-ZtContainerEnvironment { + <# + .SYNOPSIS + Detects dev container / remote environments and sets up browser auth compatibility. + + .DESCRIPTION + When running inside a dev container (e.g. VS Code Remote Containers, Codespaces, or Docker), + interactive browser authentication requires several dependencies to work correctly: + + 1. An xdg-open command that routes to the host browser (via $BROWSER or $VSCODE_BROWSER) + 2. Network connectivity to Microsoft auth endpoints + 3. Correct localhost resolution (IPv4 127.0.0.1) for MSAL callback + 4. Port forwarding capability for the MSAL callback listener + + This function checks each dependency, attempts recovery if a check fails, + and records the resulting browser-auth readiness state for diagnostics + and for callers that choose to inspect it. + + It is called automatically during Connect-ZtAssessment on non-Windows platforms. + + .EXAMPLE + PS C:\> Initialize-ZtContainerEnvironment + + Detects and configures the container environment for browser-based auth. + #> + [CmdletBinding()] + param () + + # Only relevant on non-Windows + if ($IsWindows) { return } + + $isContainer = Test-ZtContainerEnvironment + + if (-not $isContainer) { + Write-PSFMessage -Message "Not running in a container environment. No shims needed." -Level Debug + # Non-container environments should work with browser auth out of the box. + $script:ZtContainerAuthReadiness = @{ IsContainer = $false; BrowserAuthReady = $true; Issues = @() } + return + } + + Write-PSFMessage -Message "Container environment detected. Checking browser auth dependencies..." -Level Verbose + Write-Host -Object "`n🔍 Container environment detected. Checking auth dependencies..." -ForegroundColor Cyan + + $issues = [System.Collections.Generic.List[string]]::new() + $browserAuthReady = $true + + #region 1. Check xdg-open / browser helper + $browserOk = Initialize-ZtContainerBrowserShim + if (-not $browserOk) { + $issues.Add('No browser helper available (xdg-open or $BROWSER). Cannot open auth URLs.') + $browserAuthReady = $false + } + #endregion + + #region 2. Check network connectivity to Microsoft auth endpoints + $authEndpointOk = Test-ZtAuthEndpointConnectivity + if (-not $authEndpointOk) { + $issues.Add('Cannot reach Microsoft authentication endpoints (login.microsoftonline.com).') + $browserAuthReady = $false + } + #endregion + + #region 3. Check localhost resolution (IPv4 must be present for MSAL callback) + $originalLocalhostOk = Test-ZtLocalhostResolution + $localhostOk = $originalLocalhostOk + if (-not $localhostOk) { + # Attempt recovery: add 127.0.0.1 localhost to /etc/hosts + $localhostOk = Repair-ZtLocalhostResolution + if (-not $localhostOk) { + $issues.Add('Localhost does not resolve to 127.0.0.1 (IPv4). MSAL callback may fail.') + $browserAuthReady = $false + } + } + #endregion + + #region 4. Check port forwarding capability + $portForwardOk = Test-ZtPortForwarding + if (-not $portForwardOk) { + $issues.Add('Port forwarding test failed. MSAL callback listener may not be reachable from the browser.') + # Don't block on this — it's a best-effort check + } + #endregion + + #region Summary + if ($issues.Count -eq 0) { + Write-Host -Object ' ✅ All browser auth dependencies satisfied.' -ForegroundColor Green + } + else { + foreach ($issue in $issues) { + Write-PSFMessage -Message $issue -Level Warning + } + if (-not $browserAuthReady) { + Write-Host -Object ' ⚠️ Browser authentication may not work. Device code flow will be offered.' -ForegroundColor Yellow + } + } + Write-Host + #endregion + + # Store results for Connect-ZtAssessment and cleanup to use + $script:ZtContainerAuthReadiness = @{ + IsContainer = $true + BrowserAuthReady = $browserAuthReady + Issues = $issues.ToArray() + BrowserShimState = $script:ZtBrowserShimState + RepairedHosts = (-not $originalLocalhostOk -and $localhostOk) + BrowserOk = $browserOk + AuthEndpointOk = $authEndpointOk + LocalhostOk = $localhostOk + PortForwardOk = $portForwardOk + } +} diff --git a/src/powershell/private/utility/container/Remove-ZtContainerEnvironment.ps1 b/src/powershell/private/utility/container/Remove-ZtContainerEnvironment.ps1 new file mode 100644 index 0000000000..70660652ca --- /dev/null +++ b/src/powershell/private/utility/container/Remove-ZtContainerEnvironment.ps1 @@ -0,0 +1,90 @@ +function Remove-ZtContainerEnvironment { + <# + .SYNOPSIS + Cleans up container environment modifications made by Initialize-ZtContainerEnvironment. + + .DESCRIPTION + Reverses modifications made during container auth setup: + - Removes the xdg-open shim if it was created by this module + - Removes the 127.0.0.1 localhost entry from /etc/hosts if it was added + - Clears the readiness state variable + + Called automatically by Disconnect-ZtAssessment -IncludeCleanup. + + .EXAMPLE + PS C:\> Remove-ZtContainerEnvironment + + Cleans up all container environment modifications. + #> + [CmdletBinding()] + param () + + $readiness = $script:ZtContainerAuthReadiness + if (-not $readiness -or -not $readiness.IsContainer) { + Write-PSFMessage -Message "No container environment modifications to clean up." -Level Debug + return + } + + Write-PSFMessage -Message "Cleaning up container environment modifications..." -Level Verbose + + #region Remove xdg-open shim + $shimState = $readiness.BrowserShimState + if ($shimState) { + try { + $shimPath = $shimState.ShimPath + if ($shimPath -and (Test-Path $shimPath)) { + # Use sudo -n only for system paths; user paths don't need elevation + if ($shimPath -like '/usr/*') { + & sudo -n rm -f $shimPath 2>&1 > $null + } + else { + Remove-Item -Path $shimPath -Force -ErrorAction Stop + } + + if (-not (Test-Path $shimPath)) { + Write-Host -Object ' ✅ Removed xdg-open shim.' -ForegroundColor Green + Write-PSFMessage -Message "Removed xdg-open shim at $shimPath." -Level Verbose + } + else { + Write-Host -Object ' ⚠️ Could not remove xdg-open shim.' -ForegroundColor Yellow + Write-PSFMessage -Message "Could not remove xdg-open shim at $shimPath." -Level Warning + } + } + + # Revert PATH addition if we added an entry + if ($shimState.AddedPathEntry) { + $env:PATH = ($env:PATH -split ':' | Where-Object { $_ -ne $shimState.AddedPathEntry }) -join ':' + Write-PSFMessage -Message "Removed '$($shimState.AddedPathEntry)' from PATH." -Level Verbose + } + } + catch { + Write-Host -Object " ⚠️ Error removing xdg-open shim: $($_.Exception.Message)" -ForegroundColor Yellow + Write-PSFMessage -Message "Error removing xdg-open shim: $_" -Level Debug + } + } + #endregion + + #region Revert /etc/hosts modification + if ($readiness.RepairedHosts) { + try { + $hostsContent = Get-Content '/etc/hosts' -ErrorAction Stop + # Remove only the line we added (exact match) + $filteredContent = $hostsContent | Where-Object { $_ -ne '127.0.0.1 localhost' } + if ($filteredContent.Count -lt $hostsContent.Count) { + $filteredContent | & sudo -n tee /etc/hosts > $null 2>&1 + Write-Host -Object ' ✅ Reverted /etc/hosts localhost entry.' -ForegroundColor Green + Write-PSFMessage -Message "Removed '127.0.0.1 localhost' from /etc/hosts." -Level Verbose + } + } + catch { + Write-Host -Object " ⚠️ Could not revert /etc/hosts: $($_.Exception.Message)" -ForegroundColor Yellow + Write-PSFMessage -Message "Error reverting /etc/hosts: $_" -Level Debug + } + } + #endregion + + #region Clear readiness state + $script:ZtContainerAuthReadiness = $null + Write-PSFMessage -Message "Container environment cleanup complete." -Level Verbose + #endregion +} diff --git a/src/powershell/private/utility/container/Repair-ZtLocalhostResolution.ps1 b/src/powershell/private/utility/container/Repair-ZtLocalhostResolution.ps1 new file mode 100644 index 0000000000..0a7449c23f --- /dev/null +++ b/src/powershell/private/utility/container/Repair-ZtLocalhostResolution.ps1 @@ -0,0 +1,49 @@ +function Repair-ZtLocalhostResolution { + <# + .SYNOPSIS + Attempts to add 127.0.0.1 localhost to /etc/hosts if missing. + .OUTPUTS + [bool] True if repair succeeded. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + Write-PSFMessage -Message "Attempting to add '127.0.0.1 localhost' to /etc/hosts..." -Level Verbose + + try { + $hostsContent = Get-Content '/etc/hosts' -Raw -ErrorAction Stop + + if ($hostsContent -match '(?m)^\s*127\.0\.0\.1\s+.*localhost') { + Write-PSFMessage -Message "/etc/hosts already has 127.0.0.1 localhost but DNS didn't resolve it. May be overridden." -Level Debug + return $false + } + + # Add IPv4 localhost entry + '127.0.0.1 localhost' | & sudo -n tee -a /etc/hosts > $null 2>&1 + + # Verify the fix worked + $addresses = [System.Net.Dns]::GetHostAddresses('localhost') + $hasIPv4 = $addresses | Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.ToString() -eq '127.0.0.1' } + + if ($hasIPv4) { + Write-Host -Object ' ✅ Localhost: repaired — added 127.0.0.1 to /etc/hosts.' -ForegroundColor Green + return $true + } + + # .NET caches DNS. Try a fresh resolution via external tool. + $getentResult = & getent hosts localhost 2>&1 + if ($getentResult -match '127\.0\.0\.1') { + Write-Host -Object ' ✅ Localhost: repaired — 127.0.0.1 added (DNS cache may delay effect).' -ForegroundColor Green + return $true + } + + Write-Host -Object ' ⚠️ Localhost: repair attempted but 127.0.0.1 still not resolving.' -ForegroundColor Yellow + return $false + } + catch { + Write-PSFMessage -Message "Failed to repair /etc/hosts: $_" -Level Debug + Write-Host -Object ' ⚠️ Localhost: could not modify /etc/hosts.' -ForegroundColor Yellow + return $false + } +} diff --git a/src/powershell/private/utility/container/Resolve-ZtContainerAuthMethod.ps1 b/src/powershell/private/utility/container/Resolve-ZtContainerAuthMethod.ps1 new file mode 100644 index 0000000000..979e4e6067 --- /dev/null +++ b/src/powershell/private/utility/container/Resolve-ZtContainerAuthMethod.ps1 @@ -0,0 +1,65 @@ +function Resolve-ZtContainerAuthMethod { + <# + .SYNOPSIS + Determines whether to use device code flow based on container auth readiness. + .DESCRIPTION + In container environments, browser authentication may not work due to missing + dependencies (xdg-open, port forwarding, localhost resolution, etc.). + This function checks the readiness state from Initialize-ZtContainerEnvironment + and decides whether to switch to device code flow, prompt the user, or proceed + with browser auth. + + Returns $true if device code flow should be used, $false otherwise. + .PARAMETER DeviceLoginUrl + The device login URL to display to the user (varies by cloud environment). + .OUTPUTS + [bool] True if device code flow should be used. + #> + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory)] + [string] $DeviceLoginUrl + ) + + $authReadiness = $script:ZtContainerAuthReadiness + + if (-not $authReadiness -or -not $authReadiness.IsContainer) { + return $false + } + + if (-not $authReadiness.BrowserAuthReady) { + # Hard dependencies failed — browser auth won't work at all + Write-Host + Write-Host -Object ' 📱 Container detected. Browser auth is not available:' -ForegroundColor Cyan + foreach ($issue in $authReadiness.Issues) { + Write-Host -Object " • $issue" -ForegroundColor Yellow + } + Write-Host -Object ' Switching to device code flow automatically.' -ForegroundColor Yellow + Write-Host -Object " 🔗 Open ${DeviceLoginUrl} and enter the code shown below." -ForegroundColor Yellow + Write-Host + return $true + } + elseif ($authReadiness.Issues.Count -gt 0) { + # Some checks had warnings but browser auth may still work. + # Prompt so the user can choose device code if they prefer. + Write-Host + Write-Host -Object ' 📱 Container/Codespace environment detected.' -ForegroundColor Cyan + foreach ($issue in $authReadiness.Issues) { + Write-Host -Object " ⚠️ $issue" -ForegroundColor Yellow + } + Write-Host -Object ' Browser auth may hang if the callback cannot reach this container.' -ForegroundColor Yellow + Write-Host -Object " Device code flow is recommended (enter a code at ${DeviceLoginUrl})." -ForegroundColor Yellow + Write-Host + $choice = Read-Host -Prompt ' Use device code flow? [Y/n]' + if ($choice -ne 'n' -and $choice -ne 'N') { + return $true + } + return $false + } + else { + # All dependency checks passed — proceed with browser auth. + Write-PSFMessage -Message "Container detected but all browser auth dependency checks passed. Using browser auth." -Level Verbose + return $false + } +} diff --git a/src/powershell/private/utility/container/Test-ZtAuthEndpointConnectivity.ps1 b/src/powershell/private/utility/container/Test-ZtAuthEndpointConnectivity.ps1 new file mode 100644 index 0000000000..f5cedfb029 --- /dev/null +++ b/src/powershell/private/utility/container/Test-ZtAuthEndpointConnectivity.ps1 @@ -0,0 +1,39 @@ +function Test-ZtAuthEndpointConnectivity { + <# + .SYNOPSIS + Tests network connectivity to Microsoft authentication endpoints. + .OUTPUTS + [bool] True if auth endpoints are reachable. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + # Only the auth endpoint is required for authentication. + # Graph endpoint is tested separately as a warning (used later, not for auth). + $authEndpoint = 'https://login.microsoftonline.com' + + try { + $response = Invoke-WebRequest -Uri $authEndpoint -Method Head -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop + Write-Host -Object ' ✅ Network: Microsoft auth endpoint reachable.' -ForegroundColor Green + Write-PSFMessage -Message "Connectivity to $authEndpoint : OK ($($response.StatusCode))" -Level Debug + } + catch { + Write-Host -Object " ❌ Network: cannot reach $authEndpoint" -ForegroundColor Red + Write-PSFMessage -Message "Connectivity to $authEndpoint failed: $_" -Level Debug + return $false + } + + # Graph endpoint: only log at debug level, don't show to user during auth setup. + # Graph reachability is irrelevant for authentication and will be validated + # when the assessment actually runs. + try { + $null = Invoke-WebRequest -Uri 'https://graph.microsoft.com' -Method Head -TimeoutSec 10 -UseBasicParsing -ErrorAction Stop + Write-PSFMessage -Message "Connectivity to graph.microsoft.com: OK" -Level Debug + } + catch { + Write-PSFMessage -Message "Connectivity to graph.microsoft.com failed (non-blocking, will be checked during assessment): $_" -Level Debug + } + + return $true +} diff --git a/src/powershell/private/utility/container/Test-ZtContainerEnvironment.ps1 b/src/powershell/private/utility/container/Test-ZtContainerEnvironment.ps1 new file mode 100644 index 0000000000..16da4d7489 --- /dev/null +++ b/src/powershell/private/utility/container/Test-ZtContainerEnvironment.ps1 @@ -0,0 +1,43 @@ +function Test-ZtContainerEnvironment { + <# + .SYNOPSIS + Detects whether the current session is running inside a container. + + .DESCRIPTION + Checks for common container indicators: + - REMOTE_CONTAINERS or CODESPACES environment variables (VS Code) + - /.dockerenv file + - Container runtime in /proc/1/cgroup + + .OUTPUTS + [bool] True if running in a container environment. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + # VS Code dev containers / Codespaces + if ($env:REMOTE_CONTAINERS -or $env:CODESPACES -or $env:REMOTE_CONTAINERS_IPC) { + return $true + } + + # VS Code server running inside container + if ($env:VSCODE_AGENT_FOLDER -or $env:VSCODE_IPC_HOOK_CLI) { + return $true + } + + # Docker container indicator + if (Test-Path '/.dockerenv') { + return $true + } + + # Check cgroup for container runtimes + if (Test-Path '/proc/1/cgroup') { + $cgroup = Get-Content '/proc/1/cgroup' -Raw -ErrorAction Ignore + if ($cgroup -match 'docker|containerd|kubepods|lxc') { + return $true + } + } + + return $false +} diff --git a/src/powershell/private/utility/container/Test-ZtLocalhostResolution.ps1 b/src/powershell/private/utility/container/Test-ZtLocalhostResolution.ps1 new file mode 100644 index 0000000000..4f11e9a738 --- /dev/null +++ b/src/powershell/private/utility/container/Test-ZtLocalhostResolution.ps1 @@ -0,0 +1,32 @@ +function Test-ZtLocalhostResolution { + <# + .SYNOPSIS + Checks that localhost resolves to 127.0.0.1 (IPv4), which MSAL needs for its callback listener. + .DESCRIPTION + In many containers, /etc/hosts maps localhost only to ::1 (IPv6). + MSAL's callback listener and VS Code port forwarding both need IPv4 127.0.0.1. + .OUTPUTS + [bool] True if localhost resolves to 127.0.0.1. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + try { + $addresses = [System.Net.Dns]::GetHostAddresses('localhost') + $hasIPv4 = $addresses | Where-Object { $_.AddressFamily -eq 'InterNetwork' -and $_.ToString() -eq '127.0.0.1' } + + if ($hasIPv4) { + Write-Host -Object ' ✅ Localhost: resolves to 127.0.0.1 (IPv4).' -ForegroundColor Green + return $true + } + + $resolvedAddrs = ($addresses | ForEach-Object { $_.ToString() }) -join ', ' + Write-Host -Object " ⚠️ Localhost: resolves to [$resolvedAddrs] but not 127.0.0.1 (IPv4)." -ForegroundColor Yellow + return $false + } + catch { + Write-Host -Object " ⚠️ Localhost: DNS resolution failed: $_" -ForegroundColor Yellow + return $false + } +} diff --git a/src/powershell/private/utility/container/Test-ZtPortForwarding.ps1 b/src/powershell/private/utility/container/Test-ZtPortForwarding.ps1 new file mode 100644 index 0000000000..922c60d66f --- /dev/null +++ b/src/powershell/private/utility/container/Test-ZtPortForwarding.ps1 @@ -0,0 +1,64 @@ +function Test-ZtPortForwarding { + <# + .SYNOPSIS + Tests whether port forwarding is likely to work for MSAL's callback listener. + .DESCRIPTION + Starts a temporary HTTP listener, then verifies it's reachable on IPv4 127.0.0.1. + In Codespaces, VS Code auto-detects listening ports and forwards them. + .OUTPUTS + [bool] True if the listener was reachable. + #> + [CmdletBinding()] + [OutputType([bool])] + param () + + $testPort = $null + $listener = $null + + try { + # Find a free port + $tcp = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $tcp.Start() + $testPort = $tcp.LocalEndpoint.Port + $tcp.Stop() + + # Start an HTTP listener on that port (like MSAL would) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:${testPort}/") + $listener.Start() + + Write-PSFMessage -Message "Test listener started on 127.0.0.1:$testPort" -Level Debug + + # Test that we can connect to it + $client = [System.Net.Sockets.TcpClient]::new() + $connectTask = $client.ConnectAsync('127.0.0.1', $testPort) + $completed = $connectTask.Wait([timespan]::FromSeconds(3)) + $connected = $completed -and -not $connectTask.IsFaulted + $client.Close() + + if ($connected) { + Write-Host -Object " ✅ Port forwarding: callback listener test passed (port $testPort)." -ForegroundColor Green + return $true + } + + Write-Host -Object " ⚠️ Port forwarding: could not connect to test listener on 127.0.0.1:$testPort." -ForegroundColor Yellow + return $false + } + catch { + Write-PSFMessage -Message "Port forwarding test failed: $_" -Level Debug + + if ($testPort) { + Write-Host -Object " ⚠️ Port forwarding: test on port $testPort failed — $($_.Exception.Message)" -ForegroundColor Yellow + } + else { + Write-Host -Object ' ⚠️ Port forwarding: could not allocate test port.' -ForegroundColor Yellow + } + return $false + } + finally { + if ($listener -and $listener.IsListening) { + $listener.Stop() + $listener.Close() + } + } +} diff --git a/src/powershell/private/utility/report/Open-ZtReport.ps1 b/src/powershell/private/utility/report/Open-ZtReport.ps1 new file mode 100644 index 0000000000..01f8c50422 --- /dev/null +++ b/src/powershell/private/utility/report/Open-ZtReport.ps1 @@ -0,0 +1,35 @@ +function Open-ZtReport { + <# + .SYNOPSIS + Opens the assessment report, using an HTTP server in containers for VS Code port forwarding. + .DESCRIPTION + In container environments, local file:// paths don't work in the browser. + This function starts a lightweight HTTP server and opens the forwarded URL instead. + On non-container environments, opens the file directly. + Always displays a clickable link in the terminal. + .PARAMETER FilePath + The path to the HTML report file. + #> + [CmdletBinding()] + param ( + [Parameter(Mandatory)] + [string] $FilePath + ) + + $authReadiness = $script:ZtContainerAuthReadiness + $isContainer = if ($authReadiness) { $authReadiness.IsContainer } else { Test-ZtContainerEnvironment } + + if ($isContainer) { + $reportServer = Start-ZtReportServer -FilePath $FilePath + if ($reportServer) { + $null = Open-ZtFile -Path $reportServer.Url + Write-Host " 🌐 Report: $($reportServer.Url)" -ForegroundColor Green + Write-Host " Server will auto-stop after 30 minutes. Run Stop-ZtReportServer to stop earlier." -ForegroundColor DarkGray + return + } + } + + # Non-container or server failed to start — open file directly + $null = Open-ZtFile -Path $FilePath + Write-Host " 🌐 Report: $FilePath" -ForegroundColor Green +} diff --git a/src/powershell/private/utility/report/Start-ZtReportServer.ps1 b/src/powershell/private/utility/report/Start-ZtReportServer.ps1 new file mode 100644 index 0000000000..022f8ccb23 --- /dev/null +++ b/src/powershell/private/utility/report/Start-ZtReportServer.ps1 @@ -0,0 +1,98 @@ +function Start-ZtReportServer { + <# + .SYNOPSIS + Starts a lightweight HTTP server to serve the assessment report in container environments. + .DESCRIPTION + In dev containers and Codespaces, opening local file paths in the browser doesn't work + reliably. This function starts a background HTTP listener that serves the report HTML file. + VS Code auto-detects the listening port and forwards it, making the report accessible + via the browser. + + The server runs in a background PowerShell instance and serves only the specified file. + It stops automatically after the timeout period or when Stop-ZtReportServer is called. + .PARAMETER FilePath + The path to the HTML report file to serve. + .PARAMETER TimeoutMinutes + How long the server should run before auto-stopping. Default is 30 minutes. + .OUTPUTS + [hashtable] Server state with Port, Url, and Job properties, or $null if the server could not start. + #> + [CmdletBinding()] + [OutputType([hashtable])] + param ( + [Parameter(Mandatory)] + [string] $FilePath, + + [int] $TimeoutMinutes = 30 + ) + + if (-not (Test-Path -Path $FilePath)) { + Write-PSFMessage -Message "Report file not found: $FilePath" -Level Warning + return $null + } + + # Find a free port + try { + $tcp = [System.Net.Sockets.TcpListener]::new([System.Net.IPAddress]::Loopback, 0) + $tcp.Start() + $port = $tcp.LocalEndpoint.Port + $tcp.Stop() + } + catch { + Write-PSFMessage -Message "Could not allocate a port for the report server: $_" -Level Debug + return $null + } + + try { + $reportContent = Get-Content -Path $FilePath -Raw -ErrorAction Stop + } + catch { + Write-PSFMessage -Message "Could not read report file for the report server: $FilePath. $_" -Level Warning + return $null + } + $timeoutMs = $TimeoutMinutes * 60 * 1000 + + # Start the HTTP server in a background PowerShell instance + $serverJob = [powershell]::Create() + $null = $serverJob.AddScript({ + param($port, $reportContent, $timeoutMs) + $listener = [System.Net.HttpListener]::new() + $listener.Prefixes.Add("http://127.0.0.1:${port}/") + $listener.Start() + + $stopwatch = [System.Diagnostics.Stopwatch]::StartNew() + while ($listener.IsListening -and $stopwatch.ElapsedMilliseconds -lt $timeoutMs) { + $contextTask = $listener.GetContextAsync() + if ($contextTask.Wait(5000)) { + $context = $contextTask.Result + $response = $context.Response + $buffer = [System.Text.Encoding]::UTF8.GetBytes($reportContent) + $response.ContentType = 'text/html; charset=utf-8' + $response.ContentLength64 = $buffer.Length + $response.OutputStream.Write($buffer, 0, $buffer.Length) + $response.OutputStream.Close() + } + } + $listener.Stop() + $listener.Close() + }).AddArgument($port).AddArgument($reportContent).AddArgument($timeoutMs) + + $null = $serverJob.BeginInvoke() + + # Brief pause to let the listener start + Start-Sleep -Milliseconds 200 + + Write-PSFMessage -Message "Report server started on port $port (timeout: ${TimeoutMinutes}m)." -Level Debug + + $state = @{ + Port = $port + Url = "http://localhost:${port}" + Job = $serverJob + } + + # Stop any previously running server before tracking the new one + Stop-ZtReportServer + + $script:ZtReportServer = $state + return $state +} diff --git a/src/powershell/public/Connect-ZtAssessment.ps1 b/src/powershell/public/Connect-ZtAssessment.ps1 index e32c898aab..0a3d05385d 100644 --- a/src/powershell/public/Connect-ZtAssessment.ps1 +++ b/src/powershell/public/Connect-ZtAssessment.ps1 @@ -113,6 +113,9 @@ function Connect-ZtAssessment { } if ($IgnoreLanguageMode) { $script:IgnoreLanguageMode = $true } + # Set up container environment compatibility (xdg-open shim for browser auth, etc.) + Initialize-ZtContainerEnvironment + if ($Service -contains 'All') { $Service = [string[]]@('Graph', 'Azure', 'AipService', 'ExchangeOnline', 'SecurityCompliance', 'SharePointOnline') } @@ -251,8 +254,50 @@ function Connect-ZtAssessment { $connectMgGraphParams.ContextScope = 'Process' } + # TODO: WIRE container auth method resolver here. + # If the user did not pass -UseDeviceCode, ask Resolve-ZtContainerAuthMethod whether + # the container readiness state warrants switching to device code flow automatically + # (or prompting the user). Example: + # + # if (-not $UseDeviceCode) { + # $deviceLoginUrl = 'https://microsoft.com/devicelogin' # adjust per $Environment + # if (Resolve-ZtContainerAuthMethod -DeviceLoginUrl $deviceLoginUrl) { + # $connectMgGraphParams.UseDeviceCode = $true + # } + # } + + # TODO: WIRE browser URL capture here (only when NOT using device code). + # Install-ZtBrowserUrlCapture sets up a temp xdg-open/open wrapper that records + # the MSAL auth URL; Start-ZtBrowserUrlWatcher prints it to the terminal as a + # clickable fallback link. Always pair with Remove-ZtBrowserUrlCapture in a finally. + # Example: + # + # $browserCapture = $null + # $browserWatcher = $null + # if (-not $connectMgGraphParams.UseDeviceCode) { + # $browserCapture = Install-ZtBrowserUrlCapture + # if ($browserCapture) { $browserWatcher = Start-ZtBrowserUrlWatcher -CaptureState $browserCapture } + # } + # try { + # $null = Connect-MgGraph @connectMgGraphParams -ErrorAction Stop + # } + # finally { + # Remove-ZtBrowserUrlCapture -CaptureState $browserCapture -Watcher $browserWatcher + # } + Write-PSFMessage -Message "Connecting to Microsoft Graph with params: $($connectMgGraphParams | Out-String)" -Level Verbose $null = Connect-MgGraph @connectMgGraphParams -ErrorAction Stop + + # TODO: WIRE container auth-failure hint here. + # If Connect-MgGraph completes but Get-MgContext is still null (common when the + # browser callback never reached the container), surface a friendly message. + # Example: + # + # if (-not (Get-MgContext)) { + # Write-Warning (Get-ZtContainerAuthFailureHint) + # throw 'Graph authentication did not complete.' + # } + $contextTenantId = (Get-MgContext).TenantId Write-Host -Object " ✅ Connected" -ForegroundColor Green Add-ZtConnectedService -Service 'Graph' diff --git a/src/powershell/public/Disconnect-ZtAssessment.ps1 b/src/powershell/public/Disconnect-ZtAssessment.ps1 index 429e1d80aa..d70f6fc752 100644 --- a/src/powershell/public/Disconnect-ZtAssessment.ps1 +++ b/src/powershell/public/Disconnect-ZtAssessment.ps1 @@ -145,6 +145,22 @@ } Write-PSFMessage $dbCleanupMessage -Level Warning } + + # Stop any running report server + try { + Stop-ZtReportServer + } + catch { + Write-PSFMessage "Error stopping report server: $($_.Exception.Message)" -Level Warning + } + + # Revert container environment modifications (xdg-open shim, /etc/hosts entry) + try { + Remove-ZtContainerEnvironment + } + catch { + Write-PSFMessage "Error removing container environment: $($_.Exception.Message)" -Level Warning + } } #endregion Session state cleanup diff --git a/src/powershell/public/Invoke-ZtAssessment.ps1 b/src/powershell/public/Invoke-ZtAssessment.ps1 index c536505a52..8395d6d2e1 100644 --- a/src/powershell/public/Invoke-ZtAssessment.ps1 +++ b/src/powershell/public/Invoke-ZtAssessment.ps1 @@ -540,7 +540,7 @@ $titleLine Write-Host Write-Host if (-not $NoBrowser) { - Invoke-Item $htmlReportPath | Out-Null + Open-ZtReport -FilePath $htmlReportPath } if ($ExportLog) { diff --git a/src/powershell/public/Stop-ZtReportServer.ps1 b/src/powershell/public/Stop-ZtReportServer.ps1 new file mode 100644 index 0000000000..d4cc84ea54 --- /dev/null +++ b/src/powershell/public/Stop-ZtReportServer.ps1 @@ -0,0 +1,16 @@ +function Stop-ZtReportServer { + <# + .SYNOPSIS + Stops the background report HTTP server if one is running. + #> + [CmdletBinding()] + param () + + $state = $script:ZtReportServer + if ($state -and $state.Job) { + $state.Job.Stop() + $state.Job.Dispose() + $script:ZtReportServer = $null + Write-PSFMessage -Message "Report server stopped." -Level Debug + } +}