Public/Test-GSAIntelligentLocalAccessConfiguration.ps1

#
# Function: Test-GSAIntelligentLocalAccessConfiguration
# Public function for the GSA client diagnostics toolkit.
#
function Test-GSAIntelligentLocalAccessConfiguration
{
    <#
.SYNOPSIS
    Checks Intelligent Local Access (ILA) Private Network definitions in Entra for a misconfiguration
    that can cause GSA to incorrectly bypass Private Access traffic to a local/direct connection when
    the client is NOT actually on the expected corporate network.
.DESCRIPTION
    Retrieves all Private Network definitions via Microsoft Graph (networkAccess/privateNetworks) and,
    for each DNS-based network identification, checks:
        1. Whether the probe FQDN (fqdnToResolve) is publicly resolvable (has a public DNS record).
           A publicly resolvable probe FQDN defeats the purpose of the corporate-network detection,
           since it no longer proves the client is actually inside the corporate network - this was a
           real-world root cause reported by the community, where GSA attempted (and failed) to bypass
           Private Access traffic locally for users on hotel/home networks who were never on-premises.
        2. Whether the DNS server address(es) used for the probe are private/internal (RFC1918)
           addresses rather than public/routable ones - a public DNS server can be queried from any
           network, undermining the "only reachable from inside the corporate network" assumption.
        3. Whether the expected IP resolution(s) for the probe are private/internal addresses, as
           documented by Microsoft for Private Network identification.
    Each Private Network's associated application IDs are resolved to friendly display names (via the
    same tags/Any filter the Entra portal uses to list ZTNA/Quick Access apps) for more actionable
    logging, and any App ID that no longer resolves to a known app is flagged as possibly stale.
    This is an Entra/Graph configuration check only; it does not require the GSA client to be
    installed and can be run from any machine with Graph access.
 
    Caveat: plain DNS queries (even ones aimed at an explicit public server IP) can be transparently
    intercepted and answered locally - by the GSA client's own WFP driver for any FQDN it tunnels, or by
    a home/office router with its own local DNS records (e.g. a UniFi "Host (A)" record) - which would
    make a naive public-resolvability check unreliable. To avoid this, the probe FQDN is checked via
    DNS-over-HTTPS (DoH): an HTTPS request to a public resolver's API rather than a classic port-53
    lookup, which isn't subject to either kind of interception.
.PARAMETER AccessToken
    A Microsoft Graph access token with NetworkAccess.Read.All and Application.Read.All permissions.
    If omitted, an interactive Connect-MgGraph sign-in is triggered automatically (requires the
    Microsoft.Graph.Authentication module), reusing any already-active Graph session.
.PARAMETER FQDN
    Manually check a single candidate probe FQDN instead of retrieving Private Network definitions from
    Microsoft Graph - no Graph call or sign-in is made. Useful to validate a candidate FQDN before
    configuring it as an Intelligent Local Access Private Network in Entra.
.PARAMETER DnsServerAddress
    One or more DNS server IPv4 addresses to also validate as private/internal (RFC1918). Only used
    together with -FQDN.
.PARAMETER ExpectedIPAddress
    One or more expected resolved IPv4 addresses to also validate as private/internal (RFC1918). Only
    used together with -FQDN.
