Public/Invoke-AACVirtualNetworkAssessment.ps1
|
function Invoke-AACVirtualNetworkAssessment { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Assesses virtual networks: address space and available IPs, every subnet, peerings, NSGs and application security groups, DNS, DDoS, flow logs, outbound access and gateways - with findings and what to do, a Spectre.Console view, objects, and CSV, PDF and interactive HTML reports. .DESCRIPTION Reads, with Azure Resource Graph - read-only, Reader is enough, no Az modules - the virtual networks and everything in or around them: network interfaces, route tables, NAT gateways, network security groups, application security groups, public IPs, private endpoints, private DNS zone links, DNS resolvers, VPN and ExpressRoute gateways, Azure Firewalls, Bastion hosts and Network Watcher flow logs. Without parameters it assesses every virtual network the account can see; -SubscriptionId, -ManagementGroupId, -ResourceGroupName and -Name narrow it. For each virtual network: Metadata name, role (hub, spoke, peered, standalone), location, resource group, subscription, tags, provisioning state, flow timeout, BGP community Address space its prefixes; total IPs, IPs in subnets, unallocated IPs; usable (Azure keeps 5 per subnet), used and available IPs; the free CIDR blocks a new subnet can take; overlaps with any other network you can see Subnets prefix, usable / used / available IPs and % used, purpose (gateway, firewall, Bastion, Route Server, delegated, private endpoints, workloads), NSG, route table and its default route, BGP propagation, NAT gateway, the outbound path (NAT gateway, firewall or NVA, forced tunnelling, public IPs, private subnet, default outbound access), service endpoints and policies, delegations, private endpoints and their network policies, NICs, VMs, NICs with public IPs or IP forwarding, flow logs Peerings state, sync, remote network / subscription / region, global, the allow* flags, gateway transit, remote address space, subnet peering, reverse peering Security DNS servers and private DNS zone links, DDoS protection, virtual network encryption, flow logs, Firewall and Bastion, private endpoints, ASGs (who is in each, which rules name it) - and the NSGs on its subnets, assessed rule by rule as Get-AACNetworkSecurityGroup does Findings, each with a severity, the network and item, and what to do: High a full subnet (or 95% used); a peering not Connected; an NSG or a 0.0.0.0/0 route on GatewaySubnet; Bastion, Firewall or Route Server subnets too small; an NSG rule open to the internet on a management or database port Medium a subnet 80% used; a peering out of sync; a subnet with workloads and no NSG; VMs with public IPs; default outbound access (being retired); public IPs without DDoS Network Protection; no virtual network flow logs; private endpoints whose privatelink zone isn't linked; private endpoint connections not approved; a Basic VPN gateway; a GatewaySubnet under /27 Low oversized or empty subnets; no free address space; overlapping address spaces; encryption off; a single DNS server; private endpoint network policies off; IP forwarding with no route to it; empty or unused ASGs; gateways that aren't zone-redundant Info global peering (charged per GB); a remote network you can't read; no Bastion where VMs have public IPs What you get depends on where the command runs: at the prompt tiles, the networks with their IP capacity and risk, the High and Medium findings, and - for up to three networks - each in detail with its subnets, peerings and free ranges, a page at a time piped onward the AAC.VirtualNetwork objects (each with its Subnets, Peerings, FreeRangeList, PrivateEndpointList and Findings), with no view -PassThru the view and the objects -NoDisplay the objects only -CsvPath writes every subnet to CSV. -HtmlPath writes an interactive report - tiles, charts and tables of networks, subnets, peerings, free ranges, findings, private endpoints, ASGs and NSG rules, each collapsible and downloadable as CSV. -PdfPath writes a PDF with a section per network. With any of them, the console shows only the progress and the files written. .PARAMETER SubscriptionId Only the virtual networks in these subscriptions. .PARAMETER ManagementGroupId Only the virtual networks under these management groups. .PARAMETER ResourceGroupName Only the virtual networks in these resource groups. .PARAMETER Name Only these virtual networks; wildcards work, e.g. 'vnet-hub-*'. .PARAMETER CsvPath Write every subnet to this CSV file. .PARAMETER PdfPath Write a PDF report to this file. .PARAMETER HtmlPath Write an interactive HTML report to this file. .PARAMETER Title The PDF and HTML reports' title. .PARAMETER PassThru Show the view and also return the objects. .PARAMETER NoDisplay Return the objects without showing the view. .PARAMETER NoPaging Show the whole view at once instead of a page at a time. .EXAMPLE Connect-AAC Invoke-AACVirtualNetworkAssessment Every virtual network the account can see, assessed. .EXAMPLE Invoke-AACVirtualNetworkAssessment -SubscriptionId 00000000-0000-0000-0000-000000000000 -Name 'vnet-hub-*', 'vnet-spoke-app' Some networks in detail: subnets, peerings, free ranges and findings. .EXAMPLE Invoke-AACVirtualNetworkAssessment -ManagementGroupId 'mg-landingzones' -HtmlPath .\out\VNet.html -PdfPath .\out\VNet.pdf -CsvPath .\out\Subnets.csv A landing zone's networks as an interactive HTML report, a PDF and a CSV of every subnet. .EXAMPLE (Invoke-AACVirtualNetworkAssessment -NoDisplay).Subnets | Where-Object UsedPercent -GE 80 | Sort-Object UsedPercent -Descending The subnets running out of IPs. .EXAMPLE Invoke-AACVirtualNetworkAssessment -Name 'vnet-spoke-app' -NoDisplay | Select-Object -ExpandProperty FreeRangeList Where a new subnet fits. .OUTPUTS AAC.VirtualNetwork (piped onward, or with -PassThru or -NoDisplay) #> [CmdletBinding()] [OutputType('AAC.VirtualNetwork')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [ValidateNotNullOrEmpty()] [string[]] $ManagementGroupId, [ValidateNotNullOrEmpty()] [string[]] $ResourceGroupName, [SupportsWildcards()] [ValidateNotNullOrEmpty()] [string[]] $Name, [string] $CsvPath, [string] $PdfPath, [string] $HtmlPath, [string] $Title = 'Virtual network assessment', [switch] $PassThru, [switch] $NoDisplay, [switch] $NoPaging ) # A failure anywhere below ends as a Spectre.Console error panel and this # command's own terminating error, not a line inside the module. A stopped # pipeline (Select-Object -First, Ctrl+C) is no failure: just return - a # rethrow would stop the caller's whole script, not only this command. trap { if ($_.Exception -is [System.Management.Automation.PipelineStoppedException]) { return }; $PSCmdlet.ThrowTerminatingError((Show-AACError -ErrorRecord $_ -Cmdlet $PSCmdlet)) } $pipedOnward = $MyInvocation.PipelinePosition -lt $MyInvocation.PipelineLength $interactive = -not $NoDisplay -and -not $pipedOnward $showView = $interactive -and -not ($CsvPath -or $PdfPath -or $HtmlPath) $returnObjects = $PassThru -or $NoDisplay -or $pipedOnward $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } } $csvFullPath = & $resolve $CsvPath $pdfFullPath = & $resolve $PdfPath $htmlFullPath = & $resolve $HtmlPath $quote = { param([string] $Text) "'" + ($Text -replace '\\', '\\' -replace "'", "\'") + "'" } # Read here, not inside the progress block (it runs in Invoke-AACProgress's scope). $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }) ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }) GroupFilter = $(if ($ResourceGroupName) { " | where resourceGroup in~ ($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' }) Name = @($Name | Where-Object { $_ }) ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }) Title = $Title } $at = { param($Value, [string[]] $Keys) foreach ($key in $Keys) { if ($Value -is [System.Collections.IDictionary]) { $Value = $Value[$key] } else { return $null } } $Value } if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Virtual network assessment' -Color 'deepskyblue3_1' } $state = Invoke-AACProgress -ScriptBlock { $null = Get-AACAccessToken # --- The networks in scope ---------------------------------------------------------------------------------- Update-AACProgress -Id 'vnets' -Description 'Finding the virtual networks' -Indeterminate $scope = @{} if ($request.SubscriptionId.Count) { $scope['SubscriptionId'] = $request.SubscriptionId } if ($request.ManagementGroupId.Count) { $scope['ManagementGroupId'] = $request.ManagementGroupId } $found = Invoke-AACGraphBatch @scope -Query ([ordered]@{ vnets = "resources | where type =~ 'microsoft.network/virtualnetworks'$($request.GroupFilter) | project id, name, resourceGroup, subscriptionId, location, tags, properties" }) $vnets = @($found.Rows['vnets'] | Where-Object { $_ }) if ($request.Name.Count) { $vnets = @($vnets | Where-Object { $vnetName = [string]$_['name']; @($request.Name | Where-Object { $vnetName -like $_ }).Count }) $missing = @($request.Name | Where-Object { $pattern = $_; -not @($vnets | Where-Object { [string]$_['name'] -like $pattern }).Count }) if ($missing.Count -eq $request.Name.Count) { $problem = [System.InvalidOperationException]::new("No virtual network named $(($missing | ForEach-Object { "'$_'" }) -join ', ') was found$(if ($request.SubscriptionId.Count -or $request.ManagementGroupId.Count -or $request.GroupFilter) { ' in that scope' } else { ' in any subscription you can see' }).") $problem.Data['AACHint'] = 'Wildcards work: -Name ''vnet-hub-*''.' throw $problem } foreach ($item in $missing) { Write-Warning "No virtual network named '$item' was found; it's left out." } } if (-not $vnets.Count) { $problem = [System.InvalidOperationException]::new('No virtual network was found in that scope.') $problem.Data['AACHint'] = 'Check the scope (-SubscriptionId, -ManagementGroupId, -ResourceGroupName) and that your account has Reader on it.' throw $problem } # The networks' subscriptions: their NICs, gateways, endpoints and NSGs are there. $subscriptions = @($vnets | ForEach-Object { ([string]$_['subscriptionId']).ToLowerInvariant() } | Select-Object -Unique) Update-AACProgress -Id 'vnets' -Complete -Description ('{0:N0} virtual network(s) in {1:N0} subscription(s)' -f $vnets.Count, $subscriptions.Count) # --- What is in and around them ------------------------------------------------------------------------------- $ipConfigurations = 'ipConfigurations = properties.ipConfigurations' $inScope = [ordered]@{ nics = "resources | where type =~ 'microsoft.network/networkinterfaces' | project id, name, resourceGroup, nsg = tolower(tostring(properties.networkSecurityGroup.id)), vm = tolower(tostring(properties.virtualMachine.id)), ipForwarding = tobool(properties.enableIPForwarding), privateEndpoint = tolower(tostring(properties.privateEndpoint.id)), ipConfigurations = properties.ipConfigurations" nsgs = "resources | where type =~ 'microsoft.network/networksecuritygroups' | project id, name, resourceGroup, subscriptionId, location, tags, properties" routeTables = "resources | where type =~ 'microsoft.network/routetables' | project id, name, resourceGroup, routes = properties.routes, disableBgpRoutePropagation = tobool(properties.disableBgpRoutePropagation)" natGateways = "resources | where type =~ 'microsoft.network/natgateways' | project id, name, resourceGroup, zones, publicIpAddresses = properties.publicIpAddresses, publicIpPrefixes = properties.publicIpPrefixes" asgs = "resources | where type =~ 'microsoft.network/applicationsecuritygroups' | project id, name, resourceGroup, location" publicIps = "resources | where type =~ 'microsoft.network/publicipaddresses' | project id, name, ipAddress = tostring(properties.ipAddress), sku = tostring(sku.name), protectionMode = tostring(properties.ddosSettings.protectionMode), attachedTo = tolower(tostring(properties.ipConfiguration.id))" privateEndpoints = "resources | where type =~ 'microsoft.network/privateendpoints' | extend connection = coalesce(properties.privateLinkServiceConnections[0], properties.manualPrivateLinkServiceConnections[0]) | project id, name, resourceGroup, subnet = tolower(tostring(properties.subnet.id)), target = tostring(connection.properties.privateLinkServiceId), groupIds = connection.properties.groupIds, status = tostring(connection.properties.privateLinkServiceConnectionState.status)" flowLogs = "resources | where type =~ 'microsoft.network/networkwatchers/flowlogs' | extend analytics = properties.flowAnalyticsConfiguration.networkWatcherFlowAnalyticsConfiguration | project id, name, target = tolower(tostring(properties.targetResourceId)), enabled = tobool(properties.enabled), retentionEnabled = tobool(properties.retentionPolicy.enabled), retentionDays = toint(properties.retentionPolicy.days), storageId = tostring(properties.storageId), analytics = tobool(analytics.enabled), workspace = tostring(analytics.workspaceResourceId), interval = toint(analytics.trafficAnalyticsInterval), version = toint(properties.format.version)" gateways = "resources | where type =~ 'microsoft.network/virtualnetworkgateways' | project id, name, resourceGroup, gatewayType = tostring(properties.gatewayType), vpnType = tostring(properties.vpnType), sku = tostring(properties.sku.name), activeActive = tobool(properties.activeActive), enableBgp = tobool(properties.enableBgp), $ipConfigurations" firewalls = "resources | where type =~ 'microsoft.network/azurefirewalls' | project id, name, resourceGroup, tier = tostring(properties.sku.tier), zones, threatIntelMode = tostring(properties.threatIntelMode), $ipConfigurations" bastions = "resources | where type =~ 'microsoft.network/bastionhosts' | project id, name, resourceGroup, sku = tostring(sku.name), $ipConfigurations" dnsResolvers = "resources | where type =~ 'microsoft.network/dnsresolvers' | project id, name, resourceGroup, vnet = tolower(tostring(properties.virtualNetwork.id))" } # Every network and private DNS zone link the account can see: remote # peerings, overlapping address spaces, zones linked from a hub. $everywhere = [ordered]@{ subscriptions = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name" allVnets = "resources | where type =~ 'microsoft.network/virtualnetworks' | project id, name, subscriptionId, location, prefixes = properties.addressSpace.addressPrefixes, peerings = properties.virtualNetworkPeerings" dnsLinks = "resources | where type =~ 'microsoft.network/privatednszones/virtualnetworklinks' | project id, zone = tolower(tostring(split(id, '/')[8])), vnet = tolower(tostring(properties.virtualNetwork.id)), registration = tobool(properties.registrationEnabled), state = tostring(properties.virtualNetworkLinkState)" } $total = $inScope.Count + $everywhere.Count Update-AACProgress -Id 'read' -Total $total -Description 'Reading NICs, route tables, NSGs, ASGs, endpoints, gateways and flow logs' $onProgress = { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $Name" } $scoped = Invoke-AACGraphBatch -SubscriptionId $subscriptions -Query $inScope -AllowFailure @($inScope.Keys) -OnProgress $onProgress $shared = Invoke-AACGraphBatch -Query $everywhere -AllowFailure @($everywhere.Keys) -OnProgress $onProgress $rows = { param([string] $Key) @(if ($scoped.Rows.Contains($Key)) { $scoped.Rows[$Key] } elseif ($shared.Rows.Contains($Key)) { $shared.Rows[$Key] }) | Where-Object { $_ } } $failed = @(@($scoped.Errors.Keys) + @($shared.Errors.Keys) | Where-Object { $_ }) foreach ($key in $failed) { Write-Warning "The $key couldn't be read - the assessment carries on without them: $(if ($scoped.Errors.Contains($key)) { $scoped.Errors[$key] } else { $shared.Errors[$key] })" } Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} NIC(s), {1:N0} NSG(s), {2:N0} route table(s), {3:N0} private endpoint(s), {4:N0} network(s) to compare with' -f @(& $rows 'nics').Count, @(& $rows 'nsgs').Count, @(& $rows 'routeTables').Count, @(& $rows 'privateEndpoints').Count, @(& $rows 'allVnets').Count) # Each network's peerings as the remote network IDs (reverse peering). $allNetworks = @(foreach ($row in & $rows 'allVnets') { @{ id = [string]$row['id']; name = [string]$row['name']; subscriptionId = [string]$row['subscriptionId']; location = [string]$row['location']; prefixes = @($row['prefixes'] | Where-Object { $_ }) peers = @(@($row['peerings']) | Where-Object { $_ } | ForEach-Object { ([string](& $at $_ 'properties', 'remoteVirtualNetwork', 'id')).ToLowerInvariant() }) } }) $subscriptionNames = @{} foreach ($row in & $rows 'subscriptions') { $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name'] } # --- The NSGs on these networks, assessed as Get-AACNetworkSecurityGroup does ------------------------------ Update-AACProgress -Id 'assess' -Description 'Assessing the subnets, peerings, NSGs and security' -Indeterminate $vnetIds = @{} foreach ($vnet in $vnets) { $vnetIds[([string]$vnet['id']).ToLowerInvariant()] = $true } $subnetRows = @(foreach ($vnet in $vnets) { foreach ($subnet in @(& $at $vnet 'properties', 'subnets') | Where-Object { $_ }) { $sp = & $at $subnet 'properties' @{ subnetId = ([string]$subnet['id']).ToLowerInvariant(); name = [string]$subnet['name']; vnetId = ([string]$vnet['id']).ToLowerInvariant(); vnetName = [string]$vnet['name'] prefix = [string]$(if (& $at $sp 'addressPrefix') { & $at $sp 'addressPrefix' } else { @(& $at $sp 'addressPrefixes')[0] }) nsg = ([string](& $at $sp 'networkSecurityGroup', 'id')).ToLowerInvariant() } } }) $nics = @(& $rows 'nics' | Where-Object { @(@($_['ipConfigurations']) | Where-Object { $_ } | Where-Object { ([string](& $at $_ 'properties', 'subnet', 'id')) -match '^(.+/virtualnetworks/[^/]+)/subnets/' -and $vnetIds.Contains($Matches[1].ToLowerInvariant()) }).Count }) $nsgNics = @(foreach ($nic in $nics) { $configurations = @($nic['ipConfigurations'] | Where-Object { $_ }) @{ id = [string]$nic['id']; name = [string]$nic['name']; resourceGroup = [string]$nic['resourceGroup']; nsg = [string]$nic['nsg']; vm = [string]$nic['vm'] subnet = $(if ($configurations) { [string](& $at $configurations[0] 'properties', 'subnet', 'id') } else { '' }) ips = @($configurations | ForEach-Object { [string](& $at $_ 'properties', 'privateIPAddress') } | Where-Object { $_ }) asgs = @($configurations | ForEach-Object { @(& $at $_ 'properties', 'applicationSecurityGroups') } | Where-Object { $_ } | ForEach-Object { [string](& $at $_ 'id') }) } }) $applied = @{} foreach ($id in @($subnetRows | ForEach-Object { $_.nsg }) + @($nsgNics | ForEach-Object { $_.nsg })) { if ($id) { $applied[$id.ToLowerInvariant()] = $true } } $nsgs = @(& $rows 'nsgs' | Where-Object { $applied.Contains(([string]$_['id']).ToLowerInvariant()) }) $nsgAssessment = ConvertTo-AACNsgAssessment -NetworkSecurityGroup $nsgs -NetworkInterface $nsgNics -Subnet $subnetRows -FlowLog @(& $rows 'flowLogs') -Diagnostic $null -SubscriptionName $subscriptionNames $assessment = ConvertTo-AACVirtualNetworkAssessment -VirtualNetwork $vnets -AllNetwork $allNetworks -NetworkInterface $nics ` -RouteTable @(& $rows 'routeTables') -NatGateway @(& $rows 'natGateways') -ApplicationSecurityGroup @(& $rows 'asgs') -PublicIp @(& $rows 'publicIps') ` -PrivateEndpoint @(& $rows 'privateEndpoints') -FlowLog @(& $rows 'flowLogs') -Firewall @(& $rows 'firewalls') -Bastion @(& $rows 'bastions') ` -Gateway @(& $rows 'gateways') -DnsLink @(& $rows 'dnsLinks') -DnsResolver @(& $rows 'dnsResolvers') -NetworkSecurityGroup $nsgs ` -NsgAssessment $nsgAssessment -SubscriptionName $subscriptionNames $assessment['Failed'] = $failed $stats = $assessment.Stats Update-AACProgress -Id 'assess' -Complete -Description ('Assessed {0:N0} network(s), {1:N0} subnet(s), {2:N0} peering(s), {3:N0} NSG(s): {4} high, {5} medium, {6} low finding(s)' -f $stats.VirtualNetworks, $stats.Subnets, $stats.Peerings, $nsgs.Count, $stats.High, $stats.Medium, $stats.Low) $detail = [ordered]@{ Scope = if ($request.ManagementGroupId.Count) { "management group(s) $($request.ManagementGroupId -join ', ')" } elseif ($request.SubscriptionId.Count) { "subscription(s) $($request.SubscriptionId -join ', ')" } else { 'every subscription the account can see' } } if ($request.GroupFilter) { $detail['Resource groups'] = $request.ResourceGroupName -join ', ' } if ($request.Name.Count) { $detail['Virtual networks'] = $request.Name -join ', ' } $csvRows = @($assessment.Subnets | Select-Object -Property VirtualNetwork, Subnet, Purpose, Prefix, Size, Usable, Used, Available, UsedPercent, Nsg, RouteTable, DefaultRoute, NatGateway, Outbound, DefaultOutboundAccess, ServiceEndpoints, Delegations, PrivateEndpoints, PrivateEndpointPolicies, Nics, Vms, PublicIpNics, FlowLogs, SubscriptionName, ResourceGroup, SubnetId) $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject $csvRows -Noun 'subnet' -PdfPath $pdfFullPath -WritePdf { Write-AACVirtualNetworkPdf -Assessment $assessment -Path $pdfFullPath -Title $request.Title -Detail $detail } -HtmlPath $htmlFullPath -WriteHtml { Write-AACVirtualNetworkHtml -Assessment $assessment -Path $htmlFullPath -Title $request.Title -Detail $detail } @{ Assessment = $assessment; Scope = $detail } } if ($showView) { Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock { Show-AACVirtualNetworkView -Assessment $state.Assessment -Scope $state.Scope } } if ($returnObjects) { $state.Assessment.VirtualNetworks } } |