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() } |