tests/Test-Assessment.41062.ps1
|
<#
.SYNOPSIS Checks that Automated Investigation and Response (AIR) recommendations in Microsoft Defender for Endpoint are reviewed and actioned. .NOTES Test ID: 41062 Workshop Task: SECOPS-062 Pillar: SecOps Category: Threat investigation and response Required permission: SecurityAlert.Read.All #> function Test-Assessment-41062 { [ZtTest( Category = 'Threat investigation and response', CompatibleLicense = ('WINDEFATP'), ImplementationCost = 'Low', Pillar = 'SecOps', RiskLevel = 'High', Service = ('Graph'), SfiPillar = 'Accelerate response and remediation', TenantType = ('Workforce'), TestId = 41062, 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 Microsoft Defender for Endpoint AIR action review status' $now = Get-Date # Server-side filter: scope to active MDE alerts only. # investigationState is NOT in the supported $filter properties for alerts_v2 (returns HTTP 400); # all investigationState evaluation is applied client-side in Assessment Logic. $alertFilter = "serviceSource eq 'microsoftDefenderForEndpoint' and (status eq 'new' or status eq 'inProgress')" $alertSelect = 'title,severity,serviceSource,investigationState,incidentId,createdDateTime,lastUpdateDateTime,alertWebUrl,incidentWebUrl' Write-ZtProgress -Activity $activity -Status 'Querying Microsoft Defender for Endpoint alerts' $allAlerts = $null try { # Q1: Retrieve all active MDE alerts paged at 100 per request; auto-paging follows nextLink. $allAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -ApiVersion beta -Filter $alertFilter -Select $alertSelect -Top 100 -ErrorAction Stop } catch { $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_ if ($httpStatus -in @(401, 403)) { Write-PSFMessage "Insufficient permissions to query MDE alerts (HTTP $httpStatus): $_" -Tag Test -Level Warning $params = @{ TestId = '41062' Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned' Status = $false Result = '⚠️ Insufficient Graph permission; the assessment runtime cannot read security alerts. Ensure the account has SecurityAlert.Read.All.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } Write-PSFMessage "Failed to query MDE alerts (HTTP $httpStatus): $_" -Tag Test -Level Warning $params = @{ TestId = '41062' Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned' Status = $false Result = '⚠️ Transient Microsoft Graph error or unexpected response shape; re-run after 5–10 minutes.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } $allAlerts = @($allAlerts) # No active alerts: probe whether MDE is deployed in the tenant before deciding between Pass and Skip. if ($allAlerts.Count -eq 0) { Write-ZtProgress -Activity $activity -Status 'Probing Microsoft Defender for Endpoint telemetry presence' $deploymentProbeAlerts = $null try { $deploymentProbeAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -ApiVersion beta -Filter "serviceSource eq 'microsoftDefenderForEndpoint'" -Top 1 -DisablePaging -ErrorAction Stop } catch { $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_ if ($httpStatus -in @(401, 403)) { Write-PSFMessage "Insufficient permissions to probe MDE alert presence (HTTP $httpStatus): $_" -Tag Test -Level Warning $params = @{ TestId = '41062' Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned' Status = $false Result = '⚠️ Insufficient Graph permission; the assessment runtime cannot read security alerts. Ensure the account has SecurityAlert.Read.All.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } Write-PSFMessage "Failed to probe MDE alert presence (HTTP $httpStatus): $_" -Tag Test -Level Warning $params = @{ TestId = '41062' Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned' Status = $false Result = '⚠️ Transient Microsoft Graph error or unexpected response shape; re-run after 5–10 minutes.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } if ($null -eq $deploymentProbeAlerts -or @($deploymentProbeAlerts).Count -eq 0) { Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No Microsoft Defender for Endpoint alerts are available in this tenant.' return } $params = @{ TestId = '41062' Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned' Status = $true Result = '✅ There are no open endpoint AIR actions awaiting remediation.' } Add-ZtTestResultDetail @params return } #endregion Data Collection #region Assessment Logic $threshold24h = $now.AddHours(-24) # Q1 client-side: pendingApproval alerts whose last update is more than 24 hours ago. # lastUpdateDateTime is used (not createdDateTime) per spec — a recently re-queued action # resets the clock and should not count as stale until the analyst has had 24 h to act on it. $pendingApprovalAlerts = @($allAlerts | Where-Object { $_.investigationState -eq 'pendingApproval' -and $null -ne $_.lastUpdateDateTime -and $null -ne $_.createdDateTime -and $_.lastUpdateDateTime -lt $threshold24h }) # Q2 client-side: medium/high-severity alerts in AIR failure states older than 24 hours. # Informational and low-severity alerts are excluded because AIR investigations on those # frequently land in partiallyInvestigated or terminatedBySystem by design and do not # represent an uncontained threat on the host. $airFailureStates = @('failed', 'innerFailure', 'partiallyRemediated', 'partiallyInvestigated') $failedAirAlerts = @($allAlerts | Where-Object { $_.investigationState -in $airFailureStates -and $_.severity -in @('medium', 'high') -and $null -ne $_.createdDateTime -and $_.createdDateTime -lt $threshold24h }) # pendingApproval and the failure-state values are mutually exclusive investigationState values; # a single alert cannot appear in both result sets. $failingAlerts = @($pendingApprovalAlerts) + @($failedAirAlerts) $passed = ($failingAlerts.Count -eq 0) if ($passed) { $testResultMarkdown = '✅ Microsoft Defender for Endpoint AIR actions are being reviewed and remediated within 24 hours.' } else { $testResultMarkdown = "❌ One or more endpoint AIR actions are stuck in ``pendingApproval`` or failed, and the malicious artifact remains active on the host.`n`n%TestResult%" } # Build per-alert result rows as plain data and record why the alert failed. # Decoration (emoji + display emphasis) is applied during markdown rendering. $alertResults = @() $alertsWithReason = @( @($pendingApprovalAlerts | ForEach-Object { [PSCustomObject]@{ Alert = $_ FailureType = 'PendingApprovalStale' } }) @($failedAirAlerts | ForEach-Object { [PSCustomObject]@{ Alert = $_ FailureType = 'FailedAirStale' } }) ) $alertResults += foreach ($entry in $alertsWithReason) { $alert = $entry.Alert $hoursOpen = [math]::Round(($now - [datetime]$alert.createdDateTime).TotalHours, 1) [PSCustomObject]@{ Title = $alert.title Severity = $alert.severity InvestigationState = $alert.investigationState IncidentId = $alert.incidentId IncidentWebUrl = $alert.incidentWebUrl Created = $alert.createdDateTime HoursOpen = $hoursOpen ServiceSource = $alert.serviceSource AlertWebUrl = $alert.alertWebUrl FailureType = $entry.FailureType } } $alertResults = @($alertResults) #endregion Assessment Logic #region Report Generation if (-not $passed) { $actionCenterUrl = 'https://security.microsoft.com/action-center' $maxDisplay = 10 $totalCount = $alertResults.Count $hasMoreItems = $totalCount -gt $maxDisplay $displayAlerts = @($alertResults | Select-Object -First $maxDisplay) $tableRows = '' foreach ($row in $displayAlerts) { $titleMd = if ($row.AlertWebUrl) { "[$(Get-SafeMarkdown $row.Title)]($($row.AlertWebUrl))" } else { Get-SafeMarkdown $row.Title } $incidentMd = if ($row.IncidentWebUrl) { "[$($row.IncidentId)]($($row.IncidentWebUrl))" } elseif ($row.IncidentId) { $row.IncidentId } else { '—' } $createdMd = Get-FormattedDate -DateString $row.Created.ToString('o') # pendingApproval rows do not use severity in the fail predicate; failed-AIR rows do. $severityDisplay = if ($row.FailureType -eq 'FailedAirStale') { "❌ $($row.Severity)" } else { $row.Severity } $stateDisplay = "❌ $($row.InvestigationState)" $hoursDisplay = "❌ $($row.HoursOpen)" $statusDisplay = '❌ Fail' $tableRows += "| $titleMd | $severityDisplay | $stateDisplay | $incidentMd | $createdMd | $hoursDisplay | $($row.ServiceSource) | $statusDisplay |`n" } if ($hasMoreItems) { $remaining = $totalCount - $maxDisplay $tableRows += "`n... and $remaining more. [Defender XDR > Action center]($actionCenterUrl)`n" } $formatTemplate = @' ## [Defender XDR > Action center]({0}) | Alert title | Severity | Investigation state | Incident id | Created | Hours open | Service source | Status | | :---------- | :------- | :------------------ | :---------- | :------ | ---------: | :------------- | :----- | {1} '@ $mdInfo = $formatTemplate -f $actionCenterUrl, $tableRows $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo } #endregion Report Generation $params = @{ TestId = '41062' Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned' Status = $passed Result = $testResultMarkdown } Add-ZtTestResultDetail @params } |