Public/Get-AACSecurityAlert.ps1

function Get-AACSecurityAlert {
    <#
    .EXTERNALHELP Azure.Admin.Console-help.xml
    .SYNOPSIS
        Lists Microsoft Defender for Cloud's security alerts across your
        subscriptions - what was detected, on which resource, its MITRE
        ATT&CK tactics and techniques, the hosts, accounts and IP addresses
        involved, and what to do - open and most severe first, with the
        alerts that keep coming back called out.
    .DESCRIPTION
        The equivalent of Get-AzSecurityAlert (Az.Security), read for every
        subscription in one Azure Resource Graph query rather than one
        subscription and location at a time, and with more to filter on and
        more in each alert:
 
          Alert, Severity, Status what Defender for Cloud detected
          Intent, Techniques the MITRE ATT&CK tactics (kill-chain
                                    stages) and technique IDs
          Resource the Azure resource it is about (type,
                                    resource group, subscription), else the
                                    compromised entity
          Hosts, Accounts, the entities in the alert, each kind on
          IpAddresses, Entities its own, ready to pivot on
          TimeGenerated, AgeDays when, and how long ago
          Remediation Defender's steps, as plain text
          ExtendedProperties the alert's own details (a dictionary)
          Occurrences how many alerts in the list share its
                                    type and resource
          Incident a Defender incident (correlated alerts)
          AlertUrl the alert in the Azure portal
 
        By default the open alerts (Active and In progress); -Status picks
        others (-Name on its own reads every status, as Get-AzSecurityAlert
        -Name does). -Severity, -Status and -Days are applied in Resource
        Graph; the rest in PowerShell.
 
        At the prompt: tiles, the alerts by severity, MITRE tactic (in
        kill-chain order), day and resource, the alerts, and the recurring
        ones - three or more of a type on one resource, to fix at the source
        or tune with a suppression rule. Subscriptions with no Defender plan
        on are pointed out: they raise no alerts, so an empty list there
        proves nothing.
 
        Read-only; Reader (or Security Reader) is enough. Changing an alert's
        state is done in Defender for Cloud.
    .PARAMETER SubscriptionId
        Only these subscriptions.
    .PARAMETER ManagementGroupId
        Only the subscriptions under these management groups (at any depth).
    .PARAMETER ResourceGroupName
        Only alerts on resources in these resource groups.
    .PARAMETER ResourceId
        Only alerts on these resources, or on anything under them (a
        resource group or subscription ID works too). Takes ResourceId or Id
        from the pipeline, so resources from Get-AACInventory, Get-AzResource
        or Get-AzVM can be piped in.
    .PARAMETER Name
        Only alerts whose name (Get-AzSecurityAlert's -Name, the alert's
        resource name) or display name matches; wildcards work. Without
        -Status, every status is read.
    .PARAMETER AlertType
        Only alerts of these types (for example VM_SuspiciousLogin, or
        'K8S_*'); wildcards work.
    .PARAMETER Severity
        Only alerts of these severities (High, Medium, Low, Informational).
    .PARAMETER Status
        Only alerts in these states (Active, InProgress, Resolved,
        Dismissed). Default: Active and InProgress.
    .PARAMETER Tactic
        Only alerts with one of these MITRE ATT&CK tactics (for example
        InitialAccess, CredentialAccess, 'Lateral*'); wildcards work.
    .PARAMETER Days
        Only alerts generated in the last this many days.
    .PARAMETER Incident
        Only incidents: Defender's correlations of related alerts.
    .PARAMETER CsvPath
        Write the alerts 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 alerts.
    .PARAMETER NoDisplay
        Return the alerts without showing the view.
    .PARAMETER NoPaging
        Show the whole view at once.
    .EXAMPLE
        Get-AACSecurityAlert
        The open alerts in every subscription you can see, most severe first.
    .EXAMPLE
        Get-AACSecurityAlert -Severity High -Days 7 -HtmlPath .\out\Alerts.html
        This week's open High alerts, as an interactive HTML report.
    .EXAMPLE
        Get-AACSecurityAlert -Status Active, InProgress, Resolved, Dismissed -Days 90 -CsvPath .\out\Alerts.csv
        Every alert of the last 90 days, whatever its state, to CSV.
    .EXAMPLE
        Get-AACSecurityAlert -Tactic CredentialAccess, LateralMovement -NoDisplay | Select-Object Alert, Resource, Accounts, IpAddresses
        Who and where for the alerts that point to an attacker moving around.
    .EXAMPLE
        Get-AACSecurityAlert -NoDisplay | Where-Object IpAddresses -Match '203\.0\.113\.' | Group-Object Resource
        Every resource an IP address range shows up against.
    .EXAMPLE
        Get-AACInventory -NoDisplay | Where-Object ResourceGroup -EQ 'rg-prod' | Get-AACSecurityAlert
        The open alerts on the resources piped in.
    .EXAMPLE
        Get-AACSecurityAlert -Name '2517*' -NoDisplay | Select-Object -ExpandProperty ExtendedProperties
        One alert by its name, whatever its state, with Defender's details.
    .OUTPUTS
        AAC.SecurityAlert
    #>

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

        [string[]] $ManagementGroupId,

        [string[]] $ResourceGroupName,

        [Parameter(ValueFromPipelineByPropertyName)]
        [Alias('Id')]
        [string[]] $ResourceId,

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

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

        [ValidateSet('High', 'Medium', 'Low', 'Informational')]
        [string[]] $Severity,

        [ValidateSet('Active', 'InProgress', 'Resolved', 'Dismissed')]
        [string[]] $Status,

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

        [ValidateRange(1, 3650)]
        [int] $Days,

        [switch] $Incident,

        [string] $CsvPath,

        [string] $HtmlPath,

        [string] $PdfPath,

        [string] $Title = 'Defender for Cloud security alerts',

        [switch] $PassThru,

        [switch] $NoDisplay,

        [switch] $NoPaging
    )

    begin {
        $wantedResources = [System.Collections.Generic.List[string]]::new()
    }

    process {
        foreach ($id in @($ResourceId | Where-Object { $_ })) { $wantedResources.Add($id.TrimEnd('/').ToLowerInvariant()) }
    }

    end {
        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 { $_ }) }
        # Open alerts unless told otherwise; one asked for by name, whatever its state.
        $states = @(if ($Status) { $Status } elseif (-not $Name) { 'Active', 'InProgress' })
        $filter = @{ Days = $Days }
        if ($states.Count) { $filter.Status = $states }
        if ($Severity) { $filter.Severity = $Severity }
        $queries = Get-AACSecurityAlertQuery @filter
        $statusLabel = if ($states.Count) { ($states | ForEach-Object { @{ InProgress = 'in progress' }[$_] ?? $_.ToLowerInvariant() }) -join ', ' } else { 'every status' }

        $null = Get-AACAccessToken
        if ($interactive) { Write-AACRule -Title 'Azure Admin Console :: Security alerts' -Color 'red3' }
        $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)"
            Update-AACProgress -Id 'read' -Total $queries.Count -Description 'Reading the security alerts and Defender plans'
            $read = Invoke-AACGraphBatch -Query $queries -SubscriptionId $scope.GraphScope -AllowFailure @('plans') -OnProgress {
                param($QueryName, $Done, $Total)
                Update-AACProgress -Id 'read' -Increment 1 -Description "Read the $QueryName ($Done of $Total)"
            }
            $result = ConvertTo-AACSecurityAlert -Read $read -SubscriptionName $scope.Names
            Update-AACProgress -Id 'read' -Complete -Description ('{0:N0} alert(s) ({1}): {2:N0} open, {3:N0} high' -f $result.Stats.Alerts, $statusLabel, $result.Stats.Open, $result.Stats.High)
            @{ Result = $result; Scope = $scope }
        }

        # --- The filters Resource Graph didn't apply ---------------------------------------------------------------
        $result = $state.Result
        $alerts = @($result.Alerts)
        $matchesAny = { param([string[]] $Value, [string[]] $Pattern) foreach ($v in $Value) { foreach ($p in $Pattern) { if ($v -and $v -like $p) { return $true } } }; $false }
        if ($Name) { $alerts = @($alerts | Where-Object { & $matchesAny @($_.AlertName, $_.Alert) $Name }) }
        if ($AlertType) { $alerts = @($alerts | Where-Object { & $matchesAny @($_.AlertType) $AlertType }) }
        if ($Tactic) { $alerts = @($alerts | Where-Object { & $matchesAny @($_.Intent -split ', ') $Tactic }) }
        if ($Incident) { $alerts = @($alerts | Where-Object Incident) }
        if ($ResourceGroupName) { $groups = @($ResourceGroupName | ForEach-Object { $_.ToLowerInvariant() }); $alerts = @($alerts | Where-Object { $groups -contains ([string]$_.ResourceGroup).ToLowerInvariant() }) }
        if ($wantedResources.Count) {
            $alerts = @($alerts | Where-Object {
                    foreach ($id in @($_.ResourceIds | ForEach-Object { $_.ToLowerInvariant() })) { foreach ($want in $wantedResources) { if ($id -eq $want -or $id.StartsWith("$want/")) { return $true } } }
                    $false
                })
        }
        $recurring = @($result.Recurring | Where-Object { $r = $_; @($alerts | Where-Object { $_.AlertType -eq $r.AlertType -and $_.ResourceId -eq $r.ResourceId }).Count })

        # --- The report --------------------------------------------------------------------------------------------
        $severityTones = @{ High = 'bad'; Medium = 'warn'; Low = 'info'; Informational = 'neutral' }
        $statusTones = @{ Active = 'bad'; InProgress = 'warn'; Resolved = 'good'; Dismissed = 'neutral' }
        $open = @($alerts | Where-Object Open)
        $high = @($open | Where-Object Severity -EQ 'High').Count
        $medium = @($open | Where-Object Severity -EQ 'Medium').Count
        $incidents = @($alerts | Where-Object Incident).Count
        $resources = @($open | ForEach-Object { if ($_.ResourceId) { $_.ResourceId.ToLowerInvariant() } else { $_.Resource } } | Where-Object { $_ } | Select-Object -Unique).Count
        $oldest = [int](@($open | ForEach-Object { $_.AgeDays } | Where-Object { $null -ne $_ } | Measure-Object -Maximum).Maximum)
        # The MITRE ATT&CK tactics in kill-chain order (Defender's older names last).
        $killChain = @('PreAttack', 'Reconnaissance', 'ResourceDevelopment', 'InitialAccess', 'Execution', 'Persistence', 'PrivilegeEscalation', 'DefenseEvasion', 'CredentialAccess', 'Discovery', 'LateralMovement', 'Collection', 'CommandAndControl', 'Exfiltration', 'Impact', 'Probing', 'Exploitation')
        $tactics = @($alerts | ForEach-Object { @($_.Intent -split ', ' | Where-Object { $_ }) } | Group-Object | Sort-Object { $i = [array]::IndexOf($killChain, $_.Name); if ($i -lt 0) { 99 } else { $i } })
        $rows = @($alerts | ForEach-Object {
                [pscustomobject][ordered]@{
                    Severity           = $_.Severity
                    Status             = $_.Status
                    Alert              = $_.Alert
                    Resource           = $_.Resource
                    ResourceType       = $_.ResourceType
                    ResourceGroup      = $_.ResourceGroup
                    SubscriptionName   = $_.SubscriptionName
                    Intent             = $_.Intent
                    Techniques         = $_.Techniques
                    TimeGenerated      = $_.TimeGenerated
                    AgeDays            = $_.AgeDays
                    Occurrences        = $_.Occurrences
                    Incident           = $(if ($_.Incident) { 'Yes' } else { 'No' })
                    CompromisedEntity  = $_.CompromisedEntity
                    Hosts              = $_.Hosts
                    Accounts           = $_.Accounts
                    IpAddresses        = $_.IpAddresses
                    Entities           = $_.Entities
                    Description        = $_.Description
                    Remediation        = $_.Remediation
                    ExtendedProperties = (@($_.ExtendedProperties.GetEnumerator() | ForEach-Object { "$($_.Key): $($_.Value)" }) -join '; ')
                    AlertType          = $_.AlertType
                    Product            = $_.Product
                    StartTime          = $_.StartTime
                    EndTime            = $_.EndTime
                    AlertName          = $_.AlertName
                    Location           = $_.Location
                    AlertUrl           = $_.AlertUrl
                    SubscriptionId     = $_.SubscriptionId
                    ResourceId         = $_.ResourceId
                    Id                 = $_.Id
                }
            })
        $headline = if (-not $alerts.Count) { "No security alerts ($statusLabel) in scope." }
        elseif ($open.Count) { "$($open.Count) open alert(s) on $resources resource(s): $high high, $medium medium$(if ($incidents) { "; $incidents incident(s)" }) - the oldest is $oldest day(s) old" }
        else { "$($alerts.Count) alert(s), none open." }
        $report = @{
            Subtitle = 'Microsoft Defender for Cloud security alerts: what was detected, where, and what to do'
            Facts    = [ordered]@{ Scope = $state.Scope.Label; Status = $statusLabel; Window = $(if ($Days) { "the last $Days day(s)" } else { 'every alert Defender for Cloud holds' }) }
            Status   = $(if ($high) { 'Failed' } elseif ($open.Count) { 'Warning' } else { 'Success' })
            Headline = $headline
            Tiles    = @(
                @{ Value = '{0:N0}' -f $open.Count; Label = 'open alerts'; Tone = $(if ($high) { 'bad' } elseif ($open.Count) { 'warn' } else { 'good' }) }
                @{ Value = '{0:N0}' -f $high; Label = 'high'; Tone = $(if ($high) { 'bad' } else { 'good' }); Table = 'alerts'; Filters = @{ Severity = 'High' } }
                @{ Value = '{0:N0}' -f $medium; Label = 'medium'; Tone = $(if ($medium) { 'warn' } else { 'good' }); Table = 'alerts'; Filters = @{ Severity = 'Medium' } }
                @{ Value = '{0:N0}' -f $incidents; Label = 'incidents'; Tone = $(if ($incidents) { 'violet' } else { 'good' }); Table = 'alerts'; Filters = @{ Incident = 'Yes' } }
                @{ Value = '{0:N0}' -f $resources; Label = 'resources with open alerts'; Tone = $(if ($resources) { 'warn' } else { 'good' }) }
                @{ Value = '{0:N0}' -f $recurring.Count; Label = 'recurring'; Tone = $(if ($recurring.Count) { 'info' } else { 'good' }); Table = 'recurring' }
            )
            Notices  = @($result.Notices | ForEach-Object { @{ Status = 'Warning'; Text = $_ } })
            Charts   = @(
                @{ Title = 'Alerts by severity'; Kind = 'donut'; CenterLabel = 'alerts'; Items = @(foreach ($s in 'High', 'Medium', 'Low', 'Informational') { $n = @($alerts | Where-Object Severity -EQ $s).Count; if ($n) { @{ Label = $s; Value = $n; Tone = $severityTones[$s]; Filter = $s } } }); Table = 'alerts'; Column = 'Severity' }
                @{ Title = 'By MITRE ATT&CK tactic'; Items = @($tactics | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'alerts'; Column = 'Intent'; Tone = 'bad'; Console = $true }
                @{ Title = 'Most alerted resources'; Items = @($alerts | Where-Object Resource | Group-Object Resource | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'alerts'; Column = 'Resource'; Tone = 'warn'; Console = $true }
                @{ Title = 'Alerts by day'; Items = @($alerts | Where-Object TimeGenerated | Group-Object { $_.TimeGenerated.ToString('yyyy-MM-dd', [cultureinfo]::InvariantCulture) } | Sort-Object Name | Select-Object -Last 30 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count } }); Tone = 'info' }
                @{ Title = 'By alert type'; Items = @($alerts | Group-Object Alert | Sort-Object Count -Descending | Select-Object -First 10 | ForEach-Object { @{ Label = $_.Name; Value = $_.Count; Filter = $_.Name } }); Table = 'alerts'; Column = 'Alert'; Tone = 'violet' }
            )
            Tables   = @(
                @{ Id = 'alerts'; Title = 'Security alerts'; Section = 'Alerts'; Rows = $rows; Noun = 'alerts'; GroupBy = @('Severity', 'Status', 'Intent', 'Resource', 'SubscriptionName'); ConsoleLimit = 25
                    Empty = "No security alerts ($statusLabel) in scope."; EmptyStatus = 'Success'
                    Columns = @(
                        @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $severityTones; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'Status'; Label = 'Status'; Type = 'badge'; Tones = $statusTones; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'Alert'; Label = 'Alert'; Type = 'wide'; Console = $true; Pdf = $true }
                        @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
                        @{ Key = 'Intent'; Label = 'Tactics'; Facet = $true; Console = $true }
                        @{ Key = 'AgeDays'; Label = 'Age (days)'; Type = 'number'; Console = $true; Pdf = $true }
                        @{ Key = 'TimeGenerated'; Label = 'Generated'; Type = 'datetime'; Pdf = $true }
                        @{ Key = 'Occurrences'; Label = 'Occurrences'; Type = 'number' }
                        @{ Key = 'Incident'; Label = 'Incident'; Type = 'badge'; Tones = @{ Yes = 'violet'; No = 'neutral' }; Facet = $true }
                        @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true }
                        @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                        @{ Key = 'ResourceType'; Label = 'Resource type'; Type = 'type'; Facet = $true }
                        @{ Key = 'Techniques'; Label = 'Techniques'; Type = 'mono' }
                        @{ Key = 'Hosts'; Label = 'Hosts' }
                        @{ Key = 'Accounts'; Label = 'Accounts' }
                        @{ Key = 'IpAddresses'; Label = 'IP addresses'; Type = 'mono' }
                        @{ Key = 'Entities'; Label = 'Entities'; Type = 'wide' }
                        @{ Key = 'Description'; Label = 'Description'; Type = 'wide' }
                        @{ Key = 'Remediation'; Label = 'What to do'; Type = 'wide'; Pdf = $true }
                        @{ Key = 'ExtendedProperties'; Label = 'Details'; Type = 'wide' }
                        @{ Key = 'Product'; Label = 'Product'; Facet = $true }
                        @{ Key = 'AlertType'; Label = 'Alert type'; Type = 'mono'; Hidden = $true }
                        @{ Key = 'AlertUrl'; Label = 'Portal'; Type = 'link'; Text = 'Open' }
                    ) }
                @{ Id = 'recurring'; Title = 'Recurring alerts'; Section = 'Recurring'; Rows = $recurring; Noun = 'recurring alerts'; ConsoleLimit = 10
                    Note = 'Three or more alerts of one type on one resource: fix the cause, or tune expected ones with a suppression rule that expires.'
                    Columns = @(
                        @{ Key = 'Severity'; Label = 'Severity'; Type = 'badge'; Tones = $severityTones; Facet = $true; Console = $true; Pdf = $true }
                        @{ Key = 'Alert'; Label = 'Alert'; Type = 'wide'; Console = $true; Pdf = $true }
                        @{ Key = 'Resource'; Label = 'Resource'; Type = 'resource'; Console = $true; Pdf = $true }
                        @{ Key = 'Occurrences'; Label = 'Occurrences'; Type = 'number'; Console = $true; Pdf = $true }
                        @{ Key = 'Open'; Label = 'Open'; Type = 'number'; Console = $true }
                        @{ Key = 'FirstSeen'; Label = 'First seen'; Type = 'datetime' }
                        @{ Key = 'LastSeen'; Label = 'Last seen'; Type = 'datetime'; Pdf = $true }
                        @{ Key = 'SubscriptionName'; Label = 'Subscription'; Facet = $true }
                        @{ Key = 'ResourceGroup'; Label = 'Resource group'; Facet = $true }
                        @{ Key = 'AlertType'; Label = 'Alert type'; Type = 'mono' }
                        @{ Key = 'Advice'; Label = 'What to do'; Type = 'wide' }
                    ) }
            )
            Hint     = '-Severity, -Tactic, -Days and -Status narrow the list (-Status Resolved, Dismissed for closed ones); -NoDisplay returns the alerts; -HtmlPath, -PdfPath or -CsvPath for a report.'
        }
        Invoke-AACReportOutput -Report $report -Title $Title -CsvObject $rows -Noun 'alert' -CsvPath (& $resolve $CsvPath) -HtmlPath (& $resolve $HtmlPath) -PdfPath (& $resolve $PdfPath) `
            -ShowView:$interactive -NoPaging:$NoPaging -Object $alerts -ReturnObject:($PassThru -or $NoDisplay -or $pipedOnward)
    }
}