.PARAMETER PublicDnsServer
    The DNS-over-HTTPS (DoH) JSON API endpoint used to check whether a probe FQDN is publicly
    resolvable (default: https://dns.google/resolve).
.PARAMETER PassThru
    Return the $true/$false test result. By default nothing is returned - only logged.
.EXAMPLE
    Test-GSAIntelligentLocalAccessConfiguration
.EXAMPLE
    Test-GSAIntelligentLocalAccessConfiguration -AccessToken $token -PassThru
.EXAMPLE
    Test-GSAIntelligentLocalAccessConfiguration -FQDN unifi.home.contoso.com -DnsServerAddress 192.168.1.1 -ExpectedIPAddress 192.168.1.1
#>

    [CmdletBinding(DefaultParameterSetName = 'Graph')]
    param (
        [Parameter(ParameterSetName = 'Graph')]
        [string]$AccessToken,
        [Parameter(ParameterSetName = 'Manual', Mandatory)]
        [string]$FQDN,
        [Parameter(ParameterSetName = 'Manual')]
        [string[]]$DnsServerAddress,
        [Parameter(ParameterSetName = 'Manual')]
        [string[]]$ExpectedIPAddress,
        [string]$PublicDnsServer = "https://dns.google/resolve",
        [switch]$PassThru,
        [ValidateSet("ERROR", "WARNING", "INFO", "DEBUG", "SUCCESS")]
        [string]$LogLevel
    )

    if ($PSBoundParameters.ContainsKey('LogLevel'))
    { $script:LogLevel = $LogLevel }

    if ($PSCmdlet.ParameterSetName -eq 'Manual')
    {
        Write-Log "Manually checking probe FQDN '$FQDN' for Intelligent Local Access misconfigurations..." "INFO"
        $passed = Test-GSAILAProbeIdentification -ContextLabel "Manual check for '$FQDN':" -FQDN $FQDN -ServerAddresses $DnsServerAddress -ExpectedIPs $ExpectedIPAddress -PublicDnsServer $PublicDnsServer
        if ($passed)
        {
            Write-Log "Test passed: No Intelligent Local Access misconfigurations detected for '$FQDN'." "SUCCESS"
        }
        if ($PassThru)
        { return $passed }
        return
    }

    # If not in Manual mode, proceed to retrieve and check Intelligent Local Access Private Network definitions via Microsoft Graph.
    Write-Log "Retrieving Intelligent Local Access Private Network definitions via Microsoft Graph..." "INFO"
    try
    {
        $result = Invoke-GSAGraphRequest -AccessToken $AccessToken -Uri "https://graph.microsoft.com/beta/networkAccess/privateNetworks"
    }
    catch
    {
        Write-Log "Failed to retrieve Private Networks via Microsoft Graph: $($_.Exception.Message)" "ERROR"
        if ($PassThru)
        { return $false }
        return
    }

    # Extract the list of private networks from the Microsoft Graph response.
    $privateNetworks = @($result.value)
    if ($privateNetworks.Count -eq 0)
    {
        Write-Log "No Private Networks (Intelligent Local Access) are configured in this tenant." "INFO"
        if ($PassThru)
        { return $true }
        return
    }

    # Retrieve and cache the display names of applications referenced by the private networks.
    $appDisplayNames = @{}
    try
    {
        $appFilter = "tags/Any(x:x eq 'IsAccessibleViaZTNAClient') or tags/Any(x:x eq 'NetworkAccessQuickAccessApplication')"
        $appsResult = Invoke-GSAGraphRequest -AccessToken $AccessToken -Uri "https://graph.microsoft.com/beta/applications?`$filter=$([uri]::EscapeDataString($appFilter))&`$select=appId,displayName&`$top=999"
        foreach ($app in @($appsResult.value))
        {
            if ($app.appId)
            { $appDisplayNames[$app.appId] = $app.displayName }
        }
    }
    catch
    {
        Write-Log "Failed to resolve Private Access/Quick Access application display names via Microsoft Graph: $($_.Exception.Message)" "DEBUG"
    }

    $allTestsPassed = $true

    # Iterate through each private network and perform validation checks.
    foreach ($network in $privateNetworks)
    {
        $networkName = $network.name
        $appIds = @($network.appIds) | Where-Object { $_ }
        $appNames = $appIds | ForEach-Object { if ($appDisplayNames.ContainsKey($_)) { $appDisplayNames[$_] } else { $_ } }
        Write-Log "Checking Private Network '$networkName' (target app(s): $($appNames -join ', '))..." "INFO"

        if ($appDisplayNames.Count -gt 0)
        {
            $unresolvedAppIds = $appIds | Where-Object { -not $appDisplayNames.ContainsKey($_) }
            foreach ($unresolvedAppId in $unresolvedAppIds)
            {
                Write-Log "Private Network '$networkName' references App ID '$unresolvedAppId', which is not a currently enabled ZTNA/Quick Access application - it may have been deleted or disabled." "WARNING"
            }
        }

        $identifications = @($network.networkIdentifications)
        if ($identifications.Count -eq 0)
        {
            Write-Log "Private Network '$networkName' has no network identification configured." "WARNING"
            $allTestsPassed = $false
            continue
        }

        foreach ($identification in $identifications)
        {
            $fqdn = $identification.fqdnToResolve.value
            $serverAddresses = @($identification.serverAddresses) | ForEach-Object { $_.value } | Where-Object { $_ }
            $expectedIPs = @($identification.expectedIpResolutions) | ForEach-Object { $_.value } | Where-Object { $_ }

            if (-not $fqdn)
            {
                Write-Log "Private Network '$networkName' has an identification entry with no probe FQDN configured." "WARNING"
                $allTestsPassed = $false
                continue
            }

            $identificationPassed = Test-GSAILAProbeIdentification -ContextLabel "Private Network '$networkName'" -FQDN $fqdn -ServerAddresses $serverAddresses -ExpectedIPs $expectedIPs -PublicDnsServer $PublicDnsServer
            if (-not $identificationPassed)
            {
                $allTestsPassed = $false
            }
        }
    }

    if ($allTestsPassed)
    {
        Write-Log "Test passed: No Intelligent Local Access misconfigurations detected across $($privateNetworks.Count) Private Network(s)." "SUCCESS"
    }

    if ($PassThru)
    { return $allTestsPassed }
}