SHELL/MFA.STATUS.ps1

$CheckId = "MFA.STATUS"
$Title = "Supplemental - MFA status and Conditional Access coverage for users"
$Level = "L1"
$BenchmarkType = "Automated"

function Get-PropValue {
    param(
        [AllowNull()]$Object,
        [string]$Name
    )

    if ($null -eq $Object) { return $null }

    if ($Object -is [System.Collections.IDictionary]) {
        foreach ($Key in $Object.Keys) {
            if ([string]$Key -ieq $Name) { return $Object[$Key] }
        }
    }

    if ($Object.PSObject -and $Object.PSObject.Properties) {
        foreach ($Property in $Object.PSObject.Properties) {
            if ([string]$Property.Name -ieq $Name) { return $Property.Value }
        }
    }

    return $null
}

function Invoke-GraphPaged {
    param(
        [Parameter(Mandatory = $true)][string]$Uri,
        [hashtable]$Headers
    )

    $Items = [System.Collections.Generic.List[object]]::new()
    $Next = $Uri

    while (-not [string]::IsNullOrWhiteSpace($Next)) {
        if ($Headers) {
            $Response = Invoke-MgGraphRequest -Method GET -Uri $Next -Headers $Headers -ErrorAction Stop
        }
        else {
            $Response = Invoke-MgGraphRequest -Method GET -Uri $Next -ErrorAction Stop
        }

        foreach ($Item in @(Get-PropValue -Object $Response -Name "value")) {
            $Items.Add($Item)
        }

        $Next = [string](Get-PropValue -Object $Response -Name "@odata.nextLink")
    }

    return @($Items)
}

