Loading_
Loading_
Exports every conditional access policy with its assignments, grant controls and exclusions, then flags the gaps auditors always find.
#Requires -Modules Microsoft.Graph.Identity.SignIns, Microsoft.Graph.Groups<#.SYNOPSIS Audits Entra ID conditional access policies and flags common gaps. .PARAMETER OutputPath Directory for the CSV exports. Created if missing. .PARAMETER StaleReportOnlyDays Age at which a report-only policy is considered abandoned. .EXAMPLE .\Invoke-CaAudit.ps1 -OutputPath C:\Audit -StaleReportOnlyDays 30#>[CmdletBinding()]param( [string] $OutputPath = ".\ca-audit", [int] $StaleReportOnlyDays = 30) Connect-MgGraph -Scopes "Policy.Read.All","Directory.Read.All","RoleManagement.Read.Directory" -NoWelcomeNew-Item -ItemType Directory -Path $OutputPath -Force | Out-Null $nameCache = @{}function Resolve-DirectoryName { param([string] $Id) if ([string]::IsNullOrWhiteSpace($Id)) { return "" } if ($Id -eq "All" -or $Id -eq "None" -or $Id -eq "GuestsOrExternalUsers") { return $Id } if ($nameCache.ContainsKey($Id)) { return $nameCache[$Id] } $name = $Id try { $name = (Get-MgDirectoryObject -DirectoryObjectId $Id).AdditionalProperties.displayName } catch { Write-Verbose "Could not resolve $Id" } $nameCache[$Id] = $name return $name} Write-Host "Reading conditional access policies..." -ForegroundColor Cyan$policies = Get-MgIdentityConditionalAccessPolicy -All $rows = foreach ($p in $policies) { [pscustomobject]@{ Name = $p.DisplayName State = $p.State Created = $p.CreatedDateTime Modified = $p.ModifiedDateTime IncludeUsers = ($p.Conditions.Users.IncludeUsers | ForEach-Object { Resolve-DirectoryName $_ }) -join "; " ExcludeUsers = ($p.Conditions.Users.ExcludeUsers | ForEach-Object { Resolve-DirectoryName $_ }) -join "; " IncludeGroups = ($p.Conditions.Users.IncludeGroups | ForEach-Object { Resolve-DirectoryName $_ }) -join "; " ExcludeGroups = ($p.Conditions.Users.ExcludeGroups | ForEach-Object { Resolve-DirectoryName $_ }) -join "; " IncludeApps = ($p.Conditions.Applications.IncludeApplications) -join "; " GrantControls = ($p.GrantControls.BuiltInControls) -join "; " GrantOperator = $p.GrantControls.Operator SessionControls = if ($p.SessionControls) { "Yes" } else { "No" } }} $rows | Sort-Object Name | Export-Csv -Path (Join-Path $OutputPath "policies.csv") -NoTypeInformation -Encoding UTF8 # ── Findings ─────────────────────────────────────────────────────────────$privilegedIds = @()try { $privilegedIds = Get-MgRoleManagementDirectoryRoleAssignment -All | Select-Object -ExpandProperty PrincipalId -Unique} catch { Write-Warning "Could not enumerate role assignments; privileged-exclusion check skipped." } $cutoff = (Get-Date).AddDays(-$StaleReportOnlyDays)$findings = New-Object System.Collections.Generic.List[object] foreach ($p in $policies) { if ($p.State -eq "enabledForReportingButNotEnforced" -and $p.CreatedDateTime -lt $cutoff) { $findings.Add([pscustomobject]@{ Policy = $p.DisplayName; Severity = "Medium" Finding = "Report-only for more than $StaleReportOnlyDays days - enforce it or delete it" }) } if (-not $p.Conditions.Users.IncludeUsers -and -not $p.Conditions.Users.IncludeGroups -and -not $p.Conditions.Users.IncludeRoles) { $findings.Add([pscustomobject]@{ Policy = $p.DisplayName; Severity = "High" Finding = "No users, groups or roles assigned - policy applies to nobody" }) } foreach ($groupId in $p.Conditions.Users.ExcludeGroups) { $count = 0 try { $count = (Get-MgGroupMember -GroupId $groupId -All).Count } catch {} if ($count -gt 5) { $findings.Add([pscustomobject]@{ Policy = $p.DisplayName; Severity = "High" Finding = "Exclusion group '$(Resolve-DirectoryName $groupId)' has $count members" }) } } foreach ($userId in $p.Conditions.Users.ExcludeUsers) { if ($privilegedIds -contains $userId) { $findings.Add([pscustomobject]@{ Policy = $p.DisplayName; Severity = "Critical" Finding = "Excludes privileged account '$(Resolve-DirectoryName $userId)'" }) } }} $findings | Sort-Object { switch ($_.Severity) { "Critical" {0} "High" {1} default {2} } } | Export-Csv -Path (Join-Path $OutputPath "findings.csv") -NoTypeInformation -Encoding UTF8 Write-Host ""Write-Host "$($policies.Count) policies audited, $($findings.Count) findings." -ForegroundColor Green$findings | Group-Object Severity | ForEach-Object { Write-Host " $($_.Name): $($_.Count)" }Disconnect-MgGraph | Out-NullConditional access drifts. Policies get created in report-only mode for a pilot and never enabled, exclusion groups accumulate members nobody remembers adding, and break-glass accounts end up excluded from the one policy that mattered.
This reads the full policy set through Microsoft Graph, resolves every group and role GUID to a display name, and writes a flat CSV you can actually diff between months.
The findings pass is opinionated: report-only policies older than 30 days, policies with no assigned users, exclusion groups larger than five members, and any policy that excludes an account holding a privileged role.
| Name | Type | Required | Description |
|---|---|---|---|
OutputPath | string | Optional | Directory for CSV exports. |
StaleReportOnlyDays | int | Optional | Age at which report-only counts as abandoned. |
The platform turns any script into a governed automation — versioned, gated, audited and reversible.