Private/Conditions/Test-NetworkInScope.ps1

function Test-NetworkInScope {
    <#
    .SYNOPSIS
        Evaluates if a network location is in scope for a Conditional Access policy.

    .DESCRIPTION
        This function evaluates if a network location is in scope for a Conditional Access policy
        based on IP address, named location, and special location combinations.

        It supports:
        - CIDR notation for IP ranges
        - Special value combinations ("All", "AllTrusted")
        - Named location evaluation (trusted vs untrusted)
        - Country/region-based location matching

    .PARAMETER Policy
        The Conditional Access policy to evaluate.

    .PARAMETER LocationContext
        The location context for the sign-in scenario, containing IP address and/or named location information.

    .EXAMPLE
        Test-NetworkInScope -Policy $policy -LocationContext $LocationContext
    #>

    [CmdletBinding()]
    [OutputType([System.Collections.Hashtable])]
    param (
        [Parameter(Mandatory = $true)]
        [object]$Policy,

        [Parameter(Mandatory = $true)]
        [object]$LocationContext
    )

    # If no location conditions specified or the locations object is null, all networks are in scope
    if (-not $Policy.Conditions.Locations -or
        $null -eq $Policy.Conditions.Locations -or
        (-not $Policy.Conditions.Locations.PSObject.Properties.Name -contains "IncludeLocations" -and
        -not $Policy.Conditions.Locations.PSObject.Properties.Name -contains "ExcludeLocations") -or
        (($null -eq $Policy.Conditions.Locations.IncludeLocations -or
            $Policy.Conditions.Locations.IncludeLocations.Count -eq 0) -and
         ($null -eq $Policy.Conditions.Locations.ExcludeLocations -or
        $Policy.Conditions.Locations.ExcludeLocations.Count -eq 0))) {
        Write-Verbose "No location conditions specified in policy, all networks are in scope"
        return @{
            InScope = $true
            Reason  = "No location conditions specified"
        }
    }

    $includeLocations = $Policy.Conditions.Locations.IncludeLocations
    $excludeLocations = $Policy.Conditions.Locations.ExcludeLocations

    # Extract location context information
    $ipAddress = $LocationContext.IpAddress
    $namedLocationId = $LocationContext.NamedLocationId
    $countryCode = $LocationContext.CountryCode
    $isTrustedLocation = $LocationContext.IsTrustedLocation

    Write-Verbose "Testing network scope for policy: $($Policy.DisplayName)"
    Write-Verbose "IP: $ipAddress, Named Location ID: $namedLocationId, Country: $countryCode, Trusted: $isTrustedLocation"

    # Get all named locations
    $namedLocations = Get-NamedLocations

    # If named location ID is provided but no trust status, determine it
    if ($namedLocationId -and -not [bool]::TryParse($isTrustedLocation, [ref]$null)) {
        $isTrustedLocation = Test-LocationIsTrusted -LocationId $namedLocationId
        Write-Verbose "Determined trust status from named location: $isTrustedLocation"
    }

    # Case 1: Include "All" and exclude "AllTrusted" = All untrusted locations
    if ((Test-SpecialValue -Collection $includeLocations -ValueType "AllLocations") -and
        (Test-SpecialValue -Collection $excludeLocations -ValueType "AllTrustedLocations")) {

        Write-Verbose "Special case: Include 'All' and exclude 'AllTrusted' = All untrusted locations"

        if ($isTrustedLocation) {
            return @{
                InScope = $false
                Reason  = "Trusted location excluded when policy includes all locations but excludes trusted locations"
            }
        }
        else {
            return @{
                InScope = $true
                Reason  = "Untrusted location included when policy includes all locations but excludes trusted locations"
            }
        }
    }

    # Case 2: Include "All" with no exclusions = All locations
    if ((Test-SpecialValue -Collection $includeLocations -ValueType "AllLocations") -and
        ($null -eq $excludeLocations -or $excludeLocations.Count -eq 0)) {

        Write-Verbose "Special case: Include 'All' with no exclusions = All locations"

        return @{
            InScope = $true
            Reason  = "All locations included with no exclusions"
        }
    }

    # Case 3: Include "AllTrusted" = Only trusted locations
    if (Test-SpecialValue -Collection $includeLocations -ValueType "AllTrustedLocations") {
        Write-Verbose "Special case: Include 'AllTrusted' = Only trusted locations"

        if ($isTrustedLocation) {
            return @{
                InScope = $true
                Reason  = "Trusted location included when policy includes all trusted locations"
            }
        }
        else {
            return @{
                InScope = $false
                Reason  = "Untrusted location excluded when policy includes only trusted locations"
            }
        }
    }

    # Check if location is excluded
    if ($excludeLocations -and $excludeLocations.Count -gt 0) {
        # Check if named location ID is explicitly excluded
        if ($namedLocationId -and $excludeLocations -contains $namedLocationId) {
            Write-Verbose "Named location ID '$namedLocationId' explicitly excluded"
            return @{
                InScope = $false
                Reason  = "Named location explicitly excluded"
            }
        }

        # Check each excluded location
        foreach ($locationId in $excludeLocations) {
            # Skip special values which were handled above
            if ($locationId -eq "All" -or $locationId -eq "AllTrusted") {
                continue
            }

            # Check if the location exists in our cache
            if (-not $namedLocations.ContainsKey($locationId)) {
                Write-Verbose "Excluded location ID '$locationId' not found in cache, skipping"
                continue
            }

            $excludedLocation = $namedLocations[$locationId]

            # Check if IP matches an excluded location
            if ($ipAddress -and $excludedLocation.Type -eq "IP") {
                if (Test-LocationContainsIp -NamedLocation $excludedLocation -IpAddress $ipAddress) {
                    Write-Verbose "IP address '$ipAddress' in excluded named location '$($excludedLocation.DisplayName)'"
                    return @{
                        InScope = $false
                        Reason  = "IP address in excluded named location"
                    }
                }
            }

            # Check if country code matches an excluded location
            if ($countryCode -and $excludedLocation.Type -eq "CountryOrRegion") {
                if (Test-LocationContainsCountry -NamedLocation $excludedLocation -CountryCode $countryCode) {
                    Write-Verbose "Country code '$countryCode' in excluded named location '$($excludedLocation.DisplayName)'"
                    return @{
                        InScope = $false
                        Reason  = "Country in excluded named location"
                    }
                }
            }
        }
    }

    # Check if location is included (if no special "All" value handled above)
    $isIncluded = $false
    $includeReason = ""

    if ($includeLocations -and $includeLocations.Count -gt 0) {
        # Check if named location ID is explicitly included
        if ($namedLocationId -and $includeLocations -contains $namedLocationId) {
            Write-Verbose "Named location ID '$namedLocationId' explicitly included"
            $isIncluded = $true
            $includeReason = "Named location explicitly included"
        }
        else {
            # Check each included location
            foreach ($locationId in $includeLocations) {
                # Skip special values which were handled above
                if ($locationId -eq "All" -or $locationId -eq "AllTrusted") {
                    continue
                }

                # Check if the location exists in our cache
                if (-not $namedLocations.ContainsKey($locationId)) {
                    Write-Verbose "Included location ID '$locationId' not found in cache, skipping"
                    continue
                }

                $includedLocation = $namedLocations[$locationId]

                # Check if IP matches an included location
                if ($ipAddress -and $includedLocation.Type -eq "IP") {
                    if (Test-LocationContainsIp -NamedLocation $includedLocation -IpAddress $ipAddress) {
                        Write-Verbose "IP address '$ipAddress' in included named location '$($includedLocation.DisplayName)'"
                        $isIncluded = $true
                        $includeReason = "IP address in included named location"
                        break
                    }
                }

                # Check if country code matches an included location
                if ($countryCode -and $includedLocation.Type -eq "CountryOrRegion") {
                    if (Test-LocationContainsCountry -NamedLocation $includedLocation -CountryCode $countryCode) {
                        Write-Verbose "Country code '$countryCode' in included named location '$($includedLocation.DisplayName)'"
                        $isIncluded = $true
                        $includeReason = "Country in included named location"
                        break
                    }
                }
            }
        }
    }

    # Determine final result
    if ($isIncluded) {
        return @{
            InScope = $true
            Reason  = $includeReason
        }
    }
    else {
        return @{
            InScope = $false
            Reason  = "Location not in any inclusion lists"
        }
    }
}