function Convert-ToStringArray {
    param([AllowNull()]$Value)
    if ($null -eq $Value) { return @() }
    if ($Value -is [string]) { return @($Value) }
    if ($Value -is [System.Collections.IEnumerable]) {
        return @($Value | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }
    return @([string]$Value)
}

try {
    $RunStamp = Get-Date -Format "yyyyMMdd_HHmmss"
    $ProjectRoot = Split-Path -Path $PSScriptRoot -Parent
    $ManualReviewDir = Join-Path $ProjectRoot "manual_review"
    if (-not (Test-Path $ManualReviewDir)) {
        New-Item -Path $ManualReviewDir -ItemType Directory -Force | Out-Null
    }
    $CsvExportPath = Join-Path $ManualReviewDir "mfa_user_status_$RunStamp.csv"

    $SkusResponse = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/subscribedSkus" -ErrorAction Stop
    $ServicePlans = @($SkusResponse.value | ForEach-Object { @($_.servicePlans) } | Where-Object { $_ })
    $ServicePlanNames = @($ServicePlans | ForEach-Object { [string]$_.servicePlanName } | Where-Object { $_ } | Select-Object -Unique)
    $HasAadPremium = @($ServicePlanNames | Where-Object { $_ -like "AAD_PREMIUM*" }).Count -gt 0

    $SecurityDefaults = Invoke-MgGraphRequest -Method GET -Uri "https://graph.microsoft.com/beta/policies/identitySecurityDefaultsEnforcementPolicy" -ErrorAction Stop
    $SecurityDefaultsEnabled = [bool](Get-PropValue -Object $SecurityDefaults -Name "isEnabled")

    $AllCAPolicies = @(Invoke-GraphPaged -Uri "https://graph.microsoft.com/beta/policies/conditionalAccessPolicies?`$top=200")
    $MfaCAPolicies = @(
        $AllCAPolicies | Where-Object {
            $State = [string](Get-PropValue -Object $_ -Name "state")
            if ($State -ne "enabled") { return $false }

            $GrantControls = Get-PropValue -Object $_ -Name "grantControls"
            $BuiltInControls = @(Convert-ToStringArray -Value (Get-PropValue -Object $GrantControls -Name "builtInControls"))
            $HasMfaBuiltIn = @($BuiltInControls | Where-Object { $_ -eq "mfa" }).Count -gt 0

            $AuthStrength = Get-PropValue -Object $GrantControls -Name "authenticationStrength"
            $HasAuthStrength = ($null -ne $AuthStrength)

            return ($HasMfaBuiltIn -or $HasAuthStrength)
        }
    )

    $MfaReport = @(Invoke-GraphPaged -Uri "https://graph.microsoft.com/beta/reports/authenticationMethods/userRegistrationDetails?`$top=999" -Headers @{ "ConsistencyLevel" = "eventual" })
    if ($MfaReport.Count -eq 0) {
        throw "No user registration details were returned from reports/authenticationMethods/userRegistrationDetails."
    }

    $UserResults = [System.Collections.Generic.List[object]]::new()

    foreach ($Lookup in $MfaReport) {
        $UserId = [string](Get-PropValue -Object $Lookup -Name "id")
        $UserPrincipalName = [string](Get-PropValue -Object $Lookup -Name "userPrincipalName")
        $DisplayName = [string](Get-PropValue -Object $Lookup -Name "userDisplayName")
        if ([string]::IsNullOrWhiteSpace($DisplayName)) {
            $DisplayName = [string](Get-PropValue -Object $Lookup -Name "displayName")
        }

        $IsExternal = ($UserPrincipalName -match "#EXT#")
        $IsAdmin = [bool](Get-PropValue -Object $Lookup -Name "isAdmin")
        $IsMfaCapable = [bool](Get-PropValue -Object $Lookup -Name "isMfaCapable")
        $IsMfaRegistered = [bool](Get-PropValue -Object $Lookup -Name "isMfaRegistered")
        $IsSsprCapable = [bool](Get-PropValue -Object $Lookup -Name "isSsprCapable")
        $IsSsprEnabled = [bool](Get-PropValue -Object $Lookup -Name "isSsprEnabled")
        $IsSsprRegistered = [bool](Get-PropValue -Object $Lookup -Name "isSsprRegistered")
        $IsPasswordlessCapable = [bool](Get-PropValue -Object $Lookup -Name "isPasswordlessCapable")
        $DefaultMfaMethod = [string](Get-PropValue -Object $Lookup -Name "defaultMfaMethod")
        $UserPreferredMfaMethod = [string](Get-PropValue -Object $Lookup -Name "userPreferredMethodForSecondaryAuthentication")
        $SystemPreferredEnabled = [bool](Get-PropValue -Object $Lookup -Name "isSystemPreferredAuthenticationMethodEnabled")

        $SystemPreferredMethods = @(Convert-ToStringArray -Value (Get-PropValue -Object $Lookup -Name "systemPreferredAuthenticationMethods"))
        $RegisteredMethods = @(Convert-ToStringArray -Value (Get-PropValue -Object $Lookup -Name "methodsRegistered"))

        $AppliedPolicies = [System.Collections.Generic.List[string]]::new()
        $DisabledPolicies = [System.Collections.Generic.List[string]]::new()
        $CaGaps = [System.Collections.Generic.List[string]]::new()

        foreach ($Policy in $MfaCAPolicies) {
            $PolicyName = [string](Get-PropValue -Object $Policy -Name "displayName")
            if ([string]::IsNullOrWhiteSpace($PolicyName)) {
                $PolicyName = [string](Get-PropValue -Object $Policy -Name "id")
            }

            $Conditions = Get-PropValue -Object $Policy -Name "conditions"
            $Users = Get-PropValue -Object $Conditions -Name "users"
            $IncludeUsers = @(Convert-ToStringArray -Value (Get-PropValue -Object $Users -Name "includeUsers"))
            $ExcludeUsers = @(Convert-ToStringArray -Value (Get-PropValue -Object $Users -Name "excludeUsers"))

            $TargetsAllUsers = @($IncludeUsers | Where-Object { $_ -eq "All" }).Count -gt 0
            $DirectlyTargeted = @($IncludeUsers | Where-Object { $_ -eq $UserId }).Count -gt 0
            $DirectlyExcluded = @($ExcludeUsers | Where-Object { $_ -eq $UserId }).Count -gt 0

            if ($TargetsAllUsers -or $DirectlyTargeted) {
                if ($DirectlyExcluded) {
                    $CaGaps.Add("$PolicyName (user excluded)")
                }
                else {
                    $AppliedPolicies.Add($PolicyName)
                }
            }
        }

        if ($MfaCAPolicies.Count -eq 0 -and -not $SecurityDefaultsEnabled) {
            $CaGaps.Add("No enabled Conditional Access policies requiring MFA")
        }

        $InsecureDefaultMethod = $DefaultMfaMethod -in @("email", "mobilePhone")
        $Risk = "Low"
        if ($CaGaps.Count -gt 0) {
            $Risk = "Critical"
        }
        elseif ($IsAdmin -and ((-not $IsMfaRegistered) -or (-not $IsMfaCapable) -or $InsecureDefaultMethod)) {
            $Risk = "Critical"
        }
        elseif ((-not $IsAdmin) -and ((-not $IsMfaRegistered) -or (-not $IsMfaCapable) -or $InsecureDefaultMethod)) {
            $Risk = "High"
        }
        elseif (-not $IsSsprRegistered) {
            $Risk = "Medium"
        }

        $UserResults.Add([pscustomobject]@{
            Name = $DisplayName
            Id = $UserId
            UserPrincipalName = $UserPrincipalName
            Risk = $Risk
            IsEnabled = $null
            IsExternal = $IsExternal
            IsAdmin = $IsAdmin
            IsSSPRCapable = $IsSsprCapable
            IsSSPREnabled = $IsSsprEnabled
            IsSSPRRegistered = $IsSsprRegistered
            IsMFACapable = $IsMfaCapable
            IsMFARegistered = $IsMfaRegistered
            RegisteredMFAMethods = ($RegisteredMethods -join ", ")
            DefaultMFAMethod = $DefaultMfaMethod
            SystemPreferredMethodEnforced = $SystemPreferredEnabled
            SystemEnforcedMethod = ($SystemPreferredMethods -join ", ")
            UserPreferredMFAMethod = $UserPreferredMfaMethod
            IsPasswordlessCapable = $IsPasswordlessCapable
            MemberOf = $null
            AppliedCAPolicies = (@($AppliedPolicies | Select-Object -Unique) -join ", ")
            CAPoliciesNotApplied = (@($DisabledPolicies | Select-Object -Unique) -join ", ")
            PossibleCAGaps = (@($CaGaps | Select-Object -Unique) -join ", ")
        })
    }

    $SortedResults = @(
        $UserResults | Sort-Object @{
            Expression = {
                switch ([string]$_.Risk) {
                    "Critical" { 1 }
                    "High" { 2 }
                    "Medium" { 3 }
                    default { 4 }
                }
            }
        }, UserPrincipalName
    )

    $SortedResults | Export-Csv -Path $CsvExportPath -NoTypeInformation -Encoding UTF8

    $RiskCounts = @{
        Critical = @($SortedResults | Where-Object { $_.Risk -eq "Critical" }).Count
        High = @($SortedResults | Where-Object { $_.Risk -eq "High" }).Count
        Medium = @($SortedResults | Where-Object { $_.Risk -eq "Medium" }).Count
        Low = @($SortedResults | Where-Object { $_.Risk -eq "Low" }).Count
    }

    $Pass = $true
    $ErrorMessage = $null

    if ($SecurityDefaultsEnabled) {
        $Pass = $true
    }
    elseif ($MfaCAPolicies.Count -eq 0) {
        $Pass = $false
        $ErrorMessage = "No enabled Conditional Access policies requiring MFA were found and Security Defaults are disabled."
    }
    elseif ($RiskCounts.Critical -gt 0) {
        $Pass = $false
        $ErrorMessage = "One or more users are in Critical MFA risk state."
    }
    elseif ($RiskCounts.High -gt 0) {
        $Pass = $false
        $ErrorMessage = "One or more users are in High MFA risk state."
    }

    [pscustomobject]@{
        CheckId = $CheckId
        Title = $Title
        Level = $Level
        BenchmarkType = $BenchmarkType
        Status = if ($Pass) { "PASS" } else { "FAIL" }
        Pass = $Pass
        Evidence = [pscustomobject]@{
            HasAadPremiumLicensePlan = $HasAadPremium
            SecurityDefaultsEnabled = $SecurityDefaultsEnabled
            ConditionalAccessMfaPolicyCount = @($MfaCAPolicies).Count
            TotalUsersAssessed = @($SortedResults).Count
            RiskCounts = $RiskCounts
            CsvExportPath = $CsvExportPath
            CsvRowCount = @($SortedResults).Count
            ResultsPreview = @($SortedResults | Select-Object -First 50)
            SourceDocument = "Supplemental tenant MFA posture analysis (Graph beta reports + CA policy mapping)"
        }
        Error = $ErrorMessage
        Timestamp = Get-Date
    }
}
catch {
    [pscustomobject]@{
        CheckId = $CheckId
        Title = $Title
        Level = $Level
        BenchmarkType = $BenchmarkType
        Status = "ERROR"
        Pass = $null
        Evidence = [pscustomobject]@{
            SourceDocument = "Supplemental tenant MFA posture analysis (Graph beta reports + CA policy mapping)"
        }
        Error = $_.Exception.Message
        Timestamp = Get-Date
    }
}