tests/Test-Assessment.41061.ps1
|
<#
.SYNOPSIS Checks that Microsoft Defender XDR incidents from the last 24 hours are triaged and remediated. .NOTES Test ID: 41061 Workshop Task: SECOPS-061 Pillar: SecOps Category: Threat investigation and response Required permission: SecurityIncident.Read.All #> function Test-Assessment-41061 { [ZtTest( Category = 'Threat investigation and response', CompatibleLicense = ('WINDEFATP', 'MDE_LITE', 'ATP_ENTERPRISE', 'THREAT_INTELLIGENCE', 'ATA', 'ADALLOM_S_STANDALONE', 'MDE_SMB'), ImplementationCost = 'Medium', Pillar = 'SecOps', RiskLevel = 'High', Service = ('Graph'), SfiPillar = 'Accelerate response and remediation', TenantType = ('Workforce'), TestId = 41061, Title = 'All active Microsoft Defender XDR incidents are triaged and remediated', UserImpact = 'Low' )] [CmdletBinding()] param() #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose $activity = 'Checking Microsoft Defender XDR incident triage and remediation' $now = Get-Date $windowStart = $now.AddHours(-24).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') $slaThreshold = $now.AddHours(-4).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ') # Server-side OData filter encoding all three evaluation rules — only failing incidents are returned. # Rule (a): unassigned and not a merged redirect. # Rule (b): high-severity, still active, strictly older than 4 h (lt = created before the SLA threshold, exclusive). # Rule (c): resolved but missing a meaningful classification or determination. $incidentFilter = "createdDateTime ge $windowStart and " + "((assignedTo eq null and status ne 'redirected') or " + "(severity eq 'high' and status eq 'active' and createdDateTime lt $slaThreshold) or " + "(status eq 'resolved' and (classification eq 'unknown' or determination eq 'unknown')))" $incidentSelect = 'displayName,severity,status,assignedTo,classification,determination,createdDateTime,incidentWebUrl' Write-ZtProgress -Activity $activity -Status 'Querying failing Microsoft Defender XDR incidents from the last 24 hours' try { # Q1: Retrieve up to 10 incidents that breach at least one triage/remediation rule. # -DisablePaging prevents auto-following of nextLink so $top=10 is respected as a hard cap. # $count=true returns @odata.count with the true total so the report can show a truncation indicator. # Prefer: include-unknown-enum-members is required so that awaitingAction is returned as its real string. $response = Invoke-ZtGraphRequest -RelativeUri 'security/incidents' -ApiVersion beta -Filter $incidentFilter -Select $incidentSelect -Top 10 -QueryParameters @{ '$count' = 'true' } -Headers @{ Prefer = 'include-unknown-enum-members' } -ErrorAction Stop -DisablePaging } catch { $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_ if ($httpStatus -in @(401, 403)) { Write-PSFMessage "Failed to query Defender XDR incidents due to insufficient permissions (HTTP $httpStatus). Ensure the account has SecurityIncident.Read.All: $_" -Tag Test -Level Warning $params = @{ TestId = '41061' Title = 'All active Microsoft Defender XDR incidents are triaged and remediated' Status = $false Result = '⚠️ Insufficient Graph permission; the assessment runtime cannot read Defender XDR incidents. Ensure the account has SecurityIncident.Read.All.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } # Any other error (network, throttling, 5xx, etc.) is treated as a transient investigate condition. Write-PSFMessage "Failed to query Defender XDR incidents (HTTP $httpStatus): $_" -Tag Test -Level Warning $params = @{ TestId = '41061' Title = 'All active Microsoft Defender XDR incidents are triaged and remediated' Status = $false Result = '⚠️ Transient Microsoft Graph error or unexpected response shape; re-run after 5–10 minutes.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } #endregion Data Collection #region Assessment Logic $failingIncidents = @($response.value | Where-Object { $_ }) $totalFailingCount = if ($null -ne $response.'@odata.count') { $response.'@odata.count' } else { $failingIncidents.Count } # Empty response: no incidents breach any evaluation rule — tenant is compliant. $passed = ($failingIncidents.Count -eq 0) if ($passed) { $testResultMarkdown = '✅ All Microsoft Defender XDR incidents from the last 24 hours are triaged, assigned, and (when closed) classified.' } else { $testResultMarkdown = "❌ One or more incidents are unassigned, exceed SLA in ``active`` status, or were closed without classification.`n`n%TestResult%" } # Annotate each returned (failing) incident with per-rule flags for per-cell decoration in the report. $incidentResults = foreach ($incident in $failingIncidents) { $hoursOpenRaw = ($now - [datetime]$incident.createdDateTime).TotalHours $hoursOpen = [math]::Round($hoursOpenRaw, 1) $isAssigned = -not [string]::IsNullOrWhiteSpace($incident.assignedTo) # Condition (a): unassigned and not a merged redirect. $failUnassigned = (-not $isAssigned) -and ($incident.status -ne 'redirected') # Condition (b): high-severity active incident strictly past the 4-hour SLA; use unrounded value to avoid rounding a near-boundary incident to exactly 4.0. $failSla = $incident.severity -eq 'high' -and $incident.status -eq 'active' -and $hoursOpenRaw -gt 4 # Condition (c): resolved without a meaningful classification or determination. $failClassification = $incident.status -eq 'resolved' -and ($incident.classification -eq 'unknown' -or $incident.determination -eq 'unknown') [PSCustomObject]@{ DisplayName = $incident.displayName Severity = $incident.severity Status = $incident.status AssignedTo = if ($isAssigned) { $incident.assignedTo } else { '—' } Classification = $incident.classification Determination = $incident.determination Created = $incident.createdDateTime HoursOpen = $hoursOpen IncidentWebUrl = $incident.incidentWebUrl FailUnassigned = $failUnassigned FailSla = $failSla FailClassification = $failClassification } } $incidentResults = @($incidentResults) #endregion Assessment Logic #region Report Generation if (-not $passed) { $incidentsPortalUrl = 'https://security.microsoft.com/incidents' $hasMoreItems = $totalFailingCount -gt $incidentResults.Count $tableRows = '' foreach ($row in $incidentResults) { $nameMd = if ($row.IncidentWebUrl) { "[$(Get-SafeMarkdown $row.DisplayName)]($($row.IncidentWebUrl))" } else { Get-SafeMarkdown $row.DisplayName } # Decorate the specific cell that triggered failure. $assignedMd = if ($row.FailUnassigned) { '❌ —' } elseif ($row.AssignedTo -eq '—') { '—' } else { Get-SafeMarkdown $row.AssignedTo } $hoursOpenMd = if ($row.FailSla) { "❌ $($row.HoursOpen)" } else { $row.HoursOpen } $classificationMd = if ($row.FailClassification -and $row.Classification -eq 'unknown') { '❌ unknown' } else { $row.Classification } $determinationMd = if ($row.FailClassification -and $row.Determination -eq 'unknown') { '❌ unknown' } else { $row.Determination } $createdMd = Get-FormattedDate -DateString $row.Created $tableRows += "| $nameMd | $($row.Severity) | $($row.Status) | $assignedMd | $classificationMd | $determinationMd | $createdMd | $hoursOpenMd | ❌ Fail |`n" } if ($hasMoreItems) { $remaining = $totalFailingCount - $incidentResults.Count $tableRows += "`n... and $remaining more. [Defender XDR > Incidents & alerts > Incidents]($incidentsPortalUrl)`n" } $formatTemplate = @' ## [Defender XDR > Incidents & alerts > Incidents]({0}) | Incident name | Severity | Status | Assigned to | Classification | Determination | Created | Hours open | Result | | :------------ | :------- | :----- | :---------- | :------------- | :------------ | :------ | ---------: | :----- | {1} '@ $mdInfo = $formatTemplate -f $incidentsPortalUrl, $tableRows $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo } #endregion Report Generation $params = @{ TestId = '41061' Title = 'All active Microsoft Defender XDR incidents are triaged and remediated' Status = $passed Result = $testResultMarkdown } Add-ZtTestResultDetail @params } |