|
| 1 | +# Export & Compare Conditional-Access Policies (drift-detect) |
| 2 | + |
| 3 | +## Summary |
| 4 | + |
| 5 | +This script automates the export and comparison of Conditional Access Policies within an Azure AD tenant to detect configuration drift. It helps monitor changes to these critical security controls over time, ensuring they remain compliant with your organization's zero-trust baselines. |
| 6 | + |
| 7 | +The script performs the following operations: |
| 8 | +1. Authenticates to Microsoft Graph API |
| 9 | +2. Exports all Conditional Access Policies to JSON format |
| 10 | +3. Saves each policy with timestamps to establish a version history |
| 11 | +4. Compares current policies to the previous export |
| 12 | +5. Generates alerts for any detected changes |
| 13 | +6. Optionally sends notifications via email or Microsoft Teams |
| 14 | + |
| 15 | + |
| 16 | + |
| 17 | +## Prerequisites |
| 18 | + |
| 19 | +- Microsoft Graph PowerShell SDK modules installed |
| 20 | +- Permissions to read Conditional Access Policies (Application or delegated) |
| 21 | +- Required permissions: |
| 22 | + - `Policy.Read.All` for reading Conditional Access Policies |
| 23 | + - `Mail.Send` (optional for email notifications) |
| 24 | + - Teams webhook (optional for Teams notifications) |
| 25 | + |
| 26 | +## Implementation |
| 27 | + |
| 28 | +This solution provides a scheduled monitoring approach for Conditional Access Policies in your tenant. By tracking policy changes, your security team can quickly identify unexpected alterations, ensuring there's no drift from your security baselines. |
| 29 | + |
| 30 | +# [Microsoft Graph PowerShell](#tab/graphps) |
| 31 | + |
| 32 | +```powershell |
| 33 | +# Conditional Access Policy Export and Drift Detection Script |
| 34 | +# Script exports all Conditional Access Policies from Azure AD, saves them to JSON files, |
| 35 | +# and compares them with previous exports to detect any changes (drift) |
| 36 | +
|
| 37 | +#------------------------------------------------------------- |
| 38 | +# Module Management |
| 39 | +#------------------------------------------------------------- |
| 40 | +# Required modules |
| 41 | +$requiredModules = @( |
| 42 | + "Microsoft.Graph.Authentication", |
| 43 | + "Microsoft.Graph.Identity.SignIns" |
| 44 | +) |
| 45 | +
|
| 46 | +# Check and install required modules |
| 47 | +foreach ($module in $requiredModules) { |
| 48 | + # Check if module is installed |
| 49 | + if (-not (Get-Module -ListAvailable -Name $module)) { |
| 50 | + Write-Host "Module $module is not installed. Installing..." |
| 51 | + try { |
| 52 | + Install-Module -Name $module -Force -AllowClobber -Scope CurrentUser |
| 53 | + Write-Host "Module $module installed successfully" -ForegroundColor Green |
| 54 | + } |
| 55 | + catch { |
| 56 | + Write-Host "Failed to install module $module. Error: $_" -ForegroundColor Red |
| 57 | + exit 1 |
| 58 | + } |
| 59 | + } |
| 60 | + |
| 61 | + # Import the module |
| 62 | + try { |
| 63 | + Import-Module -Name $module -ErrorAction Stop |
| 64 | + Write-Host "Module $module imported successfully" -ForegroundColor Green |
| 65 | + } |
| 66 | + catch { |
| 67 | + Write-Host "Failed to import module $module. Error: $_" -ForegroundColor Red |
| 68 | + exit 1 |
| 69 | + } |
| 70 | +} |
| 71 | +
|
| 72 | +#------------------------------------------------------------- |
| 73 | +# Configuration |
| 74 | +#------------------------------------------------------------- |
| 75 | +$ConfigPath = "$PSScriptRoot\Config" |
| 76 | +$HistoryPath = "$PSScriptRoot\History" |
| 77 | +$CurrentExportPath = "$PSScriptRoot\Current" |
| 78 | +$LogPath = "$PSScriptRoot\Logs" |
| 79 | +$ComparisonReportPath = "$PSScriptRoot\Reports" |
| 80 | +$SendEmail = $false |
| 81 | +$SendTeamsNotification = $true |
| 82 | +
|
| 83 | +# Email settings (if $SendEmail is $true) |
| 84 | +$EmailFrom = "[your email address]" |
| 85 | +$EmailTo = "[recipient email address]" |
| 86 | +$SmtpServer = "smtp.office365.com" |
| 87 | +
|
| 88 | +# Teams webhook URL (if $SendTeamsNotification is $true) |
| 89 | +$TeamsWebhookUrl = "https://[your tenant].webhook.office.com/webhookb2/" |
| 90 | +
|
| 91 | +# Create required directories if they don't exist |
| 92 | +$Directories = @($ConfigPath, $HistoryPath, $CurrentExportPath, $LogPath, $ComparisonReportPath) |
| 93 | +foreach ($Dir in $Directories) { |
| 94 | + if (!(Test-Path -Path $Dir)) { |
| 95 | + New-Item -ItemType Directory -Path $Dir -Force | Out-Null |
| 96 | + } |
| 97 | +} |
| 98 | +
|
| 99 | +#------------------------------------------------------------- |
| 100 | +# Functions |
| 101 | +#------------------------------------------------------------- |
| 102 | +function Write-Log { |
| 103 | + param ( |
| 104 | + [string]$Message, |
| 105 | + [string]$Level = "INFO" |
| 106 | + ) |
| 107 | + |
| 108 | + $Timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 109 | + $LogEntry = "[$Timestamp] [$Level] $Message" |
| 110 | + |
| 111 | + # Write to console |
| 112 | + switch ($Level) { |
| 113 | + "ERROR" { Write-Host $LogEntry -ForegroundColor Red } |
| 114 | + "WARNING" { Write-Host $LogEntry -ForegroundColor Yellow } |
| 115 | + "SUCCESS" { Write-Host $LogEntry -ForegroundColor Green } |
| 116 | + default { Write-Host $LogEntry } |
| 117 | + } |
| 118 | + |
| 119 | + # Write to log file |
| 120 | + $LogFile = Join-Path -Path $LogPath -ChildPath "CA-Drift-$(Get-Date -Format 'yyyy-MM-dd').log" |
| 121 | + Add-Content -Path $LogFile -Value $LogEntry |
| 122 | +} |
| 123 | +
|
| 124 | +function Send-EmailAlert { |
| 125 | + param ( |
| 126 | + [string]$Subject, |
| 127 | + [string]$Body |
| 128 | + ) |
| 129 | + |
| 130 | + try { |
| 131 | + Send-MailMessage -From $EmailFrom -To $EmailTo -Subject $Subject -Body $Body -BodyAsHtml -SmtpServer $SmtpServer -UseSsl -Port 587 -Credential (Get-Credential -Message "Enter email credentials") |
| 132 | + Write-Log "Email notification sent successfully" -Level "SUCCESS" |
| 133 | + } |
| 134 | + catch { |
| 135 | + Write-Log "Failed to send email notification: $_" -Level "ERROR" |
| 136 | + } |
| 137 | +} |
| 138 | +
|
| 139 | +function Send-TeamsAlert { |
| 140 | + param ( |
| 141 | + [string]$Title, |
| 142 | + [string]$Message, |
| 143 | + [string]$Color = "#FF0000" # Red |
| 144 | + ) |
| 145 | + |
| 146 | + try { |
| 147 | + $JSON = @{ |
| 148 | + "@type" = "MessageCard" |
| 149 | + "@context" = "http://schema.org/extensions" |
| 150 | + "summary" = $Title |
| 151 | + "themeColor" = $Color |
| 152 | + "sections" = @( |
| 153 | + @{ |
| 154 | + "activityTitle" = $Title |
| 155 | + "activitySubtitle" = "Generated on $(Get-Date -Format 'yyyy-MM-dd HH:mm')" |
| 156 | + "text" = $Message |
| 157 | + } |
| 158 | + ) |
| 159 | + } | ConvertTo-Json -Depth 4 |
| 160 | + |
| 161 | + Invoke-RestMethod -Uri $TeamsWebhookUrl -Method Post -Body $JSON -ContentType "application/json" |
| 162 | + Write-Log "Teams notification sent successfully" -Level "SUCCESS" |
| 163 | + } |
| 164 | + catch { |
| 165 | + Write-Log "Failed to send Teams notification: $_" -Level "ERROR" |
| 166 | + } |
| 167 | +} |
| 168 | +
|
| 169 | +function Compare-Policies { |
| 170 | + param ( |
| 171 | + [string]$CurrentPolicyPath, |
| 172 | + [string]$PreviousPolicyPath, |
| 173 | + [string]$PolicyName |
| 174 | + ) |
| 175 | + |
| 176 | + try { |
| 177 | + $CurrentPolicy = Get-Content -Path $CurrentPolicyPath | ConvertFrom-Json |
| 178 | + $PreviousPolicy = Get-Content -Path $PreviousPolicyPath | ConvertFrom-Json |
| 179 | + |
| 180 | + # Compare policies using Compare-Object |
| 181 | + $Comparison = Compare-Object -ReferenceObject ($PreviousPolicy | ConvertTo-Json -Depth 10) -DifferenceObject ($CurrentPolicy | ConvertTo-Json -Depth 10) |
| 182 | + |
| 183 | + if ($Comparison) { |
| 184 | + # Policies are different |
| 185 | + Write-Log "DRIFT DETECTED: Changes found in policy '$PolicyName'" -Level "WARNING" |
| 186 | + |
| 187 | + # Generate detailed comparison report using a more detailed approach |
| 188 | + $Report = @{ |
| 189 | + PolicyName = $PolicyName |
| 190 | + ChangeDetected = $true |
| 191 | + ChangeTimestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" |
| 192 | + PreviousVersion = $PreviousPolicy |
| 193 | + CurrentVersion = $CurrentPolicy |
| 194 | + Changes = @() |
| 195 | + } |
| 196 | + |
| 197 | + # Use PowerShell's Compare-Object to do a property-by-property comparison |
| 198 | + # This is a simplified approach - in reality you would recurse through nested properties |
| 199 | + foreach ($Property in $CurrentPolicy.PSObject.Properties.Name) { |
| 200 | + if ($CurrentPolicy.$Property -ne $PreviousPolicy.$Property) { |
| 201 | + $Report.Changes += @{ |
| 202 | + Property = $Property |
| 203 | + PreviousValue = $PreviousPolicy.$Property |
| 204 | + CurrentValue = $CurrentPolicy.$Property |
| 205 | + } |
| 206 | + } |
| 207 | + } |
| 208 | + |
| 209 | + # Save the comparison report |
| 210 | + $ReportFilePath = Join-Path -Path $ComparisonReportPath -ChildPath "$PolicyName-Changes-$(Get-Date -Format 'yyyy-MM-dd-HHmmss').json" |
| 211 | + $Report | ConvertTo-Json -Depth 10 | Out-File -FilePath $ReportFilePath |
| 212 | + |
| 213 | + return $Report |
| 214 | + } |
| 215 | + else { |
| 216 | + # No changes |
| 217 | + Write-Log "No changes detected in policy '$PolicyName'" -Level "INFO" |
| 218 | + return $null |
| 219 | + } |
| 220 | + } |
| 221 | + catch { |
| 222 | + Write-Log "Error comparing policies for '$PolicyName': $_" -Level "ERROR" |
| 223 | + return $null |
| 224 | + } |
| 225 | +} |
| 226 | +
|
| 227 | +#------------------------------------------------------------- |
| 228 | +# Main Script |
| 229 | +#------------------------------------------------------------- |
| 230 | +Write-Log "Starting Conditional Access Policy export and drift detection" -Level "INFO" |
| 231 | +
|
| 232 | +try { |
| 233 | + # Connect to Microsoft Graph |
| 234 | + Write-Log "Connecting to Microsoft Graph..." |
| 235 | + Connect-MgGraph -Scopes "Policy.Read.All" -NoWelcome |
| 236 | + |
| 237 | + # Get current date/time for timestamping |
| 238 | + $Timestamp = Get-Date -Format "yyyy-MM-dd-HHmmss" |
| 239 | + |
| 240 | + # Create a directory for this export in the history |
| 241 | + $CurrentExportDir = Join-Path -Path $HistoryPath -ChildPath $Timestamp |
| 242 | + New-Item -ItemType Directory -Path $CurrentExportDir -Force | Out-Null |
| 243 | + |
| 244 | + # Get all Conditional Access Policies |
| 245 | + Write-Log "Retrieving Conditional Access Policies..." |
| 246 | + $Policies = Invoke-MgGraphRequest -Uri 'https://graph.microsoft.com/v1.0/identity/conditionalAccess/policies' -Method GET |
| 247 | + |
| 248 | + if ($Policies.value.Count -eq 0) { |
| 249 | + Write-Log "No Conditional Access Policies found in the tenant" -Level "WARNING" |
| 250 | + } |
| 251 | + else { |
| 252 | + Write-Log "Found $($Policies.value.Count) Conditional Access Policies" -Level "SUCCESS" |
| 253 | + |
| 254 | + $ChangesDetected = $false |
| 255 | + $ChangedPolicies = @() |
| 256 | + |
| 257 | + # Process each policy |
| 258 | + foreach ($Policy in $Policies.value) { |
| 259 | + # Clean policy name for file naming (remove invalid chars) |
| 260 | + $SafePolicyName = $Policy.displayName -replace '[\\/*?:"<>|]', '_' |
| 261 | + |
| 262 | + # Export current policy to the Current folder |
| 263 | + $CurrentPolicyPath = Join-Path -Path $CurrentExportPath -ChildPath "$SafePolicyName.json" |
| 264 | + $Policy | ConvertTo-Json -Depth 10 | Out-File -FilePath $CurrentPolicyPath |
| 265 | + |
| 266 | + # Save to the history folder |
| 267 | + $HistoryPolicyPath = Join-Path -Path $CurrentExportDir -ChildPath "$SafePolicyName.json" |
| 268 | + $Policy | ConvertTo-Json -Depth 10 | Out-File -FilePath $HistoryPolicyPath |
| 269 | + |
| 270 | + Write-Log "Exported policy: $($Policy.displayName)" -Level "INFO" |
| 271 | + |
| 272 | + # Find the most recent previous version of this policy (if any) |
| 273 | + $PreviousVersions = Get-ChildItem -Path $HistoryPath -Recurse -Filter "$SafePolicyName.json" | |
| 274 | + Where-Object { $_.FullName -ne $HistoryPolicyPath } | |
| 275 | + Sort-Object LastWriteTime -Descending |
| 276 | + |
| 277 | + if ($PreviousVersions.Count -gt 0) { |
| 278 | + $PreviousPolicyPath = $PreviousVersions[0].FullName |
| 279 | + |
| 280 | + # Compare with previous version |
| 281 | + $ComparisonResult = Compare-Policies -CurrentPolicyPath $CurrentPolicyPath -PreviousPolicyPath $PreviousPolicyPath -PolicyName $Policy.displayName |
| 282 | + |
| 283 | + if ($ComparisonResult) { |
| 284 | + $ChangesDetected = $true |
| 285 | + $ChangedPolicies += $ComparisonResult |
| 286 | + } |
| 287 | + } |
| 288 | + else { |
| 289 | + Write-Log "No previous version found for policy '$($Policy.displayName)' - this appears to be new" -Level "INFO" |
| 290 | + } |
| 291 | + } |
| 292 | + |
| 293 | + # Handle notifications if changes were detected |
| 294 | + if ($ChangesDetected) { |
| 295 | + Write-Log "Changes detected in Conditional Access Policies!" -Level "WARNING" |
| 296 | + |
| 297 | + # Prepare notification content |
| 298 | + $NotificationTitle = "⚠️ Conditional Access Policy Changes Detected" |
| 299 | + $NotificationBody = @" |
| 300 | +<h2>Conditional Access Policy Drift Detection</h2> |
| 301 | +<p>Changes were detected in the following policies:</p> |
| 302 | +<ul> |
| 303 | +$($ChangedPolicies | ForEach-Object { "<li><strong>$($_.PolicyName)</strong> - Changed on $($_.ChangeTimestamp)</li>" }) |
| 304 | +</ul> |
| 305 | +<p>Please review the detailed comparison reports in the following location:</p> |
| 306 | +<p><code>$ComparisonReportPath</code></p> |
| 307 | +"@ |
| 308 | + |
| 309 | + # Send notifications if configured |
| 310 | + if ($SendEmail) { |
| 311 | + Send-EmailAlert -Subject $NotificationTitle -Body $NotificationBody |
| 312 | + } |
| 313 | + |
| 314 | + if ($SendTeamsNotification) { |
| 315 | + Send-TeamsAlert -Title $NotificationTitle -Message $NotificationBody |
| 316 | + } |
| 317 | + } |
| 318 | + else { |
| 319 | + Write-Log "No changes detected in any Conditional Access Policies" -Level "SUCCESS" |
| 320 | + } |
| 321 | + } |
| 322 | + |
| 323 | + # Disconnect from Microsoft Graph |
| 324 | + Disconnect-MgGraph | Out-Null |
| 325 | + Write-Log "Script completed successfully" -Level "SUCCESS" |
| 326 | +} |
| 327 | +catch { |
| 328 | + Write-Log "Error executing script: $_" -Level "ERROR" |
| 329 | + |
| 330 | + # Try to disconnect if connected |
| 331 | + try { |
| 332 | + Disconnect-MgGraph | Out-Null |
| 333 | + } |
| 334 | + catch { |
| 335 | + # Ignore disconnect errors |
| 336 | + } |
| 337 | +} |
| 338 | +``` |
| 339 | +[!INCLUDE [More about Microsoft Graph PowerShell SDK](../../docfx/includes/MORE-GRAPHSDK.md)] |
| 340 | +*** |
| 341 | + |
| 342 | + |
| 343 | +## Contributors |
| 344 | + |
| 345 | +| Author(s) | |
| 346 | +|-----------| |
| 347 | +| [Valeras Narbutas](https://github.com/ValerasNarbutas) | |
| 348 | + |
| 349 | +## Version history |
| 350 | + |
| 351 | +| Version | Date | Comments | |
| 352 | +|---------|------|----------| |
| 353 | +| 1.0 | May 25, 2025 | Initial release | |
| 354 | + |
| 355 | +## Key learning points |
| 356 | + |
| 357 | +1. Using Microsoft Graph PowerShell to access and export Conditional Access Policies |
| 358 | +2. Implementing versioning for configuration tracking |
| 359 | +3. Detecting changes (drift) between policy versions |
| 360 | +4. Creating a notification system for security policy changes |
| 361 | + |
| 362 | +[!INCLUDE [DISCLAIMER](../../docfx/includes/DISCLAIMER.md)] |
| 363 | +<img src="https://m365-visitor-stats.azurewebsites.net/script-samples/scripts/aad-export-compare-conditional-access-policies" aria-hidden="true" /> |
0 commit comments