public/Get-MsecDefenderAlert.ps1
|
function Get-MsecDefenderAlert { <# .SYNOPSIS One row per Defender XDR alert across every workload - endpoint, Office 365, identity, cloud apps and DLP - with the incident it belongs to. .DESCRIPTION Alerts are the detections; incidents are the groupings Defender builds from them. Get-MsecDefenderIncident answers "what is being investigated"; this answers "what actually fired", which is the level at which a noisy detector or an unworked queue becomes visible. SERVICESOURCE IS OFTEN 'unknownFutureValue', AND THAT IS THE API, NOT THE DATA. Graph returns that placeholder for a source the API version does not have a name for yet. Measured on a live tenant, 231 of 569 alerts in ninety days - 40% - came back that way. It is reported verbatim rather than guessed at or folded into 'other', because the alternative is inventing a source attribution that Microsoft did not make. ProductName and DetectionSource are carried alongside and are often populated when ServiceSource is not. STATUS VOCABULARY DIFFERS FROM INCIDENTS. An alert is 'new', 'inProgress' or 'resolved'; an incident is 'active', 'inProgress', 'resolved' or 'redirected'. An alert is never 'active'. Filtering both with the same string finds nothing in one of them, silently. RESOLVEDAYS IS $null WHILE AN ALERT IS OPEN, never 0 - the same reasoning as the incident command. Here it is computed from ResolvedUtc, which Graph populates properly, rather than inferred from the last update. EVIDENCE IS NOT FLATTENED. Every alert carries an evidence array - devices, users, files, IP addresses, mailboxes - with a different shape per entity type. Flattening it would either lose most of it or produce a column set that changes per row, so the count is reported and the array stays on Raw.evidence for anything that needs it. .PARAMETER Days How far back to look at CREATION time. Default 30. .PARAMETER Severity Only these severities: informational, low, medium, high. .PARAMETER Status Only these statuses: new, inProgress, resolved. .PARAMETER ServiceSource Only alerts from these workloads, matched case-insensitively against ServiceSource - e.g. microsoftDefenderForEndpoint, microsoftDefenderForOffice365, dataLossPrevention. .EXAMPLE Connect-Msec -KeyVaultName kv-msec Get-MsecDefenderAlert -Days 90 -Severity high | Sort-Object CreatedUtc -Descending .EXAMPLE # The unworked queue: high-severity alerts nobody has picked up. Get-MsecDefenderAlert -Days 90 -Severity high -Status new | Format-Table CreatedUtc, Title, ServiceSource, IncidentId .EXAMPLE # Which detectors produce the most noise. Get-MsecDefenderAlert -Days 90 | Group-Object Title | Sort-Object Count -Descending | Select-Object -First 15 .OUTPUTS One PSCustomObject per alert, PSTypeName 'MsecDefenderAlert'. .NOTES Needs Connect-Msec. Documented as 'SecurityAlert.Read.All'; measured on a live tenant the endpoint also answers for an app holding SecurityIncident.Read.All and SecurityEvents.Read.All, both of which New-MsecApp grants - so it works today without an extra consent. If a tenant answers 403, that permission is the one to add. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [ValidateRange(1, 365)] [int] $Days = 30, [ValidateSet('informational', 'low', 'medium', 'high')] [string[]] $Severity, # NOT the incident vocabulary - see the note in the description. [ValidateSet('new', 'inProgress', 'resolved')] [string[]] $Status, [string[]] $ServiceSource ) Assert-MsecSession $startStr = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ') try { $alerts = @(Invoke-MsecGraphRequest -Path "/v1.0/security/alerts_v2?`$filter=createdDateTime ge $startStr" -All) } catch { if ($_.Exception.Message -match '403|Forbidden') { throw "Forbidden when calling /security/alerts_v2. The msec app needs the 'SecurityAlert.Read.All' application permission (admin consent required). Original error: $($_.Exception.Message)" } throw } $parse = { param($value) if ($value) { [datetime]::Parse($value, $null, [System.Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() } else { $null } } foreach ($a in $alerts) { if ($Severity -and [string]$a.severity -notin $Severity) { continue } if ($Status -and [string]$a.status -notin $Status) { continue } if ($ServiceSource -and [string]$a.serviceSource -notin $ServiceSource) { continue } $created = & $parse $a.createdDateTime $resolved = & $parse $a.resolvedDateTime [PSCustomObject]@{ PSTypeName = 'MsecDefenderAlert' Id = [string] $a.id # The alert's id in the product that raised it. For endpoint alerts this is the key # into the Defender for Endpoint API, which is the only place a comment can be # written - Graph has no writable comment on an alert. See Set-MsecDefenderAlert. ProviderAlertId = [string] $a.providerAlertId Title = [string] $a.title Severity = [string] $a.severity Status = [string] $a.status Category = [string] $a.category # 'unknownFutureValue' is Graph's placeholder, not a workload - see the help. ServiceSource = [string] $a.serviceSource DetectionSource = [string] $a.detectionSource ProductName = [string] $a.productName IncidentId = [string] $a.incidentId AssignedTo = [string] $a.assignedTo Classification = [string] $a.classification Determination = [string] $a.determination CreatedUtc = $created FirstActivityUtc = & $parse $a.firstActivityDateTime LastActivityUtc = & $parse $a.lastActivityDateTime ResolvedUtc = $resolved # $null while open, never 0. ResolveDays = if ($created -and $resolved) { [math]::Round(($resolved - $created).TotalDays, 1) } else { $null } # Counted, not flattened - the shape differs per entity type. Raw.evidence has it. EvidenceCount = @($a.evidence).Count MitreTechniques = (@($a.mitreTechniques) -join '; ') ThreatDisplayName = [string] $a.threatDisplayName AlertWebUrl = [string] $a.alertWebUrl Raw = $a } } } |