Private/ConvertTo-AACNetworkTopology.ps1

function ConvertTo-AACNetworkTopology {
    <#
    .SYNOPSIS
        Turns the resource map (ConvertTo-AACResourceMap) of the networks
        into the network topology: the tenant's hierarchy around them, each
        virtual network's role (hub, spoke, peered, standalone), network
        virtual appliances, Virtual WAN hubs, peerings to networks you can't
        see, and what to look at.
    .DESCRIPTION
        No Azure calls: Show-AACNetworkTopology reads, ConvertTo-AACResourceMap
        draws the networks, this arranges them.
 
        The boxes, by -GroupBy:
          ManagementGroup tenant > management group(s) > subscription >
                           region > resource group > virtual network > subnet
          Subscription subscription > region > resource group > ...
          Region region > virtual network > subnet
        A resource group that spans regions is drawn once in each.
 
        A network interface is drawn as part of its VM: the VM shows its IPs,
        takes the NIC's NSG chip and its links, and is marked as a network
        virtual appliance (nva) when the NIC forwards IP traffic. Route next
        hops already point at the VM, firewall or appliance that has the IP.
 
        Each virtual network gets a role:
          hub gateway transit allowed on a peering, or a gateway,
                      firewall or appliance and a peering, or 3+ peerings
          spoke peered with a hub (hub: which one)
          peered peered, but with no hub
          standalone no peerings
        A Virtual WAN hub is a hub box of its own, with its gateways and
        firewall in it, and the networks connected to it (their peerings to
        the hub's managed network, HV_<hub>_...) drawn to it. A peering to a
        network you can't see gets a box marked outside your view.
 
        Findings (Severity, Finding, Resource, Detail):
          High a peering that isn't Connected; a route to an IP nothing
                  you can see has
          Medium a spoke subnet whose internet traffic isn't sent through a
                  firewall or appliance (no 0.0.0.0/0 route to one)
          Info a peering to a network you can't see; a standalone network
 
        -Map is changed in place and returned with Findings, Layout (the
        layout that suits it: 'hubspoke' when there are hubs with spokes,
        else 'layered') and Stats.
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        # ConvertTo-AACResourceMap's result.
        [Parameter(Mandatory)]
        [hashtable] $Map,

        # The Resource Graph rows it was made from (inside and outside the scope).
        [AllowEmptyCollection()]
        [object[]] $Resource = @(),

        # Subscription rows: subscriptionId, name, chain (managementGroupAncestorsChain).
        [AllowEmptyCollection()]
        [object[]] $Subscription = @(),

        [ValidateSet('ManagementGroup', 'Subscription', 'Region')]
        [string] $GroupBy = 'ManagementGroup',

        [string] $TenantName = 'Tenant'
    )

    function Get-Value($Row, [string] $Key) {
        if ($Row -isnot [System.Collections.IDictionary]) { return $null }
        if ($Row.Contains($Key)) { return $Row[$Key] }
        foreach ($name in $Row.Keys) { if ($name -eq $Key) { return $Row[$name] } }
        $null
    }
    function Get-Path($Row, [string[]] $Keys) { $value = $Row; foreach ($key in $Keys) { $value = Get-Value $value $key; if ($null -eq $value) { return $null } }; $value }
    $lower = { param($Text) ([string]$Text).ToLowerInvariant() }
    # Every key this reads, on every box and resource: strict mode allows no missing ones.
    $clusterDefaults = @{ role = ''; hub = ''; vhub = $false; hiddenRemote = $false; context = ''; peerCount = 0; egress = ''; prefixLength = 0; location = ''; subscriptionId = ''; resourceGroup = ''; type = ''; prefix = '' }
    $nodeDefaults = @{ nva = $false; context = ''; hiddenRemote = $false }
    $complete = { param($Item, [hashtable] $Defaults) foreach ($key in $Defaults.Keys) { if (-not $Item.Contains($key)) { $Item[$key] = $Defaults[$key] } } }
    $clusters = [ordered]@{}
    foreach ($c in $Map.Clusters) { & $complete $c $clusterDefaults; $clusters[$c.id] = $c }
    $nodes = [ordered]@{}
    foreach ($n in $Map.Nodes) { & $complete $n $nodeDefaults; $nodes[$n.id] = $n }
    $rows = @{}
    foreach ($row in $Resource) { $id = & $lower (Get-Value $row 'id'); if ($id -and -not $rows.Contains($id)) { $rows[$id] = $row } }
    $edges = [System.Collections.Generic.List[object]]::new()
    foreach ($e in $Map.Edges) { if (-not $e.Contains('warn')) { $e['warn'] = $false }; $edges.Add($e) }
    $findings = [System.Collections.Generic.List[object]]::new()
    $finding = {
        param([string] $Severity, [string] $Title, [string] $Resource, [string] $Detail, [string] $Id)
        $findings.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.NetworkTopologyFinding'; Severity = $Severity; Finding = $Title; Resource = $Resource; Detail = $Detail; ResourceId = $Id })
    }
    $counter = @{ Next = 1 }
    $addEdge = {
        param([string] $From, [string] $To, [string] $Kind, [string] $Label)
        $edges.Add(@{ id = "t$($counter.Next)"; source = $From; target = $To; kind = $Kind; label = $Label; view = ''; warn = $false })
        $counter.Next++
    }

    # --- 1. A NIC is part of its VM ---------------------------------------------------------------
    $merged = @{}
    foreach ($nic in @($nodes.Values | Where-Object { $_.type -eq 'microsoft.network/networkinterfaces' })) {
        $properties = Get-Value $rows[$nic.id] 'properties'
        $forwarding = [string](Get-Value $properties 'enableIPForwarding') -eq 'True'
        $vmId = & $lower (Get-Path $properties 'virtualMachine', 'id')
        if (-not $vmId -or -not $nodes.Contains($vmId)) {
            if ($forwarding) { $nic.nva = $true; $nic.typeLabel = 'Network virtual appliance (NIC)' }
            continue
        }
        $vm = $nodes[$vmId]
        foreach ($ip in @($nic.facts)) { if ($ip -and @($vm.facts) -notcontains $ip) { $vm.facts = @(@($vm.facts) + $ip) } }
        if ($forwarding -and -not $vm.nva) {
            $vm.nva = $true
            $vm.typeLabel = 'Network virtual appliance'
            $vm.facts = @(@('IP forwarding') + @($vm.facts))
        }
        foreach ($chip in @($nic.chips)) {
            $vm.chips = @(@($vm.chips) + $chip)
            if ($nodes.Contains($chip.id)) { $nodes[$chip.id].appliedTo = @(@($nodes[$chip.id].appliedTo) | ForEach-Object { if ($_ -eq $nic.id) { $vmId } else { $_ } }) }
        }
        if (-not $vm.parent -or ($clusters.Contains($nic.parent) -and $clusters[$nic.parent].kind -eq 'subnet' -and -not ($clusters.Contains($vm.parent) -and $clusters[$vm.parent].kind -eq 'subnet'))) { $vm.parent = $nic.parent }
        $merged[$nic.id] = $vmId
        $nodes.Remove($nic.id)
    }
    if ($merged.Count) {
        $seen = @{}
        $kept = [System.Collections.Generic.List[object]]::new()
        foreach ($e in $edges) {
            if ($merged.Contains($e.source)) { $e.source = $merged[$e.source] }
            if ($merged.Contains($e.target)) { $e.target = $merged[$e.target] }
            if ($e.source -eq $e.target) { continue }
            $key = "$($e.kind)|$($e.view)|$((@($e.source, $e.target) | Sort-Object) -join '|')"
            if ($seen.Contains($key)) { continue }
            $seen[$key] = $true
            $kept.Add($e)
        }
        $edges = $kept
    }

    # --- 2. Virtual WAN hubs are hub boxes; peerings you can't see get a box ---------------------
    foreach ($hub in @($nodes.Values | Where-Object { $_.type -eq 'microsoft.network/virtualhubs' })) {
        $properties = Get-Value $rows[$hub.id] 'properties'
        $clusters[$hub.id] = @{
            id = $hub.id; kind = 'vnet'; parent = $hub.parent; name = $hub.name; icon = 'vhub'; external = $hub.external; badges = @(); chips = @()
            detail = @(@([string](Get-Value $properties 'addressPrefix'), $hub.location, 'Virtual WAN hub') | Where-Object { $_ })
            type = 'microsoft.network/virtualhubs'; resourceGroup = $hub.resourceGroup; subscriptionId = $hub.subscriptionId; location = $hub.location; vhub = $true
        }
        & $complete $clusters[$hub.id] $clusterDefaults
        $nodes.Remove($hub.id)
    }
    foreach ($n in @($nodes.Values)) {
        $hubId = & $lower (Get-Path $rows[$n.id] 'properties', 'virtualHub', 'id')
        if ($hubId -and $clusters.Contains($hubId)) { $n.parent = $hubId }
    }
    $vhubByName = @{}
    foreach ($c in @($clusters.Values | Where-Object { $_.vhub })) { $vhubByName[(& $lower $c.name)] = $c.id }
    $hidden = @{}
    # Only the networks in scope: a network outside it (drawn because one in
    # scope peers with it) has peerings of its own that weren't followed.
    foreach ($vnet in @($clusters.Values | Where-Object { $_.kind -eq 'vnet' -and -not $_.vhub -and -not $_.external })) {
        foreach ($peering in @(Get-Path $rows[$vnet.id] 'properties', 'virtualNetworkPeerings')) {
            if ($null -eq $peering) { continue }
            $remote = & $lower (Get-Path $peering 'properties', 'remoteVirtualNetwork', 'id')
            if (-not $remote -or $clusters.Contains($remote)) { continue }
            $state = [string](Get-Path $peering 'properties', 'peeringState')
            $remoteName = ($remote -split '/')[-1]
            # A Virtual WAN hub's managed network: HV_<hub name>_<guid>.
            if ($remoteName -match '^hv_(.+)_[0-9a-f-]{36}$' -and $vhubByName.Contains($Matches[1])) {
                & $addEdge $vnet.id $vhubByName[$Matches[1]] 'peering' (@($state, 'Virtual WAN') -join ' · ')
                continue
            }
            if (-not $hidden.Contains($remote)) {
                $hidden[$remote] = $true
                $clusters[$remote] = @{
                    id = $remote; kind = 'vnet'; parent = ''; name = $remoteName; icon = 'vnet'; external = $true; badges = @(); chips = @()
                    detail = @('outside your view'); type = 'microsoft.network/virtualnetworks'; hiddenRemote = $true
                    subscriptionId = ($remote -split '/')[2]; resourceGroup = ($remote -split '/')[4]; location = ''
                }
                & $complete $clusters[$remote] $clusterDefaults
                & $finding 'Info' 'Peered with a network you can''t see' $vnet.name "$($vnet.name) is peered with $remoteName ($remote), which isn't visible to your account - another tenant, or no Reader access." $vnet.id
            }
            & $addEdge $vnet.id $remote 'peering' $state
        }
    }

    # --- 3. Roles: hubs, spokes, peered, standalone ------------------------------------------------
    $ancestor = {
        param([string] $Id, [string] $Kind)
        $current = if ($nodes.Contains($Id)) { $nodes[$Id].parent } elseif ($clusters.Contains($Id)) { $clusters[$Id].parent } else { '' }
        while ($current -and $clusters.Contains($current)) { if ($clusters[$current].kind -eq $Kind) { return $current }; $current = $clusters[$current].parent }
        ''
    }
    $vnetOf = { param([string] $Id) if ($clusters.Contains($Id) -and $clusters[$Id].kind -eq 'vnet') { $Id } else { & $ancestor $Id 'vnet' } }
    $peers = @{}
    $peeringEdges = @($edges | Where-Object { $_.kind -eq 'peering' })
    foreach ($e in $peeringEdges) {
        $a = & $vnetOf $e.source; $b = & $vnetOf $e.target
        if (-not $a -or -not $b -or $a -eq $b) { continue }
        foreach ($pair in @(@($a, $b), @($b, $a))) {
            if (-not $peers.Contains($pair[0])) { $peers[$pair[0]] = [System.Collections.Generic.List[string]]::new() }
            if (-not $peers[$pair[0]].Contains($pair[1])) { $peers[$pair[0]].Add($pair[1]) }
        }
        if ($e.label -notmatch '(?i)^connected') {
            $ends = @($clusters[$a].name, $clusters[$b].name) | Sort-Object
            & $finding 'High' 'Peering not connected' ($ends -join ' <-> ') "The peering between $($clusters[$a].name) and $($clusters[$b].name) is $(@(($e.label -split ' · '))[0]). Traffic doesn't flow until both sides are Connected: re-sync or recreate the side that is Initiated or Disconnected." $a
            $e.warn = $true
        }
    }
    $transitTypes = @('microsoft.network/virtualnetworkgateways', 'microsoft.network/azurefirewalls')
    $devices = @{}
    foreach ($n in $nodes.Values) {
        if ($n.type -in $transitTypes -or $n.nva) { $v = & $vnetOf $n.id; if ($v) { $devices[$v] = $true } }
    }
    $vnets = @($clusters.Values | Where-Object { $_.kind -eq 'vnet' })
    foreach ($vnet in $vnets) {
        $transit = @(Get-Path $rows[$vnet.id] 'properties', 'virtualNetworkPeerings' | Where-Object { $_ -and [string](Get-Path $_ 'properties', 'allowGatewayTransit') -eq 'True' }).Count -gt 0
        $count = if ($peers.Contains($vnet.id)) { $peers[$vnet.id].Count } else { 0 }
        $vnet.peerCount = $count
        $vnet.role = if ($vnet.hiddenRemote) { 'outside' }
        elseif ($vnet.vhub -or $transit -or ($devices.Contains($vnet.id) -and $count -ge 1) -or $count -ge 3) { 'hub' }
        elseif ($count) { 'peered' }
        else { 'standalone' }
    }
    foreach ($vnet in $vnets) {
        if ($vnet.role -ne 'peered') { continue }
        $hub = @($peers[$vnet.id] | Where-Object { $clusters[$_].role -eq 'hub' }) | Select-Object -First 1
        if ($hub) { $vnet.role = 'spoke'; $vnet.hub = $hub }
    }
    # A hidden network that several of yours peer with is most likely their hub.
    foreach ($vnet in @($vnets | Where-Object { $_.role -eq 'outside' })) {
        if ($peers.Contains($vnet.id) -and $peers[$vnet.id].Count -ge 2) {
            $vnet.role = 'hub'
            foreach ($other in $peers[$vnet.id]) { if ($clusters[$other].role -eq 'peered') { $clusters[$other].role = 'spoke'; $clusters[$other].hub = $vnet.id } }
        }
    }
    foreach ($vnet in $vnets) {
        $label = switch ($vnet.role) { 'hub' { 'HUB' } 'spoke' { "spoke of $($clusters[$vnet.hub].name)" } 'peered' { 'peered' } 'standalone' { 'standalone' } default { '' } }
        if ($label) { $vnet.detail = @(@($label) + @($vnet.detail)) }
        if ($vnet.role -eq 'standalone') { & $finding 'Info' 'Standalone network' $vnet.name "$($vnet.name) isn't peered with any network: it reaches others only through public endpoints, a gateway of its own, or not at all." $vnet.id }
    }

    # --- 4. Egress: does a spoke's internet traffic go through a firewall or appliance? -----------
    $inspectors = @{}
    foreach ($n in $nodes.Values) { if ($n.type -eq 'microsoft.network/azurefirewalls' -or $n.nva) { $inspectors[$n.id] = $true } }
    foreach ($table in @($nodes.Values | Where-Object { $_.type -eq 'microsoft.network/routetables' })) {
        foreach ($route in @($table.routes)) {
            if ($route.risk -eq 'high') { & $finding 'High' 'Route to an unknown next hop' $table.name "$($route.name): $($route.prefix) -> $($route.nextHop) - $($route.reason)" $table.id }
        }
    }
    foreach ($subnet in @($clusters.Values | Where-Object { $_.kind -eq 'subnet' })) {
        $vnet = $clusters[$subnet.parent]
        if (-not $vnet -or $vnet.role -ne 'spoke') { continue }
        if ($subnet.name -in 'GatewaySubnet', 'AzureFirewallSubnet', 'AzureFirewallManagementSubnet', 'AzureBastionSubnet', 'RouteServerSubnet') { continue }
        # Only subnets with workloads that reach out: VMs and scale sets, not private endpoints.
        if (-not @($nodes.Values | Where-Object { $_.parent -eq $subnet.id -and $_.type -in 'microsoft.compute/virtualmachines', 'microsoft.compute/virtualmachinescalesets' -and -not $_.nva }).Count) { continue }
        $tables = @($nodes.Values | Where-Object { $_.type -eq 'microsoft.network/routetables' -and @($_.appliedTo) -contains $subnet.id })
        $inspected = @($tables | ForEach-Object { $_.routes } | Where-Object { $_.prefix -eq '0.0.0.0/0' -and ($inspectors.Contains($_.target) -or $_.nextHopType -eq 'VirtualNetworkGateway') }).Count -gt 0
        if (-not $inspected) {
            $subnet.egress = 'direct'
            & $finding 'Medium' 'Egress not inspected' "$($vnet.name)/$($subnet.name)" "No 0.0.0.0/0 route from $($subnet.name) goes to a firewall or appliance$(if (-not $tables.Count) { ' (it has no route table)' }): its internet traffic leaves without inspection. Add a route table with 0.0.0.0/0 to the hub's firewall." $subnet.id
        }
        else { $subnet.egress = 'inspected' }
    }

    # --- 5. The boxes around the networks ----------------------------------------------------------
    $chains = @{}
    foreach ($row in $Subscription) { $chains[(& $lower (Get-Value $row 'subscriptionId'))] = @(Get-Value $row 'chain') }
    $regionOf = {
        param($Item)
        $location = if ($Item.location) { $Item.location } elseif ($rows.Contains($Item.id)) { Get-Value $rows[$Item.id] 'location' } else { '' }
        if ($location) { & $lower $location } else { 'global' }
    }
    $ensure = {
        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 = @() }
            & $complete $clusters[$Id] $clusterDefaults
        }
        $Id
    }
    $tenantId = 'tenant'
    $mgParent = {
        param([string] $SubscriptionKey)
        $chain = @($chains[$SubscriptionKey] | Where-Object { $_ })
        if (-not $chain.Count) { return (& $ensure $tenantId 'tenant' '' $TenantName @() 'resource') }
        $parent = & $ensure $tenantId 'tenant' '' $TenantName @() 'resource'
        # The chain runs from the subscription's own management group to the root.
        for ($i = $chain.Count - 1; $i -ge 0; $i--) {
            $name = [string](Get-Value $chain[$i] 'name')
            $display = [string](Get-Value $chain[$i] 'displayName')
            if ($i -eq $chain.Count - 1) { $clusters[$tenantId].name = $(if ($display) { $display } else { $TenantName }); continue }
            $parent = & $ensure "/providers/microsoft.management/managementgroups/$(& $lower $name)" 'managementgroup' $parent $(if ($display) { $display } else { $name }) @($name) 'resource'
        }
        $parent
    }
    # Who sits in a resource group: re-homed by region below.
    $context = @{}
    $children = @(@($clusters.Values | Where-Object { $_.kind -eq 'vnet' }) + @($nodes.Values))
    $outside = ''
    foreach ($item in $children) {
        $group = $item.parent
        if ($item.hiddenRemote) {
            $outside = & $ensure 'outside' 'resourcegroup' '' 'Outside your view' @('networks peered with yours that your account can''t see') 'resourcegroup'
            $clusters['outside'].external = $true
            $item.parent = $outside
            continue
        }
        if (-not $group -or -not $clusters.Contains($group) -or $clusters[$group].kind -ne 'resourcegroup') { continue }
        $rg = $clusters[$group]
        $subscriptionKey = & $lower (($group -split '/')[2])
        $subscriptionBox = "/subscriptions/$subscriptionKey"
        $region = & $regionOf $item
        $subscriptionName = if ($clusters.Contains($subscriptionBox)) { $clusters[$subscriptionBox].name } else { $subscriptionKey }
        $item.context = "$subscriptionName › $region › $($rg.name)"
        switch ($GroupBy) {
            'Region' {
                $item.parent = & $ensure "region|$region" 'region' '' $region @() 'resource'
            }
            default {
                $regionBox = & $ensure "$subscriptionBox|$region" 'region' $subscriptionBox $region @() 'resource'
                $box = & $ensure "$group|$region" 'resourcegroup' $regionBox $rg.name @($rg.detail) 'resourcegroup'
                $clusters[$box].external = $rg.external
                $item.parent = $box
            }
        }
        $context[$group] = $true
    }
    foreach ($group in @($context.Keys)) { $clusters.Remove($group) }
    if ($GroupBy -eq 'Region') {
        foreach ($id in @($clusters.Keys)) { if ($clusters[$id].kind -in 'subscription', 'resourcegroup' -and $id -ne 'outside') { $clusters.Remove($id) } }
    }
    elseif ($GroupBy -eq 'ManagementGroup') {
        foreach ($c in @($clusters.Values | Where-Object { $_.kind -eq 'subscription' })) { $c.parent = & $mgParent (& $lower (($c.id -split '/')[2])) }
    }
    # Boxes left with nothing in them.
    do {
        $used = [System.Collections.Generic.HashSet[string]]::new()
        foreach ($c in $clusters.Values) { if ($c.parent) { [void]$used.Add($c.parent) } }
        foreach ($n in $nodes.Values) { if ($n.parent) { [void]$used.Add($n.parent) } }
        $empty = @($clusters.Values | Where-Object { $_.kind -in 'tenant', 'managementgroup', 'subscription', 'region', 'resourcegroup' -and -not $used.Contains($_.id) } | ForEach-Object { $_.id })
        foreach ($id in $empty) { $clusters.Remove($id) }
    } while ($empty.Count)

    # A subnet's size on the map follows its prefix: a /16 is wider than a /28.
    foreach ($subnet in @($clusters.Values | Where-Object { $_.kind -eq 'subnet' })) {
        if ([string]$subnet.prefix -match '/(\d+)') { $subnet.prefixLength = [int]$Matches[1] }
    }

    # Outermost first, as the page draws them.
    $depth = { param($C) $d = 0; $p = $C.parent; while ($p -and $clusters.Contains($p)) { $d++; $p = $clusters[$p].parent }; $d }
    $Map.Clusters = @($clusters.Values | Sort-Object -Property { & $depth $_ }, { $_.name } -Stable)
    $Map.Nodes = @($nodes.Values)
    $Map.Edges = @($edges)
    $hubs = @($vnets | Where-Object { $_.role -eq 'hub' })
    $spokes = @($vnets | Where-Object { $_.role -eq 'spoke' })
    $count = { param([string[]] $Types) @($nodes.Values | Where-Object { $_.type -in $Types -and -not $_.external }).Count }
    $rank = @{ High = 0; Medium = 1; Info = 2 }
    $Map.Findings = @($findings | Sort-Object -Property { $rank[$_.Severity] }, Finding, Resource)
    $Map.Layout = if ($hubs.Count -and $spokes.Count) { 'hubspoke' } else { 'layered' }
    $Map.Stats = @{
        ManagementGroups = @($clusters.Values | Where-Object { $_.kind -eq 'managementgroup' }).Count
        Subscriptions    = @(@($vnets | Where-Object { -not $_.hiddenRemote }) | ForEach-Object { & $lower $_.subscriptionId } | Select-Object -Unique).Count
        Regions          = @(@($vnets | Where-Object { -not $_.hiddenRemote }) | ForEach-Object { & $regionOf $_ } | Select-Object -Unique).Count
        Networks         = @($vnets | Where-Object { -not $_.hiddenRemote -and -not $_.vhub }).Count
        VirtualHubs      = @($vnets | Where-Object { $_.vhub }).Count
        Subnets          = @($clusters.Values | Where-Object { $_.kind -eq 'subnet' }).Count
        Hubs             = $hubs.Count
        Spokes           = $spokes.Count
        Standalone       = @($vnets | Where-Object { $_.role -eq 'standalone' }).Count
        Hidden           = $hidden.Count
        Peerings         = $peeringEdges.Count
        Gateways         = & $count @('microsoft.network/virtualnetworkgateways', 'microsoft.network/vpngateways', 'microsoft.network/expressroutegateways', 'microsoft.network/p2svpngateways')
        Firewalls        = & $count @('microsoft.network/azurefirewalls')
        Appliances       = @($nodes.Values | Where-Object { $_.nva }).Count
        PrivateEndpoints = & $count @('microsoft.network/privateendpoints')
        VirtualMachines  = & $count @('microsoft.compute/virtualmachines')
        ScaleSets        = & $count @('microsoft.compute/virtualmachinescalesets')
        Bastions         = & $count @('microsoft.network/bastionhosts')
        Resources        = @($nodes.Values | Where-Object { -not $_.external }).Count
        Outside          = @($nodes.Values | Where-Object { $_.external }).Count
        Connections      = $edges.Count
        Findings         = $findings.Count
    }
    $Map
}