tests/Test-Assessment.41083.ps1

<#
.SYNOPSIS
    Checks that step-up authentication is required upon risky in-session action.
 
.NOTES
    Test ID: 41083
    Workshop Task: SECOPS-083
    Pillar: SecOps
    Category: Identity threat protection
    Required permission: Policy.Read.All
#>


function Test-Assessment-41083 {

    [ZtTest(
        Category           = 'Identity threat protection',
        CompatibleLicense  = ('ADALLOM_S_STANDALONE&AAD_PREMIUM_P2'),
        ImplementationCost = 'Medium',
        Pillar             = 'SecOps',
        RiskLevel          = 'High',
        Service            = ('Graph'),
        SfiPillar          = 'Protect identities and secrets',
        TenantType         = ('Workforce'),
        TestId             = 41083,
        Title              = 'Step-up authentication is required upon risky in-session action',
        UserImpact         = 'Medium'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking risk-based step-up authentication Conditional Access policies'
    Write-ZtProgress -Activity $activity -Status 'Querying enabled Conditional Access policies'

    try {
        $enabledPolicies = Invoke-ZtGraphRequest -RelativeUri 'identity/conditionalAccess/policies' -ApiVersion beta -Filter "state eq 'enabled'" -Select 'id,displayName,state,conditions,grantControls' -ErrorAction Stop
    }
    catch {
        $httpStatus = Get-ZtHttpStatusCode -ErrorRecord $_
        Write-PSFMessage "Failed to retrieve Conditional Access policies (HTTP $httpStatus): $_" -Tag Test -Level Warning

        $resultMessage = if ($httpStatus -in @(401, 403)) {
            '⚠️ The Conditional Access policy collection could not be retrieved because the assessment account lacks Policy.Read.All permission. Grant the permission and re-run the assessment.'
        }
        else {
            '⚠️ The Conditional Access policy collection could not be retrieved because Microsoft Graph returned a transient or unexpected error. Verify connectivity and re-run the assessment.'
        }

        $params = @{
            TestId       = '41083'
            Title        = 'Step-up authentication is required upon risky in-session action'
            Status       = $false
            Result       = $resultMessage
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    $enabledPolicies = @($enabledPolicies)

    Write-ZtProgress -Activity $activity -Status 'Resolving referenced authentication strengths'

    # The strength is returned inline with each policy, but allowedCombinations is not always expanded.
    $strengthCombinations = @{}
    $unresolvedStrengths = @{}
    foreach ($policy in $enabledPolicies) {
        $strength = $policy.grantControls.authenticationStrength
        if ($null -eq $strength -or [string]::IsNullOrWhiteSpace($strength.id)) { continue }
        if ($strengthCombinations.ContainsKey($strength.id) -or $unresolvedStrengths.ContainsKey($strength.id)) { continue }

        $allowedCombinations = @($strength.allowedCombinations | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        if ($allowedCombinations.Count -eq 0) {
            try {
                $strengthDetail = Invoke-ZtGraphRequest -RelativeUri "identity/conditionalAccess/authenticationStrength/policies/$($strength.id)" -ApiVersion beta -ErrorAction Stop
                $allowedCombinations = @($strengthDetail.allowedCombinations | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
            }
            catch {
                Write-PSFMessage "Failed to retrieve authentication strength $($strength.id): $_" -Tag Test -Level Warning
                # A failed lookup must not be cached as a successfully resolved empty strength.
                $unresolvedStrengths[$strength.id] = $true
                continue
            }

            # Every strength defines at least one combination, so an empty response is unresolved data.
            if ($allowedCombinations.Count -eq 0) {
                Write-PSFMessage "Authentication strength $($strength.id) returned no allowed combinations." -Tag Test -Level Warning
                $unresolvedStrengths[$strength.id] = $true
                continue
            }
        }

        $strengthCombinations[$strength.id] = $allowedCombinations
    }
    #endregion Data Collection

    #region Assessment Logic
    # Built-in 'Phishing-resistant MFA' authentication strength.
    $phishingResistantStrengthId = '00000000-0000-0000-0000-000000000004'
    $phishingResistantMethods = @('windowsHelloForBusiness', 'fido2', 'x509CertificateMultiFactor')

    $policyResults = foreach ($policy in $enabledPolicies) {
        $signInRiskLevels = @($policy.conditions.signInRiskLevels | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        $userRiskLevels   = @($policy.conditions.userRiskLevels   | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
        $hasRiskCondition = ($signInRiskLevels -contains 'high') -or ($userRiskLevels -contains 'high')

        $authenticationStrength = $policy.grantControls.authenticationStrength
        $hasAuthenticationStrength = $null -ne $authenticationStrength -and -not [string]::IsNullOrWhiteSpace($authenticationStrength.id)
        $isPhishingResistant = $false
        $isStrengthUnresolved = $false

        if ($hasAuthenticationStrength) {
            if ($authenticationStrength.id -eq $phishingResistantStrengthId) {
                $isPhishingResistant = $true
            }
            elseif ($unresolvedStrengths.ContainsKey($authenticationStrength.id)) {
                $isStrengthUnresolved = $true
            }
            else {
                $allowedCombinations = @($strengthCombinations[$authenticationStrength.id])
                $nonResistantCombinations = @($allowedCombinations | Where-Object { $phishingResistantMethods -notcontains $_ })
                $isPhishingResistant = $allowedCombinations.Count -gt 0 -and $nonResistantCombinations.Count -eq 0
            }
        }

        $strengthName = 'None'
        if ($hasAuthenticationStrength) {
            $strengthName = if ([string]::IsNullOrWhiteSpace($authenticationStrength.displayName)) { $authenticationStrength.id } else { $authenticationStrength.displayName }
        }

        $builtInControls = @($policy.grantControls.builtInControls | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })

        # Every grant control collection shares the same operator, so an OR alongside any of them
        # lets the grant be satisfied without the authentication strength.
        $alternativeGrantControls = @(
            @(
                $policy.grantControls.builtInControls
                $policy.grantControls.customAuthenticationFactors
                $policy.grantControls.termsOfUse
            ) | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }
        )

        $strengthAlwaysRequired = $alternativeGrantControls.Count -eq 0 -or $policy.grantControls.operator -eq 'AND'

        [PSCustomObject]@{
            PolicyDisplayName      = $policy.displayName
            PolicyId               = $policy.id
            State                  = $policy.state
            SignInRiskLevels       = if ($signInRiskLevels.Count -gt 0) { $signInRiskLevels -join ', ' } else { 'None' }
            UserRiskLevels         = if ($userRiskLevels.Count -gt 0) { $userRiskLevels -join ', ' } else { 'None' }
            AuthenticationStrength = $strengthName
            PhishingResistant      = if ($isStrengthUnresolved) { 'Unknown' } elseif ($isPhishingResistant) { 'Yes' } else { 'No' }
            BuiltInControls        = if ($builtInControls.Count -gt 0) { $builtInControls -join ', ' } else { 'None' }
            RequiresMfaControl     = $builtInControls -contains 'mfa'
            HasRiskCondition       = $hasRiskCondition
            IsPhishingResistant    = $isPhishingResistant
            IsStrengthUnresolved   = $isStrengthUnresolved
            StrengthAlwaysRequired = $strengthAlwaysRequired
            Matches                = $hasRiskCondition -and $isPhishingResistant -and $strengthAlwaysRequired
        }
    }

    $policyResults = @($policyResults)
    $riskPolicies = @($policyResults | Where-Object HasRiskCondition)
    $matchingPolicies = @($riskPolicies | Where-Object Matches)
    $mfaOnlyRiskPolicies = @($riskPolicies | Where-Object { -not $_.IsPhishingResistant -and $_.RequiresMfaControl })
    # An unresolved strength only matters when no alternative grant control already satisfies the policy.
    $unresolvedRiskPolicies = @($riskPolicies | Where-Object { $_.IsStrengthUnresolved -and $_.StrengthAlwaysRequired })

    $passed = $false
    $customStatus = $null
    if ($matchingPolicies.Count -gt 0) {
        $passed = $true
        $testResultMarkdown = "✅ At least one enabled Conditional Access policy combines an identity risk condition with a phishing-resistant authentication strength grant control, so a step-up challenge raised by a risky in-session action is enforced.`n`n%TestResult%"
    }
    elseif ($unresolvedRiskPolicies.Count -gt 0) {
        $customStatus = 'Investigate'
        $testResultMarkdown = "⚠️ The allowed combinations of the custom authentication strength referenced by at least one identity risk policy could not be retrieved from Microsoft Graph, so the assessment cannot confirm whether step-up authentication is phishing-resistant. Verify connectivity and permissions, then re-run the assessment.`n`n%TestResult%"
    }
    elseif ($mfaOnlyRiskPolicies.Count -gt 0) {
        $testResultMarkdown = "❌ Enabled Conditional Access policies use an identity risk condition but grant access with the multifactor authentication built-in control instead of a phishing-resistant authentication strength. Upgrade them to the built-in Phishing-resistant MFA strength, or to a custom strength whose allowed combinations only use FIDO2, certificate-based authentication (multifactor), or Windows Hello for Business.`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "❌ No enabled Conditional Access policy combines an identity risk condition with a phishing-resistant authentication strength grant control.`n`n%TestResult%"
    }
    #endregion Assessment Logic

    #region Report Generation
    $portalUrl = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/ConditionalAccessBlade'
    $policyUrlTemplate = 'https://entra.microsoft.com/#view/Microsoft_AAD_ConditionalAccess/PolicyBlade/policyId/{0}'
    $maxDisplay = 10
    $displayPolicies = @($riskPolicies | Sort-Object -Property PolicyDisplayName | Select-Object -First $maxDisplay)

    if ($displayPolicies.Count -eq 0) {
        $mdInfo = "`n`n## [Microsoft Entra > Conditional Access > Policies]($portalUrl)`n"
    }
    else {
        $tableRows = ''
        foreach ($policy in $displayPolicies) {
            $policyName = "[$(Get-SafeMarkdown -Text $policy.PolicyDisplayName)]($($policyUrlTemplate -f $policy.PolicyId))"
            $signInRisk = Get-SafeMarkdown -Text $policy.SignInRiskLevels
            $userRisk = Get-SafeMarkdown -Text $policy.UserRiskLevels
            $authStrength = Get-SafeMarkdown -Text $policy.AuthenticationStrength
            $phishingResistant = Get-SafeMarkdown -Text $policy.PhishingResistant
            $builtInControls = Get-SafeMarkdown -Text $policy.BuiltInControls
            $status = if ($policy.Matches) { '✅ Pass' } elseif ($policy.IsStrengthUnresolved -and $policy.StrengthAlwaysRequired) { '⚠️ Unknown' } else { '❌ Fail' }
            $tableRows += "| $policyName | $($policy.State) | $signInRisk | $userRisk | $authStrength | $phishingResistant | $builtInControls | $status |`n"
        }

        if ($riskPolicies.Count -gt $maxDisplay) {
            $remaining = $riskPolicies.Count - $maxDisplay
            $tableRows += "`n... and $remaining more. [Microsoft Entra > Conditional Access > Policies]($portalUrl)`n"
        }

        $formatTemplate = @'
 
 
## [Microsoft Entra > Conditional Access > Policies]({0})
 
| Policy display name | State | Sign-in risk levels | User risk levels | Authentication strength | Phishing-resistant | Built-in controls | Status |
| :------------------ | :---- | :------------------ | :--------------- | :---------------------- | :----------------- | :---------------- | :----- |
{1}
'@


        $mdInfo = $formatTemplate -f $portalUrl, $tableRows
    }

    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '41083'
        Title  = 'Step-up authentication is required upon risky in-session action'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($null -ne $customStatus) { $params.CustomStatus = $customStatus }
    Add-ZtTestResultDetail @params
}