function Test-IpInNamedLocation {
    <#
.SYNOPSIS
    Tests if an IP address is contained within a named location.

.DESCRIPTION
    This function evaluates if an IP address falls within any of the
    IP ranges defined in a named location, supporting CIDR notation.

.PARAMETER IpAddress
    The IP address to check.

.PARAMETER NamedLocation
    The named location object containing IP ranges.

.EXAMPLE
    Test-IpInNamedLocation -IpAddress "192.168.1.1" -NamedLocation $namedLocation
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$IpAddress,

        [Parameter(Mandatory = $true)]
        [object]$NamedLocation
    )

    # If named location has no IP ranges, the IP cannot be in it
    if (-not $NamedLocation.IpRanges -or $NamedLocation.IpRanges.Count -eq 0) {
        return $false
    }

    foreach ($ipRange in $NamedLocation.IpRanges) {
        # Check if range is in CIDR notation
        if ($ipRange -match "^(.+)/(\d+)$") {
            $networkAddress = $matches[1]
            $cidrPrefix = [int]$matches[2]

            if (Test-IpInCidrRange -IpAddress $IpAddress -NetworkAddress $networkAddress -CidrPrefix $cidrPrefix) {
                return $true
            }
        }
        # Check if range is a simple IP address (exact match)
        elseif ($ipRange -eq $IpAddress) {
            return $true
        }
        # Check if range is in start-end format
        elseif ($ipRange -match "^(.+)-(.+)$") {
            $startIp = $matches[1]
            $endIp = $matches[2]

            if (Test-IpInRange -IpAddress $IpAddress -StartIp $startIp -EndIp $endIp) {
                return $true
            }
        }
    }

    return $false
}

