tests/Test-Assessment.41121.ps1

<#
.SYNOPSIS
    Checks that every open Microsoft Defender XDR alert is triaged across all Defender services,
    with no high-severity alert left unassigned or unworked past its triage window.
 
.NOTES
    Test ID: 41121
    Workshop Task: SECOPS-121
    Pillar: SecOps
    Category: Incident response operations
    Required permission: SecurityAlert.Read.All
#>


function Test-Assessment-41121 {

    [ZtTest(
        Category           = 'Incident response operations',
        CompatibleLicense  = ('WINDEFATP', 'MDE_LITE', 'ATP_ENTERPRISE', 'THREAT_INTELLIGENCE', 'ATA', 'ADALLOM_S_STANDALONE'),
        ImplementationCost = 'Low',
        Pillar             = 'SecOps',
        RiskLevel          = 'High',
        Service            = ('Graph'),
        SfiPillar          = 'Accelerate response and remediation',
        TenantType         = ('Workforce'),
        TestId             = 41121,
        Title              = 'Every open Microsoft Defender XDR alert is triaged across all Defender services, with no high-severity alert left unassigned or unworked past its triage window',
        UserImpact         = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking Microsoft Defender XDR open alert triage status'

    $testTitle         = 'Every open Microsoft Defender XDR alert is triaged across all Defender services, with no high-severity alert left unassigned or unworked past its triage window'
    $highWindowHours   = 24
    $mediumWindowHours = 72
    $resolvedCutoff    = (Get-Date).AddDays(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
    $preferHeaders     = @{ Prefer = 'include-unknown-enum-members' }

    $openAlerts     = $null
    $resolvedAlerts = $null

    # Q1: Enumerate the entire open Defender XDR alert backlog across every service.
    # serviceSource filter is deliberately omitted so results include every onboarded service.
    Write-ZtProgress -Activity $activity -Status 'Querying open Defender XDR alerts'
    try {
        $openAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -ApiVersion beta `
            -Filter "status eq 'new' or status eq 'inProgress'" -Top 500 -DisablePaging `
            -Headers $preferHeaders -ErrorAction Stop
    }
    catch {
        $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
        Write-PSFMessage "Failed to retrieve open security alerts (HTTP $httpStatus): $_" -Tag Test -Level Warning
        $msg = if ($httpStatus -in @(401, 403)) {
            '⚠️ **SecurityAlert.Read.All** permission is required to read Defender XDR alerts. Verify the permission is consented and the assessment identity has Security Reader or Security Operator role, then re-run.'
        } elseif ($httpStatus -eq 404) {
            '⚠️ The alerts_v2 endpoint returned 404. Verify that at least one Defender service is onboarded to stream alerts to the alerts_v2 endpoint, then re-run.'
        } else {
            '⚠️ Microsoft Graph returned an unexpected error while querying open Defender XDR alerts. Re-run after 5–10 minutes; file a support ticket if this persists.'
        }
        $params = @{
            TestId       = '41121'
            Title        = $testTitle
            Status       = $false
            Result       = $msg
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    # Q2: Read recently resolved alerts (informational throughput context; does not affect Pass/Fail).
    Write-ZtProgress -Activity $activity -Status 'Querying recently resolved Defender XDR alerts'
    try {
        $resolvedAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -ApiVersion beta `
            -Filter "status eq 'resolved' and lastUpdateDateTime ge $resolvedCutoff" -Top 500 -DisablePaging `
            -Headers $preferHeaders -ErrorAction Stop
    }
    catch {
        $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
        Write-PSFMessage "Failed to retrieve resolved security alerts (HTTP $httpStatus): $_" -Tag Test -Level Warning
        $msg = if ($httpStatus -in @(401, 403)) {
            '⚠️ **SecurityAlert.Read.All** permission is required to read Defender XDR alerts. Verify the permission is consented and the assessment identity has Security Reader or Security Operator role, then re-run.'
        } elseif ($httpStatus -eq 404) {
            '⚠️ The alerts_v2 endpoint returned 404 while querying resolved alerts. Verify Defender onboarding and re-run.'
        } else {
            '⚠️ Microsoft Graph returned an unexpected error while querying resolved Defender XDR alerts. Re-run after 5–10 minutes; file a support ticket if this persists.'
        }
        $params = @{
            TestId       = '41121'
            Title        = $testTitle
            Status       = $false
            Result       = $msg
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }
    #endregion Data Collection

    #region Assessment Logic
    # -DisablePaging causes Invoke-ZtGraphRequest to return the raw response envelope; unwrap .value.
    $openAlerts     = @($openAlerts.value)
    $resolvedAlerts = @($resolvedAlerts.value)

    # Disambiguate empty results: both empty = licensed but onboarding cannot be confirmed.
    if ($openAlerts.Count -eq 0 -and $resolvedAlerts.Count -eq 0) {
        $params = @{
            TestId       = '41121'
            Title        = $testTitle
            Status       = $false
            Result       = '⚠️ No open or recently resolved alerts were returned for any Defender service. The tenant is licensed but this may indicate a workload that is not yet onboarded to stream alerts to the alerts_v2 endpoint. Verify Defender onboarding across all expected services and re-run.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    $now              = Get-Date
    $severityRank     = @{ high = 0; medium = 1; low = 2; informational = 3 }
    $passed           = $true
    $customStatus     = $null
    $testResultMarkdown = $null

    if ($openAlerts.Count -eq 0) {
        # Q1 empty, Q2 non-empty: open queue is genuinely clear.
        $alertBandRows      = @()
        $criticalItems      = @()
        $importantItems     = @()
        $informationalItems = @()
        $acknowledgedItems  = @()
        $testResultMarkdown = "✅ Every open Microsoft Defender XDR alert has been triaged; no alerts are currently open across any Defender service.`n`n%TestResult%"
    }
    else {
        # Classify each open alert into a band. First matching rule wins.
        $alertBandRows = foreach ($alert in $openAlerts) {
            if ([string]::IsNullOrEmpty($alert.createdDateTime)) { continue }
            $created     = [DateTime]$alert.createdDateTime
            $lastUpdated = if (-not [string]::IsNullOrEmpty($alert.lastUpdateDateTime)) { [DateTime]$alert.lastUpdateDateTime } else { $created }
            $ageHours    = [math]::Round(($now - $created).TotalHours, 1)
            $staleHours  = [math]::Round(($now - $lastUpdated).TotalHours, 1)
            $assigned    = -not [string]::IsNullOrWhiteSpace($alert.assignedTo)
            $worked      = $alert.status -eq 'inProgress'
            $benignDisp  = $alert.classification -in @('falsePositive', 'informationalExpectedActivity')

            $band = if ($benignDisp -and $alert.status -ne 'resolved') {
                'Acknowledged'
            }
            elseif ($alert.severity -eq 'high' -and $ageHours -gt $highWindowHours -and (-not $assigned -or $alert.status -eq 'new')) {
                'Critical'
            }
            elseif (
                ($alert.severity -eq 'medium' -and $ageHours -gt $mediumWindowHours -and (-not $assigned -or $alert.status -eq 'new')) -or
                ($alert.severity -eq 'high' -and $assigned -and $worked -and $staleHours -gt $highWindowHours)
            ) {
                'Important'
            }
            else {
                'Informational'
            }

            # Append stale suffix when the alert is in-progress but has not been updated recently.
            $ageDisplay = if ($worked -and $staleHours -gt $highWindowHours) {
                "$ageHours • stale $($staleHours)h"
            }
            else {
                "$ageHours"
            }

            [PSCustomObject]@{
                Band           = $band
                Severity       = $alert.severity
                Title          = $alert.title
                AlertWebUrl    = $alert.alertWebUrl
                ServiceSource  = $alert.serviceSource
                AssignedTo     = $alert.assignedTo
                AgeHours       = $ageHours
                AgeDisplay     = $ageDisplay
                StaleHours     = $staleHours
                IncidentId     = $alert.incidentId
                IncidentWebUrl = $alert.incidentWebUrl
                Status         = $alert.status
            }
        }
        $alertBandRows = @($alertBandRows)

        $criticalItems      = @($alertBandRows | Where-Object { $_.Band -eq 'Critical' })
        $importantItems     = @($alertBandRows | Where-Object { $_.Band -eq 'Important' })
        $informationalItems = @($alertBandRows | Where-Object { $_.Band -eq 'Informational' })
        $acknowledgedItems  = @($alertBandRows | Where-Object { $_.Band -eq 'Acknowledged' })

        $passed = $criticalItems.Count -eq 0

        if ($criticalItems.Count -gt 0) {
            $testResultMarkdown = "❌ One or more high-severity Defender XDR alerts have remained unassigned or unworked (status is **new**) past the configurable triage window (default 24 hours). Review the prioritized backlog; Critical rows are listed first.`n`n%TestResult%"
        }
        else {
            $testResultMarkdown = "✅ Every high-severity Defender XDR alert across all services has been assigned and is being worked within its triage window; no alert is left untriaged past the configurable threshold.`n`n%TestResult%"
        }
    }
    #endregion Assessment Logic

    #region Report Generation
    $alertsPortalUrl    = 'https://security.microsoft.com/alerts'
    $incidentsPortalUrl = 'https://security.microsoft.com/incidents'
    $maxBandRows        = 25

    $section1 = ''
    $section2 = ''
    $section3 = ''

    # ── Section 1: Open alert backlog (Critical, Important, Informational, Acknowledged) ─────

    if ($alertBandRows.Count -gt 0) {
        $critSorted     = @($criticalItems | Sort-Object -Property AgeHours -Descending)
        $importantSorted = @($importantItems | Sort-Object -Property @(
            @{ Expression = { if ($severityRank.ContainsKey($_.Severity)) { $severityRank[$_.Severity] } else { 99 } }; Ascending = $true },
            @{ Expression = 'AgeHours'; Descending = $true }
        ))

        $critDisplay = @($critSorted      | Select-Object -First $maxBandRows)
        $impDisplay  = @($importantSorted | Select-Object -First $maxBandRows)
        $critMore    = [math]::Max(0, $criticalItems.Count - $maxBandRows)
        $impMore     = [math]::Max(0, $importantItems.Count - $maxBandRows)

        $s1Rows = ''

        foreach ($row in $critDisplay) {
            $alertCell    = if ($row.AlertWebUrl) { "[$(Get-SafeMarkdown $row.Title)]($($row.AlertWebUrl))" } else { Get-SafeMarkdown $row.Title }
            $serviceCell  = if (-not [string]::IsNullOrEmpty($row.ServiceSource)) { $row.ServiceSource } else { '—' }
            $assignedCell = if ([string]::IsNullOrWhiteSpace($row.AssignedTo)) { '—' } else { Get-SafeMarkdown $row.AssignedTo }
            $incidentCell = if ($row.IncidentWebUrl) { "[Incident $($row.IncidentId)]($($row.IncidentWebUrl))" } elseif (-not [string]::IsNullOrEmpty($row.IncidentId)) { $row.IncidentId } else { '—' }
            $s1Rows += "| **Critical** | $($row.Severity) | $alertCell | $serviceCell | $assignedCell | $($row.AgeDisplay) | $incidentCell |`n"
        }
        if ($critMore -gt 0) {
            $s1Rows += "`n_... and $critMore more Critical alerts. [View in Defender XDR Alerts]($alertsPortalUrl)_`n`n"
        }

        foreach ($row in $impDisplay) {
            $alertCell    = if ($row.AlertWebUrl) { "[$(Get-SafeMarkdown $row.Title)]($($row.AlertWebUrl))" } else { Get-SafeMarkdown $row.Title }
            $serviceCell  = if (-not [string]::IsNullOrEmpty($row.ServiceSource)) { $row.ServiceSource } else { '—' }
            $assignedCell = if ([string]::IsNullOrWhiteSpace($row.AssignedTo)) { '—' } else { Get-SafeMarkdown $row.AssignedTo }
            $incidentCell = if ($row.IncidentWebUrl) { "[Incident $($row.IncidentId)]($($row.IncidentWebUrl))" } elseif (-not [string]::IsNullOrEmpty($row.IncidentId)) { $row.IncidentId } else { '—' }
            $s1Rows += "| Important | $($row.Severity) | $alertCell | $serviceCell | $assignedCell | $($row.AgeDisplay) | $incidentCell |`n"
        }
        if ($impMore -gt 0) {
            $s1Rows += "`n_... and $impMore more Important alerts. [View in Defender XDR Alerts]($alertsPortalUrl)_`n`n"
        }

        foreach ($row in $informationalItems) {
            $alertCell    = if ($row.AlertWebUrl) { "[$(Get-SafeMarkdown $row.Title)]($($row.AlertWebUrl))" } else { Get-SafeMarkdown $row.Title }
            $serviceCell  = if (-not [string]::IsNullOrEmpty($row.ServiceSource)) { $row.ServiceSource } else { '—' }
            $assignedCell = if ([string]::IsNullOrWhiteSpace($row.AssignedTo)) { '—' } else { Get-SafeMarkdown $row.AssignedTo }
            $incidentCell = if ($row.IncidentWebUrl) { "[Incident $($row.IncidentId)]($($row.IncidentWebUrl))" } elseif (-not [string]::IsNullOrEmpty($row.IncidentId)) { $row.IncidentId } else { '—' }
            $s1Rows += "| Informational | $($row.Severity) | $alertCell | $serviceCell | $assignedCell | $($row.AgeDisplay) | $incidentCell |`n"
        }

        foreach ($row in $acknowledgedItems) {
            $alertCell    = if ($row.AlertWebUrl) { "[$(Get-SafeMarkdown $row.Title)]($($row.AlertWebUrl))" } else { Get-SafeMarkdown $row.Title }
            $serviceCell  = if (-not [string]::IsNullOrEmpty($row.ServiceSource)) { $row.ServiceSource } else { '—' }
            $assignedCell = if ([string]::IsNullOrWhiteSpace($row.AssignedTo)) { '—' } else { Get-SafeMarkdown $row.AssignedTo }
            $incidentCell = if ($row.IncidentWebUrl) { "[Incident $($row.IncidentId)]($($row.IncidentWebUrl))" } elseif (-not [string]::IsNullOrEmpty($row.IncidentId)) { $row.IncidentId } else { '—' }
            $s1Rows += "| Acknowledged | $($row.Severity) | $alertCell | $serviceCell | $assignedCell | $($row.AgeDisplay) | $incidentCell |`n"
        }

        $section1 = @"
 
 
## Open Alert Backlog — [Defender XDR > Alerts]($alertsPortalUrl)
 
| Band | Severity | Alert | Service | Assigned To | Age (h) | Incident |
| :--- | :------- | :---- | :------ | :---------- | ------: | :------- |
$s1Rows
"@

    }

    # ── Section 2: Per-service triage map ─────────────────────────────────────

    if ($alertBandRows.Count -gt 0) {
        $serviceNames = @($alertBandRows | Select-Object -ExpandProperty ServiceSource -Unique | Sort-Object)
        $s2Rows = ''
        foreach ($svc in $serviceNames) {
            $svcAlerts       = @($alertBandRows | Where-Object { $_.ServiceSource -eq $svc })
            $openCount       = $svcAlerts.Count
            $highCount       = @($svcAlerts | Where-Object { $_.Severity -eq 'high' }).Count
            $mediumCount     = @($svcAlerts | Where-Object { $_.Severity -eq 'medium' }).Count
            $lowInfoCount    = @($svcAlerts | Where-Object { $_.Severity -in @('low', 'informational') }).Count
            $unassignedCount = @($svcAlerts | Where-Object { [string]::IsNullOrWhiteSpace($_.AssignedTo) }).Count
            $critHighCount   = @($svcAlerts | Where-Object { $_.Band -eq 'Critical' }).Count
            $oldestHours     = ($svcAlerts | Measure-Object -Property AgeHours -Maximum).Maximum
            $svcDisplay      = if (-not [string]::IsNullOrEmpty($svc)) { $svc } else { '—' }
            $s2Rows += "| $svcDisplay | $openCount | $highCount / $mediumCount / $lowInfoCount | $unassignedCount | $critHighCount | $oldestHours |`n"
        }

        $section2 = @"
 
 
## Per-Service Triage Map
 
| Service | Open Alerts | High / Medium / Low-Info | Unassigned | Untriaged High | Oldest Open (h) |
| :------ | ----------: | :----------------------- | ---------: | -------------: | --------------: |
$s2Rows
"@

    }

    # ── Section 3: Recently resolved (last 30 days, informational context) ────

    if ($resolvedAlerts.Count -gt 0) {
        $resolvedByService = @($resolvedAlerts | Group-Object -Property serviceSource | Sort-Object -Property Name)
        $s3Rows = ''
        foreach ($grp in $resolvedByService) {
            $svcDisplay    = if (-not [string]::IsNullOrEmpty($grp.Name)) { $grp.Name } else { '—' }
            $resolvedCount = $grp.Count

            # Compute median time to resolve from createdDateTime → resolvedDateTime.
            $durations = @(
                $grp.Group | ForEach-Object {
                    if (-not [string]::IsNullOrEmpty($_.createdDateTime) -and -not [string]::IsNullOrEmpty($_.resolvedDateTime)) {
                        [math]::Round(([DateTime]$_.resolvedDateTime - [DateTime]$_.createdDateTime).TotalHours, 1)
                    }
                } | Where-Object { $null -ne $_ } | Sort-Object
            )

            $medianHours = if ($durations.Count -eq 0) {
                '—'
            }
            elseif ($durations.Count % 2 -eq 1) {
                $durations[($durations.Count - 1) / 2]
            }
            else {
                [math]::Round(($durations[$durations.Count / 2 - 1] + $durations[$durations.Count / 2]) / 2, 1)
            }

            $s3Rows += "| $svcDisplay | $resolvedCount | $medianHours |`n"
        }

        $section3 = @"
 
 
## Recently Resolved Alerts (Last 30 Days) — [Defender XDR > Incidents]($incidentsPortalUrl)
 
_Informational throughput context; does not affect the verdict._
 
| Service | Resolved (30d) | Median Time to Resolve (h) |
| :------ | -------------: | -------------------------: |
$s3Rows
"@

    }

    $mdInfo             = "$section1$section2$section3"
    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '41121'
        Title  = $testTitle
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params
}