Public/Get-MsecDefenderIncidentStats.ps1

function Get-MsecDefenderIncidentStats {
    <#
    .SYNOPSIS
        Microsoft Defender XDR incident summary: severity / classification / status
        breakdown for a period, plus current-backlog point-in-time view.

    .DESCRIPTION
        Queries Microsoft Graph /security/incidents and returns a single PSCustomObject
        per call covering three different cuts:

          - "Volume in window" - incidents CREATED in the last -Days days.
                                       Drives Total, severity, classification buckets.
          - "Resolution in window" - incidents RESOLVED (status='resolved') in the same
                                       window, regardless of when they were created.
                                       Drives MTTR / median TTR.
          - "Backlog (point-in-time)"- incidents currently in status active OR inProgress,
                                       independent of -Days. Drives CurrentlyOpen and
                                       OldestOpenAgeDays.

        Field names mirror the Microsoft Defender XDR portal labels: severity is
        High/Medium/Low/Informational (no 'Critical' - the API's top severity is high).
        Classification follows Microsoft's current naming (TruePositive / FalsePositive /
        BenignPositive / Unclassified). 'BenignPositive' bucket includes both the
        legacy 'benignPositive' value and the newer 'informationalExpectedActivity'
        Microsoft replaced it with.

        MTTR best practice: computed only over incidents classified as TruePositive or
        BenignPositive. FalsePositive incidents typically close in minutes and would
        artificially deflate the average; not counting them gives a more honest "time
        to handle real things" number.

        CONSEQUENCE, and read ResolvedClassifiedCount before trusting an MTTR: because
        UNCLASSIFIED incidents are excluded too, a team that closes incidents without
        setting a classification gets MeanTimeToResolveHours = $null however many
        incidents it resolved. That null means "nothing qualified to be averaged" - it is
        neither a collection failure nor a claim that resolution was instant. Equally, an
        MTTR backed by ResolvedClassifiedCount = 1 is one incident, not an average; that
        is the case where mean and median come back identical.

        Requires the 'SecurityIncident.Read.All' application permission on the msec
        app registration (admin consent required). A clearer error is raised on the
        typical 403.

    .PARAMETER Days
        Window size in days, applied to BOTH createdDateTime (for volume) and
        lastUpdateDateTime (for resolution). Default 30. Backlog metrics
        (CurrentlyOpen / OldestOpenAgeDays) ignore this and always show right-now.

    .EXAMPLE
        Get-MsecDefenderIncidentStats # last 30 days + current backlog

    .EXAMPLE
        Get-MsecDefenderIncidentStats -Days 7 # last week for a posture-meeting view

    .EXAMPLE
        Get-MsecDefenderIncidentStats -Days 90 | # quarterly window
            Select-Object TotalCreated, High, Medium, MeanTimeToResolveHours, CurrentlyOpen

    .OUTPUTS
        PSCustomObject with StartDate, EndDate, volume/severity/classification counts,
        TotalResolvedInWindow + ResolvedClassifiedCount, MTTR (mean + median in hours),
        CurrentlyOpen + OldestOpenAgeDays.
    #>

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

    Assert-MsecSession

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

    # Small helper: GET a $filter'd incident list, paginating through @odata.nextLink.
    # Don't set $top - /security/incidents caps it at 50, so passing larger values
    # is rejected. The -All flag handles paging transparently regardless of page size.
    $listIncidents = {
        param([string]$filter)
        $path = "/v1.0/security/incidents?`$filter=$filter"
        try {
            @(Invoke-MsecGraphRequest -Path $path -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
        }
    }

    # ---- Three independent queries, three different "what counts?" semantics ----
    $createdInWindow  = & $listIncidents "createdDateTime ge $startStr"
    $resolvedInWindow = & $listIncidents "lastUpdateDateTime ge $startStr and status eq 'resolved'"
    $currentlyOpen    = & $listIncidents "status eq 'active' or status eq 'inProgress'"

    # ---- Bucket the created-in-window incidents ----
    $sev = @{ high = 0; medium = 0; low = 0; informational = 0 }
    $cls = @{ truePositive = 0; falsePositive = 0; benignPositive = 0; unclassified = 0 }

    foreach ($i in $createdInWindow) {
        $s = if ($i.severity) { ([string]$i.severity).ToLower() } else { $null }
        if ($s -and $sev.ContainsKey($s)) { $sev[$s]++ }

        # Microsoft renamed 'benignPositive' to 'informationalExpectedActivity'; treat
        # both as the same bucket so the column is stable across API versions.
        $c = if ($i.classification) { [string]$i.classification } else { 'unknown' }
        switch -CaseSensitive ($c) {
            'truePositive'                  { $cls.truePositive++ }
            'falsePositive'                 { $cls.falsePositive++ }
            'benignPositive'                { $cls.benignPositive++ }
            'informationalExpectedActivity' { $cls.benignPositive++ }
            default                         { $cls.unclassified++ }
        }
    }

    # ---- Mean/median time-to-resolve, over TruePositive + BenignPositive only ----
    $resolvedRealIncidents = @($resolvedInWindow | Where-Object {
        $c = [string]$_.classification
        $c -in @('truePositive', 'benignPositive', 'informationalExpectedActivity')
    })

    $hoursList = foreach ($i in $resolvedRealIncidents) {
        $created  = [datetime]$i.createdDateTime
        $resolved = [datetime]$i.lastUpdateDateTime
        ($resolved - $created).TotalHours
    }
    $hoursList = @($hoursList)

    $meanMttr = $null
    $medianMttr = $null
    if ($hoursList.Count -gt 0) {
        $meanMttr = [math]::Round(($hoursList | Measure-Object -Average).Average, 2)

        $sorted = $hoursList | Sort-Object
        $n = $sorted.Count
        $medianMttr = [math]::Round(
            $(if ($n % 2 -eq 1) {
                $sorted[[int]([math]::Floor($n / 2))]
            } else {
                ($sorted[$n / 2 - 1] + $sorted[$n / 2]) / 2.0
            }),
            2
        )
    }

    # ---- Backlog (point-in-time) ----
    $oldestOpenAgeDays = $null
    if ($currentlyOpen.Count -gt 0) {
        $oldest = ($currentlyOpen |
                   Sort-Object @{ Expression = { [datetime]$_.createdDateTime } } |
                   Select-Object -First 1).createdDateTime
        $oldestOpenAgeDays = [math]::Round(($now - [datetime]$oldest).TotalDays, 1)
    }

    [PSCustomObject]@{
        StartDate                = $startUtc.Date
        EndDate                  = $now.Date

        # Volume in window
        TotalCreated             = $createdInWindow.Count
        High                     = $sev.high
        Medium                   = $sev.medium
        Low                      = $sev.low
        Informational            = $sev.informational
        TruePositive             = $cls.truePositive
        FalsePositive            = $cls.falsePositive
        BenignPositive           = $cls.benignPositive
        Unclassified             = $cls.unclassified

        # Resolution in window
        TotalResolvedInWindow    = $resolvedInWindow.Count
        # The DENOMINATOR behind the two MTTR figures, and the reason they can be null.
        # Emitted so a consumer never has to guess: 0 means "nothing was classified, so
        # there was nothing to average" (not "resolution was instant" and not a
        # collection failure), and a low number means the average rests on that few
        # incidents. Always <= TotalResolvedInWindow.
        ResolvedClassifiedCount  = $hoursList.Count
        MeanTimeToResolveHours   = $meanMttr
        MedianTimeToResolveHours = $medianMttr

        # Backlog (point-in-time, ignores -Days)
        CurrentlyOpen            = $currentlyOpen.Count
        OldestOpenAgeDays        = $oldestOpenAgeDays
    }
}