function Test-IpInCidrRange {
    <#
.SYNOPSIS
    Tests if an IP address is within a CIDR range.

.DESCRIPTION
    This function determines if an IP address falls within the range
    specified by a network address and CIDR prefix.

.PARAMETER IpAddress
    The IP address to check.

.PARAMETER NetworkAddress
    The network address of the CIDR range.

.PARAMETER CidrPrefix
    The CIDR prefix (subnet mask) of the range.

.EXAMPLE
    Test-IpInCidrRange -IpAddress "192.168.1.1" -NetworkAddress "192.168.0.0" -CidrPrefix 16
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$IpAddress,

        [Parameter(Mandatory = $true)]
        [string]$NetworkAddress,

        [Parameter(Mandatory = $true)]
        [int]$CidrPrefix
    )

    try {
        # Convert IP address string to bytes
        $ipBytes = [System.Net.IPAddress]::Parse($IpAddress).GetAddressBytes()

        # Convert network address string to bytes
        $networkBytes = [System.Net.IPAddress]::Parse($NetworkAddress).GetAddressBytes()

        # If IPv6, handle separately
        if ($ipBytes.Length -ne 4) {
            Write-Verbose "IPv6 address detected. IPv6 handling not fully implemented."
            return $false # For now, we'll skip IPv6 handling for simplicity
        }

        # Calculate subnet mask from CIDR prefix
        $mask = [UInt32](-bnot (([UInt32]1 -shl (32 - $CidrPrefix)) - 1))

        # Convert IP address and network to integers for comparison
        $ipInt = ([UInt32]$ipBytes[0] -shl 24) -bor ([UInt32]$ipBytes[1] -shl 16) -bor ([UInt32]$ipBytes[2] -shl 8) -bor $ipBytes[3]
        $networkInt = ([UInt32]$networkBytes[0] -shl 24) -bor ([UInt32]$networkBytes[1] -shl 16) -bor ([UInt32]$networkBytes[2] -shl 8) -bor $networkBytes[3]

        # Apply mask to both IP and network, then compare
        return (($ipInt -band $mask) -eq ($networkInt -band $mask))
    }
    catch {
        Write-Warning "Error testing IP in CIDR range: $_"
        return $false
    }
}

