Private/Test-AACFirewallMatch.ps1

function ConvertTo-AACIpRange {
    <#
    .SYNOPSIS
        Turns an address as Azure Firewall writes it - '10.1.2.3',
        '10.0.0.0/16', '10.0.0.1-10.0.0.9', an IPv6 address or prefix, or
        '*' - into a numeric range @{ Any; Family; Start; End }.
    .DESCRIPTION
        Returns nothing for anything that isn't an address: service tags
        ('AzureCloud', 'Internet'), FQDNs and the like, which can't be
        compared numerically. '*' and 'Any' become @{ Any = $true }, which
        covers every address.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param([string] $Text)

    $value = "$Text".Trim()
    if (-not $value) { return }
    if ($value -in '*', 'Any') { return @{ Any = $true } }

    $toNumber = {
        param([System.Net.IPAddress] $Address)
        $bytes = $Address.GetAddressBytes()
        [array]::Reverse($bytes)
        [System.Numerics.BigInteger]::new([byte[]]($bytes + [byte]0))
    }
    $parse = {
        param([string] $Part)
        $address = $null
        if ([System.Net.IPAddress]::TryParse($Part, [ref]$address)) { $address }
    }

    if ($value -match '^(?<a>[^-/]+)-(?<b>[^-/]+)$') {
        $a = & $parse $Matches.a
        $b = & $parse $Matches.b
        if (-not $a -or -not $b -or $a.AddressFamily -ne $b.AddressFamily) { return }
        return @{ Any = $false; Family = $a.AddressFamily; Start = (& $toNumber $a); End = (& $toNumber $b) }
    }

    $prefix = $null
    if ($value -match '^(?<ip>[^/]+)/(?<bits>\d{1,3})$') {
        $value = $Matches.ip
        $prefix = [int]$Matches.bits
    }
    $address = & $parse $value
    if (-not $address) { return }
    $width = $address.GetAddressBytes().Length * 8
    $number = & $toNumber $address
    if ($null -eq $prefix -or $prefix -ge $width) {
        return @{ Any = $false; Family = $address.AddressFamily; Start = $number; End = $number }
    }
    $hostBits = $width - $prefix
    $size = [System.Numerics.BigInteger]::Pow(2, $hostBits)
    $start = [System.Numerics.BigInteger]::Divide($number, $size) * $size
    @{ Any = $false; Family = $address.AddressFamily; Start = $start; End = $start + $size - 1 }
}

function Test-AACAddressMatch {
    <#
    .SYNOPSIS
        True when any address a rule lists covers or overlaps any address
        searched for - '10.1.2.3' is inside '10.1.0.0/16', '10.1.0.0/24'
        overlaps it, and '*' covers everything.
    .DESCRIPTION
        Both sides take IPs, CIDR prefixes, 'a-b' ranges and '*'. Rule
        entries that aren't addresses (service tags, FQDNs) never match,
        because what they cover isn't known here.
    #>

    [CmdletBinding()]
    param(
        [string[]] $Search,
        [string[]] $RuleAddress
    )

    $ruleRanges = @($RuleAddress | ForEach-Object { ConvertTo-AACIpRange $_ } | Where-Object { $_ })
    if ($ruleRanges | Where-Object { $_.Any }) { return $true }
    foreach ($text in $Search) {
        $query = ConvertTo-AACIpRange $text
        if (-not $query) { continue }
        if ($query.Any) { return $ruleRanges.Count -gt 0 }
        foreach ($range in $ruleRanges) {
            if ($range.Family -eq $query.Family -and $range.Start -le $query.End -and $query.Start -le $range.End) {
                return $true
            }
        }
    }
    $false
}

function Test-AACPortMatch {
    <#
    .SYNOPSIS
        True when any port or port range a rule lists ('443', '8000-8080',
        '*') overlaps any port or range searched for.
    #>

    [CmdletBinding()]
    param(
        [string[]] $Search,
        [string[]] $RulePort
    )

    $toRange = {
        param([string] $Text)
        $value = "$Text".Trim()
        if ($value -in '*', 'Any') { return @(0, 65535) }
        if ($value -match '^(\d+)\s*-\s*(\d+)$') { return @([int]$Matches[1], [int]$Matches[2]) }
        if ($value -match '^\d+$') { return @([int]$value, [int]$value) }
    }
    $ruleRanges = @($RulePort | ForEach-Object { , (& $toRange $_) } | Where-Object { $_ })
    foreach ($text in $Search) {
        $query = & $toRange $text
        if (-not $query) { continue }
        foreach ($range in $ruleRanges) {
            if ($range[0] -le $query[1] -and $query[0] -le $range[1]) { return $true }
        }
    }
    $false
}

function Test-AACFirewallRuleMatch {
    <#
    .SYNOPSIS
        True when an AAC.FirewallRule matches every search filter given to
        Get-AACFirewallRule - filters combine with AND, and the values of
        one filter with OR.
    .DESCRIPTION
        -SourceAddress / -DestinationAddress containment or overlap, counting
                                               the rule's addresses and the
                                               addresses of its IP Groups
        -Port overlap with the destination
                                               ports (for application rules,
                                               the port of each protocol)
        -Protocol the rule's IP protocols (a rule
                                               with 'Any' matches all) or its
                                               application protocols (Http,
                                               Https, Mssql)
        -Fqdn a host name covered by the
                                               rule's FQDNs ('*.contoso.com'
                                               covers 'www.contoso.com'), or
                                               a wildcard over them
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        $Rule,

        [string[]] $SourceAddress,
        [string[]] $DestinationAddress,
        [string[]] $Port,
        [string[]] $Protocol,
        [string[]] $Fqdn
    )

    $list = { param([string] $Text) @("$Text" -split ',\s*' | Where-Object { $_ }) }
    # "name: a, b | name2: c" -> a, b, c
    $groupAddresses = {
        param([string] $Described)
        foreach ($entry in @("$Described" -split ' \| ' | Where-Object { $_ })) {
            $null, $addresses = $entry -split ': ', 2
            & $list $addresses
        }
    }

    if ($SourceAddress) {
        $addresses = @((& $list $Rule.SourceAddresses) + @(& $groupAddresses $Rule.SourceIpGroupAddresses))
        if (-not (Test-AACAddressMatch -Search $SourceAddress -RuleAddress $addresses)) { return $false }
    }
    if ($DestinationAddress) {
        $addresses = @((& $list $Rule.DestinationAddresses) + @(& $groupAddresses $Rule.DestinationIpGroupAddresses))
        if (-not (Test-AACAddressMatch -Search $DestinationAddress -RuleAddress $addresses)) { return $false }
    }

    # Application rules carry protocol:port pairs ('Https:443'); the others
    # IP protocols and a separate port list.
    $isApplication = $Rule.RuleType -eq 'ApplicationRule'
    $pairs = @(& $list $Rule.Protocols)
    if ($Port) {
        $ports = if ($isApplication) { @($pairs | ForEach-Object { ($_ -split ':')[-1] }) } else { @(& $list $Rule.DestinationPorts) }
        if (-not (Test-AACPortMatch -Search $Port -RulePort $ports)) { return $false }
    }
    if ($Protocol) {
        $protocols = if ($isApplication) { @($pairs | ForEach-Object { ($_ -split ':')[0] }) } else { $pairs }
        if (-not ('Any' -in $protocols -or @($Protocol | Where-Object { $_ -in $protocols }).Count -gt 0)) { return $false }
    }
    if ($Fqdn) {
        $ruleFqdns = @((& $list $Rule.DestinationFqdns) + (& $list $Rule.TargetFqdns) + (& $list $Rule.TranslatedFqdn))
        $covered = @(foreach ($name in $Fqdn) {
                foreach ($ruleFqdn in $ruleFqdns) {
                    if ($name -like $ruleFqdn -or $ruleFqdn -like $name) { $true }
                }
            })
        if ($covered.Count -eq 0) { return $false }
    }
    $true
}