Skip to content

Commit 0e83ca9

Browse files
authored
Merge pull request #843 from ValerasNarbutas/newsample/ExportCompareConditionalAccessPolicies
Conditional Access Policies drift dettection script #842
2 parents 439798b + 3b5a5c8 commit 0e83ca9

File tree

4 files changed

+427
-0
lines changed

4 files changed

+427
-0
lines changed
Lines changed: 363 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,363 @@
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+
![Conditional Access Policy Drift Detection](assets/example.png)
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" />
Loading
Loading

0 commit comments

Comments
 (0)