Skip to content

Commit af0d1ca

Browse files
authored
SecOps - 41009 - Dormant accounts have been removed from sensitive Active Directory groups (#1364)
[Spec-41009](https://github.com/microsoft/ztspecs/blob/main/specs/secops/41009.md) [Issue #821](microsoft/ztspecs#821)
2 parents 1d53665 + 30df2bf commit af0d1ca

2 files changed

Lines changed: 244 additions & 0 deletions

File tree

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
Dormant accounts (accounts that have not authenticated for an extended period — Microsoft Defender for Identity's posture assessment uses 180 days by default) that remain members of sensitive groups (Domain Admins, Enterprise Admins, Account Operators, Server Operators, Backup Operators, and any customer-tagged Tier-0 group) are high-value, low-attention targets: their owners are not actively monitoring sign-ins for anomalous activity, password rotations are unlikely, and any pre-existing credential leak (paste sites, third-party breach corpora, old endpoint backups) remains exploitable. A threat actor who obtains such a credential through credential stuffing or breach-data lookup authenticates as a Domain Admin or equivalent without triggering the behavioral baselines that protect active privileged users; from there they can replicate the directory's secrets, forge long-lived authentication artifacts, and pivot to full forest compromise — all paths that begin with one stale, over-privileged identity. Removing dormant principals from sensitive groups — surfaced by MDI's "Remove dormant accounts from sensitive groups" posture assessment — eliminates the latent privilege without disabling the underlying account, and is a prerequisite for any meaningful Tier-0 admin-tier-isolation strategy.
2+
3+
**Remediation action**
4+
5+
- [Remove dormant accounts from sensitive groups](https://learn.microsoft.com/en-us/defender-for-identity/security-posture-assessments/accounts)
6+
- [Microsoft Defender for Identity security posture assessments](https://learn.microsoft.com/en-us/defender-for-identity/security-assessment)
7+
- [Protected accounts and groups in Active Directory](https://learn.microsoft.com/en-us/windows-server/identity/ad-ds/plan/security-best-practices/appendix-c--protected-accounts-and-groups-in-active-directory)
8+
9+
<!--- Results --->
10+
%TestResult%
Lines changed: 234 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,234 @@
1+
<#
2+
.SYNOPSIS
3+
Dormant accounts have been removed from sensitive Active Directory groups.
4+
5+
.DESCRIPTION
6+
Validates the Microsoft Defender for Identity "Remove dormant accounts from sensitive
7+
groups" posture recommendation via Microsoft Secure Score.
8+
9+
Accounts that have not authenticated for 180+ days while retaining membership in
10+
sensitive groups such as Domain Admins or Enterprise Admins are high-value targets:
11+
their owners are not monitoring sign-ins, passwords are rarely rotated, and any
12+
pre-existing credential exposure remains exploitable indefinitely.
13+
14+
The check reads the Secure Score control profile for AATP_DormantAccounts and the
15+
latest per-control score snapshot, then returns:
16+
Pass – MDI reports no dormant accounts in sensitive groups.
17+
Fail – One or more dormant accounts remain in sensitive groups.
18+
Investigate – The MDI posture control is not present in this tenant's Secure Score
19+
(MDI not onboarded, sensors not healthy, or not yet provisioned).
20+
21+
.NOTES
22+
Test ID: 41009
23+
Workshop Task: SECOPS-009
24+
Pillar: SecOps
25+
Category: Identity threat protection
26+
Risk Level: High
27+
Supported Clouds: Global, USGov, USGovDoD
28+
Required Permission: SecurityEvents.Read.All (Application or Delegated)
29+
#>
30+
31+
function Test-Assessment-41009 {
32+
[ZtTest(
33+
Category = 'Identity threat protection',
34+
ImplementationCost = 'Low',
35+
CompatibleLicense = ('ATA'),
36+
Service = ('Graph'),
37+
Pillar = 'SecOps',
38+
RiskLevel = 'High',
39+
SfiPillar = 'Protect identities and secrets',
40+
TenantType = ('Workforce'),
41+
TestId = 41009,
42+
Title = 'Dormant accounts have been removed from sensitive Active Directory groups',
43+
UserImpact = 'Low'
44+
)]
45+
[CmdletBinding()]
46+
param()
47+
48+
#region Data Collection
49+
50+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
51+
$activity = 'Checking dormant accounts in sensitive Active Directory groups via MDI Secure Score'
52+
Write-ZtProgress -Activity $activity -Status 'Retrieving MDI secure score control profile'
53+
54+
# Q1 – Retrieve the MDI "Remove dormant accounts from sensitive groups" control profile.
55+
# This uses a $filter query; an empty result set indicates the profile is absent from this tenant.
56+
57+
$controlProfile = $null
58+
$errorMsgQ1 = $null
59+
$httpStatusQ1 = $null
60+
61+
try {
62+
63+
$profileResults = Invoke-ZtGraphRequest -RelativeUri 'security/secureScoreControlProfiles' -Filter "service eq 'Azure ATP' and id eq 'AATP_DormantAccounts'" -ApiVersion beta -ErrorAction Stop
64+
$controlProfile = $profileResults | Select-Object -First 1
65+
}
66+
catch {
67+
$errorMsgQ1 = $_
68+
$httpStatusQ1 = Get-ZtHttpStatusCode -ErrorRecord $_
69+
Write-PSFMessage "Failed to retrieve MDI control profile: $errorMsgQ1" -Level Warning
70+
}
71+
72+
# Q2 – Retrieve the most recent Secure Score snapshot.
73+
$latestSecureScore = $null
74+
75+
if ($null -ne $controlProfile) {
76+
Write-ZtProgress -Activity $activity -Status 'Retrieving latest Microsoft Secure Score'
77+
try {
78+
$scoreResponse = Invoke-ZtGraphRequest -RelativeUri 'security/secureScores' -Top 1 -ApiVersion beta -DisablePaging -ErrorAction Stop
79+
$latestSecureScore = $scoreResponse.value | Select-Object -First 1
80+
}
81+
catch {
82+
Write-PSFMessage "Failed to retrieve Secure Score: $_" -Level Warning
83+
}
84+
}
85+
86+
#endregion Data Collection
87+
88+
#region Assessment Logic
89+
90+
$passed = $false
91+
$customStatus = $null
92+
93+
# ── Investigate: Q1 returned no profile (MDI not onboarded or not yet provisioned) or 401/403 (insufficient permissions) ──
94+
if ($null -eq $controlProfile) {
95+
if ($httpStatusQ1 -in @(401, 403)) {
96+
$investigateReason = 'The **SecurityEvents.Read.All** permission is required to read Secure Score control profiles. Verify the permission is consented and re-run the assessment.'
97+
}
98+
elseif ($null -ne $errorMsgQ1) {
99+
$investigateReason = "Microsoft Graph returned an unexpected error retrieving the MDI Secure Score control profile. Re-run the assessment in 5–10 minutes and open a support ticket if the error persists."
100+
}
101+
else {
102+
$investigateReason = "The Microsoft Defender for Identity posture recommendation `"Remove dormant accounts from sensitive groups`" was not found in the tenant's Microsoft Secure Score; verify that MDI posture assessments are enabled."
103+
}
104+
105+
$testResultMarkdown = "⚠️ $investigateReason"
106+
$customStatus = 'Investigate'
107+
108+
$params = @{
109+
TestId = '41009'
110+
Title = 'Dormant accounts have been removed from sensitive Active Directory groups'
111+
Status = $passed
112+
Result = $testResultMarkdown
113+
CustomStatus = $customStatus
114+
}
115+
Add-ZtTestResultDetail @params
116+
return
117+
}
118+
119+
# Resolve profile fields
120+
$controlId = $controlProfile.id
121+
$profileTitle = $controlProfile.title
122+
$maxScore = $controlProfile.maxScore
123+
$actionUrl = $controlProfile.actionUrl
124+
125+
# ── Investigate: Q2 returned no data at all ──
126+
if ($null -eq $latestSecureScore) {
127+
$testResultMarkdown = "⚠️ The MDI dormant-accounts control profile exists but the current Microsoft Secure Score snapshot could not be retrieved."
128+
$customStatus = 'Investigate'
129+
130+
$params = @{
131+
TestId = '41009'
132+
Title = 'Dormant accounts have been removed from sensitive Active Directory groups'
133+
Status = $passed
134+
Result = $testResultMarkdown
135+
CustomStatus = $customStatus
136+
}
137+
Add-ZtTestResultDetail @params
138+
return
139+
}
140+
141+
# ── Locate the per-control entry inside controlScores[] ──
142+
$controlScoreEntry = $null
143+
if ($latestSecureScore.controlScores) {
144+
$controlScoreEntry = $latestSecureScore.controlScores |
145+
Where-Object { $_.controlName -eq $controlId } |
146+
Select-Object -First 1
147+
}
148+
149+
# ── Investigate: profile exists but the snapshot has no entry for this control ──
150+
if ($null -eq $controlScoreEntry) {
151+
$testResultMarkdown = "⚠️ The MDI dormant-accounts control profile ($controlId) exists but the latest Secure Score snapshot has no scored entry for this control."
152+
$customStatus = 'Investigate'
153+
154+
$params = @{
155+
TestId = '41009'
156+
Title = 'Dormant accounts have been removed from sensitive Active Directory groups'
157+
Status = $passed
158+
Result = $testResultMarkdown
159+
CustomStatus = $customStatus
160+
}
161+
Add-ZtTestResultDetail @params
162+
return
163+
}
164+
165+
# ── Evaluate Pass / Fail ──
166+
$currentScore = $controlScoreEntry.score
167+
$scoreInPercentage = $controlScoreEntry.scoreInPercentage
168+
$implementationStatus = $controlScoreEntry.implementationStatus
169+
$lastSynced = $controlScoreEntry.lastSynced
170+
171+
if ($currentScore -eq $maxScore) {
172+
$passed = $true
173+
$testResultMarkdown = "✅ No dormant accounts are members of sensitive Active Directory groups.`n`n%TestResult%"
174+
}
175+
else {
176+
$passed = $false
177+
$testResultMarkdown = "❌ One or more dormant accounts are members of sensitive Active Directory groups.`n`n%TestResult%"
178+
}
179+
180+
#endregion Assessment Logic
181+
182+
#region Report Generation
183+
184+
$defenderLink = 'https://security.microsoft.com/securescore?viewid=actions'
185+
186+
$scoreDisplay = if ($null -ne $currentScore) { $currentScore } else { '-' }
187+
$maxDisplay = if ($null -ne $maxScore) { $maxScore } else { '-' }
188+
$pctDisplay = if ($null -ne $scoreInPercentage) { "$([math]::Round($scoreInPercentage, 1))%" } else { '-' }
189+
$impStatusDisplay = if (-not [string]::IsNullOrEmpty($implementationStatus)) { $implementationStatus } else { '-' }
190+
$syncDisplay = if (-not [string]::IsNullOrEmpty($lastSynced)) { Get-FormattedDate -DateString $lastSynced } else { '-' }
191+
$statusLabel = if ($passed) { '✅ Pass' } else { '❌ Fail' }
192+
193+
$actionLinkMarkdown = ''
194+
if (-not [string]::IsNullOrWhiteSpace($actionUrl)) {
195+
$actionLinkMarkdown = "[$(Get-SafeMarkdown $profileTitle)]($actionUrl)"
196+
}
197+
elseif (-not [string]::IsNullOrWhiteSpace($profileTitle)) {
198+
$actionLinkMarkdown = Get-SafeMarkdown $profileTitle
199+
}
200+
else {
201+
$actionLinkMarkdown = 'Remove dormant accounts from sensitive groups'
202+
}
203+
204+
$tableRows = "| $actionLinkMarkdown | $scoreDisplay | $maxDisplay | $pctDisplay | $impStatusDisplay | $syncDisplay | $statusLabel |`n"
205+
206+
$mdFailLink = ''
207+
if (-not $passed) {
208+
$mdFailLink = "`n## [Defender XDR > Secure Score > Recommendations]($defenderLink)`n"
209+
}
210+
211+
$mdTable = @"
212+
213+
$mdFailLink
214+
| Recommendation title | Current score | Maximum score | Score % | Implementation status | Last synced | Status |
215+
| :------------------- | :-----------: | :-----------: | :-----: | :-------------------- | :---------- | :----: |
216+
$tableRows
217+
"@
218+
219+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdTable
220+
221+
#endregion Report Generation
222+
223+
$params = @{
224+
TestId = '41009'
225+
Title = 'Dormant accounts have been removed from sensitive Active Directory groups'
226+
Status = $passed
227+
Result = $testResultMarkdown
228+
}
229+
if ($customStatus) {
230+
$params.CustomStatus = $customStatus
231+
}
232+
233+
Add-ZtTestResultDetail @params
234+
}

0 commit comments

Comments
 (0)