Public/Compare-AzPolicyCompliance.ps1
|
function Compare-AzPolicyCompliance { <# .SYNOPSIS Compare Azure Policy assignments against baseline for compliance scoring. .DESCRIPTION Analyzes policy assignments to identify: - Missing policies (in baseline but not assigned) - Extra policies (assigned but not in baseline) - Version mismatches (different versions between baseline and assignments) Calculates compliance scores: - ALZ Score: Percentage of ALZ baseline policies deployed - MCSB Score: Percentage of MCSB baseline policies deployed - Global Score: Weighted average of ALZ and MCSB scores ⚡ OPTIMIZED VERSION - Uses HashSet for O(1) lookups in InCommon calculation. .PARAMETER Baseline Baseline object from Get-AzPolicyBaseline. Must contain .Policies array and .Index object. .PARAMETER Assignments Array of assignments from Get-AzPolicyAssignmentScan. Each assignment must have .ExpandedPolicies property. .PARAMETER MatchByNameOnly Match policies by normalized name only, ignoring IDs. Useful when baseline uses built-in IDs but assignments use custom definitions. Default: $false (match by ID first, fallback to name) .PARAMETER InitiativesFolder Folder containing per-initiative policy CSV files. Required for compliance score calculation. If not specified, scores will not include initiative-level analysis. .EXAMPLE $baseline = Get-AzPolicyBaseline -IncludeAlz -IncludeMcsb $assignments = Get-AzPolicyAssignmentScan -ManagementGroupId "MyMG" $result = Compare-AzPolicyCompliance -Baseline $baseline -Assignments $assignments Write-Host "Global Compliance Score: $($result.Scores.GlobalScore)%" .OUTPUTS PSCustomObject with properties: - Summary: Array of per-assignment summary objects - Details: PSCustomObject with Missing, Extra, VersionMismatches, InCommon arrays - Scores: PSCustomObject with AlzScore, McsbScore, GlobalScore - Metrics: PSCustomObject with detailed metrics #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter(Mandatory = $true)] [ValidateNotNull()] [PSCustomObject]$Baseline, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object[]]$Assignments, [bool]$MatchByNameOnly = $false, [string]$InitiativesFolder, [Parameter()] [switch]$ExcludeExtraPolicies ) begin { Write-Debug "Compare-AzPolicyCompliance: Starting" Write-Debug " Baseline policies: $($Baseline.Policies.Count)" Write-Debug " Assignments: $($Assignments.Count)" Write-Debug " MatchByNameOnly: $MatchByNameOnly" # Validate baseline structure (support hashtable and PSCustomObject) $hasPolicies = $Baseline.PSObject.Properties['Policies'] -or $Baseline.ContainsKey('Policies') $hasIndex = $Baseline.PSObject.Properties['Index'] -or $Baseline.ContainsKey('Index') if (-not $hasPolicies -or -not $hasIndex) { throw "Invalid baseline object. Must contain .Policies and .Index properties." } # Allow empty Baseline.Policies but validate it exists if ($null -eq $Baseline.Policies) { throw "Invalid baseline object. Baseline.Policies cannot be null." } } process { # ✅ DÉDUPLICATION DES ASSIGNMENTS AVANT ANALYSE Write-Verbose "Checking for duplicate assignments before comparison..." $originalAssignmentCount = $Assignments.Count # Grouper par AssignmentId et garder la première occurrence $uniqueAssignments = $Assignments | Group-Object -Property AssignmentId | ForEach-Object { if ($_.Count -gt 1) { # Log détaillé du doublon $scopes = $_.Group | ForEach-Object { if ($_.Scope) { $_.Scope } elseif ($_.ResourceId) { $_.ResourceId } else { "Unknown scope" } } Write-Verbose "⚠️ Duplicate assignment detected:" Write-Verbose " AssignmentId: $($_.Name)" Write-Verbose " DisplayName: $($_.Group[0].AssignmentDisplayName)" Write-Verbose " Found at $($_.Count) scopes: $($scopes -join ' | ')" Write-Verbose " Keeping first occurrence: $($scopes[0])" } $_.Group[0] # Garder la première occurrence } $deduplicatedAssignmentCount = $uniqueAssignments.Count $removedAssignments = $originalAssignmentCount - $deduplicatedAssignmentCount if ($removedAssignments -gt 0) { Write-Host " ℹ️ Deduplicated assignments: $originalAssignmentCount → $deduplicatedAssignmentCount (removed $removedAssignments duplicates)" -ForegroundColor Cyan Write-Verbose " Removed $removedAssignments duplicate assignments before comparison" } else { Write-Verbose " No duplicate assignments found" } # ✅ Remplacer par la version dédupliquée $Assignments = @($uniqueAssignments) Write-Verbose "Proceeding with $($Assignments.Count) unique assignments for comparison" try { $summary = @() $allMissing = @() $allExtra = @() $allVersionMismatches = @() $allInCommon = @() Write-Verbose "Analyzing $($Assignments.Count) assignments against baseline..." # Step 1: Compare each assignment against baseline $progressCounter = 0 foreach ($assignment in $Assignments) { $progressCounter++ if ($progressCounter % 5 -eq 0) { Write-Progress -Activity "Comparing Policy Assignments" ` -Status "Processing assignment $progressCounter of $($Assignments.Count)" ` -PercentComplete (($progressCounter / $Assignments.Count) * 100) } try { # Get expanded policies for this assignment $assignedPolicies = $assignment.ExpandedPolicies if (-not $assignedPolicies -or $assignedPolicies.Count -eq 0) { Write-Warning "Assignment '$($assignment.AssignmentDisplayName)' has no expanded policies. Skipping." continue } # Create index for this assignment's policies $assignmentIndexById = New-PolicyIndex -Items $assignedPolicies -NameProperty 'PolicyDefinitionId' $assignmentIndexByNorm = New-PolicyIndex -Items $assignedPolicies -NameProperty 'PolicyDisplayName' # Perform comparison $comparisonResult = Compare-ByMatcher -Baseline $Baseline.Policies ` -AssignedPolicies $assignedPolicies ` -BaselineIndex $Baseline.Index ` -AssignmentIndex $(if ($MatchByNameOnly) { $assignmentIndexByNorm } else { $assignmentIndexById }) ` -MatchByNameOnly $MatchByNameOnly # Create summary for this assignment $assignmentSummary = New-ComparisonSummary -Assignment $assignment ` -PolicySet $null ` -AssignedPolicies $assignedPolicies ` -ComparisonResult $comparisonResult ` -MatchByNameOnly $MatchByNameOnly ` -BaselineIndex $Baseline.Index $summary += $assignmentSummary # Export initiative CSV if InitiativesFolder is provided if ($InitiativesFolder -and (Test-Path $InitiativesFolder)) { Export-InitiativeCsv -Assignment $assignment ` -Policies $assignedPolicies ` -OutputFolder $InitiativesFolder } # ========== ✅ OPTIMISATION: Créer des HashSet pour InCommon ========== Write-Debug "Building fast lookup indexes for InCommon calculation..." # Créer des HashSet pour Missing, Extra, VersionDiffs $missingSet = [System.Collections.Generic.HashSet[string]]::new() foreach ($item in $comparisonResult.Missing) { [void]$missingSet.Add($item.PolicyDefinitionId) } $extraSet = [System.Collections.Generic.HashSet[string]]::new() foreach ($item in $comparisonResult.Extra) { [void]$extraSet.Add($item.PolicyDefinitionId) } $versionDiffSet = [System.Collections.Generic.HashSet[string]]::new() foreach ($item in $comparisonResult.VersionDiffs) { [void]$versionDiffSet.Add($item.PolicyDefinitionId) } Write-Debug " ├─ Missing index: $($missingSet.Count) entries" Write-Debug " ├─ Extra index: $($extraSet.Count) entries" Write-Debug " └─ VersionDiff index: $($versionDiffSet.Count) entries" # ✅ OPTIMISÉ: Calculer InCommon avec lookup O(1) foreach ($assignedPolicy in $assignedPolicies) { $policyId = $assignedPolicy.PolicyDefinitionId # Lookup O(1) au lieu de Where-Object O(n) if (-not $missingSet.Contains($policyId) -and -not $extraSet.Contains($policyId) -and -not $versionDiffSet.Contains($policyId)) { # ✅ Utiliser l'index du Baseline (O(1)) $baselinePolicy = if ($MatchByNameOnly) { $normName = Normalize-PolicyName $assignedPolicy.PolicyDisplayName if ($normName -and $Baseline.Index.ByNormName.ContainsKey($normName)) { $Baseline.Index.ByNormName[$normName] | Select-Object -First 1 } } else { $Baseline.Index.ById[$policyId] } if ($baselinePolicy) { $allInCommon += [PSCustomObject]@{ AssignmentDisplayName = $assignment.AssignmentDisplayName AssignmentName = $assignment.AssignmentName PolicyDisplayName = $assignedPolicy.PolicyDisplayName PolicyDefinitionId = $assignedPolicy.PolicyDefinitionId Version = $assignedPolicy.Version BaselineSources = $baselinePolicy.BaselineSources } } } } if (-not $ExcludeExtraPolicies) { foreach ($extra in $comparisonResult.Extra) { $allExtra += [PSCustomObject]@{ AssignmentDisplayName = $assignment.AssignmentDisplayName AssignmentName = $assignment.AssignmentName DifferenceType = "ExtraInAssignment" PolicyDisplayName = $extra.PolicyDisplayName PolicyDefinitionId = $extra.PolicyDefinitionId BaselineVersion = $null AssignmentVersion = $extra.Version BaselineSources = $null } } } else { Write-Debug " Extra policies excluded (ExcludeExtraPolicies=$true)" } foreach ($versionDiff in $comparisonResult.VersionDiffs) { $allVersionMismatches += [PSCustomObject]@{ AssignmentDisplayName = $assignment.AssignmentDisplayName AssignmentName = $assignment.AssignmentName DifferenceType = "VersionMismatch" PolicyDisplayName = $versionDiff.PolicyDisplayName PolicyDefinitionId = $versionDiff.PolicyDefinitionId BaselineVersion = $versionDiff.BaselineVersion AssignmentVersion = $versionDiff.AssignmentVersion BaselineSources = $versionDiff.BaselineSources } } } catch { Write-Warning "Failed to compare assignment '$($assignment.AssignmentDisplayName)': $($_.Exception.Message)" continue } } # Calculate globally missing policies (in baseline but not deployed anywhere) Write-Verbose "Calculating globally missing policies..." $allDeployedPolicyIds = $allInCommon | Select-Object -ExpandProperty PolicyDefinitionId -Unique $globalMissing = $Baseline.Policies | Where-Object { $policyId = $_.PolicyDefinitionId $allDeployedPolicyIds -notcontains $policyId } $allMissing = foreach ($missing in $globalMissing) { [PSCustomObject]@{ AssignmentDisplayName = "N/A (Not deployed)" AssignmentName = "N/A" DifferenceType = "MissingFromAllAssignments" PolicyDisplayName = $missing.PolicyDisplayName PolicyDefinitionId = $missing.PolicyDefinitionId BaselineVersion = $missing.Version AssignmentVersion = $null BaselineSources = $missing.BaselineSources } } Write-Progress -Activity "Comparing Policy Assignments" -Completed Write-Verbose "Comparison complete: $($summary.Count) assignments analyzed" Write-Verbose " Total missing: $($allMissing.Count)" Write-Verbose " Total extra: $($allExtra.Count)" Write-Verbose " Total version mismatches: $($allVersionMismatches.Count)" # Step 2: Calculate compliance scores Write-Verbose "Calculating compliance scores..." $scores = $null $metrics = $null if ($InitiativesFolder -and (Test-Path $InitiativesFolder)) { try { $scoreResult = Calculate-ComplianceScores -Baseline $Baseline.Policies ` -Summary $summary ` -InitiativesFolder $InitiativesFolder ` -PolicyDefinitionCache $script:policyDefinitionCache $scores = [PSCustomObject]@{ AlzScore = $scoreResult.AlzScore McsbScore = $scoreResult.McsbScore GlobalScore = $scoreResult.GlobalScore } $metrics = [PSCustomObject]@{ AlzMetrics = $scoreResult.AlzMetrics McsbMetrics = $scoreResult.McsbMetrics GlobalMetrics = $scoreResult.GlobalMetrics } Write-Verbose " ALZ Score: $($scores.AlzScore)%" Write-Verbose " MCSB Score: $($scores.McsbScore)%" Write-Verbose " Global Score: $($scores.GlobalScore)%" } catch { Write-Warning "Failed to calculate compliance scores: $($_.Exception.Message)" # Return null scores } } else { Write-Verbose " InitiativesFolder not specified or does not exist. Skipping score calculation." } # Step 3: Build result object $result = [PSCustomObject]@{ Summary = $summary Details = [PSCustomObject]@{ InCommon = $allInCommon Missing = $allMissing Extra = $allExtra VersionMismatches = $allVersionMismatches } Scores = $scores Metrics = $metrics } Write-Debug "Compare-AzPolicyCompliance: Completed successfully" return $result } catch { Write-Error "Failed to compare policy compliance: $($_.Exception.Message)" throw } } } |