Public/Get-AACPrivateEndpoint.ps1

function Get-AACPrivateEndpoint {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Audits your private endpoints - each connection's state, the
        resource behind it, its IP addresses and names, the private DNS zone
        that resolves it and whether its network can see that zone - and
        finds what breaks private connectivity: pending or disconnected
        connections, orphaned endpoints, missing or unlinked DNS zones, and
        targets still open to the Internet.
    .DESCRIPTION
        Reads, read-only:
          Resource Graph the private endpoints and their connections
                             (automatic and manual approval), the private
                             DNS zones and their virtual network links, the
                             virtual networks (DNS servers, peerings) and
                             every resource that takes private endpoint
                             connections (does it exist, is it still public?)
          Resource Manager each endpoint's private DNS zone groups (which
                             zone holds its record)
 
        One row per endpoint connection (AAC.PrivateEndpoint), Healthy,
        Review, Warning or Failed, with its issues; and the findings
        (AAC.PrivateEndpointFinding), each with what to do:
          a connection Pending, Rejected or Disconnected; an endpoint whose
          target was deleted; provisioning failed; the zone not linked to
          the endpoint's network or one peered with it; no zone group; the
          wrong zone for the service (blob in the file zone, say); the
          target still open to public networks; the same privatelink zone
          defined in several places.
 
        The zone each service needs is known for storage, Key Vault, SQL,
        Cosmos DB, PostgreSQL, MySQL, Container Registry, App Service,
        Service Bus and Event Hubs, App Configuration, AI Search, Redis,
        Azure AI services, Data Factory, Event Grid, SignalR, Automation,
        Azure Monitor and more. A network with custom DNS servers can't be
        checked from Azure alone: it is flagged for review.
 
        Read-only; Reader is enough.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ResourceGroupName
        Only endpoints in these resource groups (their zones, networks and
        targets can be anywhere).
    .PARAMETER Name
        Only endpoints whose name matches; wildcards work.
    .PARAMETER TargetType
        Only endpoints to resources of these types (for example
        'microsoft.storage/storageaccounts', or '*sql*'); wildcards work.
    .PARAMETER Status
        Only connections in these states (Failed, Warning, Review, Healthy).
    .PARAMETER SkipDnsZoneGroup
        Don't read the DNS zone groups (one call per endpoint): faster, but
        DNS isn't checked.
    .PARAMETER CsvPath
        Write the endpoint connections to this CSV file.
    .PARAMETER HtmlPath
        Write an interactive HTML report.
    .PARAMETER PdfPath
        Write a PDF report.
    .PARAMETER Title
        The reports' title.
    .PARAMETER PassThru
        Show the view and also return the endpoint connections.
    .PARAMETER NoDisplay
        Return the endpoint connections without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACPrivateEndpoint
        Every private endpoint you can see, the broken ones first, and the findings.
    .EXAMPLE
        Get-AACPrivateEndpoint -Status Failed, Warning -HtmlPath .\out\PrivateEndpoints.html
        What needs fixing, as an interactive HTML report.
    .EXAMPLE
        Get-AACPrivateEndpoint -TargetType 'microsoft.storage/storageaccounts' -NoDisplay | Select-Object Endpoint, Target, GroupId, IpAddresses, DnsZones, ZoneLinked
        The storage endpoints, their IPs and DNS.
    .OUTPUTS
        AAC.PrivateEndpoint
    #>

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

        [string[]] $ManagementGroupId,

        [string[]] $ResourceGroupName,

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

        [SupportsWildcards()]
        [string[]] $TargetType,

        [ValidateSet('Failed', 'Warning', 'Review', 'Healthy')]
        [string[]] $Status,

        [switch] $SkipDnsZoneGroup,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Azure private endpoints',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    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
    $resolve = { param([string] $Path) if ($Path) { $PSCmdlet.SessionState.Path.GetUnresolvedProviderPathFromPSPath($Path) } }
    $request = @{ SubscriptionId = @($SubscriptionId | Where-Object { $_ }); ManagementGroupId = @($ManagementGroupId | Where-Object { $_ }); ResourceGroupName = @($ResourceGroupName | Where-Object { $_ }); Name = @($Name | Where-Object { $_ }); SkipDnsZoneGroup = [bool]$SkipDnsZoneGroup }

    $null = Get-AACAccessToken
    if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Private endpoints' -Color 'mediumpurple' }
    $outcome = Invoke-AACProgress -ScriptBlock {
        Update-AACProgress -Id 'scope' -Indeterminate -Description 'Finding the subscriptions'
        $scope = Resolve-AACScope -SubscriptionId $request.SubscriptionId -ManagementGroupId $request.ManagementGroupId
        Update-AACProgress -Id 'scope' -Complete -Description "Scope: $($scope.Label)"
        $queries = Get-AACPrivateEndpointQuery -ResourceGroupName $request.ResourceGroupName
        Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading private endpoints, DNS zones, networks and targets'
        $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @('zones', 'links', 'vnets', 'targets') -OnProgress {
            param($QueryName, $Done, $Total)
            Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $QueryName ($Done of $Total)"
        }
        if ($request.Name.Count) { $read.Rows['endpoints'] = @($read.Rows['endpoints'] | Where-Object { $n = [string]$_['name']; @($request.Name | Where-Object { $n -like $_ }).Count }) }
        $endpointRows = @($read.Rows['endpoints'] | Where-Object { $_ -and $scope.Names.Contains(([string]$_['subscriptionId']).ToLowerInvariant()) })
        Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} private endpoint(s), {1:N0} private DNS zone(s)' -f $endpointRows.Count, @($read.Rows['zones']).Count)

        $zoneGroups = @{}
        if (-not $request.SkipDnsZoneGroup -and $endpointRows.Count) {
            $uris = [ordered]@{}
            foreach ($row in $endpointRows) { $uris[[string]$row['id']] = "$($row['id'])/privateDnsZoneGroups?api-version=2023-09-01" }
            Update-AACProgress -Id 'zones' -Total $uris.Count -Description "Reading the DNS zone groups of $($uris.Count) endpoint(s)"
            $answers = Invoke-AACArmParallel -Uri @($uris.Values) -OnProgress { param($ZoneDone, $ZoneTotal) Update-AACProgress -Id 'zones' -Increment 1 }
            foreach ($id in $uris.Keys) {
                $answer = $answers[$uris[$id]]
                # Assigned in each branch: an empty list out of an if expression would become $null (unread).
                if (-not $answer -or $answer.Error) { $zoneGroups[$id] = $null } else { $zoneGroups[$id] = @($answer.Items | Where-Object { $_ }) }
            }
            Update-AACProgress -Id 'zones' -Complete -Description ('Read the DNS zone groups of {0:N0} endpoint(s)' -f @($zoneGroups.Values | Where-Object { $null -ne $_ }).Count)
        }
        $result = ConvertTo-AACPrivateEndpoint -Read $read -ZoneGroup $zoneGroups -SubscriptionName $scope.Names
        @{ Result = $result; Scope = $scope }
    }

    $endpoints = @($outcome.Result.Endpoints)
    $findings = @($outcome.Result.Findings)
    if ($TargetType) {
        $endpoints = @($endpoints | Where-Object { $t = $_.TargetType; @($TargetType | Where-Object { $t -like $_ }).Count })
        $keep = @{}; foreach ($e in $endpoints) { $keep[$e.ResourceId] = $true }
        $findings = @($findings | Where-Object { $keep.Contains($_.ResourceId) })
    }
    if ($Status) { $endpoints = @($endpoints | Where-Object { $Status -contains $_.Status }) }

    $rank = Get-AACSeverityRank
    $statusTones = @{ Healthy = 'good'; Review = 'info'; Warning = 'warn'; Failed = 'bad' }
    $failed = @($endpoints | Where-Object Status -EQ 'Failed').Count
    $warning = @($endpoints | Where-Object Status -EQ 'Warning').Count
    $healthy = @($endpoints | Where-Object Status -EQ 'Healthy').Count
    $pending = @($endpoints | Where-Object ConnectionState -EQ 'Pending').Count
    $dns = @($findings | Where-Object Category -EQ 'DNS').Count
    $public = @($endpoints | Where-Object { $_.Issues -match 'target public' }).Count
    $report = @{
        Subtitle = 'Private endpoints: connections, DNS and exposure'
        Facts    = [ordered]@{ Scope = $outcome.Scope.Label; 'DNS zone groups' = $(if ($SkipDnsZoneGroup) { 'not read (-SkipDnsZoneGroup)' } else { 'read' }) }
        Status   = $(if ($failed) { 'Failed' } elseif ($warning) { 'Warning' } else { 'Success' })
        Headline = $(if ($endpoints.Count) { "$($endpoints.Count) private endpoint connection(s): $failed failed, $warning with warnings, $healthy healthy" } else { 'No private endpoints in scope.' })
        Tiles    = @(
            @{ Value = '{0:N0}' -f $endpoints.Count; Label = 'endpoint connections'; Tone = 'info' }
            @{ Value = '{0:N0}' -f $failed; Label = 'failed'; Tone = $(if ($failed) { 'bad' } else { 'good' }); Table = 'endpoints'; Filters = @{ Status = 'Failed' } }
            @{ Value = '{0:N0}' -f $warning; Label = 'warnings'; Tone = $(if ($warning) { 'warn' } else { 'good' }); Table = 'endpoints'; Filters = @{ Status = 'Warning' } }
            @{ Value = '{0:N0}' -f $pending; Label = 'pending approval'; Tone = $(if ($pending) { 'bad' } else { 'good' }); Table = 'endpoints'; Filters = @{ ConnectionState = 'Pending' } }
            @{ Value = '{0:N0}' -f $dns; Label = 'DNS findings'; Tone = $(if ($dns) { 'warn' } else { 'good' }); Table = 'findings'; Filters = @{ Category = 'DNS' } }
            @{ Value = '{0:N0}' -f $public; Label = 'targets still public'; Tone = $(if ($public) { 'warn' } else { 'good' }) }
        )
        Notices  = @($outcome.Result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
        Charts   = @(
            @{ Title = 'By status'; Kind = 'donut'; CenterLabel = 'connections'; Items = @(foreach ($s in 'Failed', 'Warning', 'Review', 'Healthy') { $n = @($endpoints | Where-Object Status -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $statusTones[$s]; Filter = $s } } }); Table = 'endpoints'; Column = 'Status' }
            @{ Title = 'Findings by kind'; Items = @($findings | Group-Object Finding | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'findings'; Column = 'Finding'; Tone = 'warn'; Console = $true }
            @{ Title = 'By target type'; Items = @($endpoints | Group-Object TargetType | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'endpoints'; Column = 'TargetType'; Tone = 'violet' }
        )
        Tables   = @(
            @{ Id = 'findings'; Title = 'Findings'; Section = 'Findings'; Rows = $findings; Noun = 'findings'; GroupBy = @('Severity', 'Category', 'Subscription'); ConsoleLimit = 20
                Empty = 'No findings: every private endpoint is connected, resolvable and its target closed to public networks.'; EmptyStatus = 'Success'
                Columns = @(
                    @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Finding'; Label = 'Finding'; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Resource'; Label = 'Endpoint'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Detail'; Label = 'Detail'; Type = 'wide'; Console = $true; Pdf = $true }
                    @{ Key = 'Category'; Label = 'Area'; Facet = $true }
                    @{ Key = 'Impact'; Label = 'Impact'; Type = 'wide' }
                    @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                    @{ Key = 'Link'; Label = 'Docs'; Type = 'link'; Text = 'Docs' }
                ) }
            @{ Id = 'endpoints'; Title = 'Private endpoint connections'; Section = 'Endpoints'; Rows = $endpoints; Noun = 'connections'; GroupBy = @('Status', 'TargetType', 'VirtualNetwork', 'Subscription'); ConsoleLimit = 25
                Empty = 'No private endpoints in scope.'
                Columns = @(
                    @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = $statusTones; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'Endpoint'; Label = 'Endpoint'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'Target'; Label = 'Target'; IdKey = 'TargetId'; Type = 'resource'; Console = $true; Pdf = $true }
                    @{ Key = 'GroupId'; Label = 'Sub-resource'; Facet = $true; Console = $true }
                    @{ Key = 'ConnectionState'; Label = 'Connection'; Type = 'badge'; Tones = @{ Approved = 'good'; Pending = 'bad'; Rejected = 'bad'; Disconnected = 'bad' }; Facet = $true; Console = $true; Pdf = $true }
                    @{ Key = 'ZoneLinked'; Label = 'DNS'; Type = 'badge'; Tones = @{ Yes = 'good'; 'Peered network' = 'good'; 'Custom DNS' = 'info'; No = 'bad'; 'No zone group' = 'warn'; 'Not checked' = 'neutral' }; Facet = $true; Console = $true }
                    @{ Key = 'IpAddresses'; Label = 'IP'; Type = 'mono'; Console = $true }
                    @{ Key = 'Issues'; Label = 'Issues'; Type = 'wide'; Pdf = $true }
                    @{ Key = 'TargetType'; Label = 'Target type'; Type = 'type'; Facet = $true }
                    @{ Key = 'DnsZones'; Label = 'DNS zone'; Type = 'mono' }
                    @{ Key = 'ExpectedZone'; Label = 'Expected zone'; Type = 'mono' }
                    @{ Key = 'Fqdns'; Label = 'Names'; Type = 'wide' }
                    @{ Key = 'VirtualNetwork'; Label = 'Network'; Facet = $true }
                    @{ Key = 'Subnet'; Label = 'Subnet' }
                    @{ Key = 'TargetPublicAccess'; Label = 'Target public access'; Facet = $true }
                    @{ Key = 'Approval'; Label = 'Approval'; Facet = $true }
                    @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                    @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true }
                ) }
        )
        Hint     = '-Status Failed, Warning narrows the list; -TargetType picks a service; -NoDisplay returns the connections; -HtmlPath, -PdfPath or -CsvPath for a report.'
    }
    Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $endpoints -Noun 'connection' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
        -ShowView:$interactive -NoPaging:$NoPaging -Object $endpoints -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
}