Private/ConvertTo-AACKeyVaultInsight.ps1
|
function ConvertTo-AACKeyVaultInsight { <# .SYNOPSIS Turns what Get-AACKeyVaultInsight read into per-vault health, posture, operations, objects, findings and a heat map. .DESCRIPTION No Azure calls. Per vault (AAC.KeyVaultInsight): health Availability (average %), Transactions, Successes (2xx), Unauthorized (401 - includes the SDKs' normal challenge), Forbidden (403), Throttled (429), OtherFailures, FailureRate (% of transactions: 403, 429 and other failures), LatencyAvgMs, LatencyMaxMs, SaturationAvg and SaturationMax (%), RequestTypes posture SoftDelete, PurgeProtection, PermissionModel (RBAC or Access policies), Network (Private, Firewall, Public), PrivateEndpoints, Defender, AuditLogsTo (the workspace, or empty) and AuditTable objects Secrets, Keys, Certificates, Expired, Expiring, NoExpiry, ObjectAccess (with -Object) Findings (AAC.KeyVaultFinding: Severity, Vault, Signal, Finding, Detail, Recommendation) - Signal is the heat map's column: Availability, Failures, Throttling, Saturation, Posture, Objects, Usage. The heat map scores each vault 0-100 per signal. Returns @{ Vaults; Operations; Objects; Findings; HeatMap; StatusCodes; RequestTypes; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [AllowEmptyCollection()] [object[]] $Vault, # Read-AACKeyVaultTelemetry's result. [hashtable] $Telemetry = @{}, [AllowEmptyCollection()] [object[]] $Pricing = @(), [AllowEmptyCollection()] [object[]] $Alert = @(), # Workspace resource ID (lower case) -> name. [hashtable] $WorkspaceName = @{}, # Read-AACKeyVaultLog's rows, or $null when not read. [AllowNull()] [object[]] $Log, # Read-AACKeyVaultObject's result, or $null when not read. [AllowNull()] [hashtable] $Object, [hashtable] $SubscriptionName = @{}, [int] $ExpiryDays = 30, [datetime] $Now = [datetime]::UtcNow ) $at = { param($Row, [string[]] $Keys) $value = $Row foreach ($key in $Keys) { if ($value -isnot [System.Collections.IDictionary]) { return $null } if ($value.Contains($key)) { $value = $value[$key]; continue } $found = $null foreach ($name in $value.Keys) { if ($name -eq $key) { $found = $value[$name]; break } } $value = $found } $value } # A metric answer's series: @(@{ Name; Dimension; Points }). $series = { param($Answer) if (-not $Answer -or (& $at $Answer 'Error') -or -not (& $at $Answer 'Body')) { return } foreach ($m in @(& $at $Answer 'Body', 'value')) { foreach ($ts in @(& $at $m 'timeseries')) { $dimension = @(@(& $at $ts 'metadatavalues') | ForEach-Object { [string](& $at $_ 'value') }) -join '/' @{ Name = [string](& $at $m 'name', 'value'); Dimension = $dimension; Points = @(& $at $ts 'data' | Where-Object { $null -ne $_ }) } } } } $sum = { param([object[]] $Points, [string] $Field) $t = 0.0; foreach ($p in $Points) { $v = & $at $p $Field; if ($null -ne $v) { $t += [double]$v } }; $t } $values = { param([object[]] $Points, [string] $Field) @(foreach ($p in $Points) { $v = & $at $p $Field; if ($null -ne $v) { [double]$v } }) } $mean = { param($Numbers) $n = @($Numbers | Where-Object { $null -ne $_ }); if ($n.Count) { ($n | Measure-Object -Average).Average } else { $null } } $peak = { param($Numbers) $n = @($Numbers | Where-Object { $null -ne $_ }); if ($n.Count) { ($n | Measure-Object -Maximum).Maximum } else { $null } } $round = { param($Value, [int] $Digits = 1) if ($null -eq $Value) { $null } else { [Math]::Round([double]$Value, $Digits) } } $subName = { param([string] $Id) if ($SubscriptionName.Contains($Id)) { $SubscriptionName[$Id] } else { $Id } } $defender = @{} foreach ($p in $Pricing) { $defender[[string]$p['subscriptionId']] = [string]$p['tier'] } $alerts = @{} foreach ($a in $Alert) { $id = [string]$a['resourceId']; $alerts[$id] = 1 + $(if ($alerts.Contains($id)) { $alerts[$id] } else { 0 }) } $findings = [System.Collections.Generic.List[object]]::new() $statusTotals = @{ '2xx' = 0.0; '401' = 0.0; '403' = 0.0; '429' = 0.0; 'Other' = 0.0 } $typeTotals = @{} $vaultRows = [System.Collections.Generic.List[object]]::new() $objectRows = [System.Collections.Generic.List[object]]::new() $heatRows = [System.Collections.Generic.List[object]]::new() $signals = @('Availability', 'Failures', 'Throttling', 'Saturation', 'Posture', 'Objects') foreach ($v in @($Vault | Sort-Object -Property { [string]$_['name'] })) { $id = [string]$v['id'] $name = [string]$v['name'] $p = $v['properties'] $t = if ($Telemetry.Contains($id)) { $Telemetry[$id] } else { @{ Core = $null; Results = $null; Types = $null; Settings = $null } } $find = { param([string] $Severity, [string] $Signal, [string] $Title, [string] $Detail, [string] $Fix) $findings.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.KeyVaultFinding'; Severity = $Severity; Vault = $name; Signal = $Signal; Finding = $Title; Detail = $Detail; Recommendation = $Fix; ResourceGroup = [string]$v['resourceGroup']; Subscription = & $subName ([string]$v['subscriptionId']); ResourceId = $id }) } # --- Health: the workbook's Overview and Failures tabs, and saturation ---------------------------------- $core = @{} foreach ($s in @(& $series $t.Core)) { $core[$s.Name] = $s.Points } $pointsOf = { param([string] $Metric) if ($core.Contains($Metric)) { @($core[$Metric]) } else { @() } } $availability = & $round (& $mean (& $values (& $pointsOf 'Availability') 'average')) 3 $transactions = [int](& $sum (& $pointsOf 'ServiceApiHit') 'total') $latencyAvg = & $round (& $mean (& $values (& $pointsOf 'ServiceApiLatency') 'average')) $latencyMax = & $round (& $peak (& $values (& $pointsOf 'ServiceApiLatency') 'maximum')) $saturationAvg = & $round (& $mean (& $values (& $pointsOf 'SaturationShoebox') 'average')) $saturationMax = & $round (& $peak (& $values (& $pointsOf 'SaturationShoebox') 'maximum')) $codes = @{ '2xx' = 0.0; '401' = 0.0; '403' = 0.0; '429' = 0.0; 'Other' = 0.0 } foreach ($s in @(& $series $t.Results)) { $n = & $sum $s.Points 'total' $bucket = if ($s.Dimension -match '^2\d\d$') { '2xx' } elseif ($s.Dimension -in '401', '403', '429') { $s.Dimension } else { 'Other' } $codes[$bucket] += $n; $statusTotals[$bucket] += $n } $types = @{} foreach ($s in @(& $series $t.Types)) { $n = & $sum $s.Points 'total'; if ($n) { $types[$s.Dimension] = $n; $typeTotals[$s.Dimension] = $n + $(if ($typeTotals.Contains($s.Dimension)) { $typeTotals[$s.Dimension] } else { 0 }) } } $failed = $codes['403'] + $codes['429'] + $codes['Other'] $failureRate = if ($transactions) { & $round (100.0 * $failed / $transactions) 2 } else { $null } $throttleRate = if ($transactions) { 100.0 * $codes['429'] / $transactions } else { 0 } $metricsRead = [bool]($t.Core -and -not (& $at $t.Core 'Error')) # --- Posture --------------------------------------------------------------------------------------------- $softDelete = [string](& $at $p 'enableSoftDelete') -ne 'False' $purge = [string](& $at $p 'enablePurgeProtection') -eq 'True' $rbac = [string](& $at $p 'enableRbacAuthorization') -eq 'True' $network = if ([string](& $at $p 'publicNetworkAccess') -eq 'Disabled') { 'Private' } elseif ([string](& $at $p 'networkAcls', 'defaultAction') -eq 'Deny') { 'Firewall' } else { 'Public' } $endpoints = @(@(& $at $p 'privateEndpointConnections') | Where-Object { [string](& $at $_ 'properties', 'privateLinkServiceConnectionState', 'status') -eq 'Approved' }).Count $plan = [string]$defender[[string]$v['subscriptionId']] $audit = $null foreach ($setting in @(if ($t.Settings -and -not (& $at $t.Settings 'Error')) { & $at $t.Settings 'Items' })) { $sp = & $at $setting 'properties' $workspace = ([string](& $at $sp 'workspaceId')).ToLowerInvariant() if (-not $workspace) { continue } $on = @(@(& $at $sp 'logs') | Where-Object { (& $at $_ 'enabled') -eq $true -and ([string](& $at $_ 'category') -eq 'AuditEvent' -or [string](& $at $_ 'categoryGroup') -in 'audit', 'allLogs') }).Count if ($on) { $audit = @{ Workspace = $workspace; Table = $(if ([string](& $at $sp 'logAnalyticsDestinationType') -eq 'Dedicated') { 'AZKVAuditLogs' } else { 'AzureDiagnostics' }) }; break } } $postureChecks = @($softDelete, $purge, $rbac, ($network -ne 'Public' -or $endpoints -gt 0), [bool]$audit, ($plan -eq 'Standard')) # --- Objects --------------------------------------------------------------------------------------------- $mine = @(if ($Object) { $Object.Items | Where-Object { $_.VaultId -eq $id } }) $access = if ($Object) { [string]$Object.Access[$id] } else { '' } foreach ($o in $mine) { $days = if ($o.Expires) { [Math]::Floor(($o.Expires - $Now).TotalDays) } else { $null } $status = if (-not $o.Enabled) { 'Disabled' } elseif ($null -eq $o.Expires) { 'No expiry' } elseif ($o.Expires -lt $Now) { 'Expired' } elseif ($days -le $ExpiryDays) { 'Expiring' } else { 'OK' } $objectRows.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.KeyVaultObject'; Vault = $name; Kind = $o.Kind; Name = $o.Name; Enabled = $o.Enabled; Expires = $o.Expires; DaysLeft = $days; Status = $status; ResourceGroup = [string]$v['resourceGroup']; Subscription = & $subName ([string]$v['subscriptionId']); ResourceId = $id }) } $countOf = { param([string] $S) @($objectRows | Where-Object { $_.ResourceId -eq $id -and $_.Status -eq $S }).Count } $expired = & $countOf 'Expired'; $expiring = & $countOf 'Expiring'; $noExpiry = & $countOf 'No expiry' # --- Findings -------------------------------------------------------------------------------------------- if (-not $softDelete) { & $find 'High' 'Posture' 'Soft delete off' 'A deleted vault, key or secret can''t be recovered.' 'Turn soft delete on (it can''t be turned off again).' } if (-not $purge) { & $find 'High' 'Posture' 'Purge protection off' 'Anyone with the right role can purge deleted keys and secrets before the retention period ends.' 'Turn on purge protection.' } if ($network -eq 'Public' -and -not $endpoints) { & $find 'High' 'Posture' 'Open to every network' 'Public network access is on, with no firewall rules and no private endpoint.' 'Disable public access and use a private endpoint, or deny by default and allow only the networks that need it.' } if (-not $rbac) { & $find 'Medium' 'Posture' 'Access policies, not RBAC' 'Access policies are vault-wide and can''t be scoped, audited or managed with PIM like Azure RBAC.' 'Migrate to the Azure RBAC permission model.' } if (-not $audit) { & $find 'Medium' 'Posture' 'No audit logs to Log Analytics' 'AuditEvent isn''t sent to a workspace: who did what can''t be investigated (the workbook''s Operations tab stays empty).' 'Add a diagnostic setting sending the audit category group to a Log Analytics workspace (resource-specific table).' } if ($plan -ne 'Standard') { & $find 'Low' 'Posture' 'Defender for Key Vault off' 'Unusual access, such as from a Tor exit node or an unfamiliar app, raises no alert.' 'Turn on Microsoft Defender for Key Vault for the subscription.' } if ($alerts.Contains($id)) { & $find 'High' 'Posture' 'Defender alerts in the last 30 days' "$($alerts[$id]) alert(s) on this vault." 'Investigate them in Defender for Cloud > Security alerts.' } if ($metricsRead -and $transactions -gt 0 -and $null -ne $availability -and $availability -lt 99.9) { & $find 'High' 'Availability' 'Availability below 99.9%' "Average availability $availability% over the period." 'Check Resource Health and the failing requests; retry transient failures with backoff in the clients.' } if ($codes['429'] -gt 0) { & $find $(if ($throttleRate -ge 1 -or $codes['429'] -ge 100) { 'High' } else { 'Medium' }) 'Throttling' 'Requests throttled (429)' ('{0:N0} throttled request(s), {1:N2}% of transactions.' -f $codes['429'], $throttleRate) 'Cache secrets in the application (don''t read them per request), spread load over vaults, and retry with exponential backoff.' } if ($null -ne $saturationMax -and $saturationMax -ge 50) { & $find $(if ($saturationMax -ge 75) { 'High' } else { 'Medium' }) 'Saturation' 'Vault close to its limits' "Overall Vault Saturation peaked at $saturationMax% (average $saturationAvg%)." 'Reduce calls (cache, batch), or split the load across vaults before it throttles.' } if ($codes['403'] -gt 0) { & $find 'Medium' 'Failures' 'Requests denied (403)' ('{0:N0} request(s) refused for lack of permission.' -f $codes['403']) 'Find the caller (-IncludeOperations) and give its identity the role or access policy it needs - or stop the call.' } if ($codes['Other'] -gt 0 -and $transactions -and (100.0 * $codes['Other'] / $transactions) -ge 1) { & $find 'Medium' 'Failures' 'Other failed requests' ('{0:N0} request(s) failed (not 401, 403 or 429).' -f $codes['Other']) 'Look at the failing operations (-IncludeOperations): missing objects (404), bad requests (400) or service errors (5xx).' } if ($metricsRead -and $transactions -eq 0) { & $find 'Low' 'Usage' 'No transactions in the period' 'Nothing called the vault: it may be unused.' 'Confirm it is still needed; delete it (it is soft-deleted, so recoverable) or document why it is idle.' } if ($expired) { & $find 'High' 'Objects' 'Expired secrets, keys or certificates' "$expired object(s) have expired." 'Rotate or remove them; anything still using them is failing or about to.' } if ($expiring) { & $find 'Medium' 'Objects' "Expiring within $ExpiryDays days" "$expiring object(s) expire soon." 'Rotate them now, and set up rotation policies or Event Grid near-expiry notifications.' } if ($noExpiry) { & $find 'Low' 'Objects' 'Objects without an expiry date' "$noExpiry enabled object(s) never expire." 'Set expiry dates and rotation policies, so stale credentials don''t live forever.' } if ($access -in 'Not reachable', 'Denied') { & $find 'Info' 'Objects' "Objects not read: $($access.ToLowerInvariant())" $(if ($access -eq 'Denied') { 'Your account has no data-plane permission to list objects.' } else { 'The vault''s firewall or private endpoint blocks this machine.' }) $(if ($access -eq 'Denied') { 'Grant Key Vault Reader (RBAC) or a List access policy to read expiry dates.' } else { 'Run from a network the vault allows, or skip -IncludeObjects for it.' }) } # --- Heat map: each signal 0-100 ------------------------------------------------------------------------ $cell = { param($Score) if ($null -eq $Score) { $null } else { @{ Score = [Math]::Round([Math]::Max(0, [Math]::Min(100, [double]$Score)), 1) } } } $scores = [ordered]@{ Availability = $(if ($metricsRead -and $transactions -gt 0 -and $null -ne $availability) { ($availability - 99) * 100 } else { $null }) Failures = $(if ($transactions) { 100 - $failureRate * 10 } else { $null }) Throttling = $(if ($transactions) { 100 - $throttleRate * 20 } else { $null }) Saturation = $(if ($null -ne $saturationMax) { 100 - $saturationMax } else { $null }) Posture = 100.0 * @($postureChecks | Where-Object { $_ }).Count / $postureChecks.Count Objects = $(if ($mine.Count) { 100.0 * @($objectRows | Where-Object { $_.ResourceId -eq $id -and $_.Status -in 'OK', 'Disabled' }).Count / $mine.Count } else { $null }) } $cells = @(foreach ($s in $signals) { & $cell $scores[$s] }) $scored = @($cells | Where-Object { $_ }) $overall = if ($scored.Count) { [Math]::Round((@($scored | ForEach-Object { $_.Score }) | Measure-Object -Average).Average, 1) } else { $null } $heatRows.Add(@{ Label = $name; Filter = $name; Cells = $cells; Total = $(if ($null -ne $overall) { @{ Score = $overall } } else { $null }); Overall = $overall }) $vaultRows.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.KeyVaultInsight' Vault = $name Score = $overall Availability = $availability Transactions = $transactions Successes = [int]$codes['2xx'] Unauthorized = [int]$codes['401'] Forbidden = [int]$codes['403'] Throttled = [int]$codes['429'] OtherFailures = [int]$codes['Other'] FailureRate = $failureRate LatencyAvgMs = $latencyAvg LatencyMaxMs = $latencyMax SaturationAvg = $saturationAvg SaturationMax = $saturationMax RequestTypes = (@($types.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 4 | ForEach-Object { '{0} {1:N0}' -f $_.Key, $_.Value }) -join ', ') Sku = [string]$v['sku'] SoftDelete = $softDelete PurgeProtection = $purge PermissionModel = $(if ($rbac) { 'RBAC' } else { 'Access policies' }) Network = $network PrivateEndpoints = $endpoints Defender = $(if ($plan -eq 'Standard') { 'On' } elseif ($plan) { 'Off' } else { 'Unknown' }) AuditLogsTo = $(if ($audit) { if ($WorkspaceName.Contains($audit.Workspace)) { $WorkspaceName[$audit.Workspace] } else { ($audit.Workspace -split '/')[-1] } } else { '' }) AuditTable = $(if ($audit) { $audit.Table } else { '' }) AuditWorkspaceId = $(if ($audit) { $audit.Workspace } else { '' }) Secrets = @($mine | Where-Object Kind -EQ 'Secret').Count Keys = @($mine | Where-Object Kind -EQ 'Key').Count Certificates = @($mine | Where-Object Kind -EQ 'Certificate').Count Expired = $expired Expiring = $expiring NoExpiry = $noExpiry ObjectAccess = $access MetricsError = $(if ($t.Core -and (& $at $t.Core 'Error')) { [string](& $at $t.Core 'Error') } else { '' }) Findings = @($findings | Where-Object { $_.ResourceId -eq $id }).Count Location = [string]$v['location'] ResourceGroup = [string]$v['resourceGroup'] Subscription = & $subName ([string]$v['subscriptionId']) ResourceId = $id }) } # --- Operations (audit logs), by vault, operation and caller ------------------------------------------------- $names = @{} foreach ($v in $vaultRows) { $names[$v.ResourceId] = $v.Vault } $operations = @(foreach ($r in @($Log | Where-Object { $null -ne $_ })) { $vid = [string]$r['VaultId'] [pscustomobject][ordered]@{ PSTypeName = 'AAC.KeyVaultOperation'; Vault = $(if ($names.Contains($vid)) { $names[$vid] } else { ($vid -split '/')[-1] }); Operation = [string]$r['Operation'] Caller = [string]$r['Caller']; CallerIp = [string]$r['CallerIp']; Count = [int]$r['Count']; Failed = [int]$r['Failed']; AuthDenied = [int]$r['AuthDenied']; Throttled = [int]$r['Throttled'] P99Ms = $(if ($null -ne $r['P99Ms']) { [Math]::Round([double]$r['P99Ms'], 1) } else { $null }); ResourceId = $vid } }) foreach ($group in @($operations | Where-Object AuthDenied | Group-Object -Property ResourceId, Caller)) { $denied = ($group.Group | Measure-Object -Property AuthDenied -Sum).Sum if ($denied -lt 10) { continue } $first = $group.Group[0] $v = @($vaultRows | Where-Object ResourceId -EQ $first.ResourceId)[0] $findings.Add([pscustomobject][ordered]@{ PSTypeName = 'AAC.KeyVaultFinding'; Severity = 'Medium'; Vault = $first.Vault; Signal = 'Failures'; Finding = 'A caller is repeatedly denied' Detail = "$('{0:N0}' -f $denied) denied request(s) from $(if ($first.Caller) { $first.Caller } else { 'an unknown caller' })$(if ($first.CallerIp) { " ($($first.CallerIp))" }): $(@($group.Group | ForEach-Object { $_.Operation } | Select-Object -Unique) -join ', ')." Recommendation = 'Give that identity the role it needs, or fix its configuration (wrong vault, expired credential, missing role assignment).'; ResourceGroup = $v.ResourceGroup; Subscription = $v.Subscription; ResourceId = $first.ResourceId }) } $rank = @{ High = 0; Medium = 1; Low = 2; Info = 3 } $worst = @($heatRows | Sort-Object -Property @{ Expression = { if ($null -eq $_.Overall) { 101 } else { $_.Overall } } }, Label | Select-Object -First 40) @{ Vaults = $vaultRows.ToArray() Operations = $operations Objects = $objectRows.ToArray() Findings = @($findings | Sort-Object -Property @{ Expression = { $rank[$_.Severity] } }, Vault, Finding) HeatMap = @{ Title = 'Vaults by signal'; RowHeader = 'Vault'; Columns = $signals; Rows = $worst; Table = 'findings'; RowKey = 'Vault'; ColumnKey = 'Signal'; ConsoleColumns = 6 Note = "0-100 per signal, worst vaults first$(if ($heatRows.Count -gt 40) { " (40 of $($heatRows.Count))" }): availability, failures (403, 429, other), throttling, saturation, posture, objects. Click a cell for its findings." } StatusCodes = $statusTotals RequestTypes = $typeTotals Stats = @{ Vaults = $vaultRows.Count Transactions = [long](($vaultRows | Measure-Object -Property Transactions -Sum).Sum) Throttled = [long]$statusTotals['429'] Forbidden = [long]$statusTotals['403'] NoAuditLogs = @($vaultRows | Where-Object { -not $_.AuditLogsTo }).Count Saturated = @($vaultRows | Where-Object { $_.SaturationMax -ge 75 }).Count Expired = @($objectRows | Where-Object Status -EQ 'Expired').Count Expiring = @($objectRows | Where-Object Status -EQ 'Expiring').Count High = @($findings | Where-Object Severity -EQ 'High').Count Idle = @($vaultRows | Where-Object { $_.Transactions -eq 0 -and -not $_.MetricsError }).Count } } } |