Private/ConvertTo-AACAttackPath.ps1
|
function ConvertTo-AACAttackPath { <# .SYNOPSIS Builds the attack paths through an Azure estate - from what the Internet can reach, through the identities it runs as, to what those identities control or can read - with each path's risk, blast radius and what to do; and adds Defender for Cloud's own attack paths. .DESCRIPTION Entry points: VM a public IP whose NSGs (NIC and subnet, rules in priority order, then Azure's defaults) let the Internet in - a management port (22, 3389, 5985/6), a database port, or any port; a Basic IP with no NSG is open, a Standard one closed App an App Service or Function app open to the public Cluster an AKS cluster with a public API server and no authorized IP ranges Data store a storage account, key vault or database open to the Internet (its own path: data exposure) Pivot: the entry's managed identities (system- and user-assigned; AKS's kubelet identity too). Reach: their role assignments - control Owner, Contributor, User Access Administrator, Role Based Access Control Administrator, or a custom role with '*' or Microsoft.Authorization write - over a scope data a role with data actions (Storage Blob Data, Key Vault Secrets...), or a key vault access policy with secret, key or certificate permissions Blast radius: the resources under each scope controlled (a management group: every subscription under it), the data stores readable, and - for a VM - the other VMs in its virtual network. Risk: Critical an Internet-open entry whose identity controls a subscription, a management group or the tenant High control of a resource group or data access from an open entry; a management port open to the Internet; a storage account allowing anonymous access Medium another port open to the Internet; a data store open to every network; a public AKS API server Low a storage account that accepts every network (keys or tokens still needed) Returns @{ Paths (AAC.AttackPath); Stats; Notices }. #> [CmdletBinding()] [OutputType([hashtable])] param( # Invoke-AACGraphBatch's result for Get-AACAttackPathQuery: @{ Rows; Errors }. [Parameter(Mandatory)] [hashtable] $Read, # Subscription ID (lower case) -> name. [System.Collections.IDictionary] $SubscriptionName = @{}, # Subscription ID (lower case) -> its management group names (lower case). [System.Collections.IDictionary] $SubscriptionChain = @{} ) $rows = { param([string] $Name) @(if ($Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) } $get = { param($Row, [string] $Name) if ($Row -is [System.Collections.IDictionary]) { $Row[$Name] } elseif ($null -ne $Row) { $p = $Row.PSObject.Properties[$Name]; if ($p) { $p.Value } } } $lower = { param($Text) ([string]$Text).ToLowerInvariant() } $leaf = { param($Id) ([string]$Id).TrimEnd('/') -replace '^.*/', '' } $subOf = { param($Id) if ([string]$Id -match '(?i)^/subscriptions/([^/]+)') { $Matches[1].ToLowerInvariant() } else { '' } } $subLabel = { param($Id) $s = & $subOf $Id; if ($SubscriptionName.Contains($s)) { [string]$SubscriptionName[$s] } else { $s } } $severity = Get-AACSeverityRank # --- Ports the Internet can reach ------------------------------------------------------------------------- # (A plain list and a lookup: an ordered dictionary would read an integer key as a position.) $watched = @(22, 3389, 5985, 5986, 1433, 3306, 5432, 6379, 27017, 445, 80, 443) $portName = @{ 22 = 'SSH'; 3389 = 'RDP'; 5985 = 'WinRM'; 5986 = 'WinRM'; 1433 = 'SQL Server'; 3306 = 'MySQL'; 5432 = 'PostgreSQL'; 6379 = 'Redis'; 27017 = 'MongoDB'; 445 = 'SMB'; 80 = 'HTTP'; 443 = 'HTTPS' } $management = @(22, 3389, 5985, 5986) $nsgRules = @{} foreach ($nsg in (& $rows 'nsgs')) { $nsgRules[(& $lower (& $get $nsg 'id'))] = @(& $get $nsg 'rules') } $fromInternet = { param($Rule) $p = & $get $Rule 'properties' $sources = @(@(& $get $p 'sourceAddressPrefix') + @(& $get $p 'sourceAddressPrefixes') | Where-Object { $_ }) @($sources | Where-Object { [string]$_ -in '*', 'Internet', '0.0.0.0/0', 'Any', '::/0' }).Count -gt 0 } $coversPort = { param($Rule, [int] $Port) $p = & $get $Rule 'properties' foreach ($range in @(@(& $get $p 'destinationPortRange') + @(& $get $p 'destinationPortRanges') | Where-Object { $_ })) { $r = [string]$range if ($r -eq '*') { return $true } if ($r -match '^(\d+)-(\d+)$' -and $Port -ge [int]$Matches[1] -and $Port -le [int]$Matches[2]) { return $true } if ($r -match '^\d+$' -and [int]$r -eq $Port) { return $true } } $false } # Is $Port open from the Internet through one NSG? Its inbound rules by priority; the first that matches decides; none: denied (DenyAllInBound). $nsgAllows = { param([string] $NsgId, [int] $Port) $inbound = @($nsgRules[$NsgId] | Where-Object { [string](& $get (& $get $_ 'properties') 'direction') -eq 'Inbound' -and [string](& $get (& $get $_ 'properties') 'protocol') -in '*', 'Tcp', 'TCP' } | Sort-Object -Property { [int](& $get (& $get $_ 'properties') 'priority') }) foreach ($rule in $inbound) { if ((& $fromInternet $rule) -and (& $coversPort $rule $Port)) { return [string](& $get (& $get $rule 'properties') 'access') -eq 'Allow' } } $false } $subnetNsg = @{}; $subnetVnet = @{} foreach ($s in (& $rows 'subnets')) { $id = & $lower (& $get $s 'id'); $subnetNsg[$id] = & $lower (& $get $s 'nsg'); $subnetVnet[$id] = & $lower (& $get $s 'vnet') } $publicIpOf = @{} # ip configuration id -> public IP row foreach ($ip in (& $rows 'publicIps')) { $to = & $lower (& $get $ip 'attachedTo'); if ($to) { $publicIpOf[$to] = $ip } } # VM -> what the Internet reaches on it, and its virtual network. $exposure = @{} $vnetOfVm = @{} foreach ($nic in (& $rows 'nics')) { $vm = & $lower (& $get $nic 'vm') if (-not $vm) { continue } $nicNsg = & $lower (& $get $nic 'nsg') foreach ($config in @(& $get $nic 'ipConfigs')) { $configId = & $lower (& $get $config 'id') $p = & $get $config 'properties' $subnet = & $lower (& $get (& $get $p 'subnet') 'id') if ($subnet -and $subnetVnet.Contains($subnet)) { $vnetOfVm[$vm] = $subnetVnet[$subnet] } $ip = if ($publicIpOf.Contains($configId)) { $publicIpOf[$configId] } else { $null } if (-not $ip) { continue } $nsgs = @(@($nicNsg, $(if ($subnetNsg.Contains($subnet)) { $subnetNsg[$subnet] } else { '' })) | Where-Object { $_ }) $open = [System.Collections.Generic.List[string]]::new() if (-not $nsgs.Count) { # No NSG: a Basic public IP lets everything in; a Standard one, nothing. if ([string](& $get $ip 'sku') -ne 'Standard') { $open.Add('every port (no NSG, Basic public IP)') } } else { foreach ($port in $watched) { if (@($nsgs | Where-Object { & $nsgAllows $_ $port }).Count -eq $nsgs.Count) { $open.Add("$($portName[$port]) $port") } } } if ($open.Count) { $exposure[$vm] = @{ Ip = [string](& $get $ip 'ip'); Open = $open.ToArray() Management = @($open | Where-Object { $_ -match '\d+$' -and [int]($_ -replace '^.* ', '') -in $management -or $_ -like 'every port*' }).Count -gt 0 } } } } # --- Who can do what: roles and identities --------------------------------------------------------------------- $roles = @{} # role GUID -> @{ Name; Control; Data } foreach ($d in (& $rows 'roleDefinitions')) { $actions = @(@(& $get $d 'permissions') | ForEach-Object { @(& $get $_ 'actions') } | Where-Object { $_ }) $dataActions = @(@(& $get $d 'permissions') | ForEach-Object { @(& $get $_ 'dataActions') } | Where-Object { $_ }) $name = [string](& $get $d 'roleName') $roles[(& $leaf (& $get $d 'id')).ToLowerInvariant()] = @{ Name = $name Control = $name -in 'Owner', 'Contributor', 'User Access Administrator', 'Role Based Access Control Administrator' -or @($actions | Where-Object { $_ -eq '*' -or $_ -like 'Microsoft.Authorization/*' -or $_ -like 'Microsoft.Authorization/roleAssignments/write' }).Count -gt 0 Data = $dataActions.Count -gt 0 } } $assignmentsOf = @{} foreach ($a in (& $rows 'roleAssignments')) { $principal = & $lower (& $get $a 'principalId') if (-not $assignmentsOf.Contains($principal)) { $assignmentsOf[$principal] = [System.Collections.Generic.List[object]]::new() } $role = $roles[(& $leaf (& $get $a 'roleId')).ToLowerInvariant()] $assignmentsOf[$principal].Add(@{ Scope = & $lower (& $get $a 'scope'); Role = $(if ($role) { $role.Name } else { & $leaf (& $get $a 'roleId') }); Control = [bool]($role -and $role.Control); Data = [bool]($role -and $role.Data) }) } $identityPrincipal = @{} foreach ($i in (& $rows 'identities')) { $identityPrincipal[(& $lower (& $get $i 'id'))] = @{ Name = [string](& $get $i 'name'); PrincipalId = & $lower (& $get $i 'principalId') } } $principalsOf = { # A resource's identities: @{ Label; PrincipalId }. param($Identity, [string] $Extra) $list = [System.Collections.Generic.List[object]]::new() $system = & $lower (& $get $Identity 'principalId') if ($system) { $list.Add(@{ Label = 'system-assigned identity'; PrincipalId = $system }) } $user = & $get $Identity 'userAssignedIdentities' if ($user -is [System.Collections.IDictionary]) { foreach ($key in $user.Keys) { $principal = & $lower (& $get $user[$key] 'principalId') if (-not $principal -and $identityPrincipal.Contains((& $lower $key))) { $principal = $identityPrincipal[(& $lower $key)].PrincipalId } if ($principal) { $list.Add(@{ Label = "user-assigned identity $(& $leaf $key)"; PrincipalId = $principal }) } } } if ($Extra) { $list.Add(@{ Label = 'kubelet identity'; PrincipalId = (& $lower $Extra) }) } , $list.ToArray() } # --- The blast radius of a scope -------------------------------------------------------------------------------------- $countBySub = @{}; $countByGroup = @{}; $all = 0 foreach ($c in (& $rows 'counts')) { $s = & $lower (& $get $c 'subscriptionId'); $n = [int](& $get $c 'resources'); $all += $n $countBySub[$s] = [int]$countBySub[$s] + $n $countByGroup["$s/$(& $lower (& $get $c 'resourceGroup'))"] = $n } $scopeInfo = { param([string] $Scope) if ($Scope -eq '/' -or $Scope -eq '') { return @{ Kind = 'tenant'; Label = 'the whole tenant (root)'; Count = $all; Broad = $true } } if ($Scope -match '^/providers/microsoft.management/managementgroups/([^/]+)$') { $mg = $Matches[1] $count = 0; foreach ($s in $SubscriptionChain.Keys) { if (@($SubscriptionChain[$s]) -contains $mg) { $count += [int]$countBySub[$s] } } return @{ Kind = 'management group'; Label = "management group $mg"; Count = $count; Broad = $true } } if ($Scope -match '^/subscriptions/([^/]+)$') { $s = $Matches[1]; return @{ Kind = 'subscription'; Label = "subscription $(& $subLabel $Scope)"; Count = [int]$countBySub[$s]; Broad = $true } } if ($Scope -match '^/subscriptions/([^/]+)/resourcegroups/([^/]+)$') { return @{ Kind = 'resource group'; Label = "resource group $($Matches[2])"; Count = [int]$countByGroup["$($Matches[1])/$($Matches[2])"]; Broad = $false } } @{ Kind = 'resource'; Label = (& $leaf $Scope); Count = 1; Broad = $false } } # Key vault access policies: object ID -> vaults it can read secrets, keys or certificates of. $storeById = @{} $vaultReaders = @{} foreach ($store in (& $rows 'stores')) { $storeById[(& $lower (& $get $store 'id'))] = $store foreach ($policy in @(& $get $store 'accessPolicies')) { $permissions = & $get $policy 'permissions' $reads = @(@(& $get $permissions 'secrets') + @(& $get $permissions 'keys') + @(& $get $permissions 'certificates') | Where-Object { [string]$_ -in 'get', 'list', 'Get', 'List', 'all', 'All', 'decrypt', 'unwrapKey' }) if ($reads.Count) { $object = & $lower (& $get $policy 'objectId') if (-not $vaultReaders.Contains($object)) { $vaultReaders[$object] = [System.Collections.Generic.List[string]]::new() } $vaultReaders[$object].Add([string](& $get $store 'name')) } } } # --- The paths ----------------------------------------------------------------------------------------------------- $paths = [System.Collections.Generic.List[object]]::new() $vmCountByVnet = @{} foreach ($vm in $vnetOfVm.Keys) { $vmCountByVnet[$vnetOfVm[$vm]] = [int]$vmCountByVnet[$vnetOfVm[$vm]] + 1 } $addPath = { param($Entry, [string] $EntryKind, [string] $Exposure, [bool] $Management, $Principals, [int] $Lateral) $entryId = & $lower (& $get $Entry 'id') $reach = [System.Collections.Generic.List[object]]::new() foreach ($principal in @($Principals)) { foreach ($a in @(if ($assignmentsOf.Contains($principal.PrincipalId)) { $assignmentsOf[$principal.PrincipalId] })) { if (-not ($a.Control -or $a.Data)) { continue } $info = & $scopeInfo $a.Scope $reach.Add(@{ Identity = $principal.Label; Role = $a.Role; Control = $a.Control; Info = $info }) } foreach ($vault in @(if ($vaultReaders.Contains($principal.PrincipalId)) { $vaultReaders[$principal.PrincipalId] })) { $reach.Add(@{ Identity = $principal.Label; Role = 'Key Vault access policy (secrets, keys or certificates)'; Control = $false; Info = @{ Kind = 'resource'; Label = "key vault $vault"; Count = 1; Broad = $false } }) } } $controlBroad = @($reach | Where-Object { $_.Control -and $_.Info.Broad }) $controlNarrow = @($reach | Where-Object { $_.Control -and -not $_.Info.Broad }) $data = @($reach | Where-Object { -not $_.Control }) if (-not $reach.Count -and $EntryKind -ne 'VM') { return } # a public app with no reach beyond itself is its own business $risk = if ($controlBroad.Count) { 'Critical' } elseif ($controlNarrow.Count -or $data.Count) { 'High' } elseif ($Management) { 'High' } else { 'Medium' } $radius = [int](@($reach | ForEach-Object { $_.Info.Count }) | Measure-Object -Sum).Sum + $Lateral + 1 $steps = [System.Collections.Generic.List[string]]::new() $steps.Add('Internet') $steps.Add("$(& $get $Entry 'name') ($Exposure)") $top = @($reach | Sort-Object -Property @{ Expression = { if ($_.Control -and $_.Info.Broad) { 0 } elseif ($_.Control) { 1 } else { 2 } } }, @{ Expression = { $_.Info.Count }; Descending = $true } | Select-Object -First 1) if ($top.Count) { $steps.Add($top[0].Identity); $steps.Add("$($top[0].Role) on $($top[0].Info.Label)") } $targets = @($reach | ForEach-Object { "$($_.Role) on $($_.Info.Label) ($($_.Identity))" } | Select-Object -Unique) $category = if ($controlBroad.Count) { 'Internet to subscription control' } elseif ($controlNarrow.Count) { 'Internet to resource group control' } elseif ($data.Count) { 'Internet to data' } elseif ($Management) { 'Management port open to the Internet' } else { 'Port open to the Internet' } $remedy = [System.Collections.Generic.List[string]]::new() if ($EntryKind -eq 'VM') { $remedy.Add($(if ($Management) { 'Close the management ports to the Internet: Azure Bastion or just-in-time access instead, and no public IP on the VM.' } else { 'Allow only the sources that need it in the NSG, or put the VM behind a load balancer, Application Gateway or Front Door with a WAF.' })) } if ($EntryKind -eq 'App') { $remedy.Add('Restrict who can reach the app (access restrictions, a private endpoint, or Front Door with a WAF).') } if ($EntryKind -eq 'Cluster') { $remedy.Add('Make the API server private, or set authorized IP ranges.') } if ($controlBroad.Count -or $controlNarrow.Count) { $remedy.Add("Least privilege: replace $(@(($controlBroad + $controlNarrow) | ForEach-Object { $_.Role } | Select-Object -Unique) -join ', ') with a role scoped to the resources the workload actually manages.") } if ($data.Count) { $remedy.Add('Grant data roles on the one store (or container) the workload needs, read-only where it only reads.') } $paths.Add((New-AACFinding -TypeName 'AAC.AttackPath' -Severity $risk -Category $category -Finding "$(& $get $Entry 'name'): $(if ($reach.Count) { "Internet access reaches $($top[0].Role) on $($top[0].Info.Label)" } else { "open to the Internet - $Exposure" })" ` -ResourceId $entryId -Subscription (& $subLabel $entryId) -Detail ((@("Open: $Exposure", $(if ($Lateral) { "Lateral: $Lateral other VM(s) in the same virtual network" }), $(if ($targets.Count) { "Reach: $($targets -join '; ')" })) | Where-Object { $_ }) -join '. ') ` -Impact "An attacker who compromises it can reach $radius resource(s)$(if ($controlBroad.Count) { ' - and take over the subscription' } elseif ($data.Count) { ' - and read their data' })." ` -Remediation ($remedy -join ' ') -Effort $(if ($EntryKind -eq 'VM' -and -not $reach.Count) { 'Low' } elseif ($controlBroad.Count -or $controlNarrow.Count) { 'Medium' } else { 'Low' }) ` -Link 'https://learn.microsoft.com/azure/defender-for-cloud/concept-attack-path' -Property ([ordered]@{ Source = 'Derived'; EntryKind = $EntryKind; Exposure = $Exposure; Path = ($steps -join ' > '); BlastRadius = $radius; Targets = ($targets -join '; ') Identities = (@($Principals | ForEach-Object { $_.Label }) -join ', ') }))) } foreach ($vm in (& $rows 'vms')) { $id = & $lower (& $get $vm 'id') if (-not $exposure.Contains($id)) { continue } $e = $exposure[$id] $lateral = if ($vnetOfVm.Contains($id)) { [Math]::Max(0, [int]$vmCountByVnet[$vnetOfVm[$id]] - 1) } else { 0 } & $addPath $vm 'VM' "public IP $($e.Ip): $($e.Open -join ', ')" $e.Management (& $principalsOf (& $get $vm 'identity') '') $lateral } foreach ($app in (& $rows 'apps')) { if ([string](& $get $app 'publicAccess') -eq 'Disabled') { continue } & $addPath $app 'App' "public endpoint $(& $get $app 'hostname')" $false (& $principalsOf (& $get $app 'identity') '') 0 } foreach ($cluster in (& $rows 'clusters')) { if ((& $get $cluster 'private') -eq $true -or @(& $get $cluster 'ranges' | Where-Object { $_ }).Count) { continue } $principals = & $principalsOf (& $get $cluster 'identity') (& $get $cluster 'kubelet') $before = $paths.Count & $addPath $cluster 'Cluster' 'a public API server with no authorized IP ranges' $false $principals 0 if ($paths.Count -eq $before) { $id = & $lower (& $get $cluster 'id') $paths.Add((New-AACFinding -TypeName 'AAC.AttackPath' -Severity 'Medium' -Category 'Public API server' -Finding "$(& $get $cluster 'name'): the Kubernetes API server is open to the Internet" -ResourceId $id -Subscription (& $subLabel $id) ` -Detail 'No private cluster, no authorized IP ranges.' -Impact 'Anyone can try credentials or exploits against the cluster''s control plane.' -Remediation 'Make the API server private, or set authorized IP ranges.' -Effort 'Medium' ` -Link 'https://learn.microsoft.com/azure/aks/api-server-authorized-ip-ranges' -Property ([ordered]@{ Source = 'Derived'; EntryKind = 'Cluster'; Exposure = 'public API server'; Path = "Internet > $(& $get $cluster 'name') API server"; BlastRadius = 1; Targets = ''; Identities = '' }))) } } # Data stores the Internet reaches directly. $storeKind = @{ 'microsoft.storage/storageaccounts' = 'storage account'; 'microsoft.keyvault/vaults' = 'key vault'; 'microsoft.sql/servers' = 'SQL server'; 'microsoft.documentdb/databaseaccounts' = 'Cosmos DB account'; 'microsoft.dbforpostgresql/flexibleservers' = 'PostgreSQL server'; 'microsoft.dbformysql/flexibleservers' = 'MySQL server' } foreach ($store in (& $rows 'stores')) { $id = & $lower (& $get $store 'id'); $type = & $lower (& $get $store 'type'); $kind = $storeKind[$type] $public = [string](& $get $store 'publicAccess') -ne 'Disabled' $allNetworks = [string](& $get $store 'defaultAction') -ne 'Deny' $anonymous = $type -eq 'microsoft.storage/storageaccounts' -and [string](& $get $store 'blobPublic') -ne 'false' -and [string](& $get $store 'blobPublic') -ne 'False' if (-not $public) { continue } $risk = $null; $why = '' if ($anonymous -and $allNetworks) { $risk = 'High'; $why = 'accepts every network and allows anonymous blob access' } elseif ($type -eq 'microsoft.storage/storageaccounts' -and $allNetworks) { $risk = 'Low'; $why = 'accepts every network (a key, SAS or token is still needed)' } elseif ($type -eq 'microsoft.keyvault/vaults' -and $allNetworks) { $risk = 'Medium'; $why = 'accepts every network' } elseif ($type -notin 'microsoft.storage/storageaccounts', 'microsoft.keyvault/vaults') { $risk = 'Medium'; $why = 'has public network access enabled (only its firewall rules stand in the way)' } if (-not $risk) { continue } $paths.Add((New-AACFinding -TypeName 'AAC.AttackPath' -Severity $risk -Category 'Data store open to the Internet' -Finding "$(& $get $store 'name'): the $kind $why" -ResourceId $id -ResourceType $type -Subscription (& $subLabel $id) ` -Detail "Public network access: $(if (& $get $store 'publicAccess') { & $get $store 'publicAccess' } else { 'Enabled' }); default network action: $(if (& $get $store 'defaultAction') { & $get $store 'defaultAction' } else { 'n/a' })" ` -Impact "Its data is one stolen credential (or none, with anonymous access) away from anyone on the Internet." ` -Remediation "Turn off public network access and use a private endpoint; or allow only the networks that need it$(if ($anonymous) { ', and disallow anonymous blob access' })." -Effort $(if ($anonymous) { 'Low' } else { 'High' }) ` -Link 'https://learn.microsoft.com/azure/private-link/private-endpoint-overview' -Property ([ordered]@{ Source = 'Derived'; EntryKind = 'Data store'; Exposure = $why; Path = "Internet > $(& $get $store 'name')"; BlastRadius = 1; Targets = ''; Identities = '' }))) } # --- Defender for Cloud's attack paths ---------------------------------------------------------------------------------- foreach ($d in (& $rows 'defender')) { $p = & $get $d 'properties' $level = [string](& $get $p 'riskLevel') $risk = if ($level -in 'Critical', 'High', 'Medium', 'Low') { $level } else { 'High' } $entities = @(& $get (& $get $p 'graphComponent') 'entities') $names = @($entities | ForEach-Object { [string](& $get $_ 'entityName') } | Where-Object { $_ }) # The resource it starts from: the first entity that is one. $target = @($entities | Where-Object { [string](& $get $_ 'entityId') -like '/subscriptions/*/providers/*' } | Select-Object -First 1) $paths.Add((New-AACFinding -TypeName 'AAC.AttackPath' -Severity $risk -Category 'Defender for Cloud attack path' -Finding ([string](& $get $p 'displayName')) ` -ResourceId $(if ($target.Count) { [string](& $get $target[0] 'entityId') } else { '' }) -Subscription (& $subLabel ("/subscriptions/$(& $get $d 'subscriptionId')")) ` -Detail ([string](& $get $p 'description')) -Impact $((@(& $get $p 'riskCategories') | Where-Object { $_ }) -join ', ') ` -Remediation $(if (& $get $p 'remediation') { [string](& $get $p 'remediation') } else { 'Fix the recommendations on the path in Defender for Cloud > Attack path analysis.' }) -Effort 'Medium' ` -Link 'https://portal.azure.com/#view/Microsoft_Azure_Security/SecurityMenuBlade/~/AttackPathAnalysis' -Property ([ordered]@{ Source = 'Defender for Cloud'; EntryKind = ''; Exposure = ''; Path = ($names -join ' > '); BlastRadius = [Math]::Max(1, $entities.Count); Targets = ''; Identities = '' }))) } $notices = [System.Collections.Generic.List[string]]::new() foreach ($key in @($Read.Errors.Keys | Sort-Object)) { if (-not $Read.Errors[$key]) { continue } if ($key -eq 'defender') { $notices.Add("Defender for Cloud's attack paths couldn't be read (they need Defender CSPM): $($Read.Errors[$key]) The paths below are derived from the estate.") } else { $notices.Add("The $key couldn't be read: $($Read.Errors[$key]) - paths through them may be missing.") } } $sorted = @($paths | Sort-Object -Property @{ Expression = { $severity.Rank[$_.Severity] } }, @{ Expression = 'BlastRadius'; Descending = $true }, Resource) @{ Paths = $sorted Notices = $notices.ToArray() Stats = @{ Paths = $sorted.Count Critical = @($sorted | Where-Object Severity -EQ 'Critical').Count High = @($sorted | Where-Object Severity -EQ 'High').Count ExposedVms = $exposure.Count Management = @($exposure.Values | Where-Object Management).Count ExposedData = @($sorted | Where-Object Category -EQ 'Data store open to the Internet').Count Defender = @($sorted | Where-Object Source -EQ 'Defender for Cloud').Count MaxRadius = $(if ($sorted.Count) { [int](@($sorted.BlastRadius) | Measure-Object -Maximum).Maximum } else { 0 }) } } } |