|
| 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