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