function Test-IpInRange {
    <#
.SYNOPSIS
    Tests if an IP address is within a range specified by start and end IPs.

.DESCRIPTION
    This function determines if an IP address falls between a start and end IP address.

.PARAMETER IpAddress
    The IP address to check.

.PARAMETER StartIp
    The starting IP address of the range.

.PARAMETER EndIp
    The ending IP address of the range.

.EXAMPLE
    Test-IpInRange -IpAddress "192.168.1.10" -StartIp "192.168.1.1" -EndIp "192.168.1.20"
#>

    [CmdletBinding()]
    [OutputType([System.Boolean])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$IpAddress,

        [Parameter(Mandatory = $true)]
        [string]$StartIp,

        [Parameter(Mandatory = $true)]
        [string]$EndIp
    )

    try {
        # Convert IP addresses to bytes
        $ipBytes = [System.Net.IPAddress]::Parse($IpAddress).GetAddressBytes()
        $startBytes = [System.Net.IPAddress]::Parse($StartIp).GetAddressBytes()
        $endBytes = [System.Net.IPAddress]::Parse($EndIp).GetAddressBytes()

        # If IPv6, handle separately
        if ($ipBytes.Length -ne 4) {
            Write-Verbose "IPv6 address detected. IPv6 handling not fully implemented."
            return $false # For now, we'll skip IPv6 handling for simplicity
        }

        # Convert IP addresses to integers for comparison
        $ipInt = ([UInt32]$ipBytes[0] -shl 24) -bor ([UInt32]$ipBytes[1] -shl 16) -bor ([UInt32]$ipBytes[2] -shl 8) -bor $ipBytes[3]
        $startInt = ([UInt32]$startBytes[0] -shl 24) -bor ([UInt32]$startBytes[1] -shl 16) -bor ([UInt32]$startBytes[2] -shl 8) -bor $startBytes[3]
        $endInt = ([UInt32]$endBytes[0] -shl 24) -bor ([UInt32]$endBytes[1] -shl 16) -bor ([UInt32]$endBytes[2] -shl 8) -bor $endBytes[3]

        # Check if IP is within the range
        return ($ipInt -ge $startInt -and $ipInt -le $endInt)
    }
    catch {
        Write-Warning "Error testing IP in range: $_"
        return $false
    }
}

function Get-NamedLocation {
    <#
.SYNOPSIS
    Retrieves a named location by ID.

.DESCRIPTION
    This function retrieves a named location from Microsoft Graph API or from a cache.
    It supports retrieving both IP-based and country/region-based named locations.

.PARAMETER LocationId
    The ID of the named location to retrieve.

.EXAMPLE
    Get-NamedLocation -LocationId "a1b2c3d4-e5f6-7890-1234-567890abcdef"
#>

    [CmdletBinding()]
    [OutputType([System.Object])]
    param (
        [Parameter(Mandatory = $true)]
        [string]$LocationId
    )

    # Check if location is in cache
    if ($script:NamedLocationsCache -and $script:NamedLocationsCache.ContainsKey($LocationId)) {
        Write-Verbose "Retrieved named location '$LocationId' from cache"
        return $script:NamedLocationsCache[$LocationId]
    }

    try {
        # Retrieve named location from Graph API
        $location = Get-MgIdentityConditionalAccessNamedLocation -NamedLocationId $LocationId

        # Initialize cache if not exists
        if (-not $script:NamedLocationsCache) {
            $script:NamedLocationsCache = @{}
        }

        # Add to cache
        $script:NamedLocationsCache[$LocationId] = $location

        return $location
    }
    catch {
        Write-Warning "Error retrieving named location '$LocationId': $_"
        return $null
    }
}