tests/Test-Assessment.41041.ps1

<#
.SYNOPSIS
    Checks that AIR-recommended remediation actions are reviewed and actioned for MDO incidents.
 
.NOTES
    Test ID: 41041
    Workshop Task: SECOPS-041
    Pillar: SecOps
    Category: Email and collaboration security
    Required permission: SecurityIncident.Read.All
#>


function Test-Assessment-41041 {
    [ZtTest(
        Category = 'Email and collaboration security',
        CompatibleLicense = ('THREAT_INTELLIGENCE'),
        ImplementationCost = 'Medium',
        Pillar = 'SecOps',
        RiskLevel = 'High',
        Service = ('Graph'),
        SfiPillar = 'Accelerate response and remediation',
        TenantType = ('Workforce'),
        TestId = 41041,
        Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection

    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking MDO Automated Investigation and Response incident staleness'

    $windowStart    = (Get-Date).AddDays(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
    $incidentFilter = "(status eq 'active' or status eq 'inProgress') and createdDateTime ge $windowStart"
    $incidentSelect = 'id,displayName,severity,status,assignedTo,createdDateTime,lastUpdateDateTime,incidentWebUrl'
    $alertsExpand   = "alerts(`$select=id,status,serviceSource;`$filter=serviceSource eq 'microsoftDefenderForOffice365')"

    $allIncidents = $null
    $queryError   = $null

    Write-ZtProgress -Activity $activity -Status 'Querying active Microsoft 365 Defender incidents'

    try {
        # Q1: Enumerate active and in-progress MDO incidents in the last 30 days; server-side alert filter reduces payload.
        $allIncidents = Invoke-ZtGraphRequest -RelativeUri 'security/incidents' -ApiVersion beta -Filter $incidentFilter -Select $incidentSelect -QueryParameters @{ '$expand' = $alertsExpand } -ErrorAction Stop
    }
    catch {
        $queryError = $_
        Write-PSFMessage "Failed to retrieve security incidents: $_" -Tag Test -Level Warning
    }

    #endregion Data Collection

    #region Assessment Logic

    if ($queryError) {
        $params = @{
            TestId       = '41041'
            Title        = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned'
            Status       = $false
            Result       = '⚠️ Microsoft Graph returned an error while querying security incidents. Ensure the assessment account has SecurityIncident.Read.All permission and re-run.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    $allIncidents = @($allIncidents)

    # Q1 returned zero incidents — API is reachable but tenant has no active incidents in the window.
    if ($allIncidents.Count -eq 0) {
        Write-PSFMessage 'No active incidents in the last 30 days — skipping AIR staleness check.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotApplicable
        return
    }

    # Q2: Retain incidents that have at least one MDO alert (alerts already server-side filtered by expand).
    $mdoIncidents = @($allIncidents | Where-Object { @($_.alerts).Count -gt 0 })

    if ($mdoIncidents.Count -eq 0) {
        Write-PSFMessage 'No MDO-origin incidents in the last 30 days — skipping AIR staleness check.' -Tag Test -Level VeryVerbose
        Add-ZtTestResultDetail -SkippedBecause NotApplicable
        return
    }

    $now                = Get-Date
    $staleThresholdHours = 24

    # Q3: Classify each MDO incident. Stale = not updated in 24 hours (status already filtered to active/inProgress by Q1).
    $incidentResults = foreach ($incident in $mdoIncidents) {
        $lastUpdated     = [datetime]$incident.lastUpdateDateTime
        $hoursSinceUpdate = [math]::Round(($now - $lastUpdated).TotalHours, 1)
        $isStale         = $hoursSinceUpdate -gt $staleThresholdHours
        $isAssigned      = -not [string]::IsNullOrWhiteSpace($incident.assignedTo)

        $rowStatus = if ($isStale -and -not $isAssigned) {
            'Fail'
        }
        elseif ($isStale -and $isAssigned) {
            'Investigate'
        }
        else {
            'Pass'
        }

        [PSCustomObject]@{
            DisplayName      = $incident.displayName
            Severity         = $incident.severity
            Status           = $incident.status
            AssignedTo       = if ($isAssigned) { $incident.assignedTo } else { '—' }
            Created          = $incident.createdDateTime
            LastUpdated      = $incident.lastUpdateDateTime
            HoursSinceUpdate = $hoursSinceUpdate
            IncidentWebUrl   = $incident.incidentWebUrl
            RowStatus        = $rowStatus
        }
    }
    $incidentResults  = @($incidentResults)

    $failItems        = @($incidentResults | Where-Object { $_.RowStatus -eq 'Fail' })
    $investigateItems = @($incidentResults | Where-Object { $_.RowStatus -eq 'Investigate' })

    $passed       = $failItems.Count -eq 0 -and $investigateItems.Count -eq 0
    $customStatus = $null

    if ($failItems.Count -gt 0) {
        # Fail takes priority over Investigate when both exist.
        $testResultMarkdown = "❌ One or more MDO-origin incidents are unassigned and have not been updated in 24 hours; AIR-recommended remediation actions are likely unapproved and the original threats remain in user mailboxes.`n`n%TestResult%"
    }
    elseif ($investigateItems.Count -gt 0) {
        $customStatus       = 'Investigate'
        $testResultMarkdown = "⚠️ Stale incidents exist but are assigned to an operator; manual review is required to determine why they have not progressed.`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "✅ Microsoft Defender for Office 365 incidents are being triaged within SLA; no MDO-origin incidents are unassigned and stale.`n`n%TestResult%"
    }

    #endregion Assessment Logic

    #region Report Generation

    $incidentsPortalUrl = 'https://security.microsoft.com/incidents'
    $maxDisplay         = 10

    # Sort by hours stale descending to surface the most neglected incidents first.
    $displayResults = @($incidentResults | Sort-Object -Property HoursSinceUpdate -Descending)
    $hasMoreItems   = $incidentResults.Count -gt $maxDisplay
    if ($hasMoreItems) {
        $displayResults = @($displayResults | Select-Object -First $maxDisplay)
    }

    $tableRows = ''
    foreach ($row in $displayResults) {
        $nameMd      = if ($row.IncidentWebUrl) { "[$(Get-SafeMarkdown $row.DisplayName)]($($row.IncidentWebUrl))" } else { Get-SafeMarkdown $row.DisplayName }
        $assignedMd  = if ($row.AssignedTo -eq '—') { '—' } else { Get-SafeMarkdown $row.AssignedTo }
        $createdMd   = Get-FormattedDate -DateString $row.Created
        $updatedMd   = Get-FormattedDate -DateString $row.LastUpdated
        $rowStatusMd = switch ($row.RowStatus) {
            'Pass'        { '✅ Pass' }
            'Fail'        { '❌ Fail' }
            'Investigate' { '⚠️ Investigate' }
        }
        $tableRows += "| $nameMd | $($row.Severity) | $($row.Status) | $assignedMd | $createdMd | $updatedMd | $($row.HoursSinceUpdate) | $rowStatusMd |`n"
    }

    if ($hasMoreItems) {
        $remaining  = $incidentResults.Count - $maxDisplay
        $tableRows += "`n... and $remaining more. [Microsoft 365 Defender > Incidents & alerts > Incidents]($incidentsPortalUrl)`n"
    }

    $formatTemplate = @'
 
 
## [Microsoft 365 Defender > Incidents & alerts > Incidents]({0})
 
| Display name | Severity | Status | Assigned to | Created | Last updated | Hours since update | Result |
| :----------- | :------- | :----- | :---------- | :------ | :----------- | -----------------: | :----- |
{1}
'@


    $mdInfo             = $formatTemplate -f $incidentsPortalUrl, $tableRows
    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo

    #endregion Report Generation

    $params = @{
        TestId = '41041'
        Title  = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params
}