Skip to content

Commit a3e4c5e

Browse files
authored
SecOps - 41019 - Compromised identities surfaced by Microsoft Defender for Identity have been remediated (#1383)
2 parents 92e4815 + b3deee8 commit a3e4c5e

2 files changed

Lines changed: 284 additions & 0 deletions

File tree

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
When Microsoft Defender for Identity raises an alert that a user account has been compromised — for example through Pass-the-Hash, Pass-the-Ticket, suspicious sign-in from an exposed credential, or stolen access — the account stays under the threat actor's control until it is disabled, its password is reset, and any active sessions are revoked. While the account remains active, the threat actor can continue lateral movement to sensitive servers, request additional Kerberos tickets, dump credentials from more endpoints, and persist by registering a new authentication method or creating a mail forwarding rule. Microsoft Defender for Identity flags the impacted user and surfaces containment actions in Microsoft Defender XDR; this check confirms there are no open identity alerts whose subject account has not yet been remediated, so the kill chain is closed at the credential stage rather than allowed to advance to data access.
2+
3+
**Remediation action**
4+
5+
- [Investigate identities in Microsoft Defender XDR](https://learn.microsoft.com/en-us/defender-xdr/investigate-users)
6+
- [Microsoft Defender for Identity remediation actions](https://learn.microsoft.com/en-us/defender-for-identity/remediation-actions)
7+
- [Investigate alerts in Microsoft Defender XDR](https://learn.microsoft.com/en-us/defender-xdr/investigate-alerts)
8+
- [Investigate risk with Microsoft Entra ID Protection](https://learn.microsoft.com/en-us/entra/id-protection/howto-identity-protection-investigate-risk)
9+
10+
<!--- Results --->
11+
%TestResult%
Lines changed: 273 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,273 @@
1+
<#
2+
.SYNOPSIS
3+
Checks that compromised identities surfaced by Microsoft Defender for Identity alerts have been remediated.
4+
5+
.NOTES
6+
Test ID: 41019
7+
Workshop Task: SECOPS-019
8+
Pillar: SecOps
9+
Category: Identity threat protection
10+
Required permission: SecurityAlert.Read.All
11+
#>
12+
13+
function Test-Assessment-41019 {
14+
15+
[ZtTest(
16+
Category = 'Identity threat protection',
17+
CompatibleLicense = ('ATA'),
18+
ImplementationCost = 'Low',
19+
Pillar = 'SecOps',
20+
RiskLevel = 'High',
21+
Service = ('Graph'),
22+
SfiPillar = 'Accelerate response and remediation',
23+
TenantType = ('Workforce'),
24+
TestId = 41019,
25+
Title = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated',
26+
UserImpact = 'Medium'
27+
)]
28+
[CmdletBinding()]
29+
param()
30+
31+
#region Data Collection
32+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
33+
$activity = 'Checking Microsoft Defender for Identity compromised identity remediation'
34+
Write-ZtProgress -Activity $activity -Status 'Querying open MDI alerts'
35+
36+
# Q1: List open MDI alerts; the evidence collection is returned inline per alert.
37+
# Compromised-user evidence and remediation state are evaluated client-side.
38+
# Prefer: include-unknown-enum-members is required so that the six gated remediationStatus
39+
# values (active, pendingApproval, declined, unremediated, running, partiallyRemediated)
40+
# are returned as their real strings rather than collapsing to unknownFutureValue.
41+
$openAlerts = $null
42+
try {
43+
$openAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -Filter "serviceSource eq 'microsoftDefenderForIdentity' and status ne 'resolved'" -ApiVersion beta -Headers @{ Prefer = 'include-unknown-enum-members' } -ErrorAction Stop
44+
}
45+
catch {
46+
$httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
47+
if ($httpStatus -in @(401, 403)) {
48+
$params = @{
49+
TestId = '41019'
50+
Title = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
51+
Status = $false
52+
Result = '⚠️ Insufficient Graph permission for SecurityAlert.Read.All; the assessment runtime cannot read MDI alerts.'
53+
CustomStatus = 'Investigate'
54+
}
55+
Add-ZtTestResultDetail @params
56+
return
57+
}
58+
$params = @{
59+
TestId = '41019'
60+
Title = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
61+
Status = $false
62+
Result = '⚠️ Transient Microsoft Graph error or unexpected response shape; re-run after 5-10 minutes.'
63+
CustomStatus = 'Investigate'
64+
}
65+
Add-ZtTestResultDetail @params
66+
return
67+
}
68+
69+
# Q2: When Q1 returns nothing, probe whether MDI has any alerts at all to distinguish
70+
# "no open alerts" from "MDI is not deployed".
71+
$mdiPresenceAlerts = $null
72+
if ($null -eq $openAlerts -or @($openAlerts).Count -eq 0) {
73+
Write-ZtProgress -Activity $activity -Status 'Probing MDI telemetry presence'
74+
try {
75+
$mdiPresenceAlerts = Invoke-ZtGraphRequest -RelativeUri 'security/alerts_v2' -Filter "serviceSource eq 'microsoftDefenderForIdentity'" -Top 1 -ApiVersion beta -ErrorAction Stop
76+
}
77+
catch {
78+
$httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
79+
if ($httpStatus -in @(401, 403)) {
80+
$params = @{
81+
TestId = '41019'
82+
Title = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
83+
Status = $false
84+
Result = '⚠️ Insufficient Graph permission for SecurityAlert.Read.All; the assessment runtime cannot read MDI alerts.'
85+
CustomStatus = 'Investigate'
86+
}
87+
Add-ZtTestResultDetail @params
88+
return
89+
}
90+
$params = @{
91+
TestId = '41019'
92+
Title = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
93+
Status = $false
94+
Result = '⚠️ Transient Microsoft Graph error or unexpected response shape; re-run after 5-10 minutes.'
95+
CustomStatus = 'Investigate'
96+
}
97+
Add-ZtTestResultDetail @params
98+
return
99+
}
100+
}
101+
#endregion Data Collection
102+
103+
#region Assessment Logic
104+
# Unfiltered MDI probe also returned nothing: MDI is not deployed in this tenant.
105+
if (($null -eq $openAlerts -or @($openAlerts).Count -eq 0) -and
106+
($null -eq $mdiPresenceAlerts -or @($mdiPresenceAlerts).Count -eq 0)) {
107+
Add-ZtTestResultDetail -SkippedBecause NotApplicable -Result 'No Microsoft Defender for Identity alerts are open in this tenant.'
108+
return
109+
}
110+
111+
$containedStatuses = @('remediated', 'prevented', 'blocked')
112+
# unknownFutureValue is included here as a defensive fallback: if the Prefer header is
113+
# somehow dropped or a future API version adds new gated values, treat them as Investigate
114+
# rather than silently promoting them to Fail.
115+
$inProgressStatuses = @('active', 'running', 'pendingApproval', 'partiallyRemediated', 'unknownFutureValue')
116+
117+
# One row per compromised userEvidence — an alert can implicate multiple accounts.
118+
$compromisedRows = [System.Collections.Generic.List[PSCustomObject]]::new()
119+
120+
foreach ($alert in @($openAlerts)) {
121+
$compromisedEvidenceList = @($alert.evidence | Where-Object {
122+
$_.'@odata.type' -eq '#microsoft.graph.security.userEvidence' -and
123+
$_.roles -contains 'compromised'
124+
})
125+
126+
foreach ($userEvidence in $compromisedEvidenceList) {
127+
$rawRemediationStatus = $userEvidence.remediationStatus
128+
$isAbsent = [string]::IsNullOrEmpty($rawRemediationStatus)
129+
130+
# Absent means the product hasn't attached a remediation object yet (e.g. a freshly-raised
131+
# alert). "No data" is not a confirmed gap, so treat as Investigate rather than Fail.
132+
# Only positive not-remediated signals (none/notFound/declined/unremediated) → Fail.
133+
$rowVerdict = if ($isAbsent) {
134+
'Investigate'
135+
} elseif ($rawRemediationStatus -in $containedStatuses) {
136+
'Pass'
137+
} elseif ($rawRemediationStatus -in $inProgressStatuses) {
138+
'Investigate'
139+
} else {
140+
# none, notFound, declined, unremediated → confirmed not remediated
141+
'Fail'
142+
}
143+
144+
$compromisedRows.Add([PSCustomObject]@{
145+
AlertTitle = $alert.title
146+
AlertSeverity = $alert.severity
147+
AlertStatus = $alert.status
148+
AssignedTo = $alert.assignedTo
149+
FirstActivity = $alert.firstActivityDateTime
150+
IncidentId = $alert.incidentId
151+
IncidentWebUrl = $alert.incidentWebUrl
152+
UserAccount = $userEvidence.userAccount
153+
RemediationStatus = if ($isAbsent) { 'absent' } else { $rawRemediationStatus }
154+
RowVerdict = $rowVerdict
155+
})
156+
}
157+
}
158+
159+
# No compromised-user evidence found across all open MDI alerts.
160+
if ($compromisedRows.Count -eq 0) {
161+
$params = @{
162+
TestId = '41019'
163+
Title = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
164+
Status = $true
165+
Result = '✅ No compromised identities surfaced by Microsoft Defender for Identity are awaiting remediation.'
166+
}
167+
Add-ZtTestResultDetail @params
168+
return
169+
}
170+
171+
# Aggregate verdict: Fail > Investigate > Pass.
172+
$allVerdicts = @($compromisedRows | Select-Object -ExpandProperty RowVerdict)
173+
$passed = $true
174+
$customStatus = $null
175+
176+
if ($allVerdicts -contains 'Fail') {
177+
$passed = $false
178+
$testResultMarkdown = "❌ One or more identities flagged as compromised by Microsoft Defender for Identity have not been remediated.`n`n%TestResult%"
179+
} elseif ($allVerdicts -contains 'Investigate') {
180+
$passed = $false
181+
$customStatus = 'Investigate'
182+
$testResultMarkdown = "⚠️ Remediation is in progress for the flagged compromised identities; confirm that it completes.`n`n%TestResult%"
183+
} else {
184+
$testResultMarkdown = "✅ No compromised identities surfaced by Microsoft Defender for Identity are awaiting remediation.`n`n%TestResult%"
185+
}
186+
#endregion Assessment Logic
187+
188+
#region Report Generation
189+
$alertsPortalUrl = 'https://security.microsoft.com/alerts'
190+
191+
# Sort Fail rows first (F < I < P alphabetically), then by alert title within each verdict.
192+
$sortedRows = @($compromisedRows | Sort-Object -Property RowVerdict, AlertTitle)
193+
$totalCount = $sortedRows.Count
194+
$displayRows = @($sortedRows | Select-Object -First 10)
195+
$isTruncated = $totalCount -gt 10
196+
197+
$anyNonPass = @($compromisedRows | Where-Object { $_.RowVerdict -ne 'Pass' })
198+
$showPortalLink = $isTruncated -or $anyNonPass.Count -gt 0
199+
200+
$preTableLines = ''
201+
if ($isTruncated) {
202+
$preTableLines += "Total compromised identities: $totalCount (showing first 10)`n`n"
203+
} else {
204+
$preTableLines += "Total compromised identities: $totalCount`n`n"
205+
}
206+
if ($showPortalLink) {
207+
$preTableLines += "[Defender XDR > Investigation & response > Alerts]($alertsPortalUrl)`n`n"
208+
}
209+
210+
$tableRows = ''
211+
foreach ($row in $displayRows) {
212+
$alertTitle = Get-SafeMarkdown -Text $row.AlertTitle
213+
$userAccount = $row.UserAccount
214+
215+
# Prefer UPN; fall back to domainName\accountName (down-level format matching MDI on-prem display).
216+
$userDisplay = if (-not [string]::IsNullOrEmpty($userAccount.userPrincipalName)) {
217+
Get-SafeMarkdown -Text $userAccount.userPrincipalName
218+
} else {
219+
Get-SafeMarkdown -Text "$($userAccount.domainName)\$($userAccount.accountName)"
220+
}
221+
222+
$severity = $row.AlertSeverity
223+
$alertStatus = $row.AlertStatus
224+
$remediationStatus = switch ($row.RemediationStatus) {
225+
{ $_ -in $containedStatuses } { "$($row.RemediationStatus)" }
226+
{ $_ -in $inProgressStatuses -or $_ -eq 'absent' } { "⚠️ $($row.RemediationStatus)" }
227+
default { "$($row.RemediationStatus)" }
228+
}
229+
$assignedTo = if ([string]::IsNullOrEmpty($row.AssignedTo)) { '' } else { Get-SafeMarkdown -Text $row.AssignedTo }
230+
$firstActivity = if ([string]::IsNullOrEmpty($row.FirstActivity)) { '' } else { Get-FormattedDate -DateString $row.FirstActivity }
231+
232+
$incidentCell = if (-not [string]::IsNullOrEmpty($row.IncidentWebUrl)) {
233+
"[Incident $($row.IncidentId)]($($row.IncidentWebUrl))"
234+
} elseif (-not [string]::IsNullOrEmpty($row.IncidentId)) {
235+
$row.IncidentId
236+
} else {
237+
''
238+
}
239+
240+
$rowResult = switch ($row.RowVerdict) {
241+
'Pass' { '✅ Pass' }
242+
'Investigate' { '⚠️ Investigate' }
243+
default { '❌ Fail' }
244+
}
245+
246+
$tableRows += "| $alertTitle | $userDisplay | $severity | $alertStatus | $remediationStatus | $assignedTo | $firstActivity | $incidentCell | $rowResult |`n"
247+
}
248+
249+
if ($isTruncated) {
250+
$tableRows += "| ... | ... | ... | ... | ... | ... | ... | ... | ... |`n"
251+
}
252+
253+
$mdInfo = @"
254+
$preTableLines
255+
| Alert title | Compromised user | Severity | Alert status | Remediation status | Assigned to | First activity | Incident | Result |
256+
| :---------- | :--------------- | :------- | :----------- | :----------------- | :---------- | :------------- | :------- | :----- |
257+
$tableRows
258+
"@
259+
260+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
261+
#endregion Report Generation
262+
263+
$params = @{
264+
TestId = '41019'
265+
Title = 'Compromised identities surfaced by Microsoft Defender for Identity have been remediated'
266+
Status = $passed
267+
Result = $testResultMarkdown
268+
}
269+
if ($customStatus) {
270+
$params.CustomStatus = $customStatus
271+
}
272+
Add-ZtTestResultDetail @params
273+
}

0 commit comments

Comments
 (0)