Checks/Section5.ps1

# Section 5 — Identity Services
# CIS Microsoft Azure Foundations Benchmark v6.0.0

# ── Tenant-level checks (run once, not per subscription) ─────────────────────

function Invoke-Check5_1_1 {
    # Security defaults enabled OR Conditional Access in use
    $cid = "5.1.1"

    $url = "https://graph.microsoft.com/v1.0/policies/identitySecurityDefaultsEnforcementPolicy"
    $r   = Invoke-ArmRest -Uri $url

    if (-not $r.Success) {
        return New-ErrorResult $cid (New-GraphPermissionMessage -Permission 'Policy.Read.All' -ManualCheck 'Entra ID > Properties > Manage Security Defaults.')
    }

    $enabled = [string]$r.Data.isEnabled -eq "True" -or [string]$r.Data.isEnabled -eq "true"

    # If security defaults off, check for Conditional Access policies (acceptable alternative)
    if (-not $enabled) {
        $caResult = Invoke-ArmRest -Uri "https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies"
        if (-not $caResult.Success) {
            # Cannot tell whether CA policies exist — don't assert FAIL on unread data
            return New-ErrorResult $cid "Security defaults are disabled and Conditional Access policies could not be read: $(New-GraphPermissionMessage -Permission 'Policy.Read.All' -ManualCheck 'Entra ID > Security > Conditional Access.')"
        }
        $caEnabled = $caResult.Data -and $caResult.Data.value -and ($caResult.Data.value | Measure-Object).Count -gt 0
        if ($caEnabled) {
            return New-CISResult -ControlId $cid -Status $script:PASS `
                -Details "Security defaults off but Conditional Access policies exist ($( ($caResult.Data.value | Measure-Object).Count) policies). Acceptable alternative."
        }
    }

    New-CISResult -ControlId $cid `
        -Status $(if ($enabled) { $script:PASS } else { $script:FAIL }) `
        -Details $(if ($enabled) { "Security defaults are enabled." } else { "Security defaults are disabled and no Conditional Access policies found." }) `
        -Remediation $(if (-not $enabled) { "Entra ID > Properties > Manage Security Defaults > Enable security defaults" } else { "" })
}

function Invoke-Check5_1_2 {
    # Manual — device registration MFA setting lives in the Entra portal and has no
    # stable Graph endpoint for read.
    $cid = "5.1.2"
    New-ManualResult $cid `
        "Manual verification required — Microsoft Entra ID > Devices > Device settings > ensure 'Require Multifactor Authentication to register or join devices with Microsoft Entra' is set to 'Yes'."
}

function Invoke-Check5_1_3 {
    # MFA registered for ALL users — uses the current userRegistrationDetails endpoint.
    # The older credentialUserRegistrationDetails endpoint is deprecated and does not paginate.
    $cid = "5.1.3"

    $url = "https://graph.microsoft.com/beta/reports/authenticationMethods/userRegistrationDetails"
    $r   = Invoke-AzRestPaged -Uri $url

    if (-not $r.Success) {
        return New-ErrorResult $cid (New-GraphPermissionMessage -Permission 'UserAuthenticationMethod.Read.All (or Reports.Read.All)')
    }

    $users = @($r.Data)
    $noMfa = @($users | Where-Object { [string]$_.isMfaRegistered -ne 'True' -and [string]$_.isMfaRegistered -ne 'true' })
    $noMfaCount = $noMfa.Count

    if ($noMfaCount -eq 0) {
        return New-CISResult -ControlId $cid -Status $script:PASS -Details "All $($users.Count) user(s) have MFA registered."
    }

    $sample = ($noMfa | Select-Object -First 5 | ForEach-Object { [string]$_.userPrincipalName }) -join ', '
    New-CISResult -ControlId $cid -Status $script:FAIL `
        -Details "$noMfaCount user(s) without MFA registered (of $($users.Count) total). Sample: $sample" `
        -Remediation "Entra ID > Security > Conditional Access (or per-user MFA) > Require MFA for all users."
}

function Invoke-Check5_1_4 {
    $cid = "5.1.4"
    New-ManualResult $cid `
        "Manual verification required — setting is in the deprecated Per-user MFA portal. Disable 'Allow users to remember MFA on trusted devices', or migrate to Conditional Access sign-in frequency policies."
}

function Invoke-Check5_3_1 {
    $cid = "5.3.1"
    New-ManualResult $cid `
        "Manual verification required — confirm that privileged (admin) accounts are dedicated to administration and are not used for daily/non-privileged activities (email, web browsing, etc.)."
}

function Invoke-Check5_3_2 {
    # "Reviewed on a regular basis" is human judgement, so this stays MANUAL when
    # guests exist — but the guest inventory is pulled via Graph so the reviewer
    # gets the actual list. A tenant with no guest users passes outright.
    $cid = "5.3.2"

    # Server-side $filter on userType needs advanced-query headers (ConsistencyLevel:
    # eventual) that Invoke-AzRestMethod does not send — select and filter client-side.
    $url = "https://graph.microsoft.com/v1.0/users?`$select=userPrincipalName,userType&`$top=999"
    $r   = Invoke-AzRestPaged -Uri $url

    if (-not $r.Success) {
        return New-ErrorResult $cid (New-GraphPermissionMessage -Permission 'User.Read.All (or Directory.Read.All)' -ManualCheck 'Entra ID > Users > filter User type = Guest; review guest accounts and remove those no longer required.')
    }

    $users  = @($r.Data)
    $guests = @($users | Where-Object { [string]$_.userType -eq 'Guest' })

    if ($guests.Count -eq 0) {
        return New-CISResult -ControlId $cid -Status $script:PASS `
            -Details "No guest users exist in the tenant ($($users.Count) member user(s))."
    }

    $sample = ($guests | Select-Object -First 5 | ForEach-Object { [string]$_.userPrincipalName }) -join ', '
    New-ManualResult $cid `
        "$($guests.Count) guest user(s) found (of $($users.Count) total). Sample: $sample. Review each guest and remove those no longer required (Entra ID > Users > User type = Guest, or use Access Reviews)."
}

function Invoke-Check5_3_4 {
    $cid = "5.3.4"
    New-ManualResult $cid `
        "Manual verification required — review privileged role assignments periodically (Entra ID > Roles and administrators, or PIM Access Reviews) and remove unnecessary assignments."
}

function Invoke-Check5_3_5 {
    # Disabled accounts are tenant-wide (Graph) but role assignments live in the
    # per-subscription 'roles' prefetch, so this cross-references the two per subscription.
    param([string]$SubscriptionId, [string]$SubscriptionName, [hashtable]$PrefetchData)
    $cid = "5.3.5"
    $sid = $SubscriptionId; $sname = $SubscriptionName

    $rolesErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "roles"
    if ($rolesErr) {
        return New-ErrorResult $cid "Role assignment prefetch failed: $rolesErr" $sid $sname
    }

    $duErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "disabledUsers"
    if ($duErr -or -not ($PrefetchData -and $PrefetchData.ContainsKey('disabledUsers'))) {
        $reason = if ($duErr) { $duErr } else { "disabled-user data not available" }
        $msg = New-GraphPermissionMessage -Permission 'User.Read.All (or Directory.Read.All)'
        return New-ErrorResult $cid "Could not list disabled users: $reason. $msg" $sid $sname
    }

    $disabledUsers = @($PrefetchData['disabledUsers']['users'])
    if ($disabledUsers.Count -eq 0) {
        return New-CISResult -ControlId $cid -Status $script:PASS `
            -Details "No disabled user accounts exist in the tenant." `
            -SubscriptionId $sid -SubscriptionName $sname
    }

    $disabledById = @{}
    foreach ($u in $disabledUsers) {
        $uid = [string]$u.id
        if ($uid) { $disabledById[$uid] = [string]$u.userPrincipalName }
    }

    $roles = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "roles" -SubscriptionId $sid)
    $offending = @($roles | Where-Object { $disabledById.ContainsKey([string]$_.principalId) })

    if ($offending.Count -eq 0) {
        return New-CISResult -ControlId $cid -Status $script:PASS `
            -Details "None of the $($disabledUsers.Count) disabled user account(s) hold role assignments in this subscription." `
            -SubscriptionId $sid -SubscriptionName $sname
    }

    $sample = ($offending | Select-Object -First 5 | ForEach-Object {
        $upn = $disabledById[[string]$_.principalId]
        $who = if ($upn) { $upn } else { [string]$_.principalId }
        "$who at $([string]$_.scope)"
    }) -join "; "
    New-CISResult -ControlId $cid -Status $script:FAIL `
        -Details "$($offending.Count) role assignment(s) held by disabled user accounts: $sample" `
        -Remediation "Remove role assignments from disabled accounts (IAM > Role assignments), or delete the accounts if no longer needed." `
        -SubscriptionId $sid -SubscriptionName $sname
}

function Invoke-Check5_3_6 {
    $cid = "5.3.6"
    New-ManualResult $cid `
        "Manual verification required — review members of the 'Tenant Creator' role periodically and remove assignments that are no longer required."
}

