Overview#
Queries the incidents endpoint (GET /security/incidents) through Microsoft Graph with a server-side $filter on createdDateTime and, optionally, status and severity. Every incident becomes one row with its severity and priority score, classification, assignment, tags, portal link and DaysOpen (time since creation for incidents that are not resolved or redirected). With -IncludeAlerts the alerts are expanded ($expand=alerts) and the alert count plus the first five alert titles are added. The result is exported to CSV and the console summarises the open incidents by severity, the oldest open incident and the mean age of open high-severity incidents.
Safety: Report only — makes no changes to your tenant. Run Get-Help .\Get-DefenderIncidentsReport.ps1 -Full for the complete help text.
Parameters#
| Parameter | What it does |
|---|---|
-DaysBack | Number of days to look back based on createdDateTime. Default 30, maximum 365. |
-Status | Only incidents with this status: active, inProgress, resolved or redirected. |
-Severity | Only incidents with this severity: informational, low, medium or high. |
-IncludeAlerts | Expands the alerts of each incident and adds the AlertCount and AlertTitles columns (slower on large tenants). |
-OutputPath | Path of the CSV report. Defaults to .\Reports\DefenderIncidents_yyyyMMdd-HHmm.csv. |
-PassThru | Also emits the report objects to the pipeline. |
Examples#
PS> .\Get-DefenderIncidentsReport.ps1Exports every incident created in the last 30 days and prints the open-incident summary.
PS> .\Get-DefenderIncidentsReport.ps1 -DaysBack 90 -Status active -Severity high -IncludeAlerts -OutputPath C:\Temp\OpenHigh.csv -VerboseExports the active high-severity incidents of the last 90 days with their alert titles to the given CSV.
PS> .\Get-DefenderIncidentsReport.ps1 -Status active -PassThru | Sort-Object -Property DaysOpen -Descending | Select-Object -First 5Shows the five active incidents that have been open the longest.
Permissions, modules and notes#
Author : Omer Eltayeb Blog : https://www.oeltayeb.com GitHub : https://github.com/omer-eltayeb Version : 1.0.0 Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication Permissions : SecurityIncident.Read.All (delegated); the signed-in user needs Security Reader, Security Operator or Security Administrator in Microsoft Defender XDR. Notes : Incidents are only created for Defender services that are licensed and onboarded in the tenant. Graph incident severity tops out at 'high' (there is no 'critical' level). Redirected incidents were merged into another incident and are treated as closed.
Full source#
<#
.SYNOPSIS
Reports Microsoft Defender XDR incidents for triage, including how long each one has been open.
.DESCRIPTION
Queries the incidents endpoint (GET /security/incidents) through Microsoft Graph with a server-side $filter on
createdDateTime and, optionally, status and severity. Every incident becomes one row with its severity and
priority score, classification, assignment, tags, portal link and DaysOpen (time since creation for incidents
that are not resolved or redirected). With -IncludeAlerts the alerts are expanded ($expand=alerts) and the alert count plus the first
five alert titles are added. The result is exported to CSV and the console summarises the open incidents by
severity, the oldest open incident and the mean age of open high-severity incidents.
.PARAMETER DaysBack
Number of days to look back based on createdDateTime. Default 30, maximum 365.
.PARAMETER Status
Only incidents with this status: active, inProgress, resolved or redirected.
.PARAMETER Severity
Only incidents with this severity: informational, low, medium or high.
.PARAMETER IncludeAlerts
Expands the alerts of each incident and adds the AlertCount and AlertTitles columns (slower on large tenants).
.PARAMETER OutputPath
Path of the CSV report. Defaults to .\Reports\DefenderIncidents_yyyyMMdd-HHmm.csv.
.PARAMETER PassThru
Also emits the report objects to the pipeline.
.EXAMPLE
PS> .\Get-DefenderIncidentsReport.ps1
Exports every incident created in the last 30 days and prints the open-incident summary.
.EXAMPLE
PS> .\Get-DefenderIncidentsReport.ps1 -DaysBack 90 -Status active -Severity high -IncludeAlerts -OutputPath C:\Temp\OpenHigh.csv -Verbose
Exports the active high-severity incidents of the last 90 days with their alert titles to the given CSV.
.EXAMPLE
PS> .\Get-DefenderIncidentsReport.ps1 -Status active -PassThru | Sort-Object -Property DaysOpen -Descending | Select-Object -First 5
Shows the five active incidents that have been open the longest.
.NOTES
Author : Omer Eltayeb
Blog : https://www.oeltayeb.com
GitHub : https://github.com/omer-eltayeb
Version : 1.0.0
Requires : PowerShell 5.1 or 7.x, Microsoft.Graph.Authentication
Permissions : SecurityIncident.Read.All (delegated); the signed-in user needs Security Reader, Security Operator
or Security Administrator in Microsoft Defender XDR.
Notes : Incidents are only created for Defender services that are licensed and onboarded in the tenant.
Graph incident severity tops out at 'high' (there is no 'critical' level). Redirected incidents
were merged into another incident and are treated as closed.
.LINK
https://learn.microsoft.com/graph/api/security-list-incidents
#>
#Requires -Version 5.1
#Requires -Modules Microsoft.Graph.Authentication
[CmdletBinding()]
param(
[Parameter()]
[ValidateRange(1, 365)]
[int]$DaysBack = 30,
[Parameter()]
[ValidateSet('active', 'inProgress', 'resolved', 'redirected')]
[string]$Status,
[Parameter()]
[ValidateSet('informational', 'low', 'medium', 'high')]
[string]$Severity,
[Parameter()]
[switch]$IncludeAlerts,
[Parameter()]
[string]$OutputPath,
[Parameter()]
[switch]$PassThru
)
$ErrorActionPreference = 'Stop'
#region Helpers
function Connect-GraphIfNeeded {
<# Connects to Microsoft Graph only when there is no usable session for the required scopes. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string[]]$Scopes
)
$context = Get-MgContext
$missingScopes = @()
if ($null -ne $context) {
$missingScopes = @($Scopes | Where-Object { $context.Scopes -notcontains $_ })
}
if ($null -eq $context -or $missingScopes.Count -gt 0) {
Write-Verbose "Connecting to Microsoft Graph with scopes: $($Scopes -join ', ')"
Connect-MgGraph -Scopes $Scopes -NoWelcome -ErrorAction Stop | Out-Null
}
else {
Write-Verbose "Reusing existing Microsoft Graph session for $($context.Account)."
}
}
function Invoke-GraphPaged {
<# GET helper that follows @odata.nextLink and returns every item in 'value'. #>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string]$Uri,
[Parameter()]
[hashtable]$Headers
)
$results = New-Object -TypeName System.Collections.Generic.List[object]
$nextLink = $Uri
while (-not [string]::IsNullOrEmpty($nextLink)) {
$requestParams = @{ Method = 'GET'; Uri = $nextLink; OutputType = 'PSObject'; ErrorAction = 'Stop' }
if ($null -ne $Headers) { $requestParams['Headers'] = $Headers }
$response = Invoke-MgGraphRequest @requestParams
if ($null -ne $response.PSObject.Properties['value']) {
foreach ($item in $response.value) { $results.Add($item) }
}
elseif ($null -ne $response) {
$results.Add($response)
}
$nextLink = $response.'@odata.nextLink'
}
return $results
}
function ConvertTo-UtcDateTime {
<# Normalises a Graph date value (ISO 8601 string or [datetime]) to a UTC [datetime]; returns $null when empty. #>
param(
[Parameter()]
[object]$Value
)
if ($null -eq $Value -or [string]::IsNullOrWhiteSpace([string]$Value)) { return $null }
if ($Value -is [datetime]) { return $Value.ToUniversalTime() }
$styles = [System.Globalization.DateTimeStyles]::AssumeUniversal -bor [System.Globalization.DateTimeStyles]::AdjustToUniversal
return [datetime]::Parse([string]$Value, [System.Globalization.CultureInfo]::InvariantCulture, $styles)
}
#endregion Helpers
#region Main
$requiredScopes = @('SecurityIncident.Read.All')
$closedStatuses = @('resolved', 'redirected')
if ([string]::IsNullOrWhiteSpace($OutputPath)) {
$reportFolder = Join-Path -Path (Get-Location).Path -ChildPath 'Reports'
$OutputPath = Join-Path -Path $reportFolder -ChildPath ('DefenderIncidents_{0}.csv' -f (Get-Date -Format 'yyyyMMdd-HHmm'))
}
$outputFolder = Split-Path -Path $OutputPath -Parent
if (-not [string]::IsNullOrWhiteSpace($outputFolder) -and -not (Test-Path -Path $outputFolder)) {
New-Item -Path $outputFolder -ItemType Directory -Force | Out-Null
}
try {
Connect-GraphIfNeeded -Scopes $requiredScopes
}
catch {
throw "Unable to connect to Microsoft Graph: $($_.Exception.Message)"
}
# InvariantCulture keeps ':' as the time separator regardless of the local culture, so the OData literal stays valid.
$since = [datetime]::UtcNow.AddDays(-$DaysBack).ToString('yyyy-MM-ddTHH:mm:ssZ', [System.Globalization.CultureInfo]::InvariantCulture)
$filterParts = @("createdDateTime ge $since")
if (-not [string]::IsNullOrEmpty($Status)) { $filterParts += "status eq '$Status'" }
if (-not [string]::IsNullOrEmpty($Severity)) { $filterParts += "severity eq '$Severity'" }
$uri = 'https://graph.microsoft.com/v1.0/security/incidents?$filter=' + ($filterParts -join ' and ')
if ($IncludeAlerts) { $uri += '&$expand=alerts' }
Write-Verbose "Requesting $uri"
try {
$incidents = Invoke-GraphPaged -Uri $uri
}
catch {
throw "Failed to read incidents from Microsoft Defender XDR: $($_.Exception.Message)"
}
Write-Verbose "Retrieved $($incidents.Count) incidents."
$now = [datetime]::UtcNow
$rows = New-Object -TypeName System.Collections.Generic.List[object]
$processed = 0
foreach ($incident in $incidents) {
$processed++
if ($processed % 100 -eq 0) {
Write-Progress -Activity 'Shaping incidents' -Status "$processed of $($incidents.Count)" -PercentComplete (($processed / $incidents.Count) * 100)
}
$created = ConvertTo-UtcDateTime -Value $incident.createdDateTime
$daysOpen = $null
if ($null -ne $created -and $closedStatuses -notcontains [string]$incident.status) {
$daysOpen = [math]::Round(($now - $created).TotalDays, 1)
}
$row = [PSCustomObject]@{
Id = $incident.id
DisplayName = $incident.displayName
Severity = $incident.severity
PriorityScore = $incident.priorityScore
Status = $incident.status
Classification = $incident.classification
Determination = $incident.determination
AssignedTo = $incident.assignedTo
CreatedDateTime = $created
LastUpdateDateTime = ConvertTo-UtcDateTime -Value $incident.lastUpdateDateTime
DaysOpen = $daysOpen
SystemTags = (@($incident.systemTags) -join ';')
CustomTags = (@($incident.customTags) -join ';')
IncidentWebUrl = $incident.incidentWebUrl
}
if ($IncludeAlerts) {
$incidentAlerts = @($incident.alerts | Where-Object { $null -ne $_ })
$titles = @($incidentAlerts | ForEach-Object { $_.title } | Where-Object { -not [string]::IsNullOrEmpty($_) } | Select-Object -Unique -First 5)
$row | Add-Member -NotePropertyName 'AlertCount' -NotePropertyValue $incidentAlerts.Count
$row | Add-Member -NotePropertyName 'AlertTitles' -NotePropertyValue ($titles -join ';')
}
$rows.Add($row)
}
Write-Progress -Activity 'Shaping incidents' -Completed
$sortedRows = @($rows | Sort-Object -Property CreatedDateTime -Descending)
if ($sortedRows.Count -gt 0) {
$sortedRows | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8
}
else {
Write-Warning ('No incidents matched the filter in the last {0} days; no CSV was written.' -f $DaysBack)
}
$openRows = @($sortedRows | Where-Object { $closedStatuses -notcontains [string]$_.Status })
Write-Host ''
Write-Host ('Defender XDR incidents summary (last {0} days)' -f $DaysBack) -ForegroundColor Cyan
Write-Host (' Incidents exported : {0} ({1} open, {2} closed) -> {3}' -f $sortedRows.Count, $openRows.Count, ($sortedRows.Count - $openRows.Count), $OutputPath)
Write-Host ' Open incidents by severity:' -ForegroundColor Yellow
foreach ($level in @('high', 'medium', 'low', 'informational')) {
$levelCount = @($openRows | Where-Object { $_.Severity -eq $level }).Count
if ($levelCount -gt 0) { Write-Host (' {0,-14}: {1}' -f $level, $levelCount) }
}
if ($openRows.Count -gt 0) {
$oldest = $openRows | Sort-Object -Property CreatedDateTime | Select-Object -First 1
Write-Host (' Oldest open incident : {0} - "{1}" ({2} days, {3})' -f $oldest.Id, $oldest.DisplayName, $oldest.DaysOpen, $oldest.Severity)
# Severity 'high' is the top level in Graph; it covers what the portal shows as high and critical incidents.
$openHigh = @($openRows | Where-Object { $_.Severity -eq 'high' -and $null -ne $_.DaysOpen })
if ($openHigh.Count -gt 0) {
$meanDaysOpen = [math]::Round(($openHigh | Measure-Object -Property DaysOpen -Average).Average, 1)
Write-Host (' Mean age of open high-severity incidents : {0} days ({1} incidents)' -f $meanDaysOpen, $openHigh.Count) -ForegroundColor Yellow
}
}
if ($PassThru) {
$sortedRows
}
#endregion Main