EntraMfaRegistrationReport.psm1
|
#Region './Private/Invoke-LogRotation.ps1' -1 #Requires -Version 7.0 # Rotates log files: shifts numbered backups up (log.4→removed, log.3→log.4, ..., log→log.1). # Called inside the Write-ToLog mutex — do NOT call this function directly. function Invoke-LogRotation { [CmdletBinding(SupportsShouldProcess)] [OutputType([void])] param() try { # Don't rotate if file doesn't exist if (-not (Test-PathWrapper -LiteralPath $script:LogFile)) { return } # Remove oldest log file if it exists $oldestLog = "$script:LogFile.$script:MaxLogFiles" if (Test-PathWrapper -LiteralPath $oldestLog) { if ($PSCmdlet.ShouldProcess($oldestLog, 'Remove oldest rotated log file')) { Remove-ItemWrapper -LiteralPath $oldestLog } } # Shift existing rotated logs up for ($i = $script:MaxLogFiles - 1; $i -ge 1; $i--) { $currentLog = "$script:LogFile.$i" $nextLog = "$script:LogFile.$($i + 1)" if (Test-PathWrapper -LiteralPath $currentLog) { if ($PSCmdlet.ShouldProcess("$currentLog -> $nextLog", 'Shift rotated log file')) { Move-ItemWrapper -LiteralPath $currentLog -Destination $nextLog } } } # Rotate current log to .1 if ($PSCmdlet.ShouldProcess("$($script:LogFile) -> $($script:LogFile).1", 'Rotate current log file')) { Move-ItemWrapper -LiteralPath $script:LogFile -Destination "$script:LogFile.1" } Write-Verbose "Log rotated: $script:LogFile -> $script:LogFile.1" } catch { Write-Warning "Failed to rotate log file: $($_.Exception.Message)" } } # ============================================================================ # WRAPPER FUNCTIONS FOR MOCKABILITY # ============================================================================ # Wraps Move-Item for Pester mocking. function Move-ItemWrapper { [CmdletBinding()] [OutputType([void])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Wrapper function; ShouldProcess handled by calling function.')] param( [Parameter(Mandatory)] [string]$LiteralPath, [Parameter(Mandatory)] [string]$Destination ) Move-Item -LiteralPath $LiteralPath -Destination $Destination -Force -ErrorAction Stop } # Wraps Remove-Item for Pester mocking. function Remove-ItemWrapper { [CmdletBinding()] [OutputType([void])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Wrapper function; ShouldProcess handled by calling function.')] param( [Parameter(Mandatory)] [string]$LiteralPath ) Remove-Item -LiteralPath $LiteralPath -Force -ErrorAction Stop } #EndRegion './Private/Invoke-LogRotation.ps1' 81 #Region './Private/Write-ToLog.ps1' -1 #Requires -Version 7.0 # Timestamp format used for log file names and archives $script:LogTimestampFormat = 'yyyyMMdd_HHmmss' # Thread-safe, auto-rotating logger for EntraMfaRegistrationReport. # Entry point: Write-ToLog. Levels: INFO, DEBUG, WARN, ERROR, SUCCESS. # ============================================================================ # LOG FILE CONFIGURATION # ============================================================================ # Initialize log file path (backward compatible with $Global:LogFile) # Uses helper function to isolate global variable access for ScriptAnalyzer compliance. function Initialize-LogFilePath { [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '', Justification = 'Required for backward compatibility with scripts that set $Global:LogFile before importing the module.')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Private initializer - no external side effects, only sets module-scoped variable.')] [OutputType([string])] param() if (-not $Global:LogFile) { $Global:LogFile = [System.IO.Path]::Combine([System.IO.Path]::GetTempPath(), "EntraMfaRegistrationReport_$([System.DateTimeOffset]::UtcNow.ToString($script:LogTimestampFormat)).log") } return $Global:LogFile } if (-not $script:LogFile) { $script:LogFile = Initialize-LogFilePath } # Log rotation settings $script:MaxLogSizeBytes = 10MB # Rotate when log exceeds this size $script:MaxLogFiles = 5 # Keep this many rotated logs $script:LogDirectoryCreated = $false $script:LogMutex = $null # Register cleanup handler to dispose mutex on module removal or PowerShell exit $null = Register-EngineEvent -SourceIdentifier ([System.Management.Automation.PsEngineEvent]::Exiting) -Action { if ($script:LogMutex) { $script:LogMutex.Dispose() $script:LogMutex = $null } } # ============================================================================ # WRITE-TOLOG FUNCTION # ============================================================================ # Appends a formatted, timestamped log entry to the module log file. # Thread-safe via a named mutex. Auto-rotates at 10 MB (5 backups). # Redacts passwords, tokens, keys, and secrets. Supports -Message and -ErrorRecord parameter sets. function Write-ToLog { [CmdletBinding(SupportsShouldProcess)] [OutputType([bool])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidGlobalVars', '')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', Justification = 'Write-Host is intentional for colored console output in a logging function.')] param ( [Parameter( Mandatory, ValueFromPipeline, Position = 0, ParameterSetName = 'Message' )] [AllowEmptyString()] [string]$Message, [Parameter( Position = 1, ParameterSetName = 'Message' )] [ValidateSet('INFO', 'DEBUG', 'WARN', 'ERROR', 'SUCCESS')] [string]$Level = 'INFO', [Parameter( Mandatory, ParameterSetName = 'ErrorRecord' )] [System.Management.Automation.ErrorRecord]$ErrorRecord, [Parameter()] [switch]$NoConsole, [Parameter()] [switch]$PassThru ) begin { # Sync script-scoped variable with global for backward compatibility if ($Global:LogFile -and $script:LogFile -ne $Global:LogFile) { $script:LogFile = $Global:LogFile $script:LogDirectoryCreated = $false # Reset so new directory is validated } # Ensure log directory exists (once per invocation) if (-not $script:LogDirectoryCreated) { $logDir = Split-Path -Path $script:LogFile -Parent if ($logDir -and -not (Test-PathWrapper -LiteralPath $logDir)) { try { $null = New-ItemDirectoryWrapper -Path $logDir } catch [System.IO.IOException] { # Directory may have been created by another process if (-not (Test-PathWrapper -LiteralPath $logDir)) { throw } } } $script:LogDirectoryCreated = $true } # Initialize mutex for thread safety (reuse existing if available) if (-not $script:LogMutex) { $script:LogMutex = [System.Threading.Mutex]::new($false, 'Global\EntraMfaRegistrationReportLog') } } process { # Process ErrorRecord if provided if ($PSCmdlet.ParameterSetName -eq 'ErrorRecord') { $Message = $ErrorRecord.Exception.Message $Level = 'ERROR' # Add additional error details $errorDetails = @" Exception Type: $($ErrorRecord.Exception.GetType().FullName) Category: $($ErrorRecord.CategoryInfo.Category) Target: $($ErrorRecord.TargetObject) "@ if ($ErrorRecord.InvocationInfo) { $errorDetails += @" Location: $($ErrorRecord.InvocationInfo.ScriptName):$($ErrorRecord.InvocationInfo.ScriptLineNumber) Command: $($ErrorRecord.InvocationInfo.Line.Trim()) "@ } if ($ErrorRecord.Exception.InnerException) { $errorDetails += @" Inner Exception: $($ErrorRecord.Exception.InnerException.Message) "@ } # Log main message first, then details as DEBUG $mainResult = Write-ToLog -Message $Message -Level 'ERROR' -NoConsole:$NoConsole -PassThru $detailResult = Write-ToLog -Message $errorDetails -Level 'DEBUG' -NoConsole:$NoConsole -PassThru if ($PassThru) { return ($mainResult -and $detailResult) } return } # Redact sensitive information from log messages (case-insensitive) $sanitizedMessage = $Message # Pattern 1: key=value format $sanitizedMessage = $sanitizedMessage -replace '(?i)(password|token|key|secret|apikey|api_key|access_key|auth)=\S+', '$1=***REDACTED***' # Pattern 2: JSON format $sanitizedMessage = $sanitizedMessage -replace '(?i)(password|token|key|secret|apikey|api_key|access_key|auth)"\s*:\s*"[^"]*"', '$1": "***REDACTED***"' # Pattern 3: XML/HTML format - preserve closing tag $sanitizedMessage = $sanitizedMessage -replace '(?i)<(password|token|key|secret|apikey|api_key|access_key|auth)>[^<]*</(password|token|key|secret|apikey|api_key|access_key|auth)>', '<$1>***REDACTED***</$2>' # Pattern 4: Bearer token (e.g. an Authorization header) $sanitizedMessage = $sanitizedMessage -replace '(?i)\b(bearer)\s+\S+', '$1 ***REDACTED***' # Pattern 5: unquoted 'key: value' form. The negative lookbehind/lookahead on a # quote keep this from re-touching the quoted JSON form handled by Pattern 2. $sanitizedMessage = $sanitizedMessage -replace '(?i)(?<![\"''])\b(password|token|key|secret|apikey|api_key|access_key|auth)\b\s*:\s*(?![\"''])\S+', '$1: ***REDACTED***' $timestamp = [System.DateTimeOffset]::UtcNow.ToString('yyyy-MM-dd HH:mm:ss') $entry = "[$timestamp] [$Level] $sanitizedMessage" $success = $true if ($PSCmdlet.ShouldProcess($script:LogFile, "Write log entry: $Level")) { # Thread-safe file write using mutex $mutexAcquired = $false try { $mutexAcquired = $script:LogMutex.WaitOne(10000) # 10-second timeout to prevent deadlock if (-not $mutexAcquired) { Write-Warning 'Failed to acquire log mutex within 10 seconds. Log entry may be lost.' $success = $false return } # Check if log rotation is needed (inside mutex to prevent race conditions) if ((Test-PathWrapper -LiteralPath $script:LogFile) -and (Get-ItemWrapper -LiteralPath $script:LogFile).Length -gt $script:MaxLogSizeBytes) { try { Write-Verbose "Log file exceeds $($script:MaxLogSizeBytes / 1MB)MB, rotating..." Invoke-LogRotation } catch { Write-Warning "Log rotation failed: $($_.Exception.Message). Continuing without rotation." } } # UTF-8 without BOM is default in PowerShell 7+ # IMPORTANT: Using Add-Content appends to existing file Add-ContentWrapper -LiteralPath $script:LogFile -Value $entry } catch { $errorMsg = "Failed to write log entry to '{0}': {1}" -f $script:LogFile, $_.Exception.Message Write-Warning $errorMsg $success = $false } finally { if ($mutexAcquired -and $script:LogMutex) { $script:LogMutex.ReleaseMutex() } } } # Console output with ANSI colors (PowerShell 7.2+ PSStyle, fallback to escape codes) if (-not $NoConsole) { if ($PSStyle) { $colorRed = $PSStyle.Foreground.Red $colorYellow = $PSStyle.Foreground.Yellow $colorGreen = $PSStyle.Foreground.Green $colorCyan = $PSStyle.Foreground.Cyan $colorReset = $PSStyle.Reset } else { $colorRed = "`e[31m" $colorYellow = "`e[33m" $colorGreen = "`e[32m" $colorCyan = "`e[36m" $colorReset = "`e[0m" } switch ($Level) { 'ERROR' { Write-Host "${colorRed}✗ $sanitizedMessage${colorReset}" } 'WARN' { Write-Host "${colorYellow}⚠ $sanitizedMessage${colorReset}" } 'SUCCESS' { Write-Host "${colorGreen}✓ $sanitizedMessage${colorReset}" } 'DEBUG' { Write-Verbose -Message $sanitizedMessage } default { Write-Host "${colorCyan}ℹ $sanitizedMessage${colorReset}" } } } if ($PassThru) { return $success } } end { # Cleanup is handled by caller or module removal } } # ============================================================================ # WRAPPER FUNCTIONS FOR MOCKABILITY # ============================================================================ # Wraps Test-Path for Pester mocking. function Test-PathWrapper { [CmdletBinding()] [OutputType([bool])] param( [Parameter(Mandatory, ParameterSetName = 'Path')] [string] $Path, [Parameter(Mandatory, ParameterSetName = 'LiteralPath')] [string] $LiteralPath, [Parameter(ParameterSetName = 'Path')] [ValidateSet('Any', 'Container', 'Leaf')] [string] $PathType ) if ($PSCmdlet.ParameterSetName -eq 'LiteralPath') { return Test-Path -LiteralPath $LiteralPath } if ($PathType) { return Test-Path -Path $Path -PathType $PathType } return Test-Path -Path $Path } # Wraps New-Item -ItemType Directory for Pester mocking. function New-ItemDirectoryWrapper { [CmdletBinding()] [OutputType([System.IO.DirectoryInfo])] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Wrapper function; ShouldProcess handled by calling function.')] param( [Parameter(Mandatory)] [string]$Path ) return New-Item -Path $Path -ItemType Directory -Force -ErrorAction Stop } # Wraps Get-Item for Pester mocking. function Get-ItemWrapper { [CmdletBinding()] [OutputType([System.IO.FileInfo])] param( [Parameter(Mandatory)] [string]$LiteralPath ) return Get-Item -LiteralPath $LiteralPath } # Wraps Add-Content for Pester mocking. function Add-ContentWrapper { [CmdletBinding()] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseShouldProcessForStateChangingFunctions', '', Justification = 'Wrapper function; ShouldProcess handled by calling function.')] param( [Parameter(Mandatory)] [string]$LiteralPath, [Parameter(Mandatory)] [string]$Value ) Add-Content -LiteralPath $LiteralPath -Value $Value -ErrorAction Stop } #EndRegion './Private/Write-ToLog.ps1' 340 #Region './Public/Connect-EntraMfaRegistrationReport.ps1' -1 #Requires -Version 7.0 function Connect-EntraMfaRegistrationReport { <# .SYNOPSIS Establishes the Microsoft Graph session this module needs, against an explicitly named tenant. .DESCRIPTION Wraps Connect-MgGraph so that authentication is owned by the module rather than left to the caller, and so that the target tenant is always stated explicitly instead of being inherited from whatever session happened to be open. In multi-tenant assessment work, an inherited session is how a report gets run against the wrong tenant. TenantId is mandatory in every parameter set that Microsoft Graph accepts it in. The single exception is ManagedIdentity: Connect-MgGraph -Identity derives the tenant from the identity itself and does not expose a TenantId parameter, so requiring one there is not possible. The default is an interactive browser sign-on. Device code is available as an opt-in switch on that same parameter set, and application-only authentication by certificate, client secret or managed identity is available alongside it, so no environment is forced down the device code path. See the device code warning in the notes. Requests the AuditLog.Read.All scope by default, which is the least privileged permission Microsoft documents for the registration report this module reads. .PARAMETER TenantId The directory (tenant) ID or verified domain name of the tenant to authenticate against. Mandatory for every method except managed identity. .PARAMETER Scopes Delegated permission scopes to request. Defaults to AuditLog.Read.All, the least privileged permission for the registration report. Applies to interactive and device code sign-on only. .PARAMETER ClientId Application (client) ID of the app registration. Mandatory for certificate authentication, optional for interactive sign-on, and optional for a user-assigned managed identity. .PARAMETER UseDeviceCode Authenticates by device code instead of opening a browser. Intended only for a host with no browser available, such as an SSH session or a container. Read the device code warning in the notes before using it: Microsoft classifies this flow as high risk and many tenants block it. .PARAMETER CertificateThumbprint Thumbprint of a certificate in the local certificate store, for application-only authentication. .PARAMETER Certificate An X509Certificate2 object, for application-only authentication where the certificate is loaded by the caller rather than resolved from a store. .PARAMETER ClientSecretCredential A PSCredential whose user name is the application (client) ID and whose password is the client secret. Passing the secret inside a PSCredential keeps it a SecureString rather than a plain string. Client secrets are the least preferred method; see the notes. .PARAMETER Identity Authenticates using the Azure managed identity of the host. Combine with ClientId to select a user-assigned identity. No secret exists to leak, so this is the preferred method for anything running on Azure. .EXAMPLE Connect-EntraMfaRegistrationReport -TenantId 'contoso.onmicrosoft.com' Signs in interactively through the browser against the named tenant, requesting AuditLog.Read.All, then returns the resulting Graph context. .EXAMPLE Connect-EntraMfaRegistrationReport -TenantId '00000000-0000-0000-0000-000000000000' -UseDeviceCode Signs in by device code, for a host with no browser. Emits a warning explaining the risk and that Conditional Access may block the flow. .EXAMPLE $connection = @{ TenantId = '00000000-0000-0000-0000-000000000000' ClientId = '11111111-1111-1111-1111-111111111111' CertificateThumbprint = '0000000000000000000000000000000000000000' } Connect-EntraMfaRegistrationReport @connection Get-EntraMfaRegistrationReport -UnregisteredOnly -Path './mfa-gaps.csv' Authenticates application-only with a certificate, then runs the report unattended. .EXAMPLE Connect-EntraMfaRegistrationReport -Identity Authenticates with the host's system-assigned managed identity. No TenantId is accepted here, because Connect-MgGraph derives the tenant from the identity. .OUTPUTS Microsoft.Graph.PowerShell.Authentication.AuthContext The Graph context established, as returned by Get-MgContext. .NOTES Device code warning Microsoft classifies device code flow as high risk and recommends blocking it wherever possible. There is a Microsoft-managed Conditional Access policy that blocks it tenant-wide, and Microsoft's security operations guidance treats the flow appearing outside an input-constrained device as a signal to investigate. The attack is simple: an adversary starts the flow, sends the target a code, and asks them to enter it on the genuine Microsoft sign-in page. Use -UseDeviceCode only where a browser is genuinely unavailable on the authenticating device. If the tenant applies the Microsoft-managed block, or any Conditional Access authentication-flows policy, this switch fails at sign-in and the failure will look like a broken script rather than a policy decision. Preferred methods, strongest first 1. Managed identity (-Identity) for anything running on Azure. No secret exists to leak. 2. Certificate application-only (-CertificateThumbprint or -Certificate) for unattended automation elsewhere. 3. Interactive browser sign-on for a human at a workstation. 4. Client secret (-ClientSecretCredential). Last resort: it expires, it leaks, and it needs rotation that you must own. 5. Device code. Only where the authenticating device has no browser. Workload identity federation is not exposed as a parameter set here because Connect-MgGraph has no dedicated parameter for it; where CI provides a federated token, authenticate in the pipeline and let Get-EntraMfaRegistrationReport verify the resulting session instead of calling this function. Graph permission AuditLog.Read.All, delegated or application. For delegated access the signed-in principal must also hold a supported directory role; Reports Reader is the least privileged that qualifies. Design decisions 1. TenantId is mandatory in every parameter set except ManagedIdentity, because Connect-MgGraph's IdentityParameterSet exposes no TenantId parameter. Verified against Microsoft.Graph.Authentication 2.39.0. 2. The client secret is taken as a PSCredential rather than a string or SecureString, so the client ID and the secret travel together and the secret stays a SecureString. This also matches Connect-MgGraph's own -ClientSecretCredential parameter. 3. SupportsShouldProcess is declared because establishing a session is a state change outside this process. -WhatIf reports the tenant and method without signing in. 4. -NoWelcome is passed to Connect-MgGraph so the banner does not pollute the host. The established context is returned as an object instead. 5. This function exists because module-owned authentication with a mandatory TenantId is a standing requirement. It reverses the original specification for this module, which required the caller to authenticate and forbade interactive sign-in inside the module. Get-EntraMfaRegistrationReport still works with a session established any other way, and still never signs in by itself. #> [CmdletBinding(DefaultParameterSetName = 'Interactive', SupportsShouldProcess = $true)] [OutputType([Microsoft.Graph.PowerShell.Authentication.AuthContext])] param( [Parameter(Mandatory, ParameterSetName = 'Interactive')] [Parameter(Mandatory, ParameterSetName = 'CertificateThumbprint')] [Parameter(Mandatory, ParameterSetName = 'Certificate')] [Parameter(Mandatory, ParameterSetName = 'ClientSecret')] [ValidateNotNullOrEmpty()] [System.String] $TenantId, [Parameter(ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [System.String[]] $Scopes = @('AuditLog.Read.All'), [Parameter(ParameterSetName = 'Interactive')] [Parameter(Mandatory, ParameterSetName = 'CertificateThumbprint')] [Parameter(Mandatory, ParameterSetName = 'Certificate')] [Parameter(ParameterSetName = 'ManagedIdentity')] [ValidateNotNullOrEmpty()] [System.String] $ClientId, [Parameter(ParameterSetName = 'Interactive')] [System.Management.Automation.SwitchParameter] $UseDeviceCode, [Parameter(Mandatory, ParameterSetName = 'CertificateThumbprint')] [ValidateNotNullOrEmpty()] [System.String] $CertificateThumbprint, [Parameter(Mandatory, ParameterSetName = 'Certificate')] [ValidateNotNull()] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Parameter(Mandatory, ParameterSetName = 'ClientSecret')] [ValidateNotNull()] [System.Management.Automation.PSCredential] [System.Management.Automation.Credential()] $ClientSecretCredential, [Parameter(Mandatory, ParameterSetName = 'ManagedIdentity')] [System.Management.Automation.SwitchParameter] $Identity ) $method = $PSCmdlet.ParameterSetName $connectParameters = @{ NoWelcome = $true ErrorAction = 'Stop' } switch ($method) { 'Interactive' { $connectParameters['TenantId'] = $TenantId $connectParameters['Scopes'] = $Scopes if ($PSBoundParameters.ContainsKey('ClientId')) { $connectParameters['ClientId'] = $ClientId } if ($UseDeviceCode.IsPresent) { $method = 'DeviceCode' $connectParameters['UseDeviceCode'] = $true Write-Warning 'Device code flow is classified by Microsoft as high risk and is recommended for blocking wherever possible. Use it only where the authenticating device genuinely has no browser. If this tenant applies the Microsoft-managed Conditional Access policy that blocks device code, or any authentication-flows policy, this sign-in will fail as a policy decision rather than a script fault.' } } 'CertificateThumbprint' { $connectParameters['TenantId'] = $TenantId $connectParameters['ClientId'] = $ClientId $connectParameters['CertificateThumbprint'] = $CertificateThumbprint } 'Certificate' { $connectParameters['TenantId'] = $TenantId $connectParameters['ClientId'] = $ClientId $connectParameters['Certificate'] = $Certificate } 'ClientSecret' { $connectParameters['TenantId'] = $TenantId $connectParameters['ClientSecretCredential'] = $ClientSecretCredential Write-Warning 'Client secret authentication is the least preferred method: the secret expires, can leak, and needs a rotation process you must own. Prefer a managed identity on Azure, or certificate-based application-only authentication elsewhere.' } 'ManagedIdentity' { $connectParameters['Identity'] = $true if ($PSBoundParameters.ContainsKey('ClientId')) { $connectParameters['ClientId'] = $ClientId } } } # ManagedIdentity has no TenantId to report, so describe the target by method instead. $target = 'the tenant of the host managed identity' if ($connectParameters.ContainsKey('TenantId')) { $target = $connectParameters['TenantId'] } if (-not $PSCmdlet.ShouldProcess($target, ('Establish a Microsoft Graph session using {0} authentication' -f $method))) { return } try { Write-Verbose ('Connecting to Microsoft Graph for tenant {0} using {1} authentication.' -f $target, $method) Connect-MgGraph @connectParameters } catch [System.UnauthorizedAccessException] { throw ('Authentication was refused for tenant {0} using {1}. Confirm the principal is permitted to sign in and that any Conditional Access policy allows this authentication flow. Underlying error: {2}' -f $target, $method, $_.Exception.Message) } catch { throw ('Failed to establish a Microsoft Graph session for tenant {0} using {1}. Underlying error: {2}' -f $target, $method, $_.Exception.Message) } try { $context = Get-MgContext -ErrorAction Stop } catch { throw ('A sign-in was attempted but the resulting Microsoft Graph context could not be read. Underlying error: {0}' -f $_.Exception.Message) } if ($null -eq $context) { throw ('A sign-in was attempted for tenant {0} using {1} but no Microsoft Graph context resulted. Treat the connection as failed.' -f $target, $method) } Write-Verbose ('Connected to tenant {0} as {1}.' -f $context.TenantId, $context.Account) # Surface a missing permission here, at sign-in, rather than at first use. An empty Scopes # collection is normal for app-only and managed identity sessions, so it is not an error. $grantedScopes = @($context.Scopes | Where-Object { -not [System.String]::IsNullOrWhiteSpace($_) }) if ($grantedScopes.Count -gt 0 -and $grantedScopes -notcontains 'AuditLog.Read.All') { Write-Warning ('The established session does not hold AuditLog.Read.All, which Get-EntraMfaRegistrationReport requires. Granted scopes are: {0}.' -f ($grantedScopes -join ', ')) } Write-Output -InputObject $context } #EndRegion './Public/Connect-EntraMfaRegistrationReport.ps1' 284 #Region './Public/Get-EntraMfaRegistrationReport.ps1' -1 #Requires -Version 7.0 function Get-EntraMfaRegistrationReport { <# .SYNOPSIS Reports the multifactor authentication registration state of Microsoft Entra ID users. .DESCRIPTION Queries the Microsoft Graph v1.0 authentication methods registration report (/reports/authenticationMethods/userRegistrationDetails) and emits one object per user carrying the user's display name, directory object identifier, user principal name, directory user type, MFA registration state and whether the user holds an admin role. The function is read-only against the tenant. It performs no create, update or delete operation on any directory object. The only state change it can make is writing a CSV file to the local filesystem, which happens solely when -Path is supplied and is guarded by ShouldProcess. Objects are emitted to the pipeline in every case, so the caller may pipe the result to Export-Csv, Where-Object or Group-Object and obtain the same six-column contract that the built-in -Path export produces. Results are retrieved a page at a time by following the @odata.nextLink property until the service stops returning one, so tenants larger than a single Graph page are handled correctly. HTTP 429 responses are retried with bounded exponential backoff that honours the Retry-After header where the header can be read from the failure. .PARAMETER TenantId Optional tenant GUID to assert the active Microsoft Graph session against. When supplied and the session belongs to a different tenant, the function throws before querying anything. This guards the multi-tenant assessment case where an inherited session silently points at the wrong tenant. Must be the tenant GUID, not a domain name, because the Graph context reports the tenant as a GUID and no directory lookup is performed to resolve a domain. To establish a session against a named tenant in the first place, use Connect-EntraMfaRegistrationReport. .PARAMETER UserType Restricts the report to a single directory user type. Accepts 'Member', 'Guest' or 'All'. Defaults to 'All'. Filtering is performed client side because the userType property of the userRegistrationDetails resource is not documented as supporting the $filter query option. .PARAMETER UnregisteredOnly Returns only users whose MFA registration state is false. This filter is applied server side using $filter=isMfaRegistered eq false, which the resource documents as supported. .PARAMETER Path Optional path of the CSV file to write. When omitted no file is written and the function emits objects only. The parent directory must already exist. An existing file is not overwritten unless -Force is also supplied. .PARAMETER Force Permits an existing file at -Path to be overwritten. Has no effect when -Path is omitted. .PARAMETER MaxRetryCount Maximum number of retry attempts per page for a throttled or transient Graph response. Accepts 1 to 10 and defaults to 5. Once the attempts are exhausted the function throws rather than returning a partial result set. .EXAMPLE Get-EntraMfaRegistrationReport -Verbose Returns one object per user in the tenant, of both user types, with progress written to the verbose stream. No file is written. .EXAMPLE Get-EntraMfaRegistrationReport -UserType Member -UnregisteredOnly -Path './mfa-member-gaps.csv' Reproduces the population behind an MFA coverage finding by reporting only member users who are not registered for MFA, and writes the six-column CSV to the given path. Sample content: DisplayName,ObjectId,UserPrincipalName,UserType,MFARegistered,IsAdmin "Adele Vance",00000000-0000-0000-0000-000000000001,AdeleV@contoso.com,Member,False,False "Alex Wilber",00000000-0000-0000-0000-000000000002,AlexW@contoso.com,Member,False,True .EXAMPLE Get-EntraMfaRegistrationReport -TenantId '00000000-0000-0000-0000-000000000000' Refuses to run unless the active Graph session belongs to the named tenant. Use this in scripted assessment work where running against the wrong tenant is the real risk. .EXAMPLE Get-EntraMfaRegistrationReport -UnregisteredOnly | Where-Object -Property IsAdmin -EQ $true Returns only privileged accounts that are not registered for MFA. These are the highest priority for remediation, because an unprotected admin account exposes the whole tenant. .EXAMPLE Get-EntraMfaRegistrationReport -Path './mfa-registration.csv' -WhatIf Retrieves the report and reports what would be written without creating the file. .OUTPUTS System.Management.Automation.PSCustomObject One object per user, with these properties in this order, matching the CSV headers exactly: DisplayName string, may be null ObjectId string, the directory object GUID UserPrincipalName string, may be null UserType string, 'Member' or 'Guest' MFARegistered boolean, rendered by Export-Csv as True or False IsAdmin boolean, rendered by Export-Csv as True or False .NOTES Assessment finding served Supports an MFA registration coverage finding, reference ID-001 in the assessment framework this module was written for. Where measured coverage falls short of the configured threshold, this report identifies the individual users behind the shortfall so that MFA registration campaigns, or enforcement through Conditional Access or the Identity Protection registration policy, can be targeted and tracked until coverage exceeds the threshold. The function itself performs no remediation. No measured figure, tenant, organisation or user identity from any assessment is recorded in this module. Findings data belongs in the assessment report, not in distributed code. Graph permissions Delegated (work or school account): AuditLog.Read.All Application: AuditLog.Read.All AuditLog.Read.All is the least privileged permission Microsoft documents for both the list and get operations on userRegistrationDetails; no lower-privileged alternative exists and no higher-privileged alternative is offered. For delegated access the signed-in principal must additionally hold a supported directory role. Reports Reader is the least privileged of those; Security Reader, Global Reader, Security Operator, Security Administrator, Application Administrator and Cloud Application Administrator also qualify. Licence position Access to the authentication methods Usage and insights data, which is what this report reads, requires a Microsoft Entra ID P1 or P2 licence. Verified against Microsoft Learn, Authentication Methods Activity, Permissions and licenses. Registration versus capability MFARegistered is populated from isMfaRegistered, not isMfaCapable. isMfaRegistered means the user has registered a strong authentication method for MFA, whether or not that method is currently allowed by the tenant authentication methods policy. isMfaCapable means the user has registered such a method and the method is allowed by that policy. isMfaCapable is therefore always the smaller or equal population, and the two figures diverge whenever the policy disallows a method that users have registered. Choosing between them materially changes the reported coverage percentage, so the choice is fixed here rather than exposed as a parameter. Coverage limits of the source report Microsoft documents that userRegistrationDetails does not list disabled users, and does not list recently deleted (soft-deleted) users. The report therefore cannot cover disabled accounts at all, and the denominator it produces is the set of enabled, non-deleted users. Microsoft also documents a data latency of up to 36 hours, so a registration completed today may not appear yet. Stated design decisions and deviations 1. Server-side shaping is partial. isMfaRegistered supports $filter, so -UnregisteredOnly is applied server side. userType is not documented as supporting $filter, so -UserType is applied client side after retrieval. 2. $select is not used. The list operation for this collection documents support for $filter only, and does not document $select, so requesting a projection would be an unverified assumption. All documented properties are returned and the unwanted ones are discarded locally. 3. Invoke-MgGraphRequest is used in preference to the typed cmdlet Get-MgReportAuthenticationMethodUserRegistrationDetail. The typed cmdlet exists in the Microsoft.Graph.Reports module and its -All switch would page internally, but it exposes no hook for reading Retry-After or for warning on each backoff, which this function is required to do. Invoke-MgGraphRequest rides the existing Connect-MgGraph session, so no token is handled in this code. If per-page throttling control is not needed, Get-MgReportAuthenticationMethodUserRegistrationDetail -All is the simpler equivalent. 4. $top is not requested. The list operation documents support for $filter only, so the service default page size is used and paging follows @odata.nextLink. 5. Scope verification degrades to a warning when the Graph context reports no scopes at all. Certificate-based app-only and managed identity sessions can present an empty Scopes collection, and those are supported authentication models here, so a strict throw would break them. A context that reports scopes but omits AuditLog.Read.All throws. 6. MaxRetryCount is a parameter that the specification did not request. A bounded backoff needs an explicit bound, and making it a parameter avoids burying the limit in the body. 7. Where a user record omits a property, that property is emitted as null with a verbose entry, and the row is retained. For MFARegistered this conflicts with the stated CSV contract of True or False; the instruction never to fabricate a value and never to drop a row takes precedence, so an empty cell is possible in that column. 8. userType values are returned by Graph in lower case as 'member' or 'guest', with 'unknownFutureValue' reserved. They are normalised to 'Member' and 'Guest' for the CSV contract. Any other value is passed through unchanged with a verbose entry rather than being coerced. 9. try/catch is used around every external call. A finally block is used only on the CSV export, where it records the outcome. The paging retry loop has no unmanaged resource to release, and a finally inside a retry loop would execute on every attempt, so none is used there. 10. The exact exception shape Invoke-MgGraphRequest raises for HTTP 429 is not documented. Reading the status code and the Retry-After header is therefore attempted defensively and, if it cannot be read, the function falls back to computed exponential backoff. This path could not be exercised without a live throttled tenant and is marked for confirmation by the operator. 11. This file deliberately carries no '#Requires -Modules' statement for Microsoft.Graph.Authentication. A module source file declares its dependencies through the manifest and RequiredModules.psd1, and a '#Requires -Modules' line here would make the file impossible to dot-source for unit testing on a machine without the Graph SDK installed. Only '#Requires -Version 7.0' is declared. 12. IsAdmin is a sixth column, added at explicit request after the original five-column specification. It is populated from the isAdmin property of userRegistrationDetails, which Microsoft documents as indicating whether the user holds an admin role in the tenant. It is appended last so the original five columns keep their contract order and existing consumers are unaffected. isAdmin is not documented as supporting $filter, so it is reported but cannot be filtered server side. 13. -TenantId is optional here and asserts the active session's tenant, rather than being mandatory. Authentication is the job of Connect-EntraMfaRegistrationReport, where TenantId is mandatory; making it mandatory here as well would force a tenant GUID on every call even when a session is already correctly established, and would break the pipeline-friendly no-argument default. This function still never signs in by itself. Authentication The caller must already hold a Microsoft Graph session. The function verifies the context and never initiates an interactive sign-in. Any supported model works: managed identity, workload identity federation, certificate-based app-only, or interactive sign-in performed outside the function. Privacy Output contains personal data, specifically display names and user principal names. Handle it in accordance with the client data-handling policy, store it only where that policy permits, and do not commit generated CSV files to source control. Platform PowerShell 7.x, cross-platform. No element of this function requires Windows PowerShell 5.1 or elevation. Module placement source/Public/Get-EntraMfaRegistrationReport.ps1, with its test at tests/Unit/Public/Get-EntraMfaRegistrationReport.tests.ps1. A new containing module is generated from the Princetimber/powershell-module-template GitHub template rather than hand-scaffolded, and the Microsoft.Graph.Authentication version is pinned in RequiredModules.psd1. #> [CmdletBinding(SupportsShouldProcess = $true)] [OutputType([System.Management.Automation.PSCustomObject])] param( [Parameter()] [ValidatePattern('^[0-9a-fA-F]{8}-([0-9a-fA-F]{4}-){3}[0-9a-fA-F]{12}$')] [System.String] $TenantId, [Parameter()] [ValidateSet('Member', 'Guest', 'All')] [System.String] $UserType = 'All', [Parameter()] [System.Management.Automation.SwitchParameter] $UnregisteredOnly, [Parameter()] [ValidateNotNullOrEmpty()] [ValidateScript({ $parentPath = Split-Path -Path $_ -Parent if ([System.String]::IsNullOrWhiteSpace($parentPath)) { return $true } if (Test-Path -LiteralPath $parentPath -PathType Container) { return $true } throw ("The parent directory '{0}' does not exist. Create it before exporting." -f $parentPath) })] [System.String] $Path, [Parameter()] [System.Management.Automation.SwitchParameter] $Force, [Parameter()] [ValidateRange(1, 10)] [System.Int32] $MaxRetryCount = 5 ) $requiredScope = 'AuditLog.Read.All' $connectHint = ("Connect first, for example: Connect-MgGraph -Scopes '{0}'." -f $requiredScope) # Confirm an existing Graph session rather than creating one. Sign-in is the caller's concern. try { $graphContext = Get-MgContext -ErrorAction Stop } catch { throw ('Unable to read the Microsoft Graph context. {0} Underlying error: {1}' -f $connectHint, $_.Exception.Message) } if ($null -eq $graphContext) { throw ("No Microsoft Graph context was found. This function requires the '{0}' permission. {1}" -f $requiredScope, $connectHint) } # Assert the session belongs to the expected tenant before reading any directory data. if ($PSBoundParameters.ContainsKey('TenantId') -and $graphContext.TenantId -ne $TenantId) { throw ('The active Microsoft Graph session belongs to tenant {0}, not the expected tenant {1}. Reconnect with Connect-EntraMfaRegistrationReport -TenantId {1} before running this report.' -f $graphContext.TenantId, $TenantId) } $grantedScopes = @($graphContext.Scopes | Where-Object { -not [System.String]::IsNullOrWhiteSpace($_) }) if ($grantedScopes.Count -eq 0) { # App-only and managed identity sessions can legitimately report no scopes. Write-Warning ("The Microsoft Graph context reports no scopes, which is expected for app-only and managed identity sessions. Proceeding on the assumption that the '{0}' application permission has been granted and admin-consented." -f $requiredScope) } elseif ($grantedScopes -notcontains $requiredScope) { throw ("The current Microsoft Graph context does not hold the required permission '{0}'. Granted scopes are: {1}. {2}" -f $requiredScope, ($grantedScopes -join ', '), $connectHint) } # Validate the destination before the enumeration so a clobber conflict fails immediately # rather than after a full paginated retrieval. $exportRequested = $PSBoundParameters.ContainsKey('Path') if ($exportRequested) { if (Test-Path -LiteralPath $Path -PathType Container) { throw ("The path '{0}' is an existing directory. Supply the full path of the CSV file to write." -f $Path) } if ((Test-Path -LiteralPath $Path -PathType Leaf) -and -not $Force.IsPresent) { throw ("The file '{0}' already exists. Supply -Force to overwrite it, or choose a different path." -f $Path) } } $requestUri = '/v1.0/reports/authenticationMethods/userRegistrationDetails' if ($UnregisteredOnly.IsPresent) { # isMfaRegistered is documented as supporting $filter with eq, so this narrows server side. $requestUri = '{0}?$filter=isMfaRegistered%20eq%20false' -f $requestUri } # Reads a property from a Graph record, returning null and a verbose note when it is absent, # so an incomplete record is never fabricated and never silently dropped. $readProperty = { param( [System.Collections.IDictionary] $Record, [System.String] $PropertyName, [System.String] $RecordLabel ) if ($Record.Contains($PropertyName)) { return $Record[$PropertyName] } Write-Verbose ("Record '{0}' has no '{1}' property; emitting null for it." -f $RecordLabel, $PropertyName) return $null } # PSObject, not PSCustomObject: the [PSCustomObject]@{} literal produces a PSObject at # runtime, so a List[PSCustomObject] would reject every Add call. $report = [System.Collections.Generic.List[System.Management.Automation.PSObject]]::new() $retryableStatusCodes = @(429, 503, 504) $maximumBackoffSeconds = 300 $pageNumber = 0 $nextUri = $requestUri while (-not [System.String]::IsNullOrWhiteSpace($nextUri)) { $pageNumber++ Write-Verbose ('Requesting page {0} of the authentication methods registration report.' -f $pageNumber) $response = $null $attempt = 0 while ($null -eq $response) { try { $requestParameters = @{ Method = 'GET' Uri = $nextUri OutputType = 'Hashtable' ErrorAction = 'Stop' } $response = Invoke-MgGraphRequest @requestParameters } catch { $statusCode = 0 $retryAfterSeconds = 0 # The exception shape for a throttled Graph response is not documented, so every # hop is guarded and any failure here degrades to computed backoff. try { $failureResponse = $_.Exception.Response if ($null -ne $failureResponse) { if ($null -ne $failureResponse.StatusCode) { $statusCode = [System.Int32]$failureResponse.StatusCode } $failureHeaders = $failureResponse.Headers if ($null -ne $failureHeaders -and $null -ne $failureHeaders.RetryAfter -and $null -ne $failureHeaders.RetryAfter.Delta) { $retryAfterSeconds = [System.Int32]$failureHeaders.RetryAfter.Delta.TotalSeconds } } } catch { Write-Verbose 'Could not read the HTTP status code or Retry-After header from the failure; falling back to computed backoff.' } if ($statusCode -eq 0 -and $_.Exception.Message -match '(429|Too Many Requests)') { $statusCode = 429 } if ($retryableStatusCodes -notcontains $statusCode) { <# Graph returns 403 Authentication_RequestFromUnsupportedUserRole when the token carries AuditLog.Read.All but the signed-in principal holds no directory role permitted to read this report. The scope alone is necessary but not sufficient, and the raw Graph error does not say so, which sends operators hunting for a missing consent that is already granted. Translate it into the actual remedy. #> $unsupportedRole = $statusCode -eq 403 -or $_.Exception.Message -match 'Authentication_RequestFromUnsupportedUserRole|not in the allowed roles' if ($unsupportedRole) { throw ('Microsoft Graph refused the request with HTTP 403 because the signed-in principal is not in a directory role permitted to read the authentication methods registration report. The AuditLog.Read.All permission is necessary but not sufficient for delegated access. Assign the account one of these roles and sign in again: Reports Reader (least privileged), Security Reader, Global Reader, Security Operator, Security Administrator, Application Administrator or Cloud Application Administrator. For unattended use, authenticate app-only with AuditLog.Read.All granted as an application permission with admin consent, which needs no role assignment. Underlying error: {0}' -f $_.Exception.Message) } # Not a throttling or transient condition. Retrying would only bury the cause. throw } if ($attempt -ge $MaxRetryCount) { throw ('Microsoft Graph returned HTTP {0} for page {1} and the retry limit of {2} was reached. No partial report is returned. Underlying error: {3}' -f $statusCode, $pageNumber, $MaxRetryCount, $_.Exception.Message) } $attempt++ if ($retryAfterSeconds -gt 0) { $delaySeconds = [System.Math]::Min($retryAfterSeconds, $maximumBackoffSeconds) $delaySource = 'the Retry-After header' } else { $delaySeconds = [System.Int32][System.Math]::Min([System.Math]::Pow(2, $attempt), $maximumBackoffSeconds) $delaySource = 'computed exponential backoff' } Write-Warning ('Microsoft Graph returned HTTP {0} for page {1}. Retrying attempt {2} of {3} in {4} second(s), based on {5}.' -f $statusCode, $pageNumber, $attempt, $MaxRetryCount, $delaySeconds, $delaySource) Start-Sleep -Seconds $delaySeconds } } $pageRecords = @() if ($response.Contains('value')) { $pageRecords = @($response['value']) } else { Write-Warning ("Page {0} of the report contained no 'value' collection and was skipped." -f $pageNumber) } Write-Verbose ('Page {0} returned {1} record(s).' -f $pageNumber, $pageRecords.Count) foreach ($record in $pageRecords) { if ($record -isnot [System.Collections.IDictionary]) { Write-Warning ('A record on page {0} was not in the expected shape and was skipped.' -f $pageNumber) continue } $objectId = & $readProperty -Record $record -PropertyName 'id' -RecordLabel 'unknown' $recordLabel = if ([System.String]::IsNullOrWhiteSpace($objectId)) { 'unknown' } else { [System.String]$objectId } $displayName = & $readProperty -Record $record -PropertyName 'userDisplayName' -RecordLabel $recordLabel $userPrincipalName = & $readProperty -Record $record -PropertyName 'userPrincipalName' -RecordLabel $recordLabel $rawUserType = & $readProperty -Record $record -PropertyName 'userType' -RecordLabel $recordLabel $rawMfaRegistered = & $readProperty -Record $record -PropertyName 'isMfaRegistered' -RecordLabel $recordLabel $rawIsAdmin = & $readProperty -Record $record -PropertyName 'isAdmin' -RecordLabel $recordLabel # Graph returns 'member' and 'guest' in lower case; the CSV contract requires title case. $normalisedUserType = $null if (-not [System.String]::IsNullOrWhiteSpace($rawUserType)) { switch ([System.String]$rawUserType) { 'member' { $normalisedUserType = 'Member' } 'guest' { $normalisedUserType = 'Guest' } default { $normalisedUserType = [System.String]$rawUserType Write-Verbose ("Record '{0}' reported an unrecognised userType of '{1}'; passing it through unchanged." -f $recordLabel, $rawUserType) } } } if ($UserType -ne 'All' -and $normalisedUserType -ne $UserType) { Write-Verbose ("Record '{0}' has userType '{1}' and was excluded by the -UserType {2} filter." -f $recordLabel, $normalisedUserType, $UserType) continue } $mfaRegistered = $null if ($null -ne $rawMfaRegistered) { $mfaRegistered = [System.Boolean]$rawMfaRegistered } $isAdmin = $null if ($null -ne $rawIsAdmin) { $isAdmin = [System.Boolean]$rawIsAdmin } $report.Add([PSCustomObject]@{ DisplayName = $displayName ObjectId = $objectId UserPrincipalName = $userPrincipalName UserType = $normalisedUserType MFARegistered = $mfaRegistered IsAdmin = $isAdmin }) } $nextUri = $null if ($response.Contains('@odata.nextLink')) { $nextUri = [System.String]$response['@odata.nextLink'] Write-Verbose 'The service returned a nextLink; another page will be requested.' } } Write-Verbose ('Retrieved {0} record(s) across {1} page(s).' -f $report.Count, $pageNumber) if ($report.Count -eq 0) { Write-Warning 'The report is empty. Note that disabled and recently deleted users are not present in this Graph report, and that the report has a data latency of up to 36 hours.' } if ($exportRequested) { if ($PSCmdlet.ShouldProcess($Path, 'Write MFA registration report CSV')) { try { $exportParameters = @{ Path = $Path NoTypeInformation = $true Encoding = 'UTF8' ErrorAction = 'Stop' } $report | Export-Csv @exportParameters } catch { throw ("Failed to write the CSV report to '{0}'. Underlying error: {1}" -f $Path, $_.Exception.Message) } finally { Write-Verbose ("CSV export to '{0}' completed its attempt." -f $Path) } } } Write-Output -InputObject $report } #EndRegion './Public/Get-EntraMfaRegistrationReport.ps1' 523 |