function Invoke-Check5_3_7 {
    $cid = "5.3.7"
    New-ManualResult $cid `
        "Manual verification required — periodically review non-privileged role assignments (Access Reviews) and remove access that is no longer required."
}

function Invoke-Check5_5 {
    # Custom roles are defined per subscription scope, so this runs in the
    # per-subscription loop (it was previously a tenant-level MANUAL check).
    param([string]$SubscriptionId, [string]$SubscriptionName)
    $cid = "5.5"
    $sid = $SubscriptionId; $sname = $SubscriptionName

    try {
        # Same shape-agnostic Actions read as 5.4 (Az.Resources 10 breaking change).
        $lockRoles = @(
            Get-AzRoleDefinition -Custom -Scope "/subscriptions/$sid" -ErrorAction Stop -WarningAction SilentlyContinue |
            Where-Object {
                $def = $_
                $actions = if ($def.PSObject.Properties['Permissions'] -and $def.Permissions) {
                    @($def.Permissions | ForEach-Object { $_.Actions })
                } elseif ($def.PSObject.Properties['Actions']) {
                    @($def.Actions)
                } else { @() }
                @($actions | Where-Object { $_ -like 'Microsoft.Authorization/locks*' }).Count -gt 0
            } |
            ForEach-Object { [PSCustomObject]@{ name = $_.Name } }
        )
    } catch {
        return New-ErrorResult $cid $_.Exception.Message $sid $sname
    }

    if ($lockRoles.Count -gt 0) {
        $names = ($lockRoles | ForEach-Object { $_.name }) -join ", "
        return New-CISResult -ControlId $cid -Status $script:PASS `
            -Details "Custom role(s) granting resource-lock administration: $names" `
            -SubscriptionId $sid -SubscriptionName $sname
    }

    New-CISResult -ControlId $cid -Status $script:FAIL `
        -Details "No custom role grants Microsoft.Authorization/locks permissions in this subscription." `
        -Remediation "IAM > Roles > Add custom role with 'Microsoft.Authorization/locks/*' actions and assign it to the principals responsible for managing resource locks." `
        -SubscriptionId $sid -SubscriptionName $sname
}

function Invoke-Check5_6 {
    $cid = "5.6"

    $url = "https://management.azure.com/providers/Microsoft.Subscription/policies/default?api-version=2021-10-01"
    $r   = Invoke-ArmRest -Uri $url

    if (-not $r.Success) {
        return New-ErrorResult $cid "Tenant subscription policy could not be read (requires tenant-level read access): $($r.Error). Manual check: Azure portal > Subscriptions > Advanced options > Manage Policies."
    }

    $props = if ($r.Data -and $r.Data.PSObject.Properties['properties']) { $r.Data.properties } else { $null }
    $blockLeaving  = $props -and $props.PSObject.Properties['blockSubscriptionsLeavingTenant'] -and ([string]$props.blockSubscriptionsLeavingTenant -eq 'True')
    $blockEntering = $props -and $props.PSObject.Properties['blockSubscriptionsIntoTenant']    -and ([string]$props.blockSubscriptionsIntoTenant -eq 'True')

    if ($blockLeaving -and $blockEntering) {
        return New-CISResult -ControlId $cid -Status $script:PASS `
            -Details "Subscription tenant-transfer policy blocks both leaving and entering (Permit no one)."
    }

    $gaps = @()
    if (-not $blockLeaving)  { $gaps += "'Subscription leaving Microsoft Entra tenant' is not blocked" }
    if (-not $blockEntering) { $gaps += "'Subscription entering Microsoft Entra tenant' is not blocked" }
    New-CISResult -ControlId $cid -Status $script:FAIL `
        -Details ($gaps -join '; ') `
        -Remediation "Azure portal > Subscriptions > Advanced options > Manage Policies > set both 'Subscription leaving...' and 'Subscription entering...' to 'Permit no one'."
}

# ── Per-subscription checks ──────────────────────────────────────────────────

function Invoke-Check5_3_3 {
    param([string]$SubscriptionId, [string]$SubscriptionName, [hashtable]$PrefetchData)
    $cid = "5.3.3"
    $sid = $SubscriptionId; $sname = $SubscriptionName

    $rolesErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "roles"
    if ($rolesErr) {
        return New-ErrorResult $cid "Role assignment prefetch failed: $rolesErr" $sid $sname
    }

    $roles = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "roles" -SubscriptionId $sid)
    $uaaAssignments = @($roles | Where-Object {
        [string]$_.roleDefinitionId -match $script:ROLE_UAA -and
        [string]$_.scope -match "^/subscriptions/[^/]+$"
    })

    if ($uaaAssignments.Count -eq 0) {
        return New-CISResult -ControlId $cid -Status $script:PASS `
            -Details "No User Access Administrator assignments at subscription scope." `
            -SubscriptionId $sid -SubscriptionName $sname
    }

    $names = ($uaaAssignments | ForEach-Object {
        $pn = [string]$_.principalName
        $pi = [string]$_.principalId
        if ($pn) { "$pn ($pi)" } else { $pi }
    }) -join ", "
    New-CISResult -ControlId $cid -Status $script:FAIL `
        -Details "User Access Administrator assigned at subscription scope to: $names" `
        -Remediation "IAM > Role assignments > Remove UAA role from subscription scope; use resource-group scope or PIM time-bound assignments instead." `
        -SubscriptionId $sid -SubscriptionName $sname
}

function Invoke-Check5_4 {
    param([string]$SubscriptionId, [string]$SubscriptionName)
    $cid = "5.4"
    $sid = $SubscriptionId; $sname = $SubscriptionName

    try {
        # Az.Resources 10.0.0 removes the flattened .Actions property in favour of
        # .Permissions[n].Actions — read Permissions first and fall back to the
        # flattened shape so the check works on both sides of that breaking change.
        $customOwners = @(
            Get-AzRoleDefinition -Custom -Scope "/subscriptions/$sid" -ErrorAction Stop -WarningAction SilentlyContinue |
            Where-Object {
                $def = $_
                $actions = if ($def.PSObject.Properties['Permissions'] -and $def.Permissions) {
                    @($def.Permissions | ForEach-Object { $_.Actions })
                } elseif ($def.PSObject.Properties['Actions']) {
                    @($def.Actions)
                } else { @() }
                $actions -contains '*'
            } |
            ForEach-Object { [PSCustomObject]@{ name = $_.Name } }
        )
    } catch {
        return New-ErrorResult $cid $_.Exception.Message $sid $sname
    }

    if ($customOwners.Count -eq 0) {
        return New-CISResult -ControlId $cid -Status $script:PASS `
            -Details "No custom roles with wildcard (*) permissions found." `
            -SubscriptionId $sid -SubscriptionName $sname
    }

    $names = ($customOwners | ForEach-Object { $_.name }) -join ", "
    New-CISResult -ControlId $cid -Status $script:FAIL `
        -Details "Custom role(s) with Administrator-level (wildcard *) permissions: $names" `
        -Remediation "IAM > Roles > Review and remove custom roles with wildcard (*) actions assignable at subscription scope." `
        -SubscriptionId $sid -SubscriptionName $sname
}

function Invoke-Check5_7 {
    param([string]$SubscriptionId, [string]$SubscriptionName, [hashtable]$PrefetchData)
    $cid = "5.7"
    $sid = $SubscriptionId; $sname = $SubscriptionName

    $rolesErr = Get-PrefetchError -PrefetchData $PrefetchData -Key "roles"
    if ($rolesErr) {
        return New-ErrorResult $cid "Role assignment prefetch failed: $rolesErr" $sid $sname
    }

    $roles = @(Get-PrefetchData -PrefetchData $PrefetchData -Key "roles" -SubscriptionId $sid)
    # Only count Owner assignments directly at the subscription scope — not inherited from management groups.
    $ownerAssignments = @($roles | Where-Object {
        [string]$_.roleDefinitionId -match $script:ROLE_OWNER -and
        [string]$_.scope -match "^/subscriptions/[^/]+$"
    })
    $count = $ownerAssignments.Count

    $pass = $count -ge 2 -and $count -le 3
    New-CISResult -ControlId $cid `
        -Status $(if ($pass) { $script:PASS } else { $script:FAIL }) `
        -Details "Found $count subscription Owner(s) at subscription scope. CIS recommends between 2 and 3." `
        -Remediation $(if (-not $pass) { "IAM > Role assignments > Adjust Owner assignments to be between 2 and 3 at subscription scope." } else { "" }) `
        -SubscriptionId $sid -SubscriptionName $sname
}

function Invoke-Section5TenantChecks {
    <#
    .SYNOPSIS
    Run all tenant-level Section 5 checks (called once before subscription loop).
    #>

    $results = [System.Collections.Generic.List[object]]::new()
    $checks  = @(
        @{ Id = '5.1.1'; Fn = { Invoke-Check5_1_1 } }
        @{ Id = '5.1.2'; Fn = { Invoke-Check5_1_2 } }
        @{ Id = '5.1.3'; Fn = { Invoke-Check5_1_3 } }
        @{ Id = '5.1.4'; Fn = { Invoke-Check5_1_4 } }
        @{ Id = '5.3.1'; Fn = { Invoke-Check5_3_1 } }
        @{ Id = '5.3.2'; Fn = { Invoke-Check5_3_2 } }
        @{ Id = '5.3.4'; Fn = { Invoke-Check5_3_4 } }
        @{ Id = '5.3.6'; Fn = { Invoke-Check5_3_6 } }
        @{ Id = '5.3.7'; Fn = { Invoke-Check5_3_7 } }
        @{ Id = '5.6';   Fn = { Invoke-Check5_6   } }
    )
    foreach ($check in $checks) {
        try {
            $r = & $check.Fn
            $results.Add($r)
            $icon = switch ($r.Status) {
                'PASS'   { "`u{2705}" }  # ✅
                'FAIL'   { "`u{274C}" }  # ❌
                'ERROR'  { "`u{26A0}`u{FE0F}" }  # ⚠️
                'INFO'   { "`u{2139}`u{FE0F}" }  # ℹ️
                'MANUAL' { "`u{1F4CB}" }  # 📋
                default  { "?" }
            }
            Write-AuditLog " $($check.Id.PadRight(10)) $icon $($r.Status)" -Level INFO
        } catch {
            Write-AuditLog " $($check.Id.PadRight(10)) `u{26A0}`u{FE0F} ERROR" -Level WARNING
        }
    }
    return $results.ToArray()
}

function Invoke-Section5SubscriptionChecks {
    [CmdletBinding()]
    param(
        [string]$SubscriptionId,
        [string]$SubscriptionName,
        [hashtable]$PrefetchData
    )

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

    try { $results.Add((Invoke-Check5_3_3 -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName -PrefetchData $PrefetchData)) } catch { Write-AuditLog "5.3.3 error: $_" -Level WARNING }
    try { $results.Add((Invoke-Check5_3_5 -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName -PrefetchData $PrefetchData)) } catch { Write-AuditLog "5.3.5 error: $_" -Level WARNING }
    try { $results.Add((Invoke-Check5_4   -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName)) } catch { Write-AuditLog "5.4 error: $_" -Level WARNING }
    try { $results.Add((Invoke-Check5_5   -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName)) } catch { Write-AuditLog "5.5 error: $_" -Level WARNING }
    try { $results.Add((Invoke-Check5_7   -SubscriptionId $SubscriptionId -SubscriptionName $SubscriptionName -PrefetchData $PrefetchData)) } catch { Write-AuditLog "5.7 error: $_" -Level WARNING }

    return $results.ToArray()
}