Private/Entra/Core/Resolve-CAWhatIf.ps1

# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0
# https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/
# AI/LLM use: see AI-USAGE.md for required attribution
#
# Pure logic for Conditional Access "what-if" simulation: normalize the Graph evaluate response into a
# single verdict, the canned attack-scenario catalog, and the scenario grader. No Graph calls here — the
# live POST lives in Test-GuerrillaConditionalAccess. Offline-testable. The CA evaluate API is BETA, so
# any unrecognised/empty response normalizes to 'Unknown' -> the grader returns SKIP (Not Assessed),
# never a false PASS.

# Reduce the applied-policy set returned by /identity/conditionalAccess/evaluate to one outcome.
function ConvertTo-CAWhatIfVerdict {
    [CmdletBinding()]
    param([AllowNull()]$AppliedPolicies)

    if ($null -eq $AppliedPolicies) { return @{ Result = 'Unknown'; AppliedPolicies = @() } }
    $policies = @($AppliedPolicies)
    if ($policies.Count -eq 0) { return @{ Result = 'NotApplied'; AppliedPolicies = @() } }

    $controls = [System.Collections.Generic.List[string]]::new()
    $names = [System.Collections.Generic.List[string]]::new()
    $sawAny = $false
    foreach ($p in $policies) {
        $names.Add("$(($p.displayName ?? $p.name ?? $p.id))")
        # The evaluate response carries the policy's grant controls; tolerate the known shapes.
        $c = @()
        if ($p.grantControls.builtInControls) { $c += @($p.grantControls.builtInControls); $sawAny = $true }
        if ($p.enforcedGrantControls)         { $c += @($p.enforcedGrantControls);         $sawAny = $true }
        foreach ($x in $c) { if ($x) { $controls.Add("$x".ToLower()) } }
    }

    # If policies applied but NONE exposed a grant-control field we recognise, we can't say what they do.
    if (-not $sawAny) { return @{ Result = 'Unknown'; AppliedPolicies = @($names) } }

    $set = @($controls)
    $result =
        if ($set -contains 'block') { 'Block' }
        elseif ($set -contains 'mfa') { 'MfaRequired' }
        elseif ($set -contains 'compliantdevice' -or $set -contains 'domainjoineddevice') { 'CompliantDeviceRequired' }
        elseif ($set -contains 'passwordchange') { 'PasswordChangeRequired' }
        else { 'Grant' }
    return @{ Result = $result; AppliedPolicies = @($names) }
}

# Canned attack scenarios the simulation grades a tenant against. Each: scenario params for the evaluate
# call + the set of outcomes that count as "protected".
function Get-CAAttackScenario {
    @(
        [PSCustomObject]@{ Key = 'legacy-auth';     Name = 'Legacy authentication client'; Severity = 'High'
            Params = @{ ClientAppType = 'exchangeActiveSync' }; Expect = @('Block') }
        [PSCustomObject]@{ Key = 'no-mfa';          Name = 'Cloud app sign-in without MFA'; Severity = 'High'
            Params = @{}; Expect = @('Block', 'MfaRequired', 'CompliantDeviceRequired') }
        [PSCustomObject]@{ Key = 'high-signin-risk'; Name = 'High sign-in risk'; Severity = 'High'
            Params = @{ SignInRiskLevel = 'High' }; Expect = @('Block', 'MfaRequired') }
        [PSCustomObject]@{ Key = 'high-user-risk';  Name = 'High user risk'; Severity = 'High'
            Params = @{ UserRiskLevel = 'High' }; Expect = @('Block', 'MfaRequired', 'PasswordChangeRequired') }
        [PSCustomObject]@{ Key = 'unmanaged-device'; Name = 'Unmanaged device sign-in'; Severity = 'Medium'
            Params = @{ DevicePlatform = 'windows' }; Expect = @('Block', 'MfaRequired', 'CompliantDeviceRequired') }
    )
}

# Grade one scenario result against its expected outcomes. Unknown -> SKIP (Not Assessed).
function Resolve-CAScenarioVerdict {
    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Result,
        [Parameter(Mandatory)][string[]]$Expect
    )
    if ($Result -eq 'Unknown') { return 'SKIP' }
    if ($Result -in $Expect) { return 'PASS' }
    return 'FAIL'
}