public/Get-MsecEntraMfaRegistrationStats.ps1
|
function Get-MsecEntraMfaRegistrationStats { <# .SYNOPSIS MFA registration coverage in a single summary row - overall, for admins, and by method - for a posture report or snapshot. .DESCRIPTION Internally calls Get-MsecEntraMfaRegistration and aggregates the per-user rows. Returns one PSCustomObject covering: - Population: TotalUsers, Members, Guests - Coverage: MfaRegistered / MfaCapable (+ percentages), NotMfaCapable - Admins: AdminTotal, AdminMfaCapable (+ percentage), AdminsNotMfaCapable and AdminsNotMfaCapableUpn - the actual account names, because "3 admins without MFA" is not actionable but a list is - Strength: PasswordlessCapable, PhoneOnlyMfaCapable - Members: MembersMfaCapable / MembersSsprCapable and their percentages, plus MembersPasswordlessCapablePercent - the coverage figures scoped to members. READ THESE, not the all-user ones, in any tenant that has guests - Recovery: SsprCapable (+ percentage), GuestsSsprCapable - ByMethod: count of users per registered method **AdminsNotMfaCapable is the headline number.** A privileged account that cannot perform MFA is the single most exploitable identity condition in a tenant, and it is invisible to Conditional Access reporting - CA shows MFA being demanded, not whether the account can satisfy it. **GUESTS DILUTE EVERY ALL-USER PERCENTAGE, AND FOR SSPR THEY DESTROY IT.** MfaCapablePercent, PasswordlessCapablePercent and SsprCapablePercent all divide by the WHOLE directory. Measured on a live tenant of 177 members and 202 guests: MfaCapablePercent 61.48 MembersMfaCapablePercent 95.48 SsprCapablePercent 44.33 MembersSsprCapablePercent 94.92 NotMfaCapable 146 MembersNotMfaCapable 8 PasswordlessCapablePercent 33.51 MembersPasswordlessCapablePercent 71.75 The MFA case is blunt: a guest CAN be MFA-capable and some are. The SSPR case is not - a guest resets their password in their HOME tenant, so they can essentially never be SSPR-capable here (0 of 202 on that tenant), and every guest is dead weight in the denominator. GuestsSsprCapable is reported so that is visible rather than asserted. Both sets are emitted. The all-user columns are unchanged so an existing posture workbook's history stays comparable; the Members* ones are what to report. Coverage uses IsMfaCapable, not IsMfaRegistered: a method registered but disabled by the tenant's authentication-methods policy will not work, so counting it would overstate coverage. See Get-MsecEntraMfaRegistration for the distinction. PhoneOnlyMfaCapable counts MFA-capable users whose registered methods are ALL phone-based (SMS / voice). Those are the phishable and SIM-swappable ones, so a tenant can be at 100% coverage and still be materially weak. The phone-method list is a best-effort match on Graph's method names (see .NOTES); MethodsRegistered on the per-user rows lets you reclassify if Microsoft renames them. Permission and licensing requirements are inherited from Get-MsecEntraMfaRegistration (AuditLog.Read.All, plus Entra ID P1/P2 - the report is premium-gated). .EXAMPLE Get-MsecEntraMfaRegistrationStats | Format-List .EXAMPLE # The finding you want to walk into a posture meeting with: $m = Get-MsecEntraMfaRegistrationStats if ($m.AdminsNotMfaCapable) { "$($m.AdminsNotMfaCapable) privileged account(s) cannot do MFA: " + ($m.AdminsNotMfaCapableUpn -join ', ') } .EXAMPLE # Slot it into the posture snapshot next to the other domains. $snapshot = [pscustomobject]@{ CapturedAtUtc = (Get-Date).ToUniversalTime().ToString('u') MfaRegistration = Get-MsecEntraMfaRegistrationStats CaStats = Get-MsecEntraConditionalAccessStats -Days 7 } .OUTPUTS One PSCustomObject. Percentages are rounded to 2 decimals and are $null when the relevant population is zero (never 0, which would read as "nobody is covered"). .NOTES Phone-based methods, for PhoneOnlyMfaCapable: mobilePhone, alternateMobilePhone, officePhone, voiceCall, sms. Everything else - microsoftAuthenticatorPush, softwareOneTimePasscode, fido2SecurityKey, windowsHelloForBusiness, passKeyDeviceBound, certificate*, temporaryAccessPass - counts as stronger. Graph has renamed these before; the classification is deliberately kept in one place here so it is easy to adjust. #> [CmdletBinding()] param() Assert-MsecSession $users = @(Get-MsecEntraMfaRegistration) # Guard every percentage: an empty population must yield $null, not 0, which would # read as "measured, and nobody is covered". $pct = { param([int] $Part, [int] $Whole) if ($Whole -gt 0) { [math]::Round(100.0 * $Part / $Whole, 2) } else { $null } } $phoneMethods = @('mobilePhone', 'alternateMobilePhone', 'officePhone', 'voiceCall', 'sms') $members = @($users | Where-Object UserType -eq 'member') $guests = @($users | Where-Object UserType -eq 'guest') $mfaRegistered = @($users | Where-Object IsMfaRegistered) $mfaCapable = @($users | Where-Object IsMfaCapable) # MEMBER-SCOPED COVERAGE. The percentages above divide by EVERY user, guests included, # and guests distort them in two different ways. Measured on a live tenant with 177 # members and 202 guests: MFA coverage read 61.48% against a true member figure of # 95.48%, and SSPR read 44.33% against 94.92%. # # The two are not the same kind of error. A guest CAN be MFA-capable and some are, so # the all-user MFA number is blunt rather than wrong. A guest can essentially never be # SSPR-capable, because they reset their password in their HOME tenant - measured live, # 0 of 202 - so every guest is dead weight in that denominator and the all-user SSPR # number understates recovery coverage by however many guests the tenant holds. # # Both sets are reported. The all-user columns are unchanged so an existing posture # workbook's history stays comparable; these are the ones to read. $membersMfaCapable = @($members | Where-Object IsMfaCapable) $membersPwlCapable = @($members | Where-Object IsPasswordlessCapable) $membersSsprCapable = @($members | Where-Object IsSsprCapable) $guestsSsprCapable = @($guests | Where-Object IsSsprCapable) $admins = @($users | Where-Object IsAdmin) $adminsCapable = @($admins | Where-Object IsMfaCapable) $adminsNotCapable = @($admins | Where-Object { -not $_.IsMfaCapable }) # MFA-capable users with at least one method, all of which are phone-based. The # "at least one" guard matters: a user with an empty MethodsRegistered array would # otherwise satisfy "all methods are phone" vacuously. $phoneOnly = @($mfaCapable | Where-Object { $m = @($_.MethodsRegistered) $m.Count -gt 0 -and -not @($m | Where-Object { $_ -notin $phoneMethods }).Count }) # Method -> number of users who registered it. Users appear under several methods. $byMethod = [ordered]@{} foreach ($g in ($users.MethodsRegistered | Group-Object | Sort-Object Count -Descending)) { $byMethod[$g.Name] = $g.Count } [PSCustomObject]@{ # Population TotalUsers = $users.Count Members = $members.Count Guests = $guests.Count # Coverage (IsMfaCapable is the honest measure - see .DESCRIPTION) MfaRegistered = $mfaRegistered.Count MfaRegisteredPercent = & $pct $mfaRegistered.Count $users.Count MfaCapable = $mfaCapable.Count MfaCapablePercent = & $pct $mfaCapable.Count $users.Count NotMfaCapable = $users.Count - $mfaCapable.Count # Admins - the headline AdminTotal = $admins.Count AdminMfaCapable = $adminsCapable.Count AdminMfaCapablePercent = & $pct $adminsCapable.Count $admins.Count AdminsNotMfaCapable = $adminsNotCapable.Count AdminsNotMfaCapableUpn = @($adminsNotCapable.UserPrincipalName | Sort-Object) # Member-scoped coverage - the honest measure in a tenant with guests. See the # comment above the computation for why these exist alongside the all-user ones. MembersMfaCapable = $membersMfaCapable.Count # The one to quote out loud. NotMfaCapable counts the whole directory, so in a # guest-heavy tenant it states the problem as a sentence that is wrong by an order # of magnitude - measured live, 146 against a true member figure of 8. MembersNotMfaCapable = $members.Count - $membersMfaCapable.Count MembersMfaCapablePercent = & $pct $membersMfaCapable.Count $members.Count MembersPasswordlessCapablePercent = & $pct $membersPwlCapable.Count $members.Count MembersSsprCapable = $membersSsprCapable.Count MembersSsprCapablePercent = & $pct $membersSsprCapable.Count $members.Count # Guests GuestsMfaCapable = @($guests | Where-Object IsMfaCapable).Count # Reported so a reader can SEE that guests contribute nothing to SSPR rather than # having to take it on trust: it is the whole reason the member figure exists. GuestsSsprCapable = $guestsSsprCapable.Count # Strength of what is registered PasswordlessCapable = @($users | Where-Object IsPasswordlessCapable).Count PasswordlessCapablePercent = & $pct @($users | Where-Object IsPasswordlessCapable).Count $users.Count PhoneOnlyMfaCapable = $phoneOnly.Count PhoneOnlyMfaCapablePercent = & $pct $phoneOnly.Count $mfaCapable.Count # Self-service password reset SsprCapable = @($users | Where-Object IsSsprCapable).Count SsprCapablePercent = & $pct @($users | Where-Object IsSsprCapable).Count $users.Count ByMethod = $byMethod } } |