Public/Get-AACAttackPath.ps1
|
function Get-AACAttackPath { <# .EXTERNALHELP Azure.Admin.Console-help.xml .SYNOPSIS Maps the attack paths through your Azure estate - from what the Internet can reach, through the identities it runs as, to what an attacker could then control or read - with each path's risk, blast radius and the fix, most dangerous first. .DESCRIPTION Reads the estate in one Azure Resource Graph batch and builds the paths an attacker with a foothold would take: Internet > vm-web-01 (public IP: RDP 3389) > system-assigned identity > Contributor on subscription sub-prod (412 resources) Entry points: VMs whose public IP the NSGs (NIC and subnet, rule by rule in priority order) open to the Internet - management, database or any port; App Service and Function apps open to the public; AKS clusters with a public API server and no authorized IP ranges; and data stores (storage, key vaults, SQL, Cosmos DB, PostgreSQL, MySQL) open to every network. From each entry: its managed identities and their role assignments (read tenant-wide, so management group roles count) - control roles (Owner, Contributor, User Access Administrator, RBAC Administrator, custom roles with '*' or Microsoft.Authorization writes) and data access (data roles, key vault access policies). The blast radius is what that reaches: every resource under a controlled scope, the data stores readable, and a VM's neighbours in its virtual network. Risk: Critical - an open entry whose identity controls a subscription, management group or the tenant; High - control of a resource group, data access, an open management port, anonymous storage; Medium - other open ports, open data stores, a public API server; Low - storage that accepts every network. Each path says what to do and how much effort it is, so quick wins come first. With Defender CSPM, Defender for Cloud's own attack paths are added (Source 'Defender for Cloud'); without it, a notice says so. What it doesn't see: network routes through firewalls or NVAs, application-level flaws, and identities outside Azure RBAC. It shows where to look first, not proof of compromise. 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 paths that start in these resource groups. .PARAMETER Severity Only paths of these risks (Critical, High, Medium, Low). .PARAMETER CsvPath Write the paths 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 paths. .PARAMETER NoDisplay Return the paths without showing the view. .PARAMETER NoPaging Show the whole view at once. .EXAMPLE Get-AACAttackPath Every attack path in the subscriptions you can see, most dangerous first. .EXAMPLE Get-AACAttackPath -SubscriptionId $prod -Severity Critical, High -HtmlPath .\out\AttackPaths.html The critical and high paths in production, as an HTML report. .EXAMPLE Get-AACAttackPath -NoDisplay | Where-Object Category -EQ 'Internet to subscription control' | Select-Object Resource, Path, BlastRadius The footholds that lead to a whole subscription. .OUTPUTS AAC.AttackPath #> [CmdletBinding()] [OutputType('AAC.AttackPath')] param( [ValidatePattern('^[0-9a-fA-F]{8}(-[0-9a-fA-F]{4}){3}-[0-9a-fA-F]{12}$')] [string[]] $SubscriptionId, [string[]] $ManagementGroupId, [string[]] $ResourceGroupName, [ValidateSet('Critical', 'High', 'Medium', 'Low')] [string[]] $Severity, [string] $CsvPath, [string] $HtmlPath, [string] $PdfPath, [string] $Title = 'Azure attack paths', [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 { $_ }) } $null = Get-AACAccessToken if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Attack paths' -Color 'deepskyblue3_1' } $state = 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-AACAttackPathQuery Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading the exposure, identities, roles and data stores' $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @('defender', 'clusters', 'apps', 'identities', 'roleDefinitions', 'stores') -OnProgress { param($Name, $Done, $Total) Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $Name ($Done of $Total)" } Update-AACProgress -Id 'read' -Complete -Description ('Read {0:N0} VM(s), {1:N0} public IP(s), {2:N0} NSG(s), {3:N0} role assignment(s)' -f @($read.Rows['vms']).Count, @($read.Rows['publicIps']).Count, @($read.Rows['nsgs']).Count, @($read.Rows['roleAssignments']).Count) Update-AACProgress -Id 'paths' -Indeterminate -Description 'Building the attack paths' $chain = @{} foreach ($s in $scope.Subscriptions) { $chain[([string]$s['subscriptionId']).ToLowerInvariant()] = @(@($s['chain']) | Where-Object { $_ -is [System.Collections.IDictionary] } | ForEach-Object { ([string]$_['name']).ToLowerInvariant() }) } $result = ConvertTo-AACAttackPath -Read $read -SubscriptionName $scope.Names -SubscriptionChain $chain Update-AACProgress -Id 'paths' -Complete -Description ('{0:N0} attack path(s): {1:N0} critical, {2:N0} high' -f $result.Stats.Paths, $result.Stats.Critical, $result.Stats.High) @{ Result = $result; Scope = $scope } } $result = $state.Result $paths = @($result.Paths) if ($ResourceGroupName) { $groups = @($ResourceGroupName | ForEach-Object { $_.ToLowerInvariant() }); $paths = @($paths | Where-Object { $groups -contains ([string]$_.ResourceGroup).ToLowerInvariant() }) } if ($Severity) { $paths = @($paths | Where-Object { $Severity -contains $_.Severity }) } $rank = Get-AACSeverityRank $critical = @($paths | Where-Object Severity -EQ 'Critical').Count $high = @($paths | Where-Object Severity -EQ 'High').Count $categoryTones = @{ 'Internet to subscription control' = 'bad'; 'Internet to resource group control' = 'bad'; 'Internet to data' = 'warn'; 'Management port open to the Internet' = 'warn'; 'Port open to the Internet' = 'info'; 'Data store open to the Internet' = 'warn'; 'Public API server' = 'info'; 'Defender for Cloud attack path' = 'violet' } $report = @{ Subtitle = 'Attack paths: from the Internet, through identities, to control and data' Facts = [ordered]@{ Scope = $state.Scope.Label; Sources = $(if ($result.Stats.Defender) { 'Defender for Cloud and the estate' } else { 'the estate (Resource Graph)' }) } Status = $(if ($critical -or $high) { 'Failed' } elseif ($paths.Count) { 'Warning' } else { 'Success' }) Headline = $(if ($paths.Count) { "$($paths.Count) attack path(s): $critical critical, $high high - the largest blast radius is $($result.Stats.MaxRadius) resource(s)" } else { 'No attack paths: nothing in scope is open to the Internet with access beyond itself.' }) Tiles = @( @{ Value = '{0:N0}' -f $critical; Label = 'critical paths'; Tone = $(if ($critical) { 'bad' } else { 'good' }); Table = 'paths'; Filters = @{ Severity = 'Critical' } } @{ Value = '{0:N0}' -f $high; Label = 'high'; Tone = $(if ($high) { 'bad' } else { 'good' }); Table = 'paths'; Filters = @{ Severity = 'High' } } @{ Value = '{0:N0}' -f $result.Stats.ExposedVms; Label = 'VMs open to the Internet'; Tone = $(if ($result.Stats.ExposedVms) { 'warn' } else { 'good' }) } @{ Value = '{0:N0}' -f $result.Stats.Management; Label = 'with management ports open'; Tone = $(if ($result.Stats.Management) { 'bad' } else { 'good' }) } @{ Value = '{0:N0}' -f $result.Stats.ExposedData; Label = 'data stores open'; Tone = $(if ($result.Stats.ExposedData) { 'warn' } else { 'good' }); Table = 'paths'; Filters = @{ Category = 'Data store open to the Internet' } } @{ Value = '{0:N0}' -f $result.Stats.MaxRadius; Label = 'largest blast radius'; Tone = 'violet' } ) Notices = @($result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } }) Charts = @( @{ Title = 'Paths by risk'; Kind = 'donut'; CenterLabel = 'paths'; Items = @(foreach ($s in 'Critical', 'High', 'Medium', 'Low') { $n = @($paths | Where-Object Severity -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $rank.Tone[$s]; Filter = $s } } }); Table = 'paths'; Column = 'Severity' } @{ Title = 'Paths by kind'; Items = @($paths | Group-Object Category | Sort-Object Count -Descending | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'paths'; Column = 'Category'; Tone = 'warn'; Console = $true } @{ Title = 'Largest blast radius'; Items = @($paths | Sort-Object BlastRadius -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Resource; Value = $_.BlastRadius; Filter = $_.Resource } }); Table = 'paths'; Column = 'Resource'; Tone = 'violet' } ) Tables = @( @{ Id = 'paths'; Title = 'Attack paths'; Section = 'Attack paths'; Rows = $paths; Noun = 'paths'; GroupBy = @('Severity', 'Category', 'Subscription', 'Source'); ConsoleLimit = 20 Empty = 'No attack paths: nothing in scope is open to the Internet with access beyond itself.' Columns = @( @{ Key = 'Severity'; Label = 'Risk'; Type = 'badge'; Tones = $rank.Tone; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Category'; Label = 'Kind'; Type = 'badge'; Tones = $categoryTones; Facet = $true; Console = $true; Pdf = $true } @{ Key = 'Resource'; Label = 'Entry point'; Type = 'resource'; Console = $true; Pdf = $true } @{ Key = 'Path'; Label = 'Path'; Type = 'wide'; Console = $true; Pdf = $true } @{ Key = 'BlastRadius'; Label = 'Blast radius'; Type = 'number'; Console = $true; Pdf = $true } @{ Key = 'Effort'; Label = 'Effort'; Type = 'badge'; Tones = @{ Low = 'good'; Medium = 'warn'; High = 'bad' }; Facet = $true; Console = $true } @{ Key = 'Subscription'; Label = 'Subscription'; Facet = $true } @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true } @{ Key = 'Exposure'; Label = 'Exposure'; Type = 'wide' } @{ Key = 'Targets'; Label = 'What it reaches'; Type = 'wide'; Pdf = $true } @{ Key = 'Impact'; Label = 'Impact'; Type = 'wide' } @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true } @{ Key = 'Source'; Label = 'Source'; Facet = $true } @{ Key = 'Link'; Label = 'Docs'; Type = 'link'; Text = 'Docs' } ) } ) Hint = '-Severity Critical, High narrows the list; -NoDisplay returns the paths; -HtmlPath, -PdfPath or -CsvPath for a report.' } Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $paths -Noun 'path' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) ` -ShowView:$interactive -NoPaging:$NoPaging -Object $paths -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward) } |