public/Get-MsecDefenderIncident.ps1

function Get-MsecDefenderIncident {
    <#
    .SYNOPSIS
        One row per Defender XDR incident - what it is, how severe, whether anyone has
        triaged it, and how long it took to resolve.

    .DESCRIPTION
        The row-level companion to Get-MsecDefenderIncidentStats, which answers the same
        questions as a single summary. Use this one to see WHICH incidents, and the stats
        command for a trend line.

        REDIRECTED INCIDENTS ARE NOT SEPARATE INCIDENTS. When Defender decides two incidents
        are the same attack it merges them, leaving the absorbed one with status 'redirected'
        and a RedirectedToIncidentId. Counting those as incidents double-counts the same
        activity - measured on a live tenant, 51 of 474 in ninety days. They are returned
        anyway, because an incident that vanished from a count needs to be explainable, and
        -ExcludeRedirected drops them when you want the deduplicated number.

        CLASSIFICATION AND DETERMINATION ARE ANALYST JUDGEMENTS, NOT DETECTIONS. They stay
        'unknown' until a human sets them, so they measure triage effort rather than truth.
        Measured live: all 474 incidents were 'unknown', which is a finding about the process
        rather than about the incidents.

        RESOLVEDAYS IS $null WHILE AN INCIDENT IS OPEN, never 0. Graph reports no resolution
        time for an unresolved incident, and a zero there would read as "closed instantly" -
        which is the opposite of a still-running investigation.

    .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: active, inProgress, resolved, redirected.

    .PARAMETER ExcludeRedirected
        Drop incidents merged into another one. Use when counting; omit when explaining.

    .PARAMETER IncludeAlerts
        Also fetch each incident's alerts and report AlertCount and the distinct detection
        sources. One extra call per incident, so it is opt-in.

    .EXAMPLE
        Connect-Msec -KeyVaultName kv-msec
        Get-MsecDefenderIncident -Days 90 -ExcludeRedirected |
            Where-Object Status -ne 'resolved' | Sort-Object Severity

    .EXAMPLE
        # The triage gap: open incidents nobody has classified.
        Get-MsecDefenderIncident -Days 90 |
            Where-Object { $_.Status -eq 'active' -and $_.Classification -eq 'unknown' }

    .OUTPUTS
        One PSCustomObject per incident, PSTypeName 'MsecDefenderIncident'.

    .NOTES
        Needs Connect-Msec and the 'SecurityIncident.Read.All' application permission, which
        New-MsecApp grants.

        $top is not set: /security/incidents caps it at 50 and rejects larger values. Paging
        is handled by -All regardless of page size.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [ValidateRange(1, 365)]
        [int] $Days = 30,

        [ValidateSet('informational', 'low', 'medium', 'high')]
        [string[]] $Severity,

        [ValidateSet('active', 'inProgress', 'resolved', 'redirected')]
        [string[]] $Status,

        [switch] $ExcludeRedirected,

        [switch] $IncludeAlerts
    )

    Assert-MsecSession

    $startStr = (Get-Date).ToUniversalTime().AddDays(-$Days).ToString('yyyy-MM-ddTHH:mm:ssZ')

    try {
        $incidents = @(Invoke-MsecGraphRequest -Path "/v1.0/security/incidents?`$filter=createdDateTime ge $startStr" -All)
    }
    catch {
        if ($_.Exception.Message -match '403|Forbidden') {
            throw "Forbidden when calling /security/incidents. The msec app needs the 'SecurityIncident.Read.All' application permission (admin consent required). Re-run New-MsecApp to add and consent it. Original error: $($_.Exception.Message)"
        }
        throw
    }

    foreach ($i in $incidents) {
        if ($Severity -and [string]$i.severity -notin $Severity) { continue }
        if ($Status   -and [string]$i.status   -notin $Status)   { continue }
        if ($ExcludeRedirected -and $i.redirectIncidentId) { continue }

        $created  = if ($i.createdDateTime) { [datetime]::Parse($i.createdDateTime, $null, [System.Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() } else { $null }
        $updated  = if ($i.lastUpdateDateTime) { [datetime]::Parse($i.lastUpdateDateTime, $null, [System.Globalization.DateTimeStyles]::RoundtripKind).ToUniversalTime() } else { $null }

        # Only meaningful once resolved - see the note in the help.
        $resolveDays = if ([string]$i.status -eq 'resolved' -and $created -and $updated) {
            [math]::Round(($updated - $created).TotalDays, 1)
        } else { $null }

        $alertCount = $null
        $sources = $null
        if ($IncludeAlerts) {
            try {
                $alerts = @(Invoke-MsecGraphRequest -Path "/v1.0/security/incidents/$($i.id)/alerts" -All)
                $alertCount = $alerts.Count
                $sources = (@($alerts.serviceSource | Where-Object { $_ } | Sort-Object -Unique) -join '; ')
            }
            catch {
                # $null rather than 0: an incident whose alerts could not be read has not been
                # shown to have none.
                Write-Verbose "Could not read alerts for incident $($i.id): $($_.Exception.Message)"
            }
        }

        [PSCustomObject]@{
            PSTypeName             = 'MsecDefenderIncident'
            Id                     = [string] $i.id
            DisplayName            = [string] $i.displayName
            Severity               = [string] $i.severity
            Status                 = [string] $i.status
            # Analyst judgements, not detections - 'unknown' means nobody has triaged it.
            Classification         = [string] $i.classification
            Determination          = [string] $i.determination
            AssignedTo             = [string] $i.assignedTo
            CreatedUtc             = $created
            LastUpdateUtc          = $updated
            ResolveDays            = $resolveDays
            AlertCount             = $alertCount
            AlertSources           = $sources
            # Set when this incident was merged INTO another - see the help.
            RedirectedToIncidentId = [string] $i.redirectIncidentId
            Tags                   = (@(@($i.customTags) + @($i.systemTags) | Where-Object { $_ }) -join '; ')
            IncidentWebUrl         = [string] $i.incidentWebUrl
            Raw                    = $i
        }
    }
}