Private/ConvertTo-AACSecurityAlert.ps1
|
function ConvertTo-AACSecurityAlert { <# .SYNOPSIS Builds Get-AACSecurityAlert's alerts (AAC.SecurityAlert) from the Resource Graph rows of Get-AACSecurityAlertQuery: what was detected, on which resource, the MITRE ATT&CK tactics and techniques, the entities involved (hosts, accounts, IP addresses, files, processes), the extended properties, the remediation steps, how often it recurs - and the subscriptions with no Defender plan on. .DESCRIPTION No Azure calls. -Read is Invoke-AACGraphBatch's result (Rows: alerts, plans; Errors). -SubscriptionName names the subscriptions in scope (lower-case ID -> name); rows of others are left out. Occurrences: how many alerts in the list share the alert's type and resource - three or more is a recurring alert, worth fixing at the source or tuning with a suppression rule. Sorted: open (Active, InProgress) before closed, then severity, then newest first. Returns @{ Alerts; Recurring; Notices; Stats }. #> [CmdletBinding()] [OutputType([hashtable])] param( [Parameter(Mandatory)] [hashtable] $Read, [hashtable] $SubscriptionName = @{}, [datetime] $Now = [datetime]::UtcNow ) # --- Helpers --------------------------------------------------------------------------------------------- # Alert properties have come back in both cases: read keys case-insensitively. $get = ${function:Get-AACMember} $text = { param($Object, [string] $Key) $v = & $get $Object $Key; if ($null -eq $v) { '' } else { [string]$v } } $list = { param($Item) @(if ($Item -is [System.Collections.IEnumerable] -and $Item -isnot [string] -and $Item -isnot [System.Collections.IDictionary]) { $Item } elseif ($null -ne $Item) { , $Item }) | Where-Object { $null -ne $_ -and '' -ne $_ } } $rowsOf = { param([string] $Name) @(if ($Read.Rows -and $Read.Rows.Contains($Name)) { $Read.Rows[$Name] | Where-Object { $null -ne $_ } }) } $inScope = { param([string] $Subscription) $Subscription -and $SubscriptionName.Contains($Subscription.ToLowerInvariant()) } $subscriptionLabel = { param([string] $Id) $key = ([string]$Id).ToLowerInvariant(); if ($SubscriptionName.Contains($key)) { $SubscriptionName[$key] } else { $Id } } $date = { param([string] $Value) $d = [datetime]::MinValue if ($Value -and [datetime]::TryParse($Value, [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::AdjustToUniversal -bor [System.Globalization.DateTimeStyles]::AssumeUniversal, [ref]$d)) { $d } else { $null } } # Defender's texts carry HTML (<br>, links, ): as plain text, one step a line. $plain = { param([string] $Html) if (-not $Html) { return '' } $t = $Html -replace '(?i)<br\s*/?>|</p>|</li>', "`n" -replace '(?i)<li[^>]*>', '- ' -replace '<[^>]+>', '' ([System.Net.WebUtility]::HtmlDecode($t).Replace([char]0x00A0, ' ') -replace '[ \t]+\n', "`n" -replace '\n{3,}', "`n`n").Trim() } # /subscriptions/s/resourceGroups/g/providers/Microsoft.Compute/virtualMachines/vm -> its parts. $parse = { param([string] $Id) $r = @{ Name = ''; Type = ''; Group = '' } if (-not $Id) { return $r } if ($Id -notmatch '^/') { $r.Name = $Id; return $r } $parts = $Id.Trim('/').Split('/') $lower = @($parts | ForEach-Object { $_.ToLowerInvariant() }) $g = [array]::IndexOf($lower, 'resourcegroups') if ($g -ge 0 -and $g + 1 -lt $parts.Count) { $r.Group = $parts[$g + 1] } $p = [array]::LastIndexOf($lower, 'providers') if ($p -ge 0 -and $p + 2 -lt $parts.Count) { $r.Type = ($lower[$p + 1] + '/' + (@(for ($j = $p + 2; $j -lt $parts.Count; $j += 2) { $lower[$j] }) -join '/')) } elseif ($r.Group) { $r.Type = 'microsoft.resources/resourcegroups' } else { $r.Type = 'microsoft.resources/subscriptions' } $r.Name = $parts[-1] $r } # One entity (Defender's alert schema) -> @{ Kind; Value }, or nothing for a bare $ref. $entityOf = { param($Entity) $kind = (& $text $Entity 'type').ToLowerInvariant() if (-not $kind) { return } $value = switch -Wildcard ($kind) { 'host' { $hostName = & $text $Entity 'HostName' if (-not $hostName) { $hostName = & $text $Entity 'NetBiosName' } if (-not $hostName -and (& $text $Entity 'AzureID')) { $hostName = ((& $text $Entity 'AzureID') -split '/')[-1] } if ($hostName -and (& $text $Entity 'DnsDomain') -and $hostName -notmatch '\.') { "$hostName.$(& $text $Entity 'DnsDomain')" } else { $hostName } } 'ip' { & $text $Entity 'Address' } 'account' { $name = & $text $Entity 'Name' if ($name -and (& $text $Entity 'UPNSuffix')) { "$name@$(& $text $Entity 'UPNSuffix')" } elseif ($name -and (& $text $Entity 'NTDomain')) { "$(& $text $Entity 'NTDomain')\$name" } elseif ($name) { $name } else { & $text $Entity 'AadUserId' } } 'file' { $n = & $text $Entity 'Name'; $d = & $text $Entity 'Directory'; if ($d -and $n) { "$($d.TrimEnd('\', '/'))$(if ($d -match '/') { '/' } else { '\' })$n" } else { $n } } 'filehash' { $v = & $text $Entity 'Value'; if ($v) { "$(& $text $Entity 'Algorithm') $v".Trim() } } 'process' { $c = & $text $Entity 'CommandLine'; if ($c) { if ($c.Length -gt 160) { $c.Substring(0, 157) + '...' } else { $c } } elseif (& $text $Entity 'ProcessId') { "PID $(& $text $Entity 'ProcessId')" } } 'azure-resource' { ((& $text $Entity 'ResourceId') -split '/')[-1] } 'url' { & $text $Entity 'Url' } 'dns' { & $text $Entity 'DomainName' } 'mailbox' { & $text $Entity 'MailboxPrimaryAddress' } 'blob*' { $u = & $text $Entity 'Url'; if ($u) { $u } else { & $text $Entity 'Name' } } default { foreach ($k in 'Name', 'FriendlyName', 'DisplayName', 'Address', 'Url') { $v = & $text $Entity $k; if ($v) { $v; break } } } } if ($value) { @{ Kind = $kind; Value = [string]$value } } } $rank = (Get-AACSeverityRank).Rank $rank['Informational'] = 4 $rankOf = { param([string] $Severity) if ($Severity -and $rank.Contains($Severity)) { $rank[$Severity] } else { 5 } } $notices = [System.Collections.Generic.List[string]]::new() # --- Alerts ------------------------------------------------------------------------------------------------ $alerts = @(foreach ($row in (& $rowsOf 'alerts')) { $subscription = & $text $row 'subscriptionId' if (-not (& $inScope $subscription)) { continue } $identifiers = @(& $list (& $get $row 'resources')) # The Azure resource it is about, else the compromised entity. $target = [string](@($identifiers | ForEach-Object { & $text $_ 'AzureResourceId' } | Where-Object { $_ }) | Select-Object -First 1) $where = & $parse $target $entities = @(@(& $list (& $get $row 'entities')) | ForEach-Object { & $entityOf $_ } | Where-Object { $_ }) $seen = @{} $entities = @($entities | Where-Object { $key = "$($_.Kind)|$($_.Value)".ToLowerInvariant(); if ($seen.Contains($key)) { $false } else { $seen[$key] = 1; $true } }) $ofKind = { param([string[]] $Kind) (@($entities | Where-Object { $Kind -contains $_.Kind } | ForEach-Object { $_.Value }) -join '; ') } $extended = [ordered]@{} $bag = & $get $row 'extended' if ($bag -is [System.Collections.IDictionary]) { foreach ($k in @($bag.Keys | Sort-Object)) { $extended[[string]$k] = [string]$bag[$k] } } $when = & $date (& $text $row 'generated') $status = & $text $row 'status' $compromised = & $text $row 'entity' $item = [pscustomobject][ordered]@{ Alert = & $text $row 'name' Severity = & $text $row 'severity' Status = $status Open = $status -in 'Active', 'InProgress' Intent = (@((& $text $row 'intent') -split '\s*,\s*' | Where-Object { $_ }) -join ', ') Techniques = (@(@(& $list (& $get $row 'techniques')) + @(& $list (& $get $row 'subTechniques')) | ForEach-Object { [string]$_ } | Select-Object -Unique) -join ', ') Resource = $(if ($where.Name) { $where.Name } else { $compromised }) ResourceType = $where.Type ResourceGroup = $where.Group SubscriptionName = & $subscriptionLabel $subscription CompromisedEntity = $compromised TimeGenerated = $when StartTime = & $date (& $text $row 'start') EndTime = & $date (& $text $row 'end') AgeDays = $(if ($when) { [int][Math]::Floor(($Now - $when).TotalDays) } else { $null }) AlertType = & $text $row 'alertType' Product = & $text $row 'product' Vendor = & $text $row 'vendor' Incident = (& $text $row 'isIncident') -eq 'True' Hosts = & $ofKind 'host' Accounts = & $ofKind 'account' IpAddresses = & $ofKind 'ip' Entities = (@($entities | ForEach-Object { "$($_.Kind): $($_.Value)" }) -join '; ') Description = & $plain (& $text $row 'description') Remediation = & $plain ((@(& $list (& $get $row 'remediation')) | ForEach-Object { [string]$_ }) -join "`n") ExtendedProperties = $extended Occurrences = 1 AlertName = & $text $row 'alertName' Location = & $text $row 'location' AlertUrl = & $text $row 'link' SubscriptionId = $subscription.ToLowerInvariant() ResourceId = $target ResourceIds = @($identifiers | ForEach-Object { & $text $_ 'AzureResourceId' } | Where-Object { $_ } | Select-Object -Unique) Id = & $text $row 'id' } $item.PSObject.TypeNames.Insert(0, 'AAC.SecurityAlert') $item }) $alerts = @($alerts | Sort-Object -Property @{ Expression = { if ($_.Open) { 0 } else { 1 } } }, @{ Expression = { & $rankOf $_.Severity } }, @{ Expression = 'TimeGenerated'; Descending = $true }) # --- Recurring: the same alert type on the same resource --------------------------------------------------- $recurring = @(foreach ($group in @($alerts | Group-Object -Property { "$($_.AlertType)|$(if ($_.ResourceId) { $_.ResourceId.ToLowerInvariant() } else { $_.Resource })" })) { foreach ($alert in $group.Group) { $alert.Occurrences = $group.Count } if ($group.Count -lt 3) { continue } $first = $group.Group[0] $times = @($group.Group | ForEach-Object { $_.TimeGenerated } | Where-Object { $_ } | Sort-Object) [pscustomobject][ordered]@{ Alert = $first.Alert Severity = @($group.Group | Sort-Object { & $rankOf $_.Severity })[0].Severity Resource = $first.Resource ResourceGroup = $first.ResourceGroup SubscriptionName = $first.SubscriptionName Occurrences = $group.Count Open = @($group.Group | Where-Object Open).Count FirstSeen = $(if ($times.Count) { $times[0] } else { $null }) LastSeen = $(if ($times.Count) { $times[-1] } else { $null }) AlertType = $first.AlertType Advice = 'Recurs: fix the cause on the resource, or - if it is expected (a scanner, an admin tool) - dismiss it with a suppression rule that expires.' ResourceId = $first.ResourceId } }) $recurring = @($recurring | Sort-Object -Property @{ Expression = 'Occurrences'; Descending = $true }, Alert) # --- Plans: a subscription with none on has no alerts to show ------------------------------------------------ $plansRead = $Read.Rows -and $Read.Rows.Contains('plans') -and -not ($Read.Errors -and $Read.Errors.Contains('plans')) $unprotected = @() if ($plansRead) { $on = @{} foreach ($row in (& $rowsOf 'plans')) { $subscription = (& $text $row 'subscriptionId').ToLowerInvariant() if ((& $text $row 'tier') -eq 'Standard') { $on[$subscription] = $true } } $unprotected = @($SubscriptionName.Keys | Where-Object { -not $on.Contains($_) } | Sort-Object { $SubscriptionName[$_] }) if ($unprotected.Count) { $names = @($unprotected | Select-Object -First 5 | ForEach-Object { $SubscriptionName[$_] }) -join ', ' $more = if ($unprotected.Count -gt 5) { " and $($unprotected.Count - 5) more" } else { '' } $notices.Add("No Defender plan is on in $($unprotected.Count) subscription(s) ($names$more): they raise no security alerts, so an empty list there is not a clean bill of health.") } } elseif ($Read.Errors -and $Read.Errors.Contains('plans')) { $notices.Add("The Defender plans couldn't be read, so subscriptions with no plan on (and so no alerts) can't be pointed out.") } $open = @($alerts | Where-Object Open) @{ Alerts = $alerts Recurring = $recurring Notices = @($notices) Stats = @{ Alerts = $alerts.Count Open = $open.Count 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 OldestOpen = $(if ($open.Count) { [int](@($open | ForEach-Object { $_.AgeDays } | Where-Object { $null -ne $_ } | Measure-Object -Maximum).Maximum) } else { 0 }) Unprotected = $unprotected.Count } } } |