Public/Get-AACNetworkSecurityGroup.ps1

function Get-AACNetworkSecurityGroup {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        A detailed assessment of network security groups: what each is
        applied to, every rule, its flow logs and diagnostic settings, and the
        risks in them - with a Spectre.Console view, objects, and CSV, PDF and
        interactive HTML reports.
    .DESCRIPTION
        Reads the NSGs, the network interfaces and subnets they are applied
        to and the Network Watcher flow logs with Azure Resource Graph, and
        each NSG's diagnostic settings through Azure Resource Manager - with
        the Connect-AAC sign-in; Reader access is enough, no Az modules.
        Without parameters it assesses every NSG the account can see.
 
        For each NSG:
          Metadata name, resource ID, subscription, resource group,
                        location, tags
          Associations the subnets (VNet, prefix) and network interfaces
                        (VM, private IP) it is applied to
          Rules every rule, custom and default, in the order Azure
                        evaluates them: priority, direction, protocol,
                        source, source port, destination, destination port
                        (application security groups by name), action
          Telemetry diagnostic settings (enabled or not; Log Analytics
                        workspace, storage account, event hub; log
                        categories), flow logs (an NSG flow log, or a virtual
                        network flow log on its VNet, subnet or NIC; enabled,
                        retention, storage) and Traffic Analytics
 
        Findings, each with a severity, the rule it is about and what to do:
          High an inbound Allow from *, Internet or 0.0.0.0/0 to every
                  port or to a management or database port (SSH, RDP, WinRM,
                  SMB, Telnet, FTP, SQL, MySQL, PostgreSQL, Oracle, MongoDB,
                  Redis, VNC, Docker, ...)
          Medium a wide port range (over 100 ports) open to the internet; an
                  NSG on no subnet and no NIC; a NIC NSG and its subnet's NSG
                  that disagree - one allows what the other denies, so the
                  traffic is blocked (both are evaluated, first matching rule
                  by priority, as Azure does); applied to something with no
                  flow log, or a disabled one
          Low ICMP from the internet; everything allowed from the whole
                  virtual network; a shadowed rule - one an earlier rule
                  fully covers, so it never applies; flow logs kept under 90
                  days; no diagnostic settings
          Info flow logs without Traffic Analytics; only an NSG flow log
                  (they retire on 30 September 2027: migrate to virtual
                  network flow logs); over 800 of the 1,000 rules an NSG can
                  hold
 
        What you get depends on where the command runs:
          at the prompt tiles, a table of the NSGs with their risk, the
                           High and Medium findings, and - for up to three
                           NSGs - each one in detail with its rules, a page
                           at a time
          piped onward the AAC.NetworkSecurityGroup objects, with no view
          -PassThru the view and the objects
          -NoDisplay the objects only
        -CsvPath writes every rule (with its NSG, risk and finding) to CSV.
        -HtmlPath writes an interactive report - tiles, charts, and tables of
        the NSGs, rules, findings, associations and logging, each with its
        own CSV download. -PdfPath writes a PDF: the summary, the findings,
        and a section per NSG with its associations, telemetry and rules.
        With any of them, the console shows only the progress and the files
        written.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ResourceGroupName
        Only NSGs in these resource groups.
    .PARAMETER Name
        Only these NSGs; wildcards work, e.g. 'nsg-web-*'.
    .PARAMETER NoDiagnosticSetting
        Don't read the NSGs' diagnostic settings (one Azure Resource Manager
        call per NSG); their status is then "Not checked".
    .PARAMETER CsvPath
        Write every rule, with its NSG, risk and finding, 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
        Get-AACNetworkSecurityGroup
        Every NSG the account can see, assessed.
    .EXAMPLE
        Get-AACNetworkSecurityGroup -SubscriptionId '00000000-0000-0000-0000-000000000000' -ResourceGroupName 'rg-network' -Name 'nsg-web', 'nsg-app'
        Two NSGs in detail, with their rules.
    .EXAMPLE
        Get-AACNetworkSecurityGroup -HtmlPath .\out\NSG.html -PdfPath .\out\NSG.pdf -CsvPath .\out\NSG-rules.csv
        The full assessment as an interactive HTML report, a PDF and a CSV of every rule.
    .EXAMPLE
        (Get-AACNetworkSecurityGroup -NoDisplay).Findings | Where-Object Severity -EQ 'High'
        The High-severity findings.
    .OUTPUTS
        AAC.NetworkSecurityGroup (piped onward, or with -PassThru or -NoDisplay)
    #>

    [CmdletBinding()]
    [OutputType('AAC.NetworkSecurityGroup')]
    param(
        [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')]
        [string[]] $SubscriptionId,

        [ValidateNotNullOrEmpty()]
        [string[]] $ResourceGroupName,

        [SupportsWildcards()]
        [ValidateNotNullOrEmpty()]
        [string[]] $Name,

        [switch] $NoDiagnosticSetting,

        [string] $CsvPath,

        [string] $PdfPath,

        [string] $HtmlPath,

        [string] $Title = 'Network security group 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 "'", "\'") + "'" }
    $groupFilter = if ($ResourceGroupName) { " | where resourceGroup in~ ($((@($ResourceGroupName | ForEach-Object { & $quote $_ })) -join ', '))" } else { '' }
    $last = { param([string] $Id) if ($Id) { ($Id -split '/')[-1] } else { '' } }
    # A nested value from Resource Graph hashtables, or $null where a level is missing.
    $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 :: Network security groups' -Color 'deepskyblue3_1'
    }
    $state = Invoke-AACProgress -ScriptBlock {
        $null = Get-AACAccessToken
        # The five queries at once (Invoke-AACGraphBatch).
        Update-AACProgress -Id 'read' -Total 5 -Description 'Reading the NSGs, network interfaces, subnets and flow logs'
        $batch = Invoke-AACGraphBatch -SubscriptionId $SubscriptionId -Query ([ordered]@{
                subscriptions = "resourcecontainers | where type =~ 'microsoft.resources/subscriptions' | project subscriptionId, name"
                groups        = "resources | where type =~ 'microsoft.network/networksecuritygroups'$groupFilter | project id, name, resourceGroup, subscriptionId, location, tags, properties"
                nics          = "resources | where type =~ 'microsoft.network/networkinterfaces' | project id, name, resourceGroup, nsg = tolower(tostring(properties.networkSecurityGroup.id)), vm = tolower(tostring(properties.virtualMachine.id)), ipConfigurations = properties.ipConfigurations"
                subnets       = "resources | where type =~ 'microsoft.network/virtualnetworks' | mv-expand subnet = properties.subnets | project subnetId = tolower(tostring(subnet.id)), name = tostring(subnet.name), vnetId = tolower(id), vnetName = name, prefix = tostring(coalesce(subnet.properties.addressPrefix, subnet.properties.addressPrefixes[0])), nsg = tolower(tostring(subnet.properties.networkSecurityGroup.id))"
                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)"
            }) -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $Name ($Done of $Total queries)" }
        $subscriptionNames = @{}
        foreach ($row in @($batch.Rows['subscriptions'])) {
            $subscriptionNames[([string]$row['subscriptionId']).ToLowerInvariant()] = [string]$row['name']
        }
        $groups = @($batch.Rows['groups'])
        if ($Name) {
            $groups = @($groups | Where-Object { $nsgName = [string]$_['name']; @($Name | Where-Object { $nsgName -like $_ }).Count })
            $missing = @($Name | Where-Object { $pattern = $_; -not @($groups | Where-Object { [string]$_['name'] -like $pattern }).Count })
            if ($missing.Count -eq $Name.Count) { throw "No network security group named $(($missing | ForEach-Object { "'$_'" }) -join ', ') was found$(if ($SubscriptionId -or $ResourceGroupName) { ' in that scope' } else { ' in any subscription you can see' })." }
            foreach ($item in $missing) { Write-Warning "No network security group named '$item' was found; it's left out." }
        }

        $nics = @(foreach ($row in @($batch.Rows['nics'])) {
                $configurations = @($row['ipConfigurations'] | Where-Object { $_ })
                @{
                    id = [string]$row['id']; name = [string]$row['name']; resourceGroup = [string]$row['resourceGroup']; nsg = [string]$row['nsg']; vm = [string]$row['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') })
                }
            })

        $subnets = @($batch.Rows['subnets'])
        $flowLogs = @($batch.Rows['flowLogs'])
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} NSG(s), {1:N0} network interface(s), {2:N0} subnet(s) and {3:N0} flow log(s)' -f $groups.Count, $nics.Count, $subnets.Count, $flowLogs.Count)

        # Diagnostic settings aren't in Resource Graph: one ARM call per NSG,
        # up to 12 at once.
        $diagnostics = $null
        if (-not $NoDiagnosticSetting -and $groups.Count) {
            $diagnostics = @{}
            Update-AACProgress -Id 'diag' -Total $groups.Count -Description 'Reading the diagnostic settings'
            $uris = @{}
            foreach ($group in $groups) { $uris[([string]$group['id']).ToLowerInvariant()] = "$($group['id'])/providers/Microsoft.Insights/diagnosticSettings?api-version=2021-05-01-preview" }
            $read = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($Done, $Total) Update-AACProgress -Id 'diag' -Total $Total -Increment 1 }
            foreach ($group in $groups) {
                $key = ([string]$group['id']).ToLowerInvariant()
                $result = $read[$uris[$key]]
                if (-not $result -or $result.Error -or $null -eq $result.Items) {
                    Write-Debug "The diagnostic settings of $($group['name']) couldn't be read: $(if ($result) { $result.Error })"
                    $diagnostics[$key] = @{ Status = 'Unknown'; Settings = @() }
                    continue
                }
                $settings = @(foreach ($setting in @($result.Items | Where-Object { $_ })) {
                        $p = $setting['properties']
                        if ($p -isnot [System.Collections.IDictionary]) { $p = @{} }
                        $logs = @(@($p['logs']) | Where-Object { $_ -and [string]$_['enabled'] -eq 'True' } | ForEach-Object { if ($_['categoryGroup']) { [string]$_['categoryGroup'] } else { [string]$_['category'] } })
                        [pscustomobject]@{
                            Name = [string]$setting['name']; Workspace = & $last ([string]$p['workspaceId']); Storage = & $last ([string]$p['storageAccountId'])
                            EventHub = $(if ($p['eventHubName']) { [string]$p['eventHubName'] } elseif ($p['eventHubAuthorizationRuleId']) { (([string]$p['eventHubAuthorizationRuleId']) -split '/')[-3] } else { '' })
                            Categories = $logs -join ', '
                        }
                    })
                $diagnostics[$key] = @{ Status = $(if (@($settings | Where-Object Categories).Count) { 'Enabled' } else { 'Disabled' }); Settings = $settings }
            }
            Update-AACProgress -Id 'diag' -Complete -Description ('Read the diagnostic settings of {0:N0} NSG(s)' -f $groups.Count)
        }

        Update-AACProgress -Id 'assess' -Description 'Assessing the rules, associations and logging' -Indeterminate
        $assessment = ConvertTo-AACNsgAssessment -NetworkSecurityGroup $groups -NetworkInterface $nics -Subnet $subnets -FlowLog $flowLogs -Diagnostic $diagnostics -SubscriptionName $subscriptionNames
        $stats = $assessment.Stats
        Update-AACProgress -Id 'assess' -Complete -Description ('Assessed {0:N0} NSG(s) and {1:N0} custom rule(s): {2} high, {3} medium, {4} low finding(s)' -f $stats.Groups, $stats.Rules, $stats.High, $stats.Medium, $stats.Low)

        $scope = [ordered]@{
            Scope = if ($SubscriptionId) { "subscription(s) $($SubscriptionId -join ', ')" } else { 'every subscription the account can see' }
        }
        if ($ResourceGroupName) { $scope['Resource groups'] = $ResourceGroupName -join ', ' }
        if ($Name) { $scope['NSGs'] = $Name -join ', ' }
        $csvRows = @($assessment.Rules | Select-Object -Property Nsg, Priority, Direction, Name, Access, Protocol, Source, SourcePorts, Destination, DestinationPorts, IsDefault, Risk, Finding, Description, ResourceGroup, SubscriptionName, NsgId)
        $null = Invoke-AACExport -CsvPath $csvFullPath -CsvObject $csvRows -Noun 'rule' -PdfPath $pdfFullPath -WritePdf {
            Write-AACNsgPdf -Assessment $assessment -Path $pdfFullPath -Title $Title -Detail $scope
        } -HtmlPath $htmlFullPath -WriteHtml {
            Write-AACNsgHtml -Assessment $assessment -Path $htmlFullPath -Title $Title -Detail $scope
        }
        @{ Assessment = $assessment; Scope = $scope }
    }

    if ($showView) {
        Invoke-AACPagedOutput -NoPaging:$NoPaging -ScriptBlock {
            Show-AACNsgView -Assessment $state.Assessment -Scope $state.Scope
        }
    }
    if ($returnObjects) {
        $state.Assessment.Groups
    }
}