Private/ConvertTo-AACApplicationGatewayMap.ps1
|
function ConvertTo-AACApplicationGatewayMap { <# .SYNOPSIS Turns Application Gateways' configuration into the map's model (boxes, cards, lines), the traffic flows - listener to rule to path to backend settings to pool to targets - with their health, and findings. .DESCRIPTION No Azure calls. Boxes: subscription > resource group > virtual network > subnet > gateway (the gateway's own resource group when its network can't be read). Inside a gateway, cards in stages, left to right (ELK partitions): 0 frontend IPs and WAF policies, 1 listeners, 2 rules, 3 path rules and redirects, 4 backend settings, 5 pools and probes, 6 targets. Lines: flow (traffic), probe, waf, redirect. Health, best first: the live check (-Health.Live: each target's state and the probe's reason), else metrics (healthy and unhealthy host counts per pool and setting). A pool or target is healthy, degraded (some unhealthy), unhealthy (none healthy) or unknown. Certificates' expiry comes from publicCertData (uploaded certificates, and Key Vault ones when Azure includes it): the leaf's NotAfter. Returns @{ Clusters; Nodes; Edges (as the map page reads them); Gateways (AAC.ApplicationGateway); Flows (AAC.ApplicationGatewayFlow); Findings (AAC.ApplicationGatewayFinding); Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Gateway, # Rows read by ID (public IPs, WAF policies, networks, NICs). [AllowEmptyCollection()] [object[]] $Related = @(), # App Services whose host name is a pool target: @{ id; name; host; kind }. [AllowEmptyCollection()] [object[]] $Site = @(), [hashtable] $SubscriptionName = @{}, # Read-AACApplicationGatewayHealth's result. [hashtable] $Health = @{ Metrics = @{}; Live = @{}; Errors = @{} }, [int] $CertificateDays = 30, [datetime] $Now = [datetime]::UtcNow ) $at = { param($Row, [string[]] $Keys) $value = $Row foreach ($key in $Keys) { if ($value -isnot [System.Collections.IDictionary]) { return $null } if ($value.Contains($key)) { $value = $value[$key]; continue } $found = $null foreach ($name in $value.Keys) { if ($name -eq $key) { $found = $value[$name]; break } } $value = $found } $value } $lower = { param($Text) ([string]$Text).ToLowerInvariant() } $ref = { param($Row, [string] $Key) & $lower (& $at $Row 'properties', $Key, 'id') } $leaf = { param([string] $Id) ($Id -split '/')[-1] } $byId = @{} foreach ($r in $Related) { if ($r) { $byId[(& $lower $r['id'])] = $r } } $siteByHost = @{} foreach ($s in $Site) { if ($s) { $siteByHost[(& $lower $s['host'])] = $s } } $subName = { param([string] $Id) if ($SubscriptionName.Contains($Id)) { $SubscriptionName[$Id] } else { $Id } } $clusters = [ordered]@{} $nodes = [ordered]@{} $edges = [System.Collections.Generic.List[object]]::new() $flows = [System.Collections.Generic.List[object]]::new() $findings = [System.Collections.Generic.List[object]]::new() $gatewayRows = [System.Collections.Generic.List[object]]::new() $edgeSeen = @{} $addCluster = { param([string] $Id, [string] $Kind, [string] $Parent, [string] $Name, [string[]] $Detail, [string] $Icon) if (-not $clusters.Contains($Id)) { $clusters[$Id] = @{ id = $Id; kind = $Kind; parent = $Parent; name = $Name; detail = @($Detail | Where-Object { $_ }); icon = $Icon; external = $false; badges = @(); chips = @() } } $clusters[$Id] } $addEdge = { param([string] $From, [string] $To, [string] $Kind, [string] $Label) if (-not $From -or -not $To -or -not $nodes.Contains($From) -or -not $nodes.Contains($To)) { return } $key = "$From|$To|$Kind" if ($edgeSeen.Contains($key)) { return } $edgeSeen[$key] = $true $edges.Add(@{ id = "g$($edges.Count + 1)"; source = $From; target = $To; kind = $Kind; label = $Label; view = '' }) } foreach ($gw in @($Gateway | Where-Object { $_ } | Sort-Object -Property { [string]$_['name'] })) { $gid = & $lower $gw['id'] $gname = [string]$gw['name'] $p = $gw['properties'] $sub = & $lower $gw['subscriptionId'] $group = [string]$gw['resourceGroup'] $gwFindings = [System.Collections.Generic.List[object]]::new() $find = { param([string] $Severity, [string] $Component, [string] $Name, [string] $Title, [string] $Detail, [string] $Fix, [string] $NodeId) $f = [pscustomobject][ordered]@{ PSTypeName = 'AAC.ApplicationGatewayFinding'; Severity = $Severity; Gateway = $gname; Component = $Component; Name = $Name; Finding = $Title; Detail = $Detail; Recommendation = $Fix; ResourceGroup = $group; Subscription = & $subName $sub; ResourceId = $gid } $findings.Add($f); $gwFindings.Add($f) $target = if ($NodeId -and $nodes.Contains($NodeId)) { $nodes[$NodeId] } else { $null } if ($target) { $target.findings = @(@($target.findings) + "$($Severity): $Title - $Detail") if ($Severity -eq 'High' -or -not $target.risk) { $target.risk = $(if ($Severity -eq 'High') { 'high' } elseif ($Severity -eq 'Medium') { 'medium' } else { $target.risk }) } } } $node = { param([string] $Id, [string] $Name, [string] $Type, [string] $Label, [string] $Icon, [int] $Stage, [string[]] $Facts, [object[]] $Props, [string] $Portal) $Id = & $lower $Id if (-not $nodes.Contains($Id)) { $nodes[$Id] = @{ id = $Id; parent = $gid; name = $Name; type = $Type; typeLabel = $Label; icon = $Icon; kind = ''; location = [string]$gw['location'] resourceGroup = $group; subscriptionId = $sub; subscription = & $subName $sub; sku = ''; facts = @($Facts | Where-Object { $_ }); tags = $null orphan = ''; external = $false; chips = @(); chip = ''; appliedTo = @(); risk = ''; rules = @(); routes = @(); propagation = '' props = @($Props); findings = @(); health = ''; portal = $(if ($Portal) { $Portal } else { $gid }); elk = @{ 'elk.partitioning.partition' = [string]$Stage } } } $nodes[$Id] } # --- Boxes: subscription > resource group > network > subnet > gateway ------------------------------ $subnetId = & $lower (& $at @(& $at $p 'gatewayIPConfigurations')[0] 'properties', 'subnet', 'id') $vnetId = if ($subnetId -match '^(.+/virtualnetworks/[^/]+)/subnets/') { $Matches[1] } else { '' } $vnet = if ($vnetId -and $byId.Contains($vnetId)) { $byId[$vnetId] } else { $null } $subscriptionBox = & $addCluster "/subscriptions/$sub" 'subscription' '' (& $subName $sub) @($sub) 'subscription' $parent = if ($vnet) { $vnetGroup = [string]$vnet['resourceGroup'] $rg = & $addCluster "/subscriptions/$(& $lower $vnet['subscriptionId'])/resourcegroups/$(& $lower $vnetGroup)" 'resourcegroup' $(if ((& $lower $vnet['subscriptionId']) -eq $sub) { $subscriptionBox.id } else { (& $addCluster "/subscriptions/$(& $lower $vnet['subscriptionId'])" 'subscription' '' (& $subName (& $lower $vnet['subscriptionId'])) @() 'subscription').id }) $vnetGroup @() 'resourcegroup' $v = & $addCluster $vnetId 'vnet' $rg.id ([string]$vnet['name']) @((@(& $at $vnet 'properties', 'addressSpace', 'addressPrefixes') -join ', ')) 'vnet' $subnetRow = @(@(& $at $vnet 'properties', 'subnets') | Where-Object { (& $lower (& $at $_ 'id')) -eq $subnetId })[0] $nsg = & $leaf (& $lower (& $at $subnetRow 'properties', 'networkSecurityGroup', 'id')) $udr = & $leaf (& $lower (& $at $subnetRow 'properties', 'routeTable', 'id')) (& $addCluster $subnetId 'subnet' $v.id (& $leaf $subnetId) @([string](& $at $subnetRow 'properties', 'addressPrefix'), $(if ($nsg) { "NSG: $nsg" }), $(if ($udr) { "UDR: $udr" })) 'subnet').id } else { (& $addCluster "/subscriptions/$sub/resourcegroups/$(& $lower $group)" 'resourcegroup' $subscriptionBox.id $group @() 'resourcegroup').id } $sku = & $at $p 'sku' $tier = [string](& $at $sku 'tier') $autoscale = & $at $p 'autoscaleConfiguration' $zones = @(& $at $gw 'zones' | Where-Object { $_ }) $sslPolicy = & $at $p 'sslPolicy' $tls = if (& $at $sslPolicy 'policyName') { [string](& $at $sslPolicy 'policyName') } elseif (& $at $sslPolicy 'minProtocolVersion') { "Custom, min $(& $at $sslPolicy 'minProtocolVersion')" } else { 'Default (AppGwSslPolicy20150501)' } $capacity = if ($autoscale) { "autoscale $(& $at $autoscale 'minCapacity')-$(& $at $autoscale 'maxCapacity')" } else { "$(& $at $sku 'capacity') instance(s)" } $box = & $addCluster $gid 'appgw' $parent $gname @([string](& $at $sku 'name'), $(if ($zones.Count) { "zones $($zones -join ',')" } else { 'no zones' }), $capacity, "TLS $tls", [string](& $at $p 'operationalState')) 'appgw' $box.elk = @{ 'elk.partitioning.activate' = 'true' } $box.props = @(@('SKU', [string](& $at $sku 'name')), @('Tier', $tier), @('Capacity', $capacity), @('Zones', $(if ($zones.Count) { $zones -join ', ' } else { 'none' })), @('TLS policy', $tls), @('HTTP/2', [string](& $at $p 'enableHttp2')), @('State', [string](& $at $p 'operationalState')), @('Subnet', $subnetId)) $box.findings = @() $box.risk = '' # --- Cards -------------------------------------------------------------------------------------------- $ports = @{} foreach ($fp in @(& $at $p 'frontendPorts' | Where-Object { $null -ne $_ })) { $ports[(& $lower $fp['id'])] = [string](& $at $fp 'properties', 'port') } foreach ($fe in @(& $at $p 'frontendIPConfigurations' | Where-Object { $null -ne $_ })) { $pipId = & $ref $fe 'publicIPAddress' $ip = if ($pipId -and $byId.Contains($pipId)) { [string](& $at $byId[$pipId] 'properties', 'ipAddress') } else { [string](& $at $fe 'properties', 'privateIPAddress') } $null = & $node $fe['id'] ([string]$fe['name']) 'appgw/frontend' $(if ($pipId) { 'Frontend IP (public)' } else { 'Frontend IP (private)' }) $(if ($pipId) { 'pip' } else { 'nic' }) 0 @($ip) @(@('IP address', $ip), @('Public IP', (& $leaf $pipId))) '' } $wafPolicies = @{} $globalPolicy = & $ref @{ properties = $p } 'firewallPolicy' $policyNode = { param([string] $PolicyId) if (-not $PolicyId) { return $null } $row = $byId[$PolicyId] $mode = [string](& $at $row 'properties', 'policySettings', 'mode') $state = [string](& $at $row 'properties', 'policySettings', 'state') $sets = @(@(& $at $row 'properties', 'managedRules', 'managedRuleSets') | ForEach-Object { "$(& $at $_ 'ruleSetType') $(& $at $_ 'ruleSetVersion')" }) $n = & $node $PolicyId (& $leaf $PolicyId) 'microsoft.network/applicationgatewaywebapplicationfirewallpolicies' 'WAF policy' 'waf' 0 @("$mode$(if ($state -eq 'Disabled') { ', disabled' })", ($sets -join ', ')) @(@('Mode', $mode), @('State', $state), @('Rule sets', ($sets -join ', ')), @('Custom rules', [string]@(& $at $row 'properties', 'customRules').Count)) $PolicyId $wafPolicies[$PolicyId] = @{ Mode = $mode; State = $state; Sets = $sets; Read = [bool]$row } $n } $null = & $policyNode $globalPolicy $certs = @{} foreach ($c in @(& $at $p 'sslCertificates' | Where-Object { $null -ne $_ })) { $expires = $null $data = [string](& $at $c 'properties', 'publicCertData') if ($data) { try { $collection = [System.Security.Cryptography.X509Certificates.X509Certificate2Collection]::new() $collection.Import([Convert]::FromBase64String($data)) $all = @($collection) $leafCert = @($all | Where-Object { $subject = $_.Subject; -not @($all | Where-Object { $_.Issuer -eq $subject -and $_.Subject -ne $subject }).Count } | Sort-Object NotAfter)[0] if ($leafCert) { $expires = $leafCert.NotAfter.ToUniversalTime() } } catch { $expires = $null } } $certs[(& $lower $c['id'])] = @{ Name = [string]$c['name']; KeyVault = [string](& $at $c 'properties', 'keyVaultSecretId'); Expires = $expires } } $listenerIds = @{} foreach ($l in @(& $at $p 'httpListeners' | Where-Object { $null -ne $_ })) { $lid = & $lower $l['id'] $protocol = [string](& $at $l 'properties', 'protocol') $port = $ports[(& $ref $l 'frontendPort')] $hosts = @(@(& $at $l 'properties', 'hostNames') + @(& $at $l 'properties', 'hostName') | Where-Object { $_ } | Select-Object -Unique) $cert = $certs[(& $ref $l 'sslCertificate')] $policy = & $ref $l 'firewallPolicy' $n = & $node $lid ([string]$l['name']) 'appgw/listener' 'Listener' 'appgw' 1 @("$protocol $port", ($hosts -join ', '), $(if ($cert -and $cert.Expires) { "cert to $($cert.Expires.ToString('yyyy-MM-dd'))" })) @( @('Protocol', $protocol), @('Port', $port), @('Host names', $(if ($hosts.Count) { $hosts -join ', ' } else { '(any - basic listener)' })), @('Certificate', $(if ($cert) { "$($cert.Name)$(if ($cert.KeyVault) { ' (Key Vault)' } else { ' (uploaded)' })" })), @('Certificate expires', $(if ($cert -and $cert.Expires) { $cert.Expires.ToString('yyyy-MM-dd') })), @('WAF policy', $(if ($policy) { & $leaf $policy } elseif ($globalPolicy) { "$(& $leaf $globalPolicy) (gateway)" }))) $listenerIds[$lid] = @{ Protocol = $protocol; Port = $port; Hosts = $hosts; Cert = $cert; Policy = $(if ($policy) { $policy } else { $globalPolicy }); Used = $false } $null = & $addEdge (& $lower (& $ref $l 'frontendIPConfiguration')) $lid 'flow' $port $null = & $policyNode $policy if ($policy) { $null = & $addEdge $policy $lid 'waf' 'per site' } elseif ($globalPolicy) { $null = & $addEdge $globalPolicy $lid 'waf' '' } } $settingsIds = @{} foreach ($s in @(& $at $p 'backendHttpSettingsCollection' | Where-Object { $null -ne $_ })) { $sid = & $lower $s['id'] $sp = & $at $s 'properties' $hostMode = if ((& $at $sp 'pickHostNameFromBackendAddress') -eq $true) { 'pick from backend' } elseif (& $at $sp 'hostName') { "override: $(& $at $sp 'hostName')" } else { 'from request' } $draining = & $at $sp 'connectionDraining' $null = & $node $sid ([string]$s['name']) 'appgw/settings' 'Backend settings' 'appgw' 4 @("$(& $at $sp 'protocol') $(& $at $sp 'port')", "$(& $at $sp 'requestTimeout') s") @( @('Protocol', [string](& $at $sp 'protocol')), @('Port', [string](& $at $sp 'port')), @('Request timeout', "$(& $at $sp 'requestTimeout') s"), @('Host name', $hostMode), @('Cookie affinity', [string](& $at $sp 'cookieBasedAffinity')), @('Connection draining', $(if ((& $at $draining 'enabled') -eq $true) { "on, $(& $at $draining 'drainTimeoutInSec') s" } else { 'off' })), @('Custom probe', $(& $leaf (& $ref $s 'probe'))), @('Path override', [string](& $at $sp 'path'))) $settingsIds[$sid] = @{ Row = $s; Used = $false; Probe = (& $ref $s 'probe') } } $probeIds = @{} foreach ($pr in @(& $at $p 'probes' | Where-Object { $null -ne $_ })) { $prid = & $lower $pr['id'] $pp = & $at $pr 'properties' $null = & $node $prid ([string]$pr['name']) 'appgw/probe' 'Health probe' 'monitor' 5 @("$(& $at $pp 'protocol') $(& $at $pp 'path')", "every $(& $at $pp 'interval') s") @( @('Protocol', [string](& $at $pp 'protocol')), @('Host', $(if ((& $at $pp 'pickHostNameFromBackendHttpSettings') -eq $true) { 'from backend settings' } else { [string](& $at $pp 'host') })), @('Path', [string](& $at $pp 'path')), @('Interval', "$(& $at $pp 'interval') s"), @('Timeout', "$(& $at $pp 'timeout') s"), @('Unhealthy after', "$(& $at $pp 'unhealthyThreshold') failures"), @('Healthy status codes', (@(& $at $pp 'match', 'statusCodes') -join ', '))) $probeIds[$prid] = @{ Row = $pr; Used = $false } } foreach ($sid in @($settingsIds.Keys)) { if ($settingsIds[$sid].Probe) { $probeIds[$settingsIds[$sid].Probe].Used = $true; $null = & $addEdge $sid $settingsIds[$sid].Probe 'probe' '' } } # Pools, their targets, and health. $metrics = if ($Health.Metrics.Contains($gid)) { $Health.Metrics[$gid] } else { @{} } $live = if ($Health.Live.Contains($gid)) { @($Health.Live[$gid]) } else { @() } $state = { param([int] $Healthy, [int] $Unhealthy, [bool] $Known) if (-not $Known) { 'unknown' } elseif ($Unhealthy -and -not $Healthy) { 'unhealthy' } elseif ($Unhealthy) { 'degraded' } elseif ($Healthy) { 'healthy' } else { 'unknown' } } $poolIds = @{} foreach ($pool in @(& $at $p 'backendAddressPools' | Where-Object { $null -ne $_ })) { $poid = & $lower $pool['id'] $poolName = [string]$pool['name'] $addresses = @(@(& $at $pool 'properties', 'backendAddresses') | ForEach-Object { $a = [string](& $at $_ 'fqdn'); if (-not $a) { $a = [string](& $at $_ 'ipAddress') }; $a } | Where-Object { $_ }) $configurations = @(@(& $at $pool 'properties', 'backendIPConfigurations') | ForEach-Object { & $lower (& $at $_ 'id') } | Where-Object { $_ }) $poolLive = @($live | Where-Object { $_.Pool -eq $poid }) $metricKeys = @($metrics.Keys | Where-Object { $_ -like "$($poolName.ToLowerInvariant())|*" }) $healthy = 0; $unhealthy = 0; $known = $false if ($poolLive.Count) { $known = $true foreach ($server in @($poolLive | Group-Object -Property Address)) { if (@($server.Group | Where-Object { $_.Health -ne 'Healthy' }).Count) { $unhealthy++ } else { $healthy++ } } } elseif ($metricKeys.Count) { $known = $true $healthy = [int](@($metricKeys | ForEach-Object { [double]$metrics[$_].Healthy }) | Measure-Object -Maximum).Maximum $unhealthy = [int](@($metricKeys | ForEach-Object { [double]$metrics[$_].Unhealthy }) | Measure-Object -Maximum).Maximum } $poolHealth = & $state $healthy $unhealthy $known $n = & $node $poid $poolName 'appgw/pool' 'Backend pool' 'appgw' 5 @("$($addresses.Count + $configurations.Count) target(s)", $(if ($known) { "$healthy healthy, $unhealthy unhealthy" })) @(@('Targets', [string]($addresses.Count + $configurations.Count)), @('Healthy', $(if ($known) { [string]$healthy })), @('Unhealthy', $(if ($known) { [string]$unhealthy })), @('Health from', $(if ($poolLive.Count) { 'live backend health check' } elseif ($metricKeys.Count) { 'metrics, last 30 minutes' } else { 'not known' }))) $n.health = $poolHealth $poolIds[$poid] = @{ Name = $poolName; Count = $addresses.Count + $configurations.Count; Healthy = $healthy; Unhealthy = $unhealthy; Known = $known; Used = $false; Targets = @() } $targets = @( foreach ($a in $addresses) { $siteRow = $siteByHost[$a.ToLowerInvariant()] @{ Id = "$poid/targets/$($a.ToLowerInvariant())"; Name = $(if ($siteRow) { [string]$siteRow['name'] } else { $a }); Address = $a; Label = $(if ($siteRow) { 'App Service' } elseif ($a -match '^\d+\.\d+\.\d+\.\d+$') { 'IP address' } else { 'FQDN' }); Icon = $(if ($siteRow) { if ([string]$siteRow['kind'] -match 'functionapp') { 'functionapp' } else { 'appservice' } } else { 'resource' }); Portal = $(if ($siteRow) { [string]$siteRow['id'] } else { '' }) } } foreach ($cfg in $configurations) { $nicId = ($cfg -split '/ipconfigurations/')[0] $nic = $byId[$nicId] $vmId = & $lower (& $at $nic 'properties', 'virtualMachine', 'id') $ip = [string](@(@(& $at $nic 'properties', 'ipConfigurations') | Where-Object { (& $lower (& $at $_ 'id')) -eq $cfg } | ForEach-Object { & $at $_ 'properties', 'privateIPAddress' })[0]) @{ Id = "$poid/targets/$cfg"; Name = $(if ($vmId) { & $leaf $vmId } else { & $leaf $nicId }); Address = $ip; Label = $(if ($vmId) { 'Virtual machine' } else { 'Network interface' }); Icon = $(if ($vmId) { 'vm' } else { 'nic' }); Portal = $(if ($vmId) { $vmId } else { $nicId }) } }) foreach ($t in $targets) { $mine = @($poolLive | Where-Object { $_.Address -eq $t.Address }) $tn = & $node $t.Id $t.Name 'appgw/target' $t.Label $t.Icon 6 @($t.Address, $(if ($mine.Count) { (@($mine.Health | Select-Object -Unique) -join '/') })) @(@('Address', $t.Address), @('Health', $(if ($mine.Count) { (@($mine | ForEach-Object { "$($_.Health) via $(& $leaf $_.Settings)" }) -join '; ') })), @('Probe says', $(if ($mine.Count) { (@($mine | Where-Object { $_.Health -ne 'Healthy' -and $_.Reason } | ForEach-Object { $_.Reason } | Select-Object -Unique) -join ' | ') }))) $t.Portal if ($mine.Count) { $tn.health = & $state @($mine | Where-Object Health -EQ 'Healthy').Count @($mine | Where-Object Health -NE 'Healthy').Count $true } $null = & $addEdge $poid $t.Id 'flow' '' } $poolIds[$poid].Targets = @($targets | ForEach-Object { $_.Name }) } $redirects = @{} foreach ($rd in @(& $at $p 'redirectConfigurations' | Where-Object { $null -ne $_ })) { $rdid = & $lower $rd['id'] $rp = & $at $rd 'properties' $to = if (& $ref $rd 'targetListener') { & $leaf (& $ref $rd 'targetListener') } else { [string](& $at $rp 'targetUrl') } $null = & $node $rdid ([string]$rd['name']) 'appgw/redirect' 'Redirect' 'appgw' 3 @("$(& $at $rp 'redirectType') to $to") @(@('Type', [string](& $at $rp 'redirectType')), @('To', $to), @('Include path', [string](& $at $rp 'includePath')), @('Include query string', [string](& $at $rp 'includeQueryString'))) $redirects[$rdid] = @{ Target = (& $ref $rd 'targetListener'); Url = [string](& $at $rp 'targetUrl'); To = $to } $null = & $addEdge $rdid (& $ref $rd 'targetListener') 'redirect' '' } $rewrites = @{} foreach ($rw in @(& $at $p 'rewriteRuleSets' | Where-Object { $null -ne $_ })) { $rewrites[(& $lower $rw['id'])] = [string]$rw['name'] } $paths = @{} foreach ($pm in @(& $at $p 'urlPathMaps' | Where-Object { $null -ne $_ })) { $paths[(& $lower $pm['id'])] = $pm } # --- Rules and the flows they make --------------------------------------------------------------------- foreach ($rule in @(& $at $p 'requestRoutingRules' | Where-Object { $null -ne $_ } | Sort-Object -Property { [int](& $at $_ 'properties', 'priority') })) { $rid = & $lower $rule['id'] $rp = & $at $rule 'properties' $type = [string](& $at $rp 'ruleType') $priority = [string](& $at $rp 'priority') $lid = & $ref $rule 'httpListener' $null = & $node $rid ([string]$rule['name']) 'appgw/rule' $(if ($priority) { "Rule (priority $priority)" } else { 'Rule' }) 'appgw' 2 @($(if ($type -eq 'PathBasedRouting') { 'path-based' } else { 'basic' })) @(@('Type', $type), @('Priority', $priority), @('Listener', (& $leaf $lid)), @('Rewrite set', $rewrites[(& $ref $rule 'rewriteRuleSet')])) if ($listenerIds.Contains($lid)) { $listenerIds[$lid].Used = $true } $null = & $addEdge $lid $rid 'flow' '' $entries = if ($type -eq 'PathBasedRouting') { $pm = $paths[(& $ref $rule 'urlPathMap')] foreach ($pr in @(& $at $pm 'properties', 'pathRules' | Where-Object { $null -ne $_ })) { @{ Id = & $lower $pr['id']; Name = [string]$pr['name']; Paths = (@(& $at $pr 'properties', 'paths') -join ', '); Pool = (& $ref $pr 'backendAddressPool'); Settings = (& $ref $pr 'backendHttpSettings'); Redirect = (& $ref $pr 'redirectConfiguration'); Rewrite = (& $ref $pr 'rewriteRuleSet'); Policy = (& $ref $pr 'firewallPolicy') } } if ($pm) { @{ Id = "$(& $lower $pm['id'])/default"; Name = "$($pm['name']) (default)"; Paths = '(default)'; Pool = (& $ref $pm 'defaultBackendAddressPool'); Settings = (& $ref $pm 'defaultBackendHttpSettings'); Redirect = (& $ref $pm 'defaultRedirectConfiguration'); Rewrite = (& $ref $pm 'defaultRewriteRuleSet'); Policy = '' } } } else { @{ Id = ''; Name = ''; Paths = '*'; Pool = (& $ref $rule 'backendAddressPool'); Settings = (& $ref $rule 'backendHttpSettings'); Redirect = (& $ref $rule 'redirectConfiguration'); Rewrite = (& $ref $rule 'rewriteRuleSet'); Policy = '' } } foreach ($e in @($entries)) { $from = $rid if ($e.Id) { $null = & $node $e.Id $e.Name 'appgw/path' 'Path rule' 'appgw' 3 @($e.Paths) @(@('Paths', $e.Paths), @('Rewrite set', $rewrites[$e.Rewrite]), @('WAF policy', (& $leaf $e.Policy))) $null = & $addEdge $rid $e.Id 'flow' '' if ($e.Policy) { $null = & $policyNode $e.Policy; $null = & $addEdge $e.Policy $e.Id 'waf' 'per path' } $from = $e.Id } if ($e.Redirect) { $null = & $addEdge $from $e.Redirect 'flow' 'redirect' } if ($e.Settings) { $null = & $addEdge $from $e.Settings 'flow' ''; if ($settingsIds.Contains($e.Settings)) { $settingsIds[$e.Settings].Used = $true } } if ($e.Settings -and $e.Pool) { $null = & $addEdge $e.Settings $e.Pool 'flow' '' } $poolInfo = if ($e.Pool -and $poolIds.Contains($e.Pool)) { $poolIds[$e.Pool]; $poolIds[$e.Pool].Used = $true } else { $null } $listener = $listenerIds[$lid] $sRow = if ($e.Settings -and $settingsIds.Contains($e.Settings)) { $settingsIds[$e.Settings].Row } else { $null } $redirect = if ($e.Redirect) { $redirects[$e.Redirect] } else { $null } $policyId = if ($e.Policy) { $e.Policy } elseif ($listener) { $listener.Policy } else { '' } $flows.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.ApplicationGatewayFlow' Gateway = $gname Listener = & $leaf $lid Protocol = $(if ($listener) { $listener.Protocol }) Port = $(if ($listener) { $listener.Port }) HostNames = $(if ($listener) { $listener.Hosts -join ', ' }) Certificate = $(if ($listener -and $listener.Cert) { $listener.Cert.Name }) CertExpires = $(if ($listener -and $listener.Cert) { $listener.Cert.Expires }) WafPolicy = & $leaf $policyId WafMode = $(if ($policyId -and $wafPolicies.Contains($policyId)) { $wafPolicies[$policyId].Mode }) Rule = [string]$rule['name'] Priority = $priority Path = $e.Paths Redirect = $(if ($redirect) { $redirect.To }) Rewrite = $rewrites[$e.Rewrite] BackendSettings = & $leaf $e.Settings BackendPort = $(if ($sRow) { [string](& $at $sRow 'properties', 'port') }) Probe = $(if ($sRow) { & $leaf (& $ref $sRow 'probe') }) Pool = & $leaf $e.Pool Targets = $(if ($poolInfo) { $poolInfo.Targets -join ', ' }) Healthy = $(if ($poolInfo -and $poolInfo.Known) { $poolInfo.Healthy }) Unhealthy = $(if ($poolInfo -and $poolInfo.Known) { $poolInfo.Unhealthy }) Health = $(if ($poolInfo) { & $state $poolInfo.Healthy $poolInfo.Unhealthy $poolInfo.Known } elseif ($redirect) { 'redirect' } else { '' }) ResourceId = $gid }) } } # --- Findings ------------------------------------------------------------------------------------------ $isV2 = $tier -in 'Standard_v2', 'WAF_v2', 'Basic' if (-not $isV2) { & $find 'High' 'Gateway' $gname 'v1 SKU (retired)' "Tier ${tier}: Application Gateway v1 is retired." 'Migrate to Standard_v2 or WAF_v2 (Microsoft''s migration script copies the configuration).' $gid } $wafConfig = & $at $p 'webApplicationFirewallConfiguration' if ($tier -like 'WAF*' -and -not $globalPolicy -and (& $at $wafConfig 'enabled') -ne $true) { & $find 'High' 'WAF' $gname 'WAF tier without a WAF' 'The gateway pays for WAF but has no WAF policy or enabled configuration.' 'Associate a WAF policy in Prevention mode.' $gid } if ($tier -notlike 'WAF*' -and @($listenerIds.Values | Where-Object { $_.Protocol -eq 'Https' }).Count) { & $find 'Medium' 'WAF' $gname 'Public HTTPS without a WAF' "Tier $tier inspects no traffic." 'Move to WAF_v2 with a WAF policy, or put Front Door with WAF in front.' $gid } foreach ($policyId in @($wafPolicies.Keys)) { $w = $wafPolicies[$policyId] if (-not $w.Read) { continue } if ($w.Mode -eq 'Detection') { & $find 'High' 'WAF' (& $leaf $policyId) 'WAF policy in Detection mode' 'Attacks are logged, not blocked.' 'Tune exclusions from the logs, then switch to Prevention.' $policyId } if ($w.State -eq 'Disabled') { & $find 'High' 'WAF' (& $leaf $policyId) 'WAF policy disabled' 'The policy is attached but doesn''t inspect anything.' 'Enable the policy.' $policyId } foreach ($set in $w.Sets) { if ($set -match '^OWASP (\d+\.\d+)' -and [version]$Matches[1] -lt [version]'3.2') { & $find 'Low' 'WAF' (& $leaf $policyId) 'Old managed rule set' "$set." 'Upgrade to the Default Rule Set (DRS 2.1) or OWASP 3.2.' $policyId } } } if ($tls -match '20150501|20170401$|TLSv1_0|TLSv1_1') { & $find 'Medium' 'TLS' $gname 'Old TLS policy' "TLS policy: $tls - TLS 1.0/1.1 or weak ciphers allowed." 'Use AppGwSslPolicy20220101 (or 20220101S) or a custom policy with TLS 1.2 minimum.' $gid } if ($isV2 -and ((-not $zones.Count) -or ($autoscale -and [int](& $at $autoscale 'minCapacity') -lt 2) -or (-not $autoscale -and [int](& $at $sku 'capacity') -lt 2))) { & $find 'Medium' 'Gateway' $gname 'Not zone-redundant or under 2 instances' "$capacity; $(if ($zones.Count) { "zones $($zones -join ',')" } else { 'no zones' })." 'Deploy across zones 1, 2 and 3 with a minimum of 2 instances.' $gid } foreach ($lid in @($listenerIds.Keys)) { $l = $listenerIds[$lid] $lname = & $leaf $lid if (-not $l.Used) { & $find 'Low' 'Listener' $lname 'Listener no rule uses' "$($l.Protocol) $($l.Port)." 'Delete it, or add the rule it was meant for.' $lid; $nodes[$lid].orphan = 'no rule uses it' } if ($l.Protocol -eq 'Http' -and $l.Used) { $toHttps = @($flows | Where-Object { $_.ResourceId -eq $gid -and $_.Listener -eq $lname -and $_.Redirect -and @($listenerIds.Values | Where-Object { $_.Protocol -eq 'Https' }).Count }) if (-not $toHttps.Count) { & $find 'Medium' 'Listener' $lname 'HTTP not redirected to HTTPS' "Port $($l.Port) serves plain HTTP." 'Redirect it (permanent) to the HTTPS listener.' $lid } } if ($l.Cert) { if ($l.Cert.Expires -and $l.Cert.Expires -lt $Now) { & $find 'High' 'Certificate' $l.Cert.Name 'Certificate expired' "Expired $($l.Cert.Expires.ToString('yyyy-MM-dd')) - listener $lname." 'Renew it; with Key Vault, the gateway picks up the new version within 4 hours.' $lid } elseif ($l.Cert.Expires -and ($l.Cert.Expires - $Now).TotalDays -le $CertificateDays) { & $find 'Medium' 'Certificate' $l.Cert.Name "Certificate expires within $CertificateDays days" "Expires $($l.Cert.Expires.ToString('yyyy-MM-dd')) - listener $lname." 'Renew it now.' $lid } if (-not $l.Cert.KeyVault) { & $find 'Medium' 'Certificate' $l.Cert.Name 'Certificate uploaded, not from Key Vault' "Listener $lname." 'Store it in Key Vault and reference it, so renewals reach the gateway automatically.' $lid } } } foreach ($sid in @($settingsIds.Keys)) { $si = $settingsIds[$sid] $sname = & $leaf $sid $sp = & $at $si.Row 'properties' if (-not $si.Used) { & $find 'Low' 'Backend settings' $sname 'Backend settings no rule uses' '' 'Delete them.' $sid; $nodes[$sid].orphan = 'no rule uses them'; continue } if (-not $si.Probe) { & $find 'Medium' 'Backend settings' $sname 'No custom health probe' 'The default probe calls / on the target''s IP with the setting''s host - often not what the app answers.' 'Add a custom probe on a health endpoint, with the right host.' $sid } else { $pp = & $at $probeIds[$si.Probe].Row 'properties' $settingsHost = if ((& $at $sp 'pickHostNameFromBackendAddress') -eq $true) { 'backend' } else { [string](& $at $sp 'hostName') } $probeHost = if ((& $at $pp 'pickHostNameFromBackendHttpSettings') -eq $true) { $settingsHost } else { [string](& $at $pp 'host') } # Only two explicit host names that differ: picking the host from the backend is a valid pairing. if ($settingsHost -and $settingsHost -ne 'backend' -and $probeHost -and $probeHost -ne 'backend' -and $probeHost -ne $settingsHost) { & $find 'Medium' 'Probe' (& $leaf $si.Probe) 'Probe host differs from the backend settings' "Probe asks for '$probeHost', traffic goes with '$settingsHost'." 'Let the probe pick the host from the backend settings, or match them.' $si.Probe } } if ((& $at $sp 'connectionDraining', 'enabled') -ne $true) { & $find 'Low' 'Backend settings' $sname 'Connection draining off' 'Removing or updating a target drops its in-flight requests.' 'Turn on connection draining (30-60 s).' $sid } } foreach ($prid in @($probeIds.Keys)) { if (-not $probeIds[$prid].Used) { & $find 'Low' 'Probe' (& $leaf $prid) 'Probe no backend setting uses' '' 'Delete it, or attach it to the backend settings it was meant for.' $prid; $nodes[$prid].orphan = 'no backend setting uses it' } } foreach ($poid in @($poolIds.Keys)) { $po = $poolIds[$poid] if (-not $po.Used) { & $find 'Low' 'Pool' $po.Name 'Backend pool no rule uses' "$($po.Count) target(s)." 'Delete it, or route to it.' $poid; $nodes[$poid].orphan = 'no rule uses it'; continue } if (-not $po.Count) { & $find 'High' 'Pool' $po.Name 'Rule sends traffic to an empty pool' 'Requests to it get 502.' 'Add targets, or point the rule elsewhere.' $poid; continue } if ($po.Known -and $po.Unhealthy -and -not $po.Healthy) { & $find 'High' 'Pool' $po.Name 'Every target unhealthy' "$($po.Unhealthy) unhealthy - requests get 502." 'Check the probe (-IncludeHealth shows its reason), the NSG on the targets, and the targets themselves.' $poid } elseif ($po.Known -and $po.Unhealthy) { & $find 'Medium' 'Pool' $po.Name 'Some targets unhealthy' "$($po.Healthy) healthy, $($po.Unhealthy) unhealthy." 'Find the failing targets (-IncludeHealth gives the probe''s reason).' $poid } } if ($Health.Errors.Contains($gid)) { & $find 'Info' 'Health' $gname 'Live backend health not read' ([string]$Health.Errors[$gid]) 'Run with an account allowed Microsoft.Network/applicationGateways/backendhealth/action, or rely on the metrics.' $gid } $box.findings = @($gwFindings | Where-Object { $_.Component -in 'Gateway', 'WAF', 'TLS', 'Health' -and $_.ResourceId -eq $gid } | ForEach-Object { "$($_.Severity): $($_.Finding) - $($_.Detail)" }) if (@($gwFindings | Where-Object Severity -EQ 'High').Count) { $box.risk = 'high' } $gatewayRows.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.ApplicationGateway'; Gateway = $gname; Sku = [string](& $at $sku 'name'); Tier = $tier; Capacity = $capacity; Zones = ($zones -join ','); TlsPolicy = $tls State = [string](& $at $p 'operationalState'); Listeners = $listenerIds.Count; Rules = @(& $at $p 'requestRoutingRules' | Where-Object { $null -ne $_ }).Count; Pools = $poolIds.Count Targets = [int](@($poolIds.Values | ForEach-Object { $_.Count }) | Measure-Object -Sum).Sum Unhealthy = [int](@($poolIds.Values | Where-Object Known | ForEach-Object { $_.Unhealthy }) | Measure-Object -Sum).Sum WafPolicy = (& $leaf $globalPolicy); Findings = $gwFindings.Count; High = @($gwFindings | Where-Object Severity -EQ 'High').Count Subnet = $subnetId; ResourceGroup = $group; Subscription = & $subName $sub; ResourceId = $gid }) } $rank = @{ High = 0; Medium = 1; Low = 2; Info = 3 } $depth = { param($C) $d = 0; $x = $C.parent; while ($x -and $clusters.Contains($x)) { $d++; $x = $clusters[$x].parent }; $d } @{ Clusters = @($clusters.Values | Sort-Object -Property { & $depth $_ } -Stable) Nodes = @($nodes.Values) Edges = $edges.ToArray() Gateways = $gatewayRows.ToArray() Flows = $flows.ToArray() Findings = @($findings | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, Gateway, Component) Stats = @{ Gateways = $gatewayRows.Count Listeners = @($nodes.Values | Where-Object { $_.type -eq 'appgw/listener' }).Count Rules = @($nodes.Values | Where-Object { $_.type -eq 'appgw/rule' }).Count Pools = @($nodes.Values | Where-Object { $_.type -eq 'appgw/pool' }).Count Targets = @($nodes.Values | Where-Object { $_.type -eq 'appgw/target' }).Count Unhealthy = $(if ($gatewayRows.Count) { [int](($gatewayRows | Measure-Object -Property Unhealthy -Sum).Sum) } else { 0 }) Findings = $findings.Count High = @($findings | Where-Object Severity -EQ 'High').Count Connections = $edges.Count } } } |