Private/ConvertTo-AACDiagnosticCoverage.ps1

function ConvertTo-AACDiagnosticCoverage {
    <#
    .SYNOPSIS
        Works out, for every resource that has resource logs, whether its
        diagnostic settings export them to a Log Analytics workspace - and
        what is misconfigured.
    .DESCRIPTION
        No Azure calls: Get-AACDiagnosticSetting reads, this decides.
 
          -Target what diagnostic settings were read for: @{ Id;
                       Resource; Type; Kind; ResourceGroup; SubscriptionId;
                       Location; Key } - the resources, a storage account's
                       blob, file, queue and table services, subscriptions
          -Category by Key ("type|kind"): @{ State = 'Logs' | 'NoLogs' |
                       'Unknown'; Logs = @(@{ Name; Groups }); Metrics =
                       @(names); Error }
          -Setting by target Id: Invoke-AACArmParallel's result for
                       {id}/providers/Microsoft.Insights/diagnosticSettings
          -Workspace every workspace the account can see, by lower-case ID:
                       @{ Name; Location }
          -ExpectedWorkspace workspace names or IDs logs should go to
 
        A log category reaches Log Analytics when a setting with a workspace
        that exists enables it - by name, or through a category group it
        belongs to (allLogs, audit).
 
        Returns @{ Coverage; Settings; Findings; ByType; Workspaces; Stats }:
          Coverage AAC.DiagnosticCoverage, one per target, with Status:
                       Exported every log category reaches a workspace
                       Partial some do
                       Not to workspace settings, but none to a workspace
                       No setting no diagnostic setting at all
                       No logs the type has no log categories
                       Unknown couldn't be read
          Settings AAC.DiagnosticSettingDetail, one per setting, flattened
          Findings AAC.DiagnosticFinding, by severity
    #>

    [CmdletBinding()]
    [OutputType([hashtable])]
    param(
        [AllowEmptyCollection()]
        [object[]] $Target = @(),

        [System.Collections.IDictionary] $Category = @{},

        [System.Collections.IDictionary] $Setting = @{},

        [System.Collections.IDictionary] $Workspace = @{},

        [string[]] $ExpectedWorkspace = @(),

        [System.Collections.IDictionary] $SubscriptionName = @{}
    )

    $leaf = { param([string] $Id) if ($Id) { ($Id.TrimEnd('/') -split '/')[-1] } else { '' } }
    $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
    }
    $object = {
        param([string] $TypeName, [System.Collections.IDictionary] $Property)
        $item = [pscustomobject]$Property
        $item.PSObject.TypeNames.Insert(0, $TypeName)
        $item
    }
    $expected = @($ExpectedWorkspace | Where-Object { $_ } | ForEach-Object { $_.Trim().ToLowerInvariant() })
    $isExpected = { param([string] $WorkspaceId) $id = $WorkspaceId.ToLowerInvariant(); @($expected | Where-Object { $_ -eq $id -or $_ -eq (& $leaf $id) }).Count -gt 0 }
    $docs = 'https://learn.microsoft.com/azure/azure-monitor/essentials/diagnostic-settings'

    $coverage = [System.Collections.Generic.List[object]]::new()
    $details = [System.Collections.Generic.List[object]]::new()
    $findings = [System.Collections.Generic.List[object]]::new()
    $workspaceUse = @{}

    foreach ($t in $Target) {
        $subscription = if ($SubscriptionName.Contains(([string]$t.SubscriptionId).ToLowerInvariant())) { $SubscriptionName[([string]$t.SubscriptionId).ToLowerInvariant()] } else { [string]$t.SubscriptionId }
        $base = [ordered]@{ Resource = $t.Resource; ResourceType = $t.Type; ResourceGroup = $t.ResourceGroup; SubscriptionName = $subscription }
        $find = {
            param([string] $Severity, [string] $Title, [string] $SettingName, [string] $Detail, [string] $Action)
            $row = [ordered]@{ Severity = $Severity; Finding = $Title }
            foreach ($key in $base.Keys) { $row[$key] = $base[$key] }
            $row['Setting'] = $SettingName; $row['Detail'] = $Detail; $row['Action'] = $Action; $row['LearnMore'] = $docs; $row['ResourceId'] = $t.Id
            $item = & $object 'AAC.DiagnosticFinding' $row
            $findings.Add($item)
            $item
        }
        $typeInfo = $Category[$t.Key]
        $state = if ($typeInfo) { $typeInfo.State } else { 'Unknown' }
        $logs = @(if ($typeInfo) { $typeInfo.Logs })
        $read = $Setting[$t.Id]
        $settingsRead = $state -eq 'Logs' -and $read -and -not $read.Error
        $listed = if ($settingsRead) { if ($null -ne $read.Items) { $read.Items } else { & $at $read.Body 'value' } }
        $items = @($listed | Where-Object { $_ -is [System.Collections.IDictionary] })
        $own = [System.Collections.Generic.List[object]]::new()
        $covered = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        $sentBy = @{}
        $workspaces = [System.Collections.Generic.List[string]]::new()

        foreach ($item in $items) {
            $properties = & $at $item 'properties'
            $name = [string]$item['name']
            $workspaceId = [string](& $at $properties 'workspaceId')
            $storage = [string](& $at $properties 'storageAccountId')
            $eventHub = [string](& $at $properties 'eventHubAuthorizationRuleId')
            $partner = [string](& $at $properties 'marketplacePartnerId')
            $known = if ($workspaceId) { $Workspace[$workspaceId.ToLowerInvariant()] }
            $logEntries = @(& $at $properties 'logs' | Where-Object { $_ -is [System.Collections.IDictionary] })
            $metricEntries = @(& $at $properties 'metrics' | Where-Object { $_ -is [System.Collections.IDictionary] })
            $enabledLogs = @($logEntries | Where-Object { $_['enabled'] })
            $enabledNames = @($enabledLogs | ForEach-Object { if ($_['category']) { [string]$_['category'] } else { "group:$([string]$_['categoryGroup'])" } })
            $disabledNames = @($logEntries | Where-Object { -not $_['enabled'] } | ForEach-Object { if ($_['category']) { [string]$_['category'] } else { "group:$([string]$_['categoryGroup'])" } })
            $enabledMetrics = @($metricEntries | Where-Object { $_['enabled'] } | ForEach-Object { [string]$_['category'] })
            # The log categories this setting turns on: named ones, and every
            # category in an enabled group.
            $reaches = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
            foreach ($entry in $enabledLogs) {
                if ($entry['category']) { $null = $reaches.Add([string]$entry['category']) }
                elseif ($entry['categoryGroup']) {
                    $group = [string]$entry['categoryGroup']
                    foreach ($log in $logs) { if ($group -eq 'allLogs' -or @($log.Groups) -contains $group) { $null = $reaches.Add($log.Name) } }
                }
            }
            $retention = @(@($logEntries) + @($metricEntries) | Where-Object { (& $at $_ 'retentionPolicy', 'enabled') -and [int](& $at $_ 'retentionPolicy', 'days') -gt 0 } | ForEach-Object { [int](& $at $_ 'retentionPolicy', 'days') } | Sort-Object -Unique)
            $destinationType = [string](& $at $properties 'logAnalyticsDestinationType')
            $detail = [ordered]@{}
            foreach ($key in $base.Keys) { $detail[$key] = $base[$key] }
            $detail['Setting'] = $name
            $detail['Destinations'] = @(@($(if ($workspaceId) { 'Log Analytics' }), $(if ($storage) { 'Storage' }), $(if ($eventHub) { 'Event Hubs' }), $(if ($partner) { 'Partner' })) | Where-Object { $_ }) -join ', '
            $detail['Workspace'] = $(if ($known) { $known.Name } elseif ($workspaceId) { & $leaf $workspaceId } else { '' })
            $detail['WorkspaceFound'] = $(if ($workspaceId) { [bool]$known } else { $null })
            $detail['WorkspaceLocation'] = $(if ($known) { $known.Location } else { '' })
            $detail['DestinationTable'] = $(if (-not $workspaceId) { '' } elseif ($destinationType -eq 'Dedicated') { 'Resource-specific' } else { 'AzureDiagnostics' })
            $detail['StorageAccount'] = & $leaf $storage
            $detail['EventHub'] = $(if ($eventHub) { "$(& $leaf ($eventHub -replace '/authorizationrules/.*$', ''))$(if (& $at $properties 'eventHubName') { "/$(& $at $properties 'eventHubName')" })" } else { '' })
            $detail['Partner'] = & $leaf $partner
            $detail['LogsEnabled'] = $enabledNames -join ', '
            $detail['LogsDisabled'] = $disabledNames -join ', '
            $detail['LogCategoriesReached'] = $reaches.Count
            $detail['MetricsEnabled'] = $enabledMetrics -join ', '
            $detail['RetentionDays'] = $retention -join ', '
            $detail['WorkspaceResourceId'] = $workspaceId
            $detail['ResourceId'] = $t.Id
            $row = & $object 'AAC.DiagnosticSettingDetail' $detail
            $details.Add($row)
            $own.Add($row)

            if ($workspaceId -and $known) {
                $workspaces.Add($known.Name)
                foreach ($name2 in $reaches) {
                    $null = $covered.Add($name2)
                    $key = "$($workspaceId.ToLowerInvariant())|$name2"
                    if (-not $sentBy.Contains($key)) { $sentBy[$key] = [System.Collections.Generic.List[string]]::new() }
                    $sentBy[$key].Add($name)
                }
                $useKey = $workspaceId.ToLowerInvariant()
                if (-not $workspaceUse.Contains($useKey)) { $workspaceUse[$useKey] = @{ Workspace = $known.Name; Location = $known.Location; Found = $true; Resources = [System.Collections.Generic.HashSet[string]]::new(); Settings = 0; ResourceId = $workspaceId } }
                $null = $workspaceUse[$useKey].Resources.Add($t.Id); $workspaceUse[$useKey].Settings++
            }
            elseif ($workspaceId) {
                $useKey = $workspaceId.ToLowerInvariant()
                if (-not $workspaceUse.Contains($useKey)) { $workspaceUse[$useKey] = @{ Workspace = (& $leaf $workspaceId); Location = ''; Found = $false; Resources = [System.Collections.Generic.HashSet[string]]::new(); Settings = 0; ResourceId = $workspaceId } }
                $null = $workspaceUse[$useKey].Resources.Add($t.Id); $workspaceUse[$useKey].Settings++
            }

            # Misconfigurations of this setting.
            if (-not $enabledLogs.Count -and -not $enabledMetrics.Count) {
                $null = & $find 'Medium' 'Diagnostic setting with nothing enabled' $name 'No log category or metric is enabled: the setting sends nothing.' 'Enable the allLogs category group, or delete the setting.'
            }
            if ($workspaceId -and -not $known) {
                $null = & $find 'High' 'Sends to a workspace that doesn''t exist' $name "The workspace $(& $leaf $workspaceId) isn't among the workspaces you can see: it was deleted, or is in a subscription you can't read. Logs sent to a deleted workspace are lost." 'Point the setting at an existing Log Analytics workspace.'
            }
            if ($known -and $expected.Count -and -not (& $isExpected $workspaceId)) {
                $null = & $find 'Medium' 'Sends to a workspace other than the expected one' $name "Logs go to $($known.Name), not to $($ExpectedWorkspace -join ', ')." 'Send them to the central workspace, or add this one to -ExpectedWorkspace if it is intended.'
            }
            if ($known -and $known.Location -and $t.Location -and $t.Location -ne 'global' -and $known.Location -ne $t.Location) {
                $null = & $find 'Low' 'Workspace in another region' $name "The resource is in $($t.Location), the workspace in $($known.Location): data leaves the region (bandwidth charges, data residency)." 'Use a workspace in the same region where residency or egress cost matters.'
            }
            if ($retention.Count) {
                $null = & $find 'Low' 'Deprecated retention policy on a diagnostic setting' $name "Retention of $($retention -join ', ') day(s) is set on the setting's categories. Diagnostic settings storage retention is retired: it no longer deletes anything." 'Remove the retention policy and use an Azure Storage lifecycle management policy instead.'
            }
        }

        # Duplicates: the same category to the same workspace from two settings.
        foreach ($key in @($sentBy.Keys)) {
            if ($sentBy[$key].Count -gt 1) {
                $parts = $key -split '\|', 2
                $null = & $find 'Medium' 'The same logs sent to a workspace twice' ($sentBy[$key] -join ', ') "Category $($parts[1]) goes to $(& $leaf $parts[0]) from $($sentBy[$key].Count) settings: it is ingested, and billed, $($sentBy[$key].Count) times." 'Keep one setting per destination workspace.'
            }
        }

        $logNames = @($logs | ForEach-Object { $_.Name })
        $missing = @($logNames | Where-Object { -not $covered.Contains($_) })
        $status = switch ($true) {
            ($state -eq 'NoLogs') { 'No logs'; break }
            ($state -ne 'Logs' -or ($read -and $read.Error)) { 'Unknown'; break }
            (-not $items.Count) { 'No setting'; break }
            (-not $covered.Count) { 'Not to workspace'; break }
            ([bool]$missing.Count) { 'Partial'; break }
            default { 'Exported' }
        }
        $isActivityLog = $t.Type -eq 'microsoft.resources/subscriptions'
        $what = if ($isActivityLog) { 'activity log' } else { 'resource logs' }
        switch ($status) {
            'No setting' { $null = & $find 'High' $(if ($isActivityLog) { 'Activity log not exported' } else { 'No diagnostic setting' }) '' "Its $($logNames.Count) log categor$(if ($logNames.Count -eq 1) { 'y goes' } else { 'ies go' }) nowhere: $($logNames -join ', ')." "Add a diagnostic setting that sends the allLogs category group to a Log Analytics workspace$(if (-not $isActivityLog) { ' - or assign the built-in policy initiative that does it for every supported resource' })." }
            'Not to workspace' { $null = & $find 'High' "No $what reach Log Analytics" (@($own | ForEach-Object Setting) -join ', ') "$($items.Count) diagnostic setting(s), but none sends a log category to a Log Analytics workspace that exists (destinations: $(@($own | ForEach-Object { if ($_.Destinations) { $_.Destinations } else { 'none' } } | Select-Object -Unique) -join '; '))." 'Add a Log Analytics workspace destination with the allLogs category group to a setting.' }
            'Partial' { $null = & $find 'Medium' 'Some log categories don''t reach Log Analytics' (@($own | ForEach-Object Setting) -join ', ') "$($missing.Count) of $($logNames.Count) categories are missing: $($missing -join ', ')." 'Enable the allLogs category group on the workspace setting, so new categories are included too.' }
        }
        $own = $own.ToArray()
        $ownFindings = @($findings | Where-Object { $_.ResourceId -eq $t.Id })
        $severityRank = @{ High = 3; Medium = 2; Low = 1 }
        $worst = @($ownFindings | Sort-Object { $severityRank[$_.Severity] } -Descending | Select-AACFirst 1 | ForEach-Object Severity)
        $row = [ordered]@{ Status = $status }
        foreach ($key in $base.Keys) { $row[$key] = $base[$key] }
        $row['Location'] = $t.Location
        $row['LogCategories'] = $logNames.Count
        $row['CategoriesToWorkspace'] = $covered.Count
        $row['MissingCategories'] = $missing -join ', '
        $row['Settings'] = $items.Count
        $row['SettingNames'] = @($own | ForEach-Object Setting) -join ', '
        $row['Workspaces'] = @($workspaces | Select-Object -Unique) -join ', '
        $row['Destinations'] = @($own | ForEach-Object Destinations | Where-Object { $_ } | ForEach-Object { $_ -split ', ' } | Select-Object -Unique) -join ', '
        # Why the logs don't (all) arrive, in a line.
        $row['Reason'] = switch ($status) {
            'No setting' { "$($logNames.Count) log categor$(if ($logNames.Count -eq 1) { 'y' } else { 'ies' }), no diagnostic setting" }
            'Not to workspace' {
                @(foreach ($detail in $own) {
                        if (-not $detail.WorkspaceResourceId) { "$($detail.Setting): to $(if ($detail.Destinations) { $detail.Destinations } else { 'no destination' })" }
                        elseif (-not $detail.WorkspaceFound) { "$($detail.Setting): workspace $($detail.Workspace) doesn't exist" }
                        elseif (-not $detail.LogCategoriesReached) { "$($detail.Setting): no log category enabled" }
                    }) -join '; '
            }
            'Partial' { "missing: $($missing -join ', ')" }
            'Unknown' { 'couldn''t be read' }
            default { '' }
        }
        $row['Severity'] = $(if ($worst) { $worst[0] } else { '' })
        $row['Findings'] = @($ownFindings | ForEach-Object Finding) -join '; '
        $row['Error'] = $(if ($status -eq 'Unknown') { if ($read -and $read.Error) { [string]$read.Error } elseif ($typeInfo -and $typeInfo.Error) { [string]$typeInfo.Error } else { 'Not read.' } } else { '' })
        $row['ResourceId'] = $t.Id
        $row['Detail'] = $own
        $coverage.Add((& $object 'AAC.DiagnosticCoverage' $row))
    }

    $rank = @{ High = 0; Medium = 1; Low = 2 }
    $statusOrder = @{ 'No setting' = 0; 'Not to workspace' = 1; 'Partial' = 2; 'Unknown' = 3; 'Exported' = 4; 'No logs' = 5 }
    $sortedCoverage = @($coverage | Sort-Object -Property @{ Expression = { $statusOrder[$_.Status] } }, ResourceType, Resource)
    $sortedFindings = @($findings | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, Finding, Resource)
    $withLogs = @($sortedCoverage | Where-Object { $_.Status -notin 'No logs' })
    $count = { param([string] $Status) @($sortedCoverage | Where-Object Status -EQ $Status).Count }
    $byType = @($withLogs | Group-Object ResourceType | ForEach-Object {
            $exported = @($_.Group | Where-Object Status -EQ 'Exported').Count
            $known = @($_.Group | Where-Object Status -NE 'Unknown').Count
            [pscustomobject][ordered]@{
                ResourceType   = $_.Name
                Resources      = $_.Count
                Exported       = $exported
                Partial        = @($_.Group | Where-Object Status -EQ 'Partial').Count
                NotToWorkspace = @($_.Group | Where-Object Status -EQ 'Not to workspace').Count
                NoSetting      = @($_.Group | Where-Object Status -EQ 'No setting').Count
                Unknown        = @($_.Group | Where-Object Status -EQ 'Unknown').Count
                CoveragePercent = $(if ($known) { [Math]::Round($exported / $known * 100, 1) } else { $null })
            }
        } | Sort-Object -Property @{ Expression = { if ($null -eq $_.CoveragePercent) { 101 } else { $_.CoveragePercent } } }, @{ Expression = 'Resources'; Descending = $true })
    $workspaceRows = @($workspaceUse.Values | ForEach-Object {
            [pscustomobject][ordered]@{ Workspace = $_.Workspace; Location = $_.Location; Found = $_.Found; Resources = $_.Resources.Count; Settings = $_.Settings; Expected = $(if ($expected.Count) { & $isExpected $_.ResourceId } else { $null }); ResourceId = $_.ResourceId }
        } | Sort-Object -Property @{ Expression = 'Resources'; Descending = $true }, Workspace)
    $assessed = @($withLogs | Where-Object Status -NE 'Unknown').Count
    $stats = [ordered]@{
        Targets         = $sortedCoverage.Count
        WithLogs        = $withLogs.Count
        Exported        = & $count 'Exported'
        Partial         = & $count 'Partial'
        NotToWorkspace  = & $count 'Not to workspace'
        NoSetting       = & $count 'No setting'
        NoLogs          = & $count 'No logs'
        Unknown         = & $count 'Unknown'
        CoveragePercent = $(if ($assessed) { [Math]::Round((& $count 'Exported') / $assessed * 100, 1) } else { $null })
        Settings        = $details.Count
        Workspaces      = @($workspaceRows | Where-Object Found).Count
        High            = @($sortedFindings | Where-Object Severity -EQ 'High').Count
        Medium          = @($sortedFindings | Where-Object Severity -EQ 'Medium').Count
        Low             = @($sortedFindings | Where-Object Severity -EQ 'Low').Count
    }
    @{
        Coverage   = $sortedCoverage
        Settings   = $details.ToArray()
        Findings   = $sortedFindings
        ByType     = $byType
        Workspaces = $workspaceRows
        Stats      = $stats
    }
}