Microsoft Defender PowerShell script Report only

Get-DefenderIncidentsReport.ps1

Reports Microsoft Defender XDR incidents for triage, including how long each one has been open.

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#

ParameterWhat it does
-DaysBackNumber of days to look back based on createdDateTime. Default 30, maximum 365.
-StatusOnly incidents with this status: active, inProgress, resolved or redirected.
-SeverityOnly incidents with this severity: informational, low, medium or high.
-IncludeAlertsExpands the alerts of each incident and adds the AlertCount and AlertTitles columns (slower on large tenants).
-OutputPathPath of the CSV report. Defaults to .\Reports\DefenderIncidents_yyyyMMdd-HHmm.csv.
-PassThruAlso emits the report objects to the pipeline.

Examples#

PowerShell
PS> .\Get-DefenderIncidentsReport.ps1

Exports every incident created in the last 30 days and prints the open-incident summary.

PowerShell
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.

PowerShell
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.

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#

PowerShell · Get-DefenderIncidentsReport.ps1
<#
.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

Scripts are provided as-is under the MIT licence. Review the permissions a script requests, test in a non-production tenant, and use -WhatIf before letting any script change anything.

Found a bug or have an improvement? Open an issue on GitHub or email me.