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" # When only user ID is provided (no network context), be more permissive # Microsoft's behavior in this case is to assume a location that matches the policy if (-not $ipAddress -and -not $namedLocationId -and -not $countryCode -and -not [bool]::TryParse($isTrustedLocation, [ref]$null)) { # If "All" is included, policy should apply when no network information is supplied if ($includeLocations -contains "All") { # Only if AllTrusted is not in exclusions if (-not ($excludeLocations -contains "AllTrusted")) { Write-Verbose "All locations included and no network info provided, assuming match" return @{ InScope = $true Reason = "All locations included, no network info specified" } } } } # 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" } # If IP address is provided without trust status, check if it's in any trusted location if ($ipAddress -and -not [bool]::TryParse($isTrustedLocation, [ref]$null)) { $locationResult = Test-TrustedLocation -IpAddress $ipAddress $isTrustedLocation = $locationResult.IsInTrustedLocation Write-Verbose "Determined IP trust status by checking locations: $isTrustedLocation" # If IP is in a trusted location, add the first location ID to the context for future reference if ($isTrustedLocation -and $locationResult.MatchingLocations.Count -gt 0 -and -not $namedLocationId) { $namedLocationId = $locationResult.MatchingLocations[0].LocationId Write-Verbose "Adding named location ID from trusted location check: $namedLocationId" } } # Update the LocationContext with the verified trusted status and location ID if found $LocationContext.IsTrustedLocation = $isTrustedLocation if ($namedLocationId) { $LocationContext.NamedLocationId = $namedLocationId } # Check for ExcludeTrustedLocations flag in the conditions # This should be evaluated immediately with no special cases $excludeTrustedLocations = $Policy.Conditions.Locations.ExcludeTrustedLocations if ($excludeTrustedLocations) { if ($isTrustedLocation) { Write-Verbose "Policy excludes trusted locations and current location is trusted" return @{ InScope = $false Reason = "Policy excludes trusted locations and current location is trusted" } } # If location is not trusted, we continue with normal evaluation # Do not add any automatic InScope=true here Write-Verbose "Policy excludes trusted locations but current location is not a trusted location" } # Special values check before checking specific named locations if ($excludeLocations) { # Check for "All" exclusion if ($excludeLocations -contains "All") { Write-Verbose "All locations excluded" return @{ InScope = $false Reason = "All locations excluded" } } # Check for "AllTrusted" exclusion if ($excludeLocations -contains "AllTrusted") { if ($isTrustedLocation) { Write-Verbose "All trusted locations excluded and IP is in a trusted location" return @{ InScope = $false Reason = "All trusted locations excluded and IP is in a trusted location" } } else { Write-Verbose "All trusted locations excluded but IP is not in a trusted location (passes this check)" # Continue evaluation - we don't return here } } } # Check for special values in include locations if ($includeLocations) { # Check for "All" inclusion if ($includeLocations -contains "All") { # Special case: All included but AllTrusted excluded if ($excludeLocations -and $excludeLocations -contains "AllTrusted") { if ($isTrustedLocation) { Write-Verbose "All locations included but trusted locations excluded, and IP is in a trusted location" return @{ InScope = $false Reason = "All locations included except trusted locations, and IP is in a trusted location" } } else { Write-Verbose "All locations included but trusted locations excluded, and IP is not in a trusted location" return @{ InScope = $true Reason = "All locations included except trusted locations, and IP is not in a trusted location" } } } # If "All" is included but there are exclusions, continue to check those if (-not $excludeLocations -or $excludeLocations.Count -eq 0) { Write-Verbose "All locations included and no exclusions" return @{ InScope = $true Reason = "All locations included" } } } # Check for "AllTrusted" inclusion if ($includeLocations -contains "AllTrusted") { if ($isTrustedLocation) { # If the location is trusted and "AllTrusted" is included, check if there are specific exclusions if (-not $excludeLocations -or $excludeLocations.Count -eq 0) { Write-Verbose "All trusted locations included, no exclusions, and IP is in a trusted location" return @{ InScope = $true Reason = "All trusted locations included" } } } else { # If "AllTrusted" is the only inclusion but the location is not trusted, it's not in scope if ($includeLocations.Count -eq 1) { Write-Verbose "Only trusted locations are included, but IP is not in a trusted location" return @{ InScope = $false Reason = "IP not in a trusted location" } } } } } # Check against excluded named locations 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" } # Check each included location if we haven't found a match yet if (-not $isIncluded) { 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 } } } } } # ADDED: Special case for user-only queries with no network info # If we got this far and have no specific network info, be more permissive with location criteria if (-not $isIncluded && -not $ipAddress && -not $namedLocationId && -not $countryCode) { Write-Verbose "No network information provided, assuming this is a user-only query" if ($includeLocations -contains "All") { $isIncluded = $true $includeReason = "All locations included in a user-only query" } } if ($isIncluded) { return @{ InScope = $true Reason = $includeReason } } else { # Provide more specific reason based on what information was provided $specificReason = if ($ipAddress) { if ($isTrustedLocation -eq $true) { "IP address $ipAddress is in a trusted location but not included in policy scope" } elseif ($isTrustedLocation -eq $false) { "IP address $ipAddress is not in any included location/network" } else { "IP address $ipAddress not found in any included locations" } } elseif ($namedLocationId) { "Named location '$namedLocationId' not in any inclusion lists" } elseif ($countryCode) { "Country '$countryCode' not in any inclusion lists" } else { "Location not in any inclusion lists" } Write-Verbose $specificReason return @{ InScope = $false Reason = $specificReason } } } 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 } } |