Private/ConvertTo-AACVirtualNetworkAssessment.ps1
|
function ConvertTo-AACVirtualNetworkAssessment { <# .SYNOPSIS Assesses virtual networks from Azure Resource Graph rows: address space and IP capacity, subnets, peerings, NSGs and ASGs, security, DNS and connectivity - the model behind Invoke-AACVirtualNetworkAssessment. .DESCRIPTION No Azure calls - every input is Resource Graph rows (hashtables): -VirtualNetwork the VNets to assess (id, name, resourceGroup, subscriptionId, location, tags, properties) -AllNetwork every VNet the account can see (id, name, subscriptionId, location, prefixes, peers) - for remote peerings and overlapping spaces -NetworkInterface NICs (id, name, nsg, vm, ipForwarding, privateEndpoint, ipConfigurations) -RouteTable, -NatGateway, -ApplicationSecurityGroup, -PublicIp, -PrivateEndpoint, -FlowLog, -Firewall, -Bastion, -Gateway, -DnsLink, -DnsResolver, -NetworkSecurityGroup (rows) -NsgAssessment ConvertTo-AACNsgAssessment's result for the NSGs applied in these VNets Returns a hashtable: VirtualNetworks AAC.VirtualNetwork: metadata, address space and IPs (total, in subnets, free, usable, used, available), DNS, DDoS, encryption, flow logs, gateways, firewalls, Bastion, peerings, role (hub or spoke), findings Subnets AAC.VirtualNetworkSubnet: prefixes, usable / used / available IPs, NSG, route table and its default route, NAT gateway, outbound path, service endpoints, delegations, private endpoints and their network policies, NICs, VMs, public IPs, special purpose Peerings AAC.VirtualNetworkPeering FreeRanges AAC.VirtualNetworkFreeRange: the address space no subnet uses, as CIDR blocks Asgs AAC.ApplicationSecurityGroupUse: NICs in each ASG and the NSG rules that name it PrivateEndpoints AAC.VirtualNetworkPrivateEndpoint Findings AAC.VirtualNetworkFinding - by severity, with the NSG assessment's findings (Source NSG) Stats #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $VirtualNetwork, [AllowEmptyCollection()] [object[]] $AllNetwork = @(), [AllowEmptyCollection()] [object[]] $NetworkInterface = @(), [AllowEmptyCollection()] [object[]] $RouteTable = @(), [AllowEmptyCollection()] [object[]] $NatGateway = @(), [AllowEmptyCollection()] [object[]] $ApplicationSecurityGroup = @(), [AllowEmptyCollection()] [object[]] $PublicIp = @(), [AllowEmptyCollection()] [object[]] $PrivateEndpoint = @(), [AllowEmptyCollection()] [object[]] $FlowLog = @(), [AllowEmptyCollection()] [object[]] $Firewall = @(), [AllowEmptyCollection()] [object[]] $Bastion = @(), [AllowEmptyCollection()] [object[]] $Gateway = @(), [AllowEmptyCollection()] [object[]] $DnsLink = @(), [AllowEmptyCollection()] [object[]] $DnsResolver = @(), [AllowEmptyCollection()] [object[]] $NetworkSecurityGroup = @(), [hashtable] $NsgAssessment, [hashtable] $SubscriptionName = @{} ) # --- Helpers --------------------------------------------------------------------------------------------------- $at = { param($Value, [string[]] $Keys) foreach ($key in $Keys) { if ($Value -is [System.Collections.IDictionary] -and $Value.Contains($key)) { $Value = $Value[$key] } else { return $null } } $Value } $lower = { param($Value) ([string]$Value).ToLowerInvariant() } $leaf = { param($Id) if ($Id) { ([string]$Id -split '/')[-1] } else { '' } } $list = { param($Value) @($Value | Where-Object { $null -ne $_ -and '' -ne $_ }) } $subscriptionOf = { param([string] $Id) if ($Id -match '^/subscriptions/([^/]+)') { $key = $Matches[1].ToLowerInvariant(); if ($SubscriptionName.Contains($key)) { $SubscriptionName[$key] } else { $Matches[1] } } else { '' } } # The VNet a subnet, NIC configuration or other child ID belongs to. $vnetOf = { param([string] $Id) if ($Id -match '^(.+/providers/microsoft\.network/virtualnetworks/[^/]+)') { $Matches[1].ToLowerInvariant() } else { '' } } $object = { param([string] $TypeName, [System.Collections.IDictionary] $Property) $item = [pscustomobject]$Property $item.PSObject.TypeNames.Insert(0, $TypeName) $item } # Measure-Object -Sum has no Sum for no input (strict mode): add up by hand. $sumOf = { param($Values, [string] $Name) $t = [long]0; foreach ($v in $Values) { $n = if ($Name) { $v.$Name } else { $v }; if ($null -ne $n) { $t += [long]$n } }; $t } $findings = [System.Collections.Generic.List[object]]::new() $finding = { param([string] $Severity, [string] $Category, [string] $Title, [string] $VirtualNetwork, [string] $Item, [string] $Detail, [string] $Action, [string] $ResourceId, [string] $Source = 'Assessment') $findings.Add((& $object 'AAC.VirtualNetworkFinding' ([ordered]@{ Severity = $Severity; Category = $Category; Finding = $Title; VirtualNetwork = $VirtualNetwork; Item = $Item; Detail = $Detail; Action = $Action; Source = $Source; ResourceId = $ResourceId }))) } # --- Indexes --------------------------------------------------------------------------------------------------- $byId = { param([object[]] $Rows) $index = @{}; foreach ($row in $Rows) { if ($row -and $row['id']) { $index[(& $lower $row['id'])] = $row } }; $index } $routeTables = & $byId $RouteTable $natGateways = & $byId $NatGateway $publicIps = & $byId $PublicIp $allNetworks = & $byId $AllNetwork # NICs by subnet, with the facts that matter. $nicsBySubnet = @{} $asgUse = @{} foreach ($nic in $NetworkInterface) { foreach ($configuration in @($nic['ipConfigurations'] | Where-Object { $_ })) { $subnetId = & $lower (& $at $configuration 'properties', 'subnet', 'id') if (-not $subnetId) { continue } if (-not $nicsBySubnet.Contains($subnetId)) { $nicsBySubnet[$subnetId] = [System.Collections.Generic.List[object]]::new() } $publicId = & $lower (& $at $configuration 'properties', 'publicIPAddress', 'id') $nicsBySubnet[$subnetId].Add(@{ Nic = $nic; Vm = [string]$nic['vm']; PublicIp = $publicId; Ip = [string](& $at $configuration 'properties', 'privateIPAddress'); Forwarding = [bool]$nic['ipForwarding']; PrivateEndpoint = [string]$nic['privateEndpoint'] }) foreach ($asg in @(& $at $configuration 'properties', 'applicationSecurityGroups')) { $asgId = & $lower (& $at $asg 'id') if (-not $asgId) { continue } if (-not $asgUse.Contains($asgId)) { $asgUse[$asgId] = [System.Collections.Generic.List[object]]::new() } $asgUse[$asgId].Add(@{ Nic = [string]$nic['name']; Vm = & $leaf $nic['vm']; VirtualNetwork = & $vnetOf $subnetId }) } } } $inVnet = { param([object[]] $Rows, [string] $VnetId) @($Rows | Where-Object { $_ -and @(@(& $at $_ 'ipConfigurations') + @(& $at $_ 'properties', 'ipConfigurations') | Where-Object { $_ } | Where-Object { (& $vnetOf (& $at $_ 'properties', 'subnet', 'id')) -eq $VnetId -or (& $vnetOf (& $at $_ 'subnet')) -eq $VnetId }).Count }) } $flowTargets = @{} foreach ($log in $FlowLog) { $target = & $lower $log['target']; if ($target -and $false -ne $log['enabled']) { $flowTargets[$target] = $log } } $special = @{ 'gatewaysubnet' = 'Gateway'; 'azurefirewallsubnet' = 'Azure Firewall'; 'azurefirewallmanagementsubnet' = 'Azure Firewall management'; 'azurebastionsubnet' = 'Azure Bastion'; 'routeserversubnet' = 'Route Server' } # The smallest prefix each special subnet needs, and whether an NSG is allowed on it. $sizing = @{ 'gatewaysubnet' = 27; 'azurefirewallsubnet' = 26; 'azurefirewallmanagementsubnet' = 26; 'azurebastionsubnet' = 26; 'routeserversubnet' = 27 } $privateZones = @{ blob = 'privatelink.blob.core.windows.net'; blob_secondary = 'privatelink.blob.core.windows.net'; file = 'privatelink.file.core.windows.net'; queue = 'privatelink.queue.core.windows.net'; table = 'privatelink.table.core.windows.net'; dfs = 'privatelink.dfs.core.windows.net'; web = 'privatelink.web.core.windows.net'; vault = 'privatelink.vaultcore.azure.net'; sqlserver = 'privatelink.database.windows.net'; registry = 'privatelink.azurecr.io'; sites = 'privatelink.azurewebsites.net'; namespace = 'privatelink.servicebus.windows.net'; account = 'privatelink.cognitiveservices.azure.com'; sql = 'privatelink.documents.azure.com'; mongodb = 'privatelink.mongo.cosmos.azure.com'; rediscache = 'privatelink.redis.cache.windows.net'; searchservice = 'privatelink.search.windows.net'; postgresqlserver = 'privatelink.postgres.database.azure.com'; mysqlserver = 'privatelink.mysql.database.azure.com'; configurationstores = 'privatelink.azconfig.io' } $vnets = [System.Collections.Generic.List[object]]::new() $subnets = [System.Collections.Generic.List[object]]::new() $peerings = [System.Collections.Generic.List[object]]::new() $free = [System.Collections.Generic.List[object]]::new() $endpoints = [System.Collections.Generic.List[object]]::new() $scoped = @{} foreach ($vnet in $VirtualNetwork) { $scoped[(& $lower $vnet['id'])] = $true } foreach ($vnet in $VirtualNetwork) { $vnetId = & $lower $vnet['id'] $name = [string]$vnet['name'] $properties = & $at $vnet 'properties' $space = @(& $list (& $at $properties 'addressSpace', 'addressPrefixes')) $spaceRanges = @($space | ForEach-Object { ConvertTo-AACCidrRange $_ }) $total = & $sumOf @($spaceRanges | Where-Object { $_.Version -eq 4 -and $_.Valid }) 'Size' $dns = @(& $list (& $at $properties 'dhcpOptions', 'dnsServers')) $links = @($DnsLink | Where-Object { (& $lower $_['vnet']) -eq $vnetId }) $zoneNames = @($links | ForEach-Object { ([string]$_['zone']).ToLowerInvariant() }) $gateways = @(& $inVnet $Gateway $vnetId) $firewalls = @(& $inVnet $Firewall $vnetId) $bastions = @(& $inVnet $Bastion $vnetId) $resolvers = @($DnsResolver | Where-Object { (& $lower $_['vnet']) -eq $vnetId }) $planId = & $lower (& $at $properties 'ddosProtectionPlan', 'id') $ddosOn = [bool](& $at $properties 'enableDdosProtection') -and $planId $encryption = & $at $properties 'encryption' $vnetFlowLog = $flowTargets[$vnetId] # --- Subnets --------------------------------------------------------------------------------------------- $vnetSubnets = [System.Collections.Generic.List[object]]::new() $usedPrefixes = [System.Collections.Generic.List[string]]::new() $publicIpCount = 0 foreach ($subnet in @(& $at $properties 'subnets' | Where-Object { $_ })) { $subnetId = & $lower $subnet['id'] $subnetName = [string]$subnet['name'] $sp = & $at $subnet 'properties' $prefixes = @(& $list $(if (& $at $sp 'addressPrefix') { & $at $sp 'addressPrefix' } else { & $at $sp 'addressPrefixes' })) foreach ($prefix in $prefixes) { $usedPrefixes.Add([string]$prefix) } $ranges = @($prefixes | ForEach-Object { ConvertTo-AACCidrRange $_ } | Where-Object { $_.Version -eq 4 -and $_.Valid }) $size = & $sumOf $ranges 'Size' $usable = & $sumOf @($ranges | ForEach-Object { [Math]::Max(0, $_.Size - 5) }) $used = @(& $at $sp 'ipConfigurations' | Where-Object { $_ }).Count $delegations = @(@(& $at $sp 'delegations') | Where-Object { $_ } | ForEach-Object { [string](& $at $_ 'properties', 'serviceName') }) $nics = @(if ($nicsBySubnet.Contains($subnetId)) { $nicsBySubnet[$subnetId] }) $computeNics = @($nics | Where-Object { -not $_.PrivateEndpoint }) $vms = @($computeNics | Where-Object Vm | ForEach-Object { $_.Vm } | Select-Object -Unique) $withPublicIp = @($computeNics | Where-Object PublicIp) $publicIpCount += $withPublicIp.Count $nsgId = & $lower (& $at $sp 'networkSecurityGroup', 'id') $routeTableId = & $lower (& $at $sp 'routeTable', 'id') $table = if ($routeTableId) { $routeTables[$routeTableId] } else { $null } $routes = @(@(if ($table) { & $at $table 'routes' }) | Where-Object { $_ }) $defaultRoute = $routes | Where-Object { [string](& $at $_ 'properties', 'addressPrefix') -eq '0.0.0.0/0' } | Select-AACFirst 1 $defaultHop = if ($defaultRoute) { "$(& $at $defaultRoute 'properties', 'nextHopType')$(if (& $at $defaultRoute 'properties', 'nextHopIpAddress') { " $(& $at $defaultRoute 'properties', 'nextHopIpAddress')" })" } else { '' } $natId = & $lower (& $at $sp 'natGateway', 'id') $defaultOutbound = & $at $sp 'defaultOutboundAccess' $purpose = $special[$subnetName.ToLowerInvariant()] $peCount = @(& $at $sp 'privateEndpoints' | Where-Object { $_ }).Count $outbound = if ($purpose) { '' } elseif ($natId) { 'NAT gateway' } elseif ($defaultRoute -and [string](& $at $defaultRoute 'properties', 'nextHopType') -eq 'VirtualAppliance') { 'Firewall / NVA (route table)' } elseif ($defaultRoute -and [string](& $at $defaultRoute 'properties', 'nextHopType') -eq 'VirtualNetworkGateway') { 'Forced tunnelling (gateway)' } elseif ($defaultRoute -and [string](& $at $defaultRoute 'properties', 'nextHopType') -eq 'None') { 'None (route drops it)' } elseif ($false -eq $defaultOutbound) { 'None (private subnet)' } elseif ($computeNics.Count -and $withPublicIp.Count -eq $computeNics.Count) { 'Public IPs on NICs' } elseif ($computeNics.Count) { 'Default outbound access' } else { '' } $subnetFlow = if ($flowTargets.Contains($subnetId)) { 'Subnet flow log' } elseif ($vnetFlowLog) { 'VNet flow log' } elseif ($nsgId -and $flowTargets.Contains($nsgId)) { 'NSG flow log' } else { 'None' } $row = & $object 'AAC.VirtualNetworkSubnet' ([ordered]@{ VirtualNetwork = $name Subnet = $subnetName Purpose = $(if ($purpose) { $purpose } elseif ($delegations.Count) { "Delegated: $($delegations -join ', ')" } elseif ($peCount -and -not $computeNics.Count) { 'Private endpoints' } else { 'Workloads' }) Prefix = $prefixes -join ', ' Size = $size Usable = $usable Used = $used Available = [Math]::Max(0, $usable - $used) UsedPercent = $(if ($usable) { [Math]::Round($used / $usable * 100, 1) } else { $null }) Nsg = & $leaf $nsgId RouteTable = & $leaf $routeTableId DefaultRoute = $defaultHop BgpPropagation = $(if ($table) { $(if (& $at $table 'disableBgpRoutePropagation') { 'Disabled' } else { 'Enabled' }) } else { '' }) NatGateway = $(if ($natId) { "$(& $leaf $natId)$(if ($natGateways.Contains($natId)) { " ($(@(@(& $at $natGateways[$natId] 'publicIpAddresses') + @(& $at $natGateways[$natId] 'publicIpPrefixes') | Where-Object { $_ }).Count) public IP(s)/prefix(es))" })" } else { '' }) Outbound = $outbound DefaultOutboundAccess = $(if ($false -eq $defaultOutbound) { 'Off (private subnet)' } elseif ($true -eq $defaultOutbound) { 'On' } else { 'On (default)' }) ServiceEndpoints = @(@(& $at $sp 'serviceEndpoints') | Where-Object { $_ } | ForEach-Object { [string](& $at $_ 'service') }) -join ', ' ServiceEndpointPolicies = @(@(& $at $sp 'serviceEndpointPolicies') | Where-Object { $_ } | ForEach-Object { & $leaf (& $at $_ 'id') }) -join ', ' Delegations = $delegations -join ', ' PrivateEndpoints = $peCount PrivateEndpointPolicies = [string](& $at $sp 'privateEndpointNetworkPolicies') PrivateLinkPolicies = [string](& $at $sp 'privateLinkServiceNetworkPolicies') Nics = $computeNics.Count Vms = $vms.Count PublicIpNics = $withPublicIp.Count IpForwardingNics = @($computeNics | Where-Object Forwarding).Count FlowLogs = $subnetFlow SubscriptionName = & $subscriptionOf $vnetId ResourceGroup = [string]$vnet['resourceGroup'] SubnetId = [string]$subnet['id'] }) $vnetSubnets.Add($row) $subnets.Add($row) # --- Subnet findings --------------------------------------------------------------------------------- if ($usable -and $row.UsedPercent -ge 80) { & $finding $(if ($row.Available -eq 0) { 'High' } elseif ($row.UsedPercent -ge 95) { 'High' } else { 'Medium' }) 'Capacity' $(if ($row.Available -eq 0) { 'Subnet full' } else { 'Subnet nearly full' }) $name $subnetName "$used of $usable usable IPs in $($row.Prefix) are in use ($($row.UsedPercent)%), $($row.Available) left. Scale-outs, new VMs and private endpoints fail when it is full." 'Add an address range to the subnet (if free space is left in the VNet), or move workloads to a larger subnet.' $row.SubnetId } if ($usable -ge 1019 -and $usable -and $row.UsedPercent -lt 2 -and -not $purpose -and -not $delegations.Count) { & $finding 'Low' 'Capacity' 'Oversized subnet' $name $subnetName "$($row.Prefix) offers $usable IPs; $used are in use." 'Size subnets for expected growth: an empty, large subnet holds address space other subnets (and peered networks) may need. It can be resized while nothing is in it.' $row.SubnetId } if ($sizing.Contains($subnetName.ToLowerInvariant())) { $needed = $sizing[$subnetName.ToLowerInvariant()] $smallest = @($ranges | Sort-Object Length -Descending | Select-AACFirst 1) if ($smallest.Count -and $smallest[0].Length -gt $needed) { & $finding $(if ($subnetName -ieq 'GatewaySubnet') { 'Medium' } else { 'High' }) 'Connectivity' "$subnetName is smaller than /$needed" $name $subnetName "$($row.Prefix) is a /$($smallest[0].Length); $purpose needs /$needed or larger$(if ($subnetName -ieq 'GatewaySubnet') { ' (recommended: coexisting VPN and ExpressRoute gateways, more connections)' })." "Resize $subnetName to /$needed or larger." $row.SubnetId } } if ($subnetName -ieq 'GatewaySubnet' -and $nsgId) { & $finding 'High' 'Connectivity' 'NSG on GatewaySubnet' $name $subnetName "NSG $(& $leaf $nsgId) is associated with the GatewaySubnet, which isn't supported: the VPN or ExpressRoute gateway may stop working." 'Remove the NSG from GatewaySubnet.' $row.SubnetId } if ($subnetName -ieq 'GatewaySubnet' -and $defaultRoute) { & $finding 'High' 'Connectivity' 'Default route on GatewaySubnet' $name $subnetName "Route table $(& $leaf $routeTableId) sends 0.0.0.0/0 to $defaultHop from the GatewaySubnet, which isn't supported." 'Remove the 0.0.0.0/0 route from the GatewaySubnet route table.' $row.SubnetId } if (-not $nsgId -and -not $purpose -and ($computeNics.Count -or $peCount)) { & $finding 'Medium' 'Security' 'Subnet without an NSG' $name $subnetName "$($computeNics.Count) NIC(s) and $peCount private endpoint(s) in it, with no network security group: all traffic the platform allows reaches them." 'Associate an NSG with the subnet (deny by default, allow what the workload needs).' $row.SubnetId } elseif (-not $nsgId -and -not $purpose -and -not $delegations.Count) { & $finding 'Low' 'Security' 'Subnet without an NSG' $name $subnetName 'Nothing is in it yet, but whatever is deployed there will have no network security group.' 'Associate an NSG before workloads are added (Azure Policy can require it).' $row.SubnetId } if ($withPublicIp.Count) { & $finding 'Medium' 'Security' 'VMs reachable on public IPs' $name $subnetName "$($withPublicIp.Count) NIC(s) have their own public IP: $(@($withPublicIp | Select-AACFirst 8 | ForEach-Object { [string]$_.Nic['name'] }) -join ', ')." 'Remove the public IPs: reach VMs through Azure Bastion or a VPN, and publish apps through a load balancer, Application Gateway or Front Door.' $row.SubnetId } if ($outbound -eq 'Default outbound access') { & $finding 'Medium' 'Security' 'Relies on default outbound access' $name $subnetName "$($computeNics.Count) NIC(s) reach the internet through Azure's implicit default outbound access - no NAT gateway, no route to a firewall, no public IP. It is being retired (new virtual networks get private subnets by default), its IP isn't yours and can change." 'Give the subnet an explicit outbound path - a NAT gateway, or a 0.0.0.0/0 route to Azure Firewall - and set it private (defaultOutboundAccess = false). Unless BGP routes from a hub (Virtual WAN, an NVA) already send it there.' $row.SubnetId } if ($peCount -and [string](& $at $sp 'privateEndpointNetworkPolicies') -in '', 'Disabled') { & $finding 'Low' 'Security' 'Private endpoint network policies off' $name $subnetName "$peCount private endpoint(s) in it, but NSGs and route tables don't apply to them (privateEndpointNetworkPolicies is Disabled)." 'Set privateEndpointNetworkPolicies to Enabled so NSG rules and user-defined routes apply to the private endpoints too.' $row.SubnetId } if ($row.IpForwardingNics -and -not @($RouteTable | Where-Object { @(& $at $_ 'routes') | Where-Object { $_ -and [string](& $at $_ 'properties', 'nextHopType') -eq 'VirtualAppliance' } }).Count) { & $finding 'Low' 'Connectivity' 'IP forwarding with no route to it' $name $subnetName "$($row.IpForwardingNics) NIC(s) forward IP traffic (a network virtual appliance), but no route table in scope sends traffic to a virtual appliance." 'Check the NVA is still needed; if so, route traffic to it with a route table.' $row.SubnetId } } # --- Address space and free ranges -------------------------------------------------------------------------- $freeBlocks = @(ConvertTo-AACCidrRange -Space $space -Used $usedPrefixes.ToArray() | Sort-Object -Property @{ Expression = { $_.Size }; Descending = $true }, Start) foreach ($block in $freeBlocks) { $free.Add((& $object 'AAC.VirtualNetworkFreeRange' ([ordered]@{ VirtualNetwork = $name; AddressSpace = $block.Space; Prefix = $block.Prefix; Size = $block.Size; ResourceId = [string]$vnet['id'] }))) } $inSubnets = & $sumOf @($usedPrefixes | ForEach-Object { ConvertTo-AACCidrRange $_ } | Where-Object { $_.Version -eq 4 -and $_.Valid }) 'Size' $usableAll = & $sumOf $vnetSubnets 'Usable' $usedAll = & $sumOf $vnetSubnets 'Used' # Overlapping address spaces with any other VNet the account can see. $overlaps = @(foreach ($other in $AllNetwork) { $otherId = & $lower $other['id'] if ($otherId -eq $vnetId) { continue } $clash = @(foreach ($mine in $spaceRanges | Where-Object { $_.Version -eq 4 -and $_.Valid }) { foreach ($theirs in @($other['prefixes']) | Where-Object { $_ } | ForEach-Object { ConvertTo-AACCidrRange $_ } | Where-Object { $_.Version -eq 4 -and $_.Valid }) { if ($mine.Start -le $theirs.End -and $theirs.Start -le $mine.End) { "$($mine.Prefix) / $($theirs.Prefix)" } } }) if ($clash.Count) { @{ Name = [string]$other['name']; Id = $otherId; Clash = $clash -join ', '; Subscription = & $subscriptionOf $otherId } } }) # --- Peerings ---------------------------------------------------------------------------------------------- $vnetPeerings = [System.Collections.Generic.List[object]]::new() foreach ($peering in @(& $at $properties 'virtualNetworkPeerings' | Where-Object { $_ })) { $pp = & $at $peering 'properties' $remoteId = & $lower (& $at $pp 'remoteVirtualNetwork', 'id') $remote = $allNetworks[$remoteId] $remoteSpace = @(& $list (& $at $pp 'remoteAddressSpace', 'addressPrefixes')) $state = [string](& $at $pp 'peeringState') $sync = [string](& $at $pp 'peeringSyncLevel') $remoteLocation = if ($remote) { [string]$remote['location'] } else { '' } $reverse = if ($remote) { @($remote['peers'] | Where-Object { (& $lower $_) -eq $vnetId }).Count -gt 0 } else { $null } $isHub = $remoteId -match '/virtualhubs/' -or (& $leaf $remoteId) -like 'HV_*' $row = & $object 'AAC.VirtualNetworkPeering' ([ordered]@{ VirtualNetwork = $name Peering = [string]$peering['name'] RemoteVirtualNetwork = $(if ($remote) { [string]$remote['name'] } else { & $leaf $remoteId }) RemoteSubscription = & $subscriptionOf $remoteId RemoteLocation = $remoteLocation State = $state Sync = $sync Global = [bool]($remoteLocation -and $remoteLocation -ne [string]$vnet['location']) AllowVirtualNetworkAccess = [bool](& $at $pp 'allowVirtualNetworkAccess') AllowForwardedTraffic = [bool](& $at $pp 'allowForwardedTraffic') AllowGatewayTransit = [bool](& $at $pp 'allowGatewayTransit') UseRemoteGateways = [bool](& $at $pp 'useRemoteGateways') RemoteAddressSpace = $remoteSpace -join ', ' SubnetPeering = $(if ($false -eq (& $at $pp 'peerCompleteVnets')) { "local $(@(& $at $pp 'localSubnetNames') -join ', ') <-> remote $(@(& $at $pp 'remoteSubnetNames') -join ', ')" } else { '' }) RemoteVisible = [bool]$remote ReversePeering = $reverse VirtualWanHub = $isHub RemoteVirtualNetworkId = $remoteId VirtualNetworkId = [string]$vnet['id'] }) $vnetPeerings.Add($row) $peerings.Add($row) $remoteName = $row.RemoteVirtualNetwork if ($state -and $state -ne 'Connected') { & $finding 'High' 'Connectivity' "Peering $state" $name $row.Peering "The peering to $remoteName is $state$(if ($state -eq 'Initiated') { ': the remote network has no peering back' }) - no traffic flows between the two networks." $(if ($state -eq 'Initiated') { "Create the peering from $remoteName back to $name." } else { 'Delete and recreate the peering on both sides (the remote side was deleted or changed).' }) $vnetId } if ($sync -and $sync -ne 'FullyInSync') { & $finding 'Medium' 'Connectivity' 'Peering out of sync' $name $row.Peering "The peering to $remoteName is $($sync): an address space changed and the peering wasn't synced - the new ranges aren't reachable across it." 'Sync the peering (Sync in the portal, or az network vnet peering sync) on the side that is out of sync.' $vnetId } if ($row.UseRemoteGateways -and -not $row.AllowForwardedTraffic) { & $finding 'Low' 'Connectivity' 'Gateway transit without forwarded traffic' $name $row.Peering "The peering uses $remoteName's gateway, but doesn't allow forwarded traffic - traffic an NVA or firewall in the hub forwards is dropped." 'Allow forwarded traffic on the peering if the hub routes traffic through a firewall or NVA.' $vnetId } if ($row.Global) { & $finding 'Info' 'Cost' 'Global peering' $name $row.Peering "$name ($([string]$vnet['location'])) peers with $remoteName ($remoteLocation): inbound and outbound data across regions is charged per GB." 'Expected for multi-region designs; check the data volume in Cost Management.' $vnetId } if (-not $row.RemoteVisible -and -not $isHub) { & $finding 'Info' 'Connectivity' 'Remote network not visible' $name $row.Peering "$remoteName is in a subscription or tenant this account can't read: its state and address space can't be checked from here." 'Check it from an account that can read the remote subscription.' $vnetId } } # --- VNet findings ------------------------------------------------------------------------------------------ # The public IPs in this network: on NICs, gateways, firewalls and Bastion hosts. $publicIds = @(@($vnetSubnets | ForEach-Object { $nicsBySubnet[$_.SubnetId.ToLowerInvariant()] } | Where-Object { $_ -and $_.PublicIp -and -not $_.PrivateEndpoint } | ForEach-Object { $_.PublicIp }) + @(@($gateways) + @($firewalls) + @($bastions) | Where-Object { $_ } | ForEach-Object { @(& $at $_ 'ipConfigurations') } | Where-Object { $_ } | ForEach-Object { & $lower (& $at $_ 'properties', 'publicIPAddress', 'id') }) | Where-Object { $_ } | Select-Object -Unique) $vnetPublicIps = [Math]::Max($publicIds.Count, $publicIpCount) $ipProtected = @($publicIds | Where-Object { $publicIps.Contains($_) -and [string](& $at $publicIps[$_] 'protectionMode') -eq 'Enabled' }).Count $computeAll = [int](& $sumOf $vnetSubnets 'Nics') $peAll = [int](& $sumOf $vnetSubnets 'PrivateEndpoints') if (-not $vnetSubnets.Count) { & $finding 'Low' 'Operations' 'Virtual network with no subnets' $name '' 'It has an address space but no subnet - nothing can be deployed in it.' 'Add subnets, or delete the virtual network if it is no longer used.' $vnetId } elseif (-not $usedAll -and -not $vnetPeerings.Count) { & $finding 'Low' 'Operations' 'Unused virtual network' $name '' 'No IP configuration in any subnet and no peering.' 'Delete it if nothing is planned for it: it holds address space.' $vnetId } if ($total -and -not $freeBlocks.Count) { & $finding 'Low' 'Capacity' 'No free address space' $name '' "Subnets cover all of $($space -join ', '): no subnet can be added." 'Add an address range to the virtual network (peerings then need a sync).' $vnetId } foreach ($overlap in $overlaps | Select-AACFirst 10) { $peered = @($vnetPeerings | Where-Object { $_.RemoteVirtualNetworkId -eq $overlap.Id }).Count if (-not $peered) { & $finding 'Low' 'Connectivity' 'Overlapping address space' $name $overlap.Name "$($overlap.Clash) overlap with $($overlap.Name) ($($overlap.Subscription)): the two networks can't be peered or routed to each other." 'Plan non-overlapping ranges (an IPAM or IP plan); re-address one of them if they ever need to connect.' $vnetId } } if ($vnetPublicIps -and -not $ddosOn) { & $finding 'Medium' 'Security' 'No DDoS Network Protection' $name '' "$vnetPublicIps public IP(s) in the network are covered by DDoS infrastructure protection only$(if ($ipProtected) { " ($ipProtected with DDoS IP Protection)" })." 'Enable Azure DDoS Network Protection on the virtual network (a plan can cover many networks), or DDoS IP Protection on each public IP.' $vnetId } if (($computeAll -or $peAll) -and -not $vnetFlowLog -and -not @($vnetSubnets | Where-Object { $_.FlowLogs -in 'Subnet flow log', 'VNet flow log' }).Count) { & $finding 'Medium' 'Operations' 'No virtual network flow logs' $name '' "Traffic in the network isn't logged$(if (@($vnetSubnets | Where-Object FlowLogs -EQ 'NSG flow log').Count) { ' except by NSG flow logs, which retire on 30 September 2027' })." 'Enable virtual network flow logs (Network Watcher) with Traffic Analytics.' $vnetId } if ($computeAll -and -not ($encryption -and [bool](& $at $encryption 'enabled'))) { & $finding 'Low' 'Security' 'Virtual network encryption off' $name '' 'Traffic between VMs in the network (and across peerings) isn''t encrypted by the network.' 'Enable virtual network encryption (supported VM sizes encrypt traffic between them with DTLS).' $vnetId } if ($dns.Count -eq 1) { & $finding 'Low' 'Resilience' 'Single custom DNS server' $name '' "Every VM resolves names through $($dns[0]) alone: when it is down, name resolution fails." 'Configure at least two DNS servers (or Azure DNS Private Resolver).' $vnetId } if ($peAll -and -not $dns.Count) { $endpointsHere = @($PrivateEndpoint | Where-Object { (& $vnetOf (& $at $_ 'subnet')) -eq $vnetId }) $unlinked = @(foreach ($endpoint in $endpointsHere) { foreach ($group in @($endpoint['groupIds'])) { $zone = $privateZones[([string]$group).ToLowerInvariant()]; if ($zone -and $zoneNames -notcontains $zone) { "$([string]$endpoint['name']) ($($group): $zone)" } } }) if ($unlinked.Count) { & $finding 'Medium' 'Connectivity' 'Private endpoints without their private DNS zone' $name '' "The network uses Azure DNS, but isn't linked to the private DNS zone of $($unlinked.Count) private endpoint(s): $(@($unlinked | Select-AACFirst 6) -join '; ')$(if ($unlinked.Count -gt 6) { '; ...' }). Their names resolve to public IPs." 'Link the privatelink zones to the virtual network (or to the hub that resolves for it), with a DNS zone group on each private endpoint.' $vnetId } } if ($computeAll -and -not $bastions.Count -and -not @($vnetPeerings | Where-Object { $_.UseRemoteGateways -or $_.AllowForwardedTraffic }).Count -and @($vnetSubnets | Where-Object PublicIpNics).Count) { & $finding 'Info' 'Security' 'No Azure Bastion' $name '' 'VMs here are reached on public IPs, and there is no Azure Bastion in this network.' 'Use Azure Bastion (here or in a peered hub) to reach VMs without public IPs.' $vnetId } foreach ($gw in $gateways) { $sku = [string](& $at $gw 'sku') if ($sku -eq 'Basic') { & $finding 'Medium' 'Resilience' 'Basic VPN gateway' $name ([string]$gw['name']) 'The Basic SKU has no zone redundancy, no BGP, limited tunnels and throughput, and no ExpressRoute coexistence.' 'Move to a VpnGw1AZ (or larger AZ) SKU.' ([string]$gw['id']) } elseif ($sku -and $sku -notmatch 'AZ$' -and $sku -ne 'Standard' -and $sku -notmatch '^ErGw') { & $finding 'Low' 'Resilience' 'Gateway not zone-redundant' $name ([string]$gw['name']) "$sku is not a zone-redundant SKU: a zone outage takes the gateway down." "Move to the AZ SKU ($($sku)AZ) where the region has zones." ([string]$gw['id']) } } $role = if ($gateways.Count -or $firewalls.Count -or @($vnetPeerings | Where-Object AllowGatewayTransit).Count) { 'Hub' } elseif (@($vnetPeerings | Where-Object { $_.UseRemoteGateways -or $_.VirtualWanHub }).Count) { 'Spoke' } elseif ($vnetPeerings.Count) { 'Peered' } else { 'Standalone' } $vnets.Add((& $object 'AAC.VirtualNetwork' ([ordered]@{ Name = $name Role = $role Location = [string]$vnet['location'] ResourceGroup = [string]$vnet['resourceGroup'] SubscriptionName = & $subscriptionOf $vnetId AddressSpace = $space -join ', ' TotalIps = $total IpsInSubnets = $inSubnets UnallocatedIps = [Math]::Max(0, $total - $inSubnets) UsableIps = $usableAll UsedIps = $usedAll AvailableIps = [Math]::Max(0, $usableAll - $usedAll) UsedPercent = $(if ($usableAll) { [Math]::Round($usedAll / $usableAll * 100, 1) } else { $null }) SubnetCount = $vnetSubnets.Count FreeRanges = @($freeBlocks | Select-AACFirst 6 | ForEach-Object { $_.Prefix }) -join ', ' PeeringCount = $vnetPeerings.Count PeeringsNotConnected = @($vnetPeerings | Where-Object { $_.State -ne 'Connected' }).Count DnsServers = $(if ($dns.Count) { $dns -join ', ' } else { 'Azure-provided' }) PrivateDnsZones = @($zoneNames | Sort-Object) -join ', ' DnsResolvers = @($resolvers | ForEach-Object { [string]$_['name'] }) -join ', ' DdosProtection = $(if ($ddosOn) { "Network Protection ($(& $leaf $planId))" } elseif ($ipProtected) { "IP Protection on $ipProtected IP(s)" } else { 'Infrastructure only' }) Encryption = $(if ($encryption -and [bool](& $at $encryption 'enabled')) { "On ($(& $at $encryption 'enforcement'))" } else { 'Off' }) FlowLogs = $(if ($vnetFlowLog) { "VNet flow log$(if ([bool]$vnetFlowLog['analytics']) { ', Traffic Analytics' })" } else { 'None' }) Gateways = @($gateways | ForEach-Object { "$([string]$_['name']) ($([string](& $at $_ 'gatewayType')) $([string](& $at $_ 'sku')))" }) -join ', ' Firewalls = @($firewalls | ForEach-Object { "$([string]$_['name']) ($([string](& $at $_ 'tier')))" }) -join ', ' Bastions = @($bastions | ForEach-Object { "$([string]$_['name']) ($([string](& $at $_ 'sku')))" }) -join ', ' Nics = $computeAll PrivateEndpoints = $peAll PublicIps = $vnetPublicIps Overlaps = @($overlaps | ForEach-Object { $_.Name }) -join ', ' FlowTimeoutMinutes = & $at $properties 'flowTimeoutInMinutes' BgpCommunity = [string](& $at $properties 'bgpCommunities', 'virtualNetworkCommunity') Tags = $(if ($vnet['tags'] -is [System.Collections.IDictionary] -and $vnet['tags'].Count) { @($vnet['tags'].Keys | Sort-Object | ForEach-Object { "$_=$($vnet['tags'][$_])" }) -join '; ' } else { '' }) ProvisioningState = [string](& $at $properties 'provisioningState') ResourceId = [string]$vnet['id'] High = 0; Medium = 0; Low = 0 }))) # Private endpoints, for the report. foreach ($endpoint in @($PrivateEndpoint | Where-Object { (& $vnetOf (& $at $_ 'subnet')) -eq $vnetId })) { $groups = @($endpoint['groupIds'] | Where-Object { $_ }) $zones = @($groups | ForEach-Object { $privateZones[([string]$_).ToLowerInvariant()] } | Where-Object { $_ }) $endpoints.Add((& $object 'AAC.VirtualNetworkPrivateEndpoint' ([ordered]@{ VirtualNetwork = $name; Subnet = & $leaf (& $at $endpoint 'subnet'); PrivateEndpoint = [string]$endpoint['name']; Target = & $leaf $endpoint['target']; TargetType = $(if ([string]$endpoint['target'] -match '/providers/([^/]+/[^/]+)/') { $Matches[1] } else { '' }) Groups = $groups -join ', '; Status = [string]$endpoint['status']; ZoneLinked = $(if ($dns.Count) { 'Custom DNS' } elseif (-not $zones.Count) { '' } elseif (@($zones | Where-Object { $zoneNames -contains $_ }).Count -eq $zones.Count) { 'Yes' } else { 'No' }) ResourceId = [string]$endpoint['id']; VirtualNetworkId = [string]$vnet['id'] }))) if ([string]$endpoint['status'] -and [string]$endpoint['status'] -ne 'Approved') { & $finding 'Medium' 'Connectivity' "Private endpoint $([string]$endpoint['status'])" $name ([string]$endpoint['name']) "The connection to $(& $leaf $endpoint['target']) is $([string]$endpoint['status']): no traffic flows through the private endpoint." 'Approve the connection on the target resource (or remove the endpoint).' ([string]$endpoint['id']) } } } # --- NSGs: the NSG assessment's findings, on the networks they apply to ------------------------------------------- if ($NsgAssessment) { foreach ($item in @($NsgAssessment.Findings)) { $nsg = ([string]$item.Nsg) $where = @($subnets | Where-Object { $_.Nsg -eq $nsg } | ForEach-Object { $_.VirtualNetwork } | Select-Object -Unique) $severity = if ([string]$item.Severity -in 'High', 'Medium', 'Low') { [string]$item.Severity } else { 'Info' } & $finding $severity 'Security' "NSG: $($item.Check)" ($where -join ', ') "$nsg$(if ($item.Rule) { " / $($item.Rule)" })" ([string]$item.Detail) ([string]$item.Recommendation) ([string]$item.NsgId) 'NSG' } } # --- ASGs: who is in them, which rules name them ---------------------------------------------------------------- $asgRules = @{} foreach ($nsg in $NetworkSecurityGroup) { foreach ($rule in @(& $at $nsg 'properties', 'securityRules' | Where-Object { $_ })) { foreach ($side in 'sourceApplicationSecurityGroups', 'destinationApplicationSecurityGroups') { foreach ($asg in @(& $at $rule 'properties', $side | Where-Object { $_ })) { $id = & $lower (& $at $asg 'id') if (-not $asgRules.Contains($id)) { $asgRules[$id] = [System.Collections.Generic.List[string]]::new() } $asgRules[$id].Add("$([string]$nsg['name'])/$([string]$rule['name']) ($(if ($side -like 'source*') { 'source' } else { 'destination' }))") } } } } $asgs = @(foreach ($asg in $ApplicationSecurityGroup) { $id = & $lower $asg['id'] $members = @(if ($asgUse.Contains($id)) { $asgUse[$id] }) $rules = @(if ($asgRules.Contains($id)) { $asgRules[$id] }) $networks = @($members | ForEach-Object { & $leaf $_.VirtualNetwork } | Select-Object -Unique) if (-not $members.Count -and -not $rules.Count -and -not @($networks | Where-Object { $_ }).Count) { & $finding 'Low' 'Operations' 'Unused application security group' '' ([string]$asg['name']) 'No NIC is in it and no NSG rule names it.' 'Delete it if it is no longer needed.' ([string]$asg['id']) } elseif ($rules.Count -and -not $members.Count) { & $finding 'Low' 'Security' 'NSG rules name an empty application security group' '' ([string]$asg['name']) "$($rules.Count) rule(s) use it - $(@($rules | Select-AACFirst 4) -join ', ') - but no NIC is in it, so they match nothing." 'Add the intended NICs to the ASG, or remove the rules.' ([string]$asg['id']) } & $object 'AAC.ApplicationSecurityGroupUse' ([ordered]@{ Asg = [string]$asg['name']; ResourceGroup = [string]$asg['resourceGroup']; Location = [string]$asg['location']; SubscriptionName = & $subscriptionOf $id Nics = $members.Count; Members = @($members | ForEach-Object { if ($_.Vm) { "$($_.Nic) ($($_.Vm))" } else { $_.Nic } } | Select-Object -Unique) -join ', ' VirtualNetworks = $networks -join ', '; Rules = $rules.Count; RuleNames = $rules -join ', '; ResourceId = [string]$asg['id'] }) }) # --- Severity counts, order --------------------------------------------------------------------------------------- $rank = @{ High = 0; Medium = 1; Low = 2; Info = 3 } $sorted = @($findings | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, Category, VirtualNetwork, Finding) foreach ($row in $vnets) { # Its own findings by resource ID (names repeat across subscriptions); # gateways, private endpoints and NSGs by the network's name. $prefix = "$($row.ResourceId.ToLowerInvariant())/" $mine = @($sorted | Where-Object { ($_.ResourceId.ToLowerInvariant() + '/').StartsWith($prefix) -or (@(([string]$_.VirtualNetwork) -split ',\s*') -contains $row.Name -and -not $_.ResourceId.ToLowerInvariant().Contains('/microsoft.network/virtualnetworks/')) }) $row.High = @($mine | Where-Object Severity -EQ 'High').Count $row.Medium = @($mine | Where-Object Severity -EQ 'Medium').Count $row.Low = @($mine | Where-Object Severity -EQ 'Low').Count $id = $row.ResourceId $row | Add-Member -NotePropertyMembers ([ordered]@{ Subnets = @($subnets | Where-Object { $_.SubnetId.ToLowerInvariant().StartsWith("$($id.ToLowerInvariant())/") }) Peerings = @($peerings | Where-Object { $_.VirtualNetworkId -eq $id }) FreeRangeList = @($free | Where-Object { $_.ResourceId -eq $id }) PrivateEndpointList = @($endpoints | Where-Object { $_.VirtualNetworkId -eq $id }) Findings = $mine }) } $sum = $sumOf $stats = [ordered]@{ VirtualNetworks = $vnets.Count Subnets = $subnets.Count TotalIps = & $sum $vnets 'TotalIps' UsableIps = & $sum $vnets 'UsableIps' UsedIps = & $sum $vnets 'UsedIps' AvailableIps = & $sum $vnets 'AvailableIps' UnallocatedIps = & $sum $vnets 'UnallocatedIps' UsedPercent = $(if ((& $sum $vnets 'UsableIps')) { [Math]::Round((& $sum $vnets 'UsedIps') / (& $sum $vnets 'UsableIps') * 100, 1) } else { $null }) FullSubnets = @($subnets | Where-Object { $null -ne $_.UsedPercent -and $_.UsedPercent -ge 80 }).Count Peerings = $peerings.Count PeeringProblems = @($peerings | Where-Object { $_.State -ne 'Connected' -or ($_.Sync -and $_.Sync -ne 'FullyInSync') }).Count SubnetsWithoutNsg = @($subnets | Where-Object { -not $_.Nsg -and $_.Purpose -notin 'Gateway', 'Azure Firewall', 'Azure Firewall management', 'Route Server' }).Count PrivateEndpoints = $endpoints.Count Asgs = $asgs.Count High = @($sorted | Where-Object Severity -EQ 'High').Count Medium = @($sorted | Where-Object Severity -EQ 'Medium').Count Low = @($sorted | Where-Object Severity -EQ 'Low').Count Info = @($sorted | Where-Object Severity -EQ 'Info').Count } @{ VirtualNetworks = @($vnets | Sort-Object -Property @{ Expression = { $_.High }; Descending = $true }, @{ Expression = { $_.Medium }; Descending = $true }, Name) Subnets = $subnets.ToArray() Peerings = $peerings.ToArray() FreeRanges = $free.ToArray() Asgs = $asgs PrivateEndpoints = $endpoints.ToArray() Findings = $sorted Nsg = $NsgAssessment Stats = $stats } } |