Skip to content

Commit ea0c2b0

Browse files
authored
SecOps 41041 - Automated Investigation and Response (AIR) recommendations are reviewed and actioned (#1380)
2 parents b981359 + aa8d3f1 commit ea0c2b0

2 files changed

Lines changed: 209 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
Microsoft Defender for Office 365 Automated Investigation and Response investigates email-related alerts and proposes remediation actions such as soft-deleting a malicious message from recipients' mailboxes, blocking a sender, URL, or file hash through the Tenant Allow/Block List, or moving suspicious mail to quarantine. When these recommended actions wait for analyst approval and the approval queue is not worked, the confirmed phishing or malware message remains in the recipient's mailbox while the verdict already says it is malicious; the user opens the link or attachment in the intervening minutes or hours and the threat actor obtains the credential or executes the payload — the detection landed but containment never reached the inbox. This check confirms there are no Microsoft Defender for Office 365 incidents left stale beyond the response window, so an email-side detection translates into removal from mailboxes rather than a paused investigation.
2+
3+
**Remediation action**
4+
5+
- [Automated investigation and response (AIR) in Office 365](https://learn.microsoft.com/en-us/defender-office-365/air-about)
6+
- [Approve or reject pending actions in AIR](https://learn.microsoft.com/en-us/defender-office-365/air-review-approve-pending-completed-actions)
7+
- [Action center in Microsoft 365 Defender](https://learn.microsoft.com/en-us/defender-xdr/m365d-action-center)
8+
- [Investigate incidents in Microsoft 365 Defender](https://learn.microsoft.com/en-us/defender-xdr/investigate-incidents)
9+
10+
<!--- Results --->
11+
%TestResult%
Lines changed: 198 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,198 @@
1+
<#
2+
.SYNOPSIS
3+
Checks that AIR-recommended remediation actions are reviewed and actioned for MDO incidents.
4+
5+
.NOTES
6+
Test ID: 41041
7+
Workshop Task: SECOPS-041
8+
Pillar: SecOps
9+
Category: Email and collaboration security
10+
Required permission: SecurityIncident.Read.All
11+
#>
12+
13+
function Test-Assessment-41041 {
14+
[ZtTest(
15+
Category = 'Email and collaboration security',
16+
CompatibleLicense = ('THREAT_INTELLIGENCE'),
17+
ImplementationCost = 'Medium',
18+
Pillar = 'SecOps',
19+
RiskLevel = 'High',
20+
Service = ('Graph'),
21+
SfiPillar = 'Accelerate response and remediation',
22+
TenantType = ('Workforce'),
23+
TestId = 41041,
24+
Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned',
25+
UserImpact = 'Low'
26+
)]
27+
[CmdletBinding()]
28+
param()
29+
30+
#region Data Collection
31+
32+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
33+
$activity = 'Checking MDO Automated Investigation and Response incident staleness'
34+
35+
$windowStart = (Get-Date).AddDays(-30).ToUniversalTime().ToString('yyyy-MM-ddTHH:mm:ssZ')
36+
$incidentFilter = "(status eq 'active' or status eq 'inProgress') and createdDateTime ge $windowStart"
37+
$incidentSelect = 'id,displayName,severity,status,assignedTo,createdDateTime,lastUpdateDateTime,incidentWebUrl'
38+
$alertsExpand = "alerts(`$select=id,status,serviceSource;`$filter=serviceSource eq 'microsoftDefenderForOffice365')"
39+
40+
$allIncidents = $null
41+
$queryError = $null
42+
43+
Write-ZtProgress -Activity $activity -Status 'Querying active Microsoft 365 Defender incidents'
44+
45+
try {
46+
# Q1: Enumerate active and in-progress MDO incidents in the last 30 days; server-side alert filter reduces payload.
47+
$allIncidents = Invoke-ZtGraphRequest -RelativeUri 'security/incidents' -ApiVersion beta -Filter $incidentFilter -Select $incidentSelect -QueryParameters @{ '$expand' = $alertsExpand } -ErrorAction Stop
48+
}
49+
catch {
50+
$queryError = $_
51+
Write-PSFMessage "Failed to retrieve security incidents: $_" -Tag Test -Level Warning
52+
}
53+
54+
#endregion Data Collection
55+
56+
#region Assessment Logic
57+
58+
if ($queryError) {
59+
$params = @{
60+
TestId = '41041'
61+
Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned'
62+
Status = $false
63+
Result = '⚠️ Microsoft Graph returned an error while querying security incidents. Ensure the assessment account has SecurityIncident.Read.All permission and re-run.'
64+
CustomStatus = 'Investigate'
65+
}
66+
Add-ZtTestResultDetail @params
67+
return
68+
}
69+
70+
$allIncidents = @($allIncidents)
71+
72+
# Q1 returned zero incidents — API is reachable but tenant has no active incidents in the window.
73+
if ($allIncidents.Count -eq 0) {
74+
Write-PSFMessage 'No active incidents in the last 30 days — skipping AIR staleness check.' -Tag Test -Level VeryVerbose
75+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
76+
return
77+
}
78+
79+
# Q2: Retain incidents that have at least one MDO alert (alerts already server-side filtered by expand).
80+
$mdoIncidents = @($allIncidents | Where-Object { @($_.alerts).Count -gt 0 })
81+
82+
if ($mdoIncidents.Count -eq 0) {
83+
Write-PSFMessage 'No MDO-origin incidents in the last 30 days — skipping AIR staleness check.' -Tag Test -Level VeryVerbose
84+
Add-ZtTestResultDetail -SkippedBecause NotApplicable
85+
return
86+
}
87+
88+
$now = Get-Date
89+
$staleThresholdHours = 24
90+
91+
# Q3: Classify each MDO incident. Stale = not updated in 24 hours (status already filtered to active/inProgress by Q1).
92+
$incidentResults = foreach ($incident in $mdoIncidents) {
93+
$lastUpdated = [datetime]$incident.lastUpdateDateTime
94+
$hoursSinceUpdate = [math]::Round(($now - $lastUpdated).TotalHours, 1)
95+
$isStale = $hoursSinceUpdate -gt $staleThresholdHours
96+
$isAssigned = -not [string]::IsNullOrWhiteSpace($incident.assignedTo)
97+
98+
$rowStatus = if ($isStale -and -not $isAssigned) {
99+
'Fail'
100+
}
101+
elseif ($isStale -and $isAssigned) {
102+
'Investigate'
103+
}
104+
else {
105+
'Pass'
106+
}
107+
108+
[PSCustomObject]@{
109+
DisplayName = $incident.displayName
110+
Severity = $incident.severity
111+
Status = $incident.status
112+
AssignedTo = if ($isAssigned) { $incident.assignedTo } else { '' }
113+
Created = $incident.createdDateTime
114+
LastUpdated = $incident.lastUpdateDateTime
115+
HoursSinceUpdate = $hoursSinceUpdate
116+
IncidentWebUrl = $incident.incidentWebUrl
117+
RowStatus = $rowStatus
118+
}
119+
}
120+
$incidentResults = @($incidentResults)
121+
122+
$failItems = @($incidentResults | Where-Object { $_.RowStatus -eq 'Fail' })
123+
$investigateItems = @($incidentResults | Where-Object { $_.RowStatus -eq 'Investigate' })
124+
125+
$passed = $failItems.Count -eq 0 -and $investigateItems.Count -eq 0
126+
$customStatus = $null
127+
128+
if ($failItems.Count -gt 0) {
129+
# Fail takes priority over Investigate when both exist.
130+
$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%"
131+
}
132+
elseif ($investigateItems.Count -gt 0) {
133+
$customStatus = 'Investigate'
134+
$testResultMarkdown = "⚠️ Stale incidents exist but are assigned to an operator; manual review is required to determine why they have not progressed.`n`n%TestResult%"
135+
}
136+
else {
137+
$testResultMarkdown = "✅ Microsoft Defender for Office 365 incidents are being triaged within SLA; no MDO-origin incidents are unassigned and stale.`n`n%TestResult%"
138+
}
139+
140+
#endregion Assessment Logic
141+
142+
#region Report Generation
143+
144+
$incidentsPortalUrl = 'https://security.microsoft.com/incidents'
145+
$maxDisplay = 10
146+
147+
# Sort by hours stale descending to surface the most neglected incidents first.
148+
$displayResults = @($incidentResults | Sort-Object -Property HoursSinceUpdate -Descending)
149+
$hasMoreItems = $incidentResults.Count -gt $maxDisplay
150+
if ($hasMoreItems) {
151+
$displayResults = @($displayResults | Select-Object -First $maxDisplay)
152+
}
153+
154+
$tableRows = ''
155+
foreach ($row in $displayResults) {
156+
$nameMd = if ($row.IncidentWebUrl) { "[$(Get-SafeMarkdown $row.DisplayName)]($($row.IncidentWebUrl))" } else { Get-SafeMarkdown $row.DisplayName }
157+
$assignedMd = if ($row.AssignedTo -eq '') { '' } else { Get-SafeMarkdown $row.AssignedTo }
158+
$createdMd = Get-FormattedDate -DateString $row.Created
159+
$updatedMd = Get-FormattedDate -DateString $row.LastUpdated
160+
$rowStatusMd = switch ($row.RowStatus) {
161+
'Pass' { '✅ Pass' }
162+
'Fail' { '❌ Fail' }
163+
'Investigate' { '⚠️ Investigate' }
164+
}
165+
$tableRows += "| $nameMd | $($row.Severity) | $($row.Status) | $assignedMd | $createdMd | $updatedMd | $($row.HoursSinceUpdate) | $rowStatusMd |`n"
166+
}
167+
168+
if ($hasMoreItems) {
169+
$remaining = $incidentResults.Count - $maxDisplay
170+
$tableRows += "`n... and $remaining more. [Microsoft 365 Defender > Incidents & alerts > Incidents]($incidentsPortalUrl)`n"
171+
}
172+
173+
$formatTemplate = @'
174+
175+
176+
## [Microsoft 365 Defender > Incidents & alerts > Incidents]({0})
177+
178+
| Display name | Severity | Status | Assigned to | Created | Last updated | Hours since update | Result |
179+
| :----------- | :------- | :----- | :---------- | :------ | :----------- | -----------------: | :----- |
180+
{1}
181+
'@
182+
183+
$mdInfo = $formatTemplate -f $incidentsPortalUrl, $tableRows
184+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
185+
186+
#endregion Report Generation
187+
188+
$params = @{
189+
TestId = '41041'
190+
Title = 'Automated Investigation and Response (AIR) recommendations are reviewed and actioned'
191+
Status = $passed
192+
Result = $testResultMarkdown
193+
}
194+
if ($customStatus) {
195+
$params.CustomStatus = $customStatus
196+
}
197+
Add-ZtTestResultDetail @params
198+
}

0 commit comments

Comments
 (0)