Private/Compare-InforcerDocModels.ps1
|
function Compare-InforcerDocModels { <# .SYNOPSIS Compares two DocModels and produces a ComparisonModel with setting-level diff rows. .DESCRIPTION Core diff engine for the Compare-InforcerEnvironments pipeline rewrite. Walks two DocModels (which have identical structure: Products -> Categories -> Policies -> Settings) and produces comparison rows by matching policies by name within the same product/category. Key behaviors: - Child settings show, parent group headers (IsConfigured=$false) do not - Both-empty settings are skipped (no noise rows) - "Onboarding blob from Connector" is always excluded (unique per tenant) - Duplicate setting names are disambiguated via parent > child paths - Source-only/Dest-only with empty value are skipped - App protection policies scoped to appGroupType level (no individual app IDs) .PARAMETER SourceModel DocModel hashtable for the source tenant (from ConvertTo-InforcerDocModel). .PARAMETER DestinationModel DocModel hashtable for the destination tenant (from ConvertTo-InforcerDocModel). .PARAMETER IncludingAssignments When true, populates SourceAssignment and DestAssignment on each comparison row. .OUTPUTS Hashtable representing the ComparisonModel consumed by ConvertTo-InforcerComparisonHtml. #> [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$SourceModel, [Parameter(Mandatory)] [hashtable]$DestinationModel, [Parameter()] [switch]$IncludingAssignments, [Parameter()] [switch]$IgnoreUnassignedPolicies, [Parameter()] [string[]]$ExcludeOS, [Parameter()] [string]$PolicyNameFilter, [Parameter()] [string]$SourceBaselineName, [Parameter()] [string]$DestinationBaselineName ) # ── Result containers ───────────────────────────────────────────────── $products = [ordered]@{} $counters = @{ Matched = 0; Conflicting = 0; SourceOnly = 0; DestOnly = 0 } $duplicatePolicies = [System.Collections.Generic.List[object]]::new() # ── Noise exclusion patterns ────────────────────────────────────────── $excludedSettingNames = @( 'Onboarding blob from Connector' 'Onboarding Blob' 'Tenant Id' 'Tenant Id (Device)' 'Deployed App Count' ) # App protection: settings that enumerate individual app IDs (noise) # Also filter @odata metadata that leaks through non-SC policies $appIdSettingPatterns = @( '^bundleId$' '^packageId$' 'apps\[\d+\]' '^apps$' '^approvedKeyboards$' '@odata\.' '^tenant\s*id' ) # Value-based exclusion patterns (ported from IntuneLens EXCLUDED_VALUE_PATTERNS) $excludedValuePatterns = @( '^Top Level Setting Group Collection$' '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' ) # ── Helper: ensure product/category exists ──────────────────────────── $ensureProductCategory = { param([string]$Product, [string]$Category) if (-not $products.Contains($Product)) { $products[$Product] = @{ Counters = @{ Matched = 0; Conflicting = 0; SourceOnly = 0; DestOnly = 0 } Categories = [ordered]@{} } } if (-not $products[$Product].Categories.Contains($Category)) { $products[$Product].Categories[$Category] = @{ ComparisonRows = [System.Collections.Generic.List[object]]::new() } } } # ── Helper: add comparison row and update counters ──────────────────── $addRow = { param([string]$Product, [string]$Category, [hashtable]$Row) & $ensureProductCategory $Product $Category [void]$products[$Product].Categories[$Category].ComparisonRows.Add($Row) $status = $Row.Status $counters[$status]++ $products[$Product].Counters[$status]++ } # ── Helper: check if a setting should be excluded by name or value ── $isExcludedSetting = { param([string]$Name, [string]$Value = '') foreach ($excluded in $excludedSettingNames) { if ($Name -eq $excluded) { return $true } } foreach ($pattern in $appIdSettingPatterns) { if ($Name -match $pattern) { return $true } } # Structural noise — array count rows (ported from IntuneLens STRUCTURAL_NOISE) if ($Value -match '^\d+ items$') { return $true } # Value-based exclusions: standalone GUIDs (values only, not names), # group collection header, etc. (ported from IntuneLens EXCLUDED_VALUE_PATTERNS) foreach ($pattern in $excludedValuePatterns) { if ($Value -match $pattern) { return $true } } return $false } # ── Known ADMX app codes for disambiguation ────────────────────────── $admxAppCodes = @{ 'excel16v2' = 'Excel'; 'excel16v8' = 'Excel'; 'word16v2' = 'Word' 'ppt16v2' = 'PowerPoint'; 'access16v2' = 'Access'; 'outlk16v2' = 'Outlook' 'visio16v2' = 'Visio'; 'proj16v2' = 'Project'; 'pub16v2' = 'Publisher' 'onent16v2' = 'OneNote'; 'office16v2' = 'Office'; 'office16v8' = 'Office' } # ── Helper: resolve context name from catalog + defId for disambiguation ─ $getCategoryName = { param([string]$DefId) if ([string]::IsNullOrEmpty($DefId)) { return '' } $catName = '' if ($null -ne $script:InforcerSettingsCatalog -and $script:InforcerSettingsCatalog.ContainsKey($DefId)) { $catName = $script:InforcerSettingsCatalog[$DefId].CategoryName } # Extract app name from ADMX defId (excel16v2, word16v2, etc.) $appName = '' $stripped = $DefId -replace '^(user|device)_vendor_msft_policy_config_', '' foreach ($code in $admxAppCodes.Keys) { if ($stripped -match "^${code}[~_]") { $appName = $admxAppCodes[$code]; break } } # Combine: "App > Category" or just "App" or just "Category" if ($appName -and $catName) { return "$appName > $catName" } if ($appName) { return $appName } if ($catName) { return $catName } # Fallback: extract profile/zone context from defId pattern if ($DefId -match '_(?:domainprofile|privateprofile|publicprofile)_') { $profile = $Matches[0].Trim('_') $display = switch ($profile) { 'domainprofile' { 'Domain Profile' } 'privateprofile' { 'Private Profile' } 'publicprofile' { 'Public Profile' } } return $display } return '' } # ── Helper: check if a setting is deprecated (name, value, or catalog) ── $isSettingDeprecated = { param([string]$Name, [string]$Value, [string]$DefId) $isDepr = $Name -match 'deprecated' -or $Value -match 'deprecated' if (-not $isDepr -and -not [string]::IsNullOrEmpty($DefId) -and $null -ne $script:InforcerSettingsCatalog -and $script:InforcerSettingsCatalog.ContainsKey($DefId)) { if ($script:InforcerSettingsCatalog[$DefId].DisplayName -match 'deprecated') { $isDepr = $true } } return $isDepr } # ── Helper: build setting paths from the Indent hierarchy ───────────── # Walks the Settings array and returns a list of hashtables: # @{ Name; SettingPath; Value; DefinitionId } # Only configured settings (IsConfigured=$true) are returned. # Parent headers (IsConfigured=$false) are tracked for path building only. $buildSettingPaths = { param([array]$Settings) $result = [System.Collections.Generic.List[object]]::new() if ($null -eq $Settings -or $Settings.Count -eq 0) { return $result } # Parent stack: list of parent names indexed by indent level $parentStack = [System.Collections.Generic.List[string]]::new() foreach ($setting in $Settings) { $indent = if ($null -ne $setting.Indent) { [int]$setting.Indent } else { 0 } $name = "$($setting.Name)" $value = "$($setting.Value)" $defId = "$($setting.DefinitionId)" $isConfigured = $setting.IsConfigured -eq $true # Resolve friendly display name from catalog when available if (-not [string]::IsNullOrEmpty($defId) -and $null -ne $script:InforcerSettingsCatalog -and $script:InforcerSettingsCatalog.ContainsKey($defId)) { $catalogEntry = $script:InforcerSettingsCatalog[$defId] if (-not [string]::IsNullOrWhiteSpace($catalogEntry.DisplayName)) { $name = $catalogEntry.DisplayName } } # Trim parent stack to current indent level while ($parentStack.Count -gt $indent) { $parentStack.RemoveAt($parentStack.Count - 1) } if (-not $isConfigured) { # Group header — add to path hierarchy (skip structural noise) if ($name -ne 'Top Level Setting Group Collection') { [void]$parentStack.Add($name) } } else { # Build the full path from parent stack + catalog category if ($parentStack.Count -gt 0) { $pathParts = @($parentStack) + @($name) $settingPath = $pathParts -join ' > ' } else { # No parent stack — try to add catalog category for context $catContext = & $getCategoryName $defId if (-not [string]::IsNullOrWhiteSpace($catContext)) { $settingPath = "$catContext > $name" } else { $settingPath = $name } } [void]$result.Add(@{ Name = $name SettingPath = $settingPath Value = $value DefinitionId = $defId IsDeprecated = (& $isSettingDeprecated $name $value $defId) }) } } return $result } # ── Helper: build a lookup from setting paths ───────────────────────── # Returns hashtable keyed by DefinitionId (or SettingPath fallback) -> @{ Name; Value; SettingPath; DefinitionId } # When duplicate paths occur, uses catalog CategoryName for disambiguation $buildSettingLookup = { param([array]$Settings) $paths = & $buildSettingPaths $Settings $lookup = [ordered]@{} foreach ($p in $paths) { $key = if (-not [string]::IsNullOrEmpty($p.DefinitionId)) { $p.DefinitionId.ToLowerInvariant() } else { $p.SettingPath.ToLowerInvariant() } # Skip excluded settings (name-based and value-based) if (& $isExcludedSetting $p.Name $p.Value) { continue } if (& $isExcludedSetting $key '') { continue } # Handle duplicate paths — use category name for disambiguation if ($lookup.Contains($key)) { $catName = & $getCategoryName $p.DefinitionId if (-not [string]::IsNullOrWhiteSpace($catName)) { $key = "$catName > $($p.Name)" $p.SettingPath = $key } # If still duplicate after category, add numeric suffix if ($lookup.Contains($key)) { $idx = 2 while ($lookup.Contains("${key} ($idx)")) { $idx++ } $key = "${key} ($idx)" $p.SettingPath = $key } } $lookup[$key] = $p } # Also retroactively disambiguate the FIRST entry when it was initially # stored without category context but a duplicate was found # (re-key the first entry with its category if the original key was bare) $keysToReplace = [System.Collections.Generic.List[string]]::new() foreach ($key in $lookup.Keys) { $entry = $lookup[$key] $catName = & $getCategoryName $entry.DefinitionId if (-not [string]::IsNullOrWhiteSpace($catName) -and $key -eq $entry.Name) { # Check if there are other entries with category-prefixed versions of this name $hasDuplicates = $false foreach ($otherKey in $lookup.Keys) { if ($otherKey -ne $key -and $otherKey -match [regex]::Escape($entry.Name) -and $otherKey -ne $entry.SettingPath) { $hasDuplicates = $true; break } } if ($hasDuplicates) { [void]$keysToReplace.Add($key) } } } foreach ($oldKey in $keysToReplace) { $entry = $lookup[$oldKey] $catName = & $getCategoryName $entry.DefinitionId $newKey = "$catName > $($entry.Name)" if (-not $lookup.Contains($newKey)) { $entry.SettingPath = $newKey $lookup.Remove($oldKey) $lookup[$newKey] = $entry } } return $lookup } # ── Helper: format assignment string from DocModel assignments ──────── $formatAssignment = { param([array]$Assignments) Format-InforcerAssignmentString -Assignments $Assignments } # ── Helper: check if value is empty/not-configured ──────────────────── $isEmptyValue = { param([string]$Val) return ([string]::IsNullOrWhiteSpace($Val) -or $Val -eq 'Not configured' -or $Val -eq 'Not Configured') } # ── Categories that should go to manual review instead of comparison ── $manualReviewCategories = [ordered]@{} # ── Build discovery script lookup (for embedding in compliance policy MR cards) ── # Maps script displayName → script settings (decoded) for lookup by deviceComplianceScriptId $discoveryScriptSettings = @{} foreach ($model in @($SourceModel, $DestinationModel)) { if ($null -eq $model -or $null -eq $model.Products) { continue } foreach ($prod in $model.Products.Values) { foreach ($catName in $prod.Categories.Keys) { if ($catName -notmatch 'custom.*device.*compliance.*discovery|device.*compliance.*discovery.*script') { continue } foreach ($policy in @($prod.Categories[$catName])) { if ($null -eq $policy -or $null -eq $policy.Basics) { continue } # Build script data with decoded content $scriptSettings = [System.Collections.Generic.List[object]]::new() foreach ($s in @($policy.Settings)) { if ($s.IsConfigured -ne $true) { continue } $sName = "$($s.Name)" $sValue = "$($s.Value)" if ($sName -match '@odata|^hashed|Hash$') { continue } # Decode base64 script content if ($sName -match '(?i)script.*content|detection.*script|remediation.*script' -and $sValue.Length -gt 20) { try { $sValue = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($sValue)) } catch {} } [void]$scriptSettings.Add(@{ Name = $sName; Value = $sValue }) } # Store by EVERY possible ID — iterate settings to find the script's own ID foreach ($s in @($policy.Settings)) { $sVal = "$($s.Value)" if ($sVal -match '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$') { $discoveryScriptSettings[$sVal] = @{ ScriptName = $policy.Basics.Name; Settings = $scriptSettings } } } } } } } # ── Collect all products from both models ───────────────────────────── $allProducts = [System.Collections.Generic.List[string]]::new() if ($SourceModel.Products) { foreach ($k in $SourceModel.Products.Keys) { if (-not $allProducts.Contains($k)) { [void]$allProducts.Add($k) } } } if ($DestinationModel.Products) { foreach ($k in $DestinationModel.Products.Keys) { if (-not $allProducts.Contains($k)) { [void]$allProducts.Add($k) } } } foreach ($productName in $allProducts) { # ExcludeOS filter (case-insensitive contains) if ($ExcludeOS) { $skipProduct = $false $prodLower = $productName.ToLowerInvariant() foreach ($ep in $ExcludeOS) { if ($prodLower -match [regex]::Escape($ep.ToLowerInvariant())) { $skipProduct = $true; break } } if ($skipProduct) { continue } } $srcProduct = if ($SourceModel.Products -and $SourceModel.Products.Contains($productName)) { $SourceModel.Products[$productName] } else { $null } $dstProduct = if ($DestinationModel.Products -and $DestinationModel.Products.Contains($productName)) { $DestinationModel.Products[$productName] } else { $null } # Collect all categories from both sides $allCategories = [System.Collections.Generic.List[string]]::new() if ($srcProduct -and $srcProduct.Categories) { foreach ($k in $srcProduct.Categories.Keys) { if (-not $allCategories.Contains($k)) { [void]$allCategories.Add($k) } } } if ($dstProduct -and $dstProduct.Categories) { foreach ($k in $dstProduct.Categories.Keys) { if (-not $allCategories.Contains($k)) { [void]$allCategories.Add($k) } } } foreach ($categoryName in $allCategories) { # Exclude Custom Indicators category entirely (noise — per-tenant unique data) if ($categoryName -match 'Custom Indicators') { continue } $srcPolicies = @() $dstPolicies = @() if ($srcProduct -and $srcProduct.Categories -and $srcProduct.Categories.Contains($categoryName)) { $srcPolicies = @($srcProduct.Categories[$categoryName]) } if ($dstProduct -and $dstProduct.Categories -and $dstProduct.Categories.Contains($categoryName)) { $dstPolicies = @($dstProduct.Categories[$categoryName]) } # Filter out unassigned policies when -IgnoreUnassignedPolicies is set if ($IgnoreUnassignedPolicies) { $srcPolicies = @($srcPolicies | Where-Object { $null -ne $_ -and $null -ne $_.Assignments -and @($_.Assignments).Count -gt 0 }) $dstPolicies = @($dstPolicies | Where-Object { $null -ne $_ -and $null -ne $_.Assignments -and @($_.Assignments).Count -gt 0 }) } # Filter by policy name (case-insensitive contains) if (-not [string]::IsNullOrWhiteSpace($PolicyNameFilter)) { $srcPolicies = @($srcPolicies | Where-Object { $null -ne $_ -and $_.Basics.Name -and $_.Basics.Name.IndexOf($PolicyNameFilter, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 }) $dstPolicies = @($dstPolicies | Where-Object { $null -ne $_ -and $_.Basics.Name -and $_.Basics.Name.IndexOf($PolicyNameFilter, [System.StringComparison]::OrdinalIgnoreCase) -ge 0 }) } $categoryLabel = "$productName / $categoryName" # Route non-comparable categories to manual review $catLower = $categoryName.ToLowerInvariant() if ($catLower -match 'script|remediation|compliance|enrollment|autopilot') { # Hide discovery scripts entirely — linked ones are embedded via linkedComplianceScript in parent policy if ($catLower -match 'custom.*device.*compliance.*discovery|device.*compliance.*discovery.*script') { continue } # Add to manual review instead of comparison # Helper: collect settings with base64 decoding for script content $collectMRSettings = { param($Policy, [string]$Side) if ($null -eq $Policy -or $null -eq $Policy.Basics) { return } # Skip discovery scripts that are claimed by a compliance policy if ($Policy._claimedByCompliancePolicy -eq $true) { return } if (-not $manualReviewCategories.Contains($categoryLabel)) { $manualReviewCategories[$categoryLabel] = [System.Collections.Generic.List[object]]::new() } $settingsSummary = [System.Collections.Generic.List[object]]::new() $seenSettingNames = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($s in @($Policy.Settings)) { if ($s.IsConfigured -eq $true -and -not [string]::IsNullOrWhiteSpace("$($s.Value)")) { $settingName = "$($s.Name)" $settingValue = "$($s.Value)" # Skip duplicate rulesContent (can come from both scheduledActions recursion and Graph injection) if ($settingName -match '(?i)^rules\s*content$' -and -not $seenSettingNames.Add('rulesContent')) { continue } # Skip internal flags if ($settingName -match '_claimed') { continue } # Skip noise: binary hashes, @odata metadata, GUIDs-only if ($settingName -match '^hashed|Hash$') { continue } if ($settingName -match '@odata') { continue } if ($settingName -match '(?i)^notification\s*template\s*id$' -and $settingValue -match '^0{8}-') { continue } # Decode base64 content (script content + compliance rules) if ($settingName -match '(?i)^(script\s*content|detection\s*script\s*content|remediation\s*script\s*content|rules\s*content|scriptContent|detectionScriptContent|remediationScriptContent|rulesContent)$') { try { $decoded = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($settingValue)) $settingValue = $decoded } catch { # Not valid base64 — keep original } } [void]$settingsSummary.Add(@{ Name = $settingName; Value = $settingValue }) # Embed linked discovery script when we find a Device Compliance Script ID if ($settingName -match '(?i)device\s*compliance\s*script\s*id' -and $settingValue -match '^[0-9a-f]{8}-') { if ($discoveryScriptSettings.ContainsKey($settingValue)) { $linkedScript = $discoveryScriptSettings[$settingValue] $scriptJson = @{ scriptName = $linkedScript.ScriptName } foreach ($ss in $linkedScript.Settings) { $scriptJson[$ss.Name] = $ss.Value } $scriptJsonStr = $scriptJson | ConvertTo-Json -Depth 100 -Compress [void]$settingsSummary.Add(@{ Name = 'Linked Compliance Script'; Value = $scriptJsonStr }) } } } } [void]$manualReviewCategories[$categoryLabel].Add(@{ PolicyName = $Policy.Basics.Name; Side = $Side; ProfileType = $Policy.Basics.ProfileType; Settings = $settingsSummary }) } foreach ($p in $srcPolicies) { & $collectMRSettings $p 'Source' } foreach ($p in $dstPolicies) { & $collectMRSettings $p 'Destination' } continue # Skip auto-comparison for this category } # ── Match policies by Basics.Name (case-insensitive) ────────── # Build lookup: lowered name -> list of policies (handle duplicates) $srcPolicyIndex = [ordered]@{} foreach ($p in $srcPolicies) { if ($null -eq $p -or $null -eq $p.Basics -or $null -eq $p.Basics.Name) { continue } $key = $p.Basics.Name.ToLowerInvariant() if (-not $srcPolicyIndex.Contains($key)) { $srcPolicyIndex[$key] = [System.Collections.Generic.List[object]]::new() } [void]$srcPolicyIndex[$key].Add($p) } $dstPolicyIndex = [ordered]@{} foreach ($p in $dstPolicies) { if ($null -eq $p -or $null -eq $p.Basics -or $null -eq $p.Basics.Name) { continue } $key = $p.Basics.Name.ToLowerInvariant() if (-not $dstPolicyIndex.Contains($key)) { $dstPolicyIndex[$key] = [System.Collections.Generic.List[object]]::new() } [void]$dstPolicyIndex[$key].Add($p) } # All unique policy name keys $allPolicyKeys = [System.Collections.Generic.List[string]]::new() foreach ($k in $srcPolicyIndex.Keys) { if (-not $allPolicyKeys.Contains($k)) { [void]$allPolicyKeys.Add($k) } } foreach ($k in $dstPolicyIndex.Keys) { if (-not $allPolicyKeys.Contains($k)) { [void]$allPolicyKeys.Add($k) } } foreach ($policyKey in $allPolicyKeys) { $hasSrc = $srcPolicyIndex.Contains($policyKey) $hasDst = $dstPolicyIndex.Contains($policyKey) if ($hasSrc -and $hasDst) { # ── Matched policy pairs — pair by ID first, then positionally ── $srcList = $srcPolicyIndex[$policyKey] $dstList = $dstPolicyIndex[$policyKey] $hasDuplicates = ($srcList.Count -gt 1) -or ($dstList.Count -gt 1) # Build paired list: match by Basics.Id when available, then positionally $pairs = [System.Collections.Generic.List[object]]::new() $usedSrc = [System.Collections.Generic.HashSet[int]]::new() $usedDst = [System.Collections.Generic.HashSet[int]]::new() # Pass 1: pair by matching policy ID for ($si = 0; $si -lt $srcList.Count; $si++) { $srcId = $srcList[$si].Basics.Id if ([string]::IsNullOrWhiteSpace($srcId)) { continue } for ($di = 0; $di -lt $dstList.Count; $di++) { if ($usedDst.Contains($di)) { continue } if ($srcId -eq $dstList[$di].Basics.Id) { [void]$pairs.Add(@{ Src = $srcList[$si]; Dst = $dstList[$di] }) [void]$usedSrc.Add($si) [void]$usedDst.Add($di) break } } } # Pass 2: pair remaining by setting similarity (most matching values) $remainingSrc = [System.Collections.Generic.List[object]]::new() $remainingDst = [System.Collections.Generic.List[object]]::new() for ($si = 0; $si -lt $srcList.Count; $si++) { if (-not $usedSrc.Contains($si)) { [void]$remainingSrc.Add($srcList[$si]) } } for ($di = 0; $di -lt $dstList.Count; $di++) { if (-not $usedDst.Contains($di)) { [void]$remainingDst.Add($dstList[$di]) } } $usedRemSrc = [System.Collections.Generic.HashSet[int]]::new() $usedRemDst = [System.Collections.Generic.HashSet[int]]::new() # Greedy: pick the pair with highest overlap first, repeat until exhausted $pairCandidates = [System.Collections.Generic.List[object]]::new() for ($si = 0; $si -lt $remainingSrc.Count; $si++) { $sLookup = & $buildSettingLookup $remainingSrc[$si].Settings for ($di = 0; $di -lt $remainingDst.Count; $di++) { $dLookup = & $buildSettingLookup $remainingDst[$di].Settings $matchCount = 0 foreach ($k in $sLookup.Keys) { if ($dLookup.Contains($k) -and $sLookup[$k].Value -eq $dLookup[$k].Value) { $matchCount++ } } [void]$pairCandidates.Add([PSCustomObject]@{ Si = $si; Di = $di; Score = $matchCount }) Write-Verbose "Similarity: src[$si] '$($remainingSrc[$si].Basics.Name)' ($($remainingSrc[$si].Basics.Id)) vs dst[$di] '$($remainingDst[$di].Basics.Name)' ($($remainingDst[$di].Basics.Id)) = $matchCount matches" } } # Sort descending by score and greedily assign $pairCandidates = @($pairCandidates | Sort-Object -Property Score -Descending) foreach ($candidate in $pairCandidates) { if ($usedRemSrc.Contains($candidate.Si) -or $usedRemDst.Contains($candidate.Di)) { continue } Write-Verbose "Paired: src[$($candidate.Si)] with dst[$($candidate.Di)] (score=$($candidate.Score))" [void]$pairs.Add(@{ Src = $remainingSrc[$candidate.Si]; Dst = $remainingDst[$candidate.Di] }) [void]$usedRemSrc.Add($candidate.Si) [void]$usedRemDst.Add($candidate.Di) } # Helper: disambiguate policy name when duplicates exist (append short ID) $disambiguateName = { param($Policy, [bool]$NeedsDisambiguation) $name = $Policy.Basics.Name if ($NeedsDisambiguation -and $Policy.Basics.Id) { $shortId = $Policy.Basics.Id if ($shortId.Length -gt 8) { $shortId = $shortId.Substring(0, 8) } $name = "$name ($shortId)" } $name } # Compare each matched pair foreach ($pair in $pairs) { $srcPolicy = $pair.Src $dstPolicy = $pair.Dst & $ensureProductCategory $productName $categoryName $srcPolicyName = & $disambiguateName $srcPolicy $hasDuplicates $dstPolicyName = & $disambiguateName $dstPolicy $hasDuplicates $srcAssignStr = if ($IncludingAssignments) { & $formatAssignment $srcPolicy.Assignments } else { '' } $dstAssignStr = if ($IncludingAssignments) { & $formatAssignment $dstPolicy.Assignments } else { '' } $srcLookup = & $buildSettingLookup $srcPolicy.Settings $dstLookup = & $buildSettingLookup $dstPolicy.Settings $allSettingKeys = [System.Collections.Generic.List[string]]::new() $seenKeys = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) foreach ($k in $srcLookup.Keys) { if ($seenKeys.Add($k)) { [void]$allSettingKeys.Add($k) } } foreach ($k in $dstLookup.Keys) { if ($seenKeys.Add($k)) { [void]$allSettingKeys.Add($k) } } foreach ($settingKey in $allSettingKeys) { $inSrc = $srcLookup.Contains($settingKey) $inDst = $dstLookup.Contains($settingKey) $srcVal = if ($inSrc) { $srcLookup[$settingKey].Value } else { '' } $dstVal = if ($inDst) { $dstLookup[$settingKey].Value } else { '' } $displayName = if ($inSrc) { $srcLookup[$settingKey].Name } else { $dstLookup[$settingKey].Name } if ((& $isEmptyValue $srcVal) -and (& $isEmptyValue $dstVal)) { continue } if ($inSrc -and $inDst) { $status = if ($srcVal -eq $dstVal) { 'Matched' } else { 'Conflicting' } } elseif ($inSrc) { if (& $isEmptyValue $srcVal) { continue } $status = 'SourceOnly' } else { if (& $isEmptyValue $dstVal) { continue } $status = 'DestOnly' } $refEntry = if ($inSrc) { $srcLookup[$settingKey] } else { $dstLookup[$settingKey] } $row = @{ ItemType = 'Setting' Name = $displayName SettingPath = $refEntry.SettingPath LookupKey = $settingKey HasDefinitionId = -not [string]::IsNullOrEmpty($refEntry.DefinitionId) Category = $categoryLabel Status = $status SourcePolicy = $srcPolicyName SourceValue = $srcVal DestPolicy = $dstPolicyName DestValue = $dstVal IsDeprecated = $refEntry.IsDeprecated -eq $true } if ($IncludingAssignments) { $row.SourceAssignment = $srcAssignStr $row.DestAssignment = $dstAssignStr } & $addRow $productName $categoryName $row } } # Extra same-named policies are NOT emitted to the main comparison. # Their overlapping settings are already detected by Phase 2/3 duplicate # scanning (which reads the raw doc model) and surfaced in the Duplicates tab. # Collect duplicate policy group info for the Duplicates tab summary. if ($hasDuplicates) { $groupPolicies = [System.Collections.Generic.List[object]]::new() foreach ($p in $srcList) { $pName = & $disambiguateName $p $true $settingCount = 0; foreach ($s in $p.Settings) { if ($s.IsConfigured -eq $true) { $settingCount++ } } [void]$groupPolicies.Add(@{ Name = $pName; Side = 'Source'; SettingCount = $settingCount; Id = $p.Basics.Id }) } foreach ($p in $dstList) { $pName = & $disambiguateName $p $true $settingCount = 0; foreach ($s in $p.Settings) { if ($s.IsConfigured -eq $true) { $settingCount++ } } [void]$groupPolicies.Add(@{ Name = $pName; Side = 'Destination'; SettingCount = $settingCount; Id = $p.Basics.Id }) } [void]$duplicatePolicies.Add(@{ PolicyName = $srcList[0].Basics.Name Category = $categoryLabel Policies = $groupPolicies }) } } elseif ($hasSrc) { # ── Source-only policies ─────────────────────────────── foreach ($srcPolicy in $srcPolicyIndex[$policyKey]) { $srcPolicyName = $srcPolicy.Basics.Name $srcAssignStr = if ($IncludingAssignments) { & $formatAssignment $srcPolicy.Assignments } else { '' } $srcLookup = & $buildSettingLookup $srcPolicy.Settings foreach ($settingKey in $srcLookup.Keys) { $srcVal = $srcLookup[$settingKey].Value # Skip empty source-only settings if (& $isEmptyValue $srcVal) { continue } $row = @{ ItemType = 'Setting' Name = $srcLookup[$settingKey].Name SettingPath = $srcLookup[$settingKey].SettingPath LookupKey = $settingKey HasDefinitionId = -not [string]::IsNullOrEmpty($srcLookup[$settingKey].DefinitionId) Category = $categoryLabel Status = 'SourceOnly' SourcePolicy = $srcPolicyName SourceValue = $srcVal DestPolicy = '' DestValue = '' IsDeprecated = $srcLookup[$settingKey].IsDeprecated -eq $true } if ($IncludingAssignments) { $row.SourceAssignment = $srcAssignStr $row.DestAssignment = '' } & $addRow $productName $categoryName $row } } } else { # ── Dest-only policies ──────────────────────────────── foreach ($dstPolicy in $dstPolicyIndex[$policyKey]) { $dstPolicyName = $dstPolicy.Basics.Name $dstAssignStr = if ($IncludingAssignments) { & $formatAssignment $dstPolicy.Assignments } else { '' } $dstLookup = & $buildSettingLookup $dstPolicy.Settings foreach ($settingKey in $dstLookup.Keys) { $dstVal = $dstLookup[$settingKey].Value # Skip empty dest-only settings if (& $isEmptyValue $dstVal) { continue } $row = @{ ItemType = 'Setting' Name = $dstLookup[$settingKey].Name SettingPath = $dstLookup[$settingKey].SettingPath LookupKey = $settingKey HasDefinitionId = -not [string]::IsNullOrEmpty($dstLookup[$settingKey].DefinitionId) Category = $categoryLabel Status = 'DestOnly' SourcePolicy = '' SourceValue = '' DestPolicy = $dstPolicyName DestValue = $dstVal IsDeprecated = $dstLookup[$settingKey].IsDeprecated -eq $true } if ($IncludingAssignments) { $row.SourceAssignment = '' $row.DestAssignment = $dstAssignStr } & $addRow $productName $categoryName $row } } } } } } # ── Cross-category reconciliation ──────────────────────────────────── # When the same Intune setting (same DefinitionId) is delivered via different # policy types (e.g., Endpoint Security template vs Settings Catalog), they # land in different categories and both appear as SourceOnly / DestOnly. # This pass matches them by DefinitionId and reclassifies as Matched/Conflicting. $sourceOnlyByDefId = [ordered]@{} # defId -> @{ Row; Product; Category } $destOnlyByDefId = [ordered]@{} foreach ($prodName in @($products.Keys)) { foreach ($catName in @($products[$prodName].Categories.Keys)) { foreach ($row in $products[$prodName].Categories[$catName].ComparisonRows) { $defId = $row.LookupKey if ([string]::IsNullOrEmpty($defId)) { continue } # Only index rows keyed by DefinitionId (not SettingPath fallbacks) if (-not $row.HasDefinitionId) { continue } if ($row.Status -eq 'SourceOnly') { if (-not $sourceOnlyByDefId.Contains($defId)) { $sourceOnlyByDefId[$defId] = [System.Collections.Generic.List[object]]::new() } [void]$sourceOnlyByDefId[$defId].Add(@{ Row = $row Product = $prodName Category = $catName }) } elseif ($row.Status -eq 'DestOnly') { if (-not $destOnlyByDefId.Contains($defId)) { $destOnlyByDefId[$defId] = [System.Collections.Generic.List[object]]::new() } [void]$destOnlyByDefId[$defId].Add(@{ Row = $row Product = $prodName Category = $catName }) } } } } # Match SourceOnly <-> DestOnly by shared DefinitionId foreach ($defId in $sourceOnlyByDefId.Keys) { if (-not $destOnlyByDefId.Contains($defId)) { continue } $srcEntries = $sourceOnlyByDefId[$defId] $dstEntries = $destOnlyByDefId[$defId] # Pair 1:1 positionally (typically one source, one dest per defId) $pairCount = [math]::Min($srcEntries.Count, $dstEntries.Count) for ($i = 0; $i -lt $pairCount; $i++) { $srcEntry = $srcEntries[$i] $dstEntry = $dstEntries[$i] $srcRow = $srcEntry.Row $dstRow = $dstEntry.Row # Determine new status $newStatus = if ($srcRow.SourceValue -eq $dstRow.DestValue) { 'Matched' } else { 'Conflicting' } # Update the source row in-place to become the reconciled row $srcRow.Status = $newStatus $srcRow.DestPolicy = $dstRow.DestPolicy $srcRow.DestValue = $dstRow.DestValue if ($srcRow.ContainsKey('DestAssignment') -and $dstRow.ContainsKey('DestAssignment')) { $srcRow.DestAssignment = $dstRow.DestAssignment } # Update counters: source row was SourceOnly, now is Matched/Conflicting $counters['SourceOnly']-- $counters[$newStatus]++ $products[$srcEntry.Product].Counters['SourceOnly']-- $products[$srcEntry.Product].Counters[$newStatus]++ # Remove the dest row from its original category $dstRows = $products[$dstEntry.Product].Categories[$dstEntry.Category].ComparisonRows [void]$dstRows.Remove($dstRow) $counters['DestOnly']-- $products[$dstEntry.Product].Counters['DestOnly']-- } } # ── Manual Review = script/remediation/custom compliance + deprecated ── $manualReview = $manualReviewCategories # ── Deprecated Settings scan (both tenants) ────────────────────────── # Scan ALL settings in both DocModels. If a policy contains ANY deprecated # setting, add it to manual review with a "contains deprecated" flag. $scanForDeprecated = { param([hashtable]$Model, [string]$Side) if ($null -eq $Model -or $null -eq $Model.Products) { return } foreach ($prodName in $Model.Products.Keys) { $prodData = $Model.Products[$prodName] if ($null -eq $prodData -or $null -eq $prodData.Categories) { continue } foreach ($catName in $prodData.Categories.Keys) { foreach ($policy in @($prodData.Categories[$catName])) { if ($null -eq $policy -or $null -eq $policy.Basics) { continue } # Check if ANY setting in this policy is deprecated (reuses $isSettingDeprecated helper) $hasDeprecated = $false $settingsSummary = [System.Collections.Generic.List[object]]::new() foreach ($s in @($policy.Settings)) { if ($s.IsConfigured -ne $true) { continue } $settingName = "$($s.Name)" $settingValue = "$($s.Value)" $defId = "$($s.DefinitionId)" if ([string]::IsNullOrWhiteSpace($settingValue)) { continue } $isDepr = & $isSettingDeprecated $settingName $settingValue $defId if ($isDepr) { # Use catalog display name when it's the one that matched if (-not [string]::IsNullOrEmpty($defId) -and $null -ne $script:InforcerSettingsCatalog -and $script:InforcerSettingsCatalog.ContainsKey($defId) -and $script:InforcerSettingsCatalog[$defId].DisplayName -match 'deprecated') { $settingName = $script:InforcerSettingsCatalog[$defId].DisplayName } $hasDeprecated = $true [void]$settingsSummary.Add(@{ Name = $settingName Value = $settingValue IsDeprecated = $true }) } } if ($hasDeprecated) { $catLabel = "$prodName / $catName" if (-not $manualReview.Contains($catLabel)) { $manualReview[$catLabel] = [System.Collections.Generic.List[object]]::new() } [void]$manualReview[$catLabel].Add(@{ PolicyName = $policy.Basics.Name Side = $Side ProfileType = $policy.Basics.ProfileType Settings = $settingsSummary HasDeprecated = $true }) } } } } } & $scanForDeprecated $SourceModel 'Source' & $scanForDeprecated $DestinationModel 'Destination' # ── Duplicate Settings scan (within-tenant, cross-policy) ──────────── # Finds settings appearing in 2+ policies with different values within the same side. # Per D-01: adds entries to $manualReview under 'Duplicate Settings (Different Values)' # Per D-06: scopes per product (platform) — no cross-product matching # Declared in parent scope so BUG-04 can access it after the scriptblock runs $duplicateDefIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $scanForDuplicates = { # Phase 1: Build per-product-per-platform setting index # Scoped by product AND platform to prevent cross-OS matching (e.g., Win Edge vs Mac Edge) # Keyed by DefinitionId (not SettingPath) to prevent false matches on same display name $scopedSettingMaps = [ordered]@{} foreach ($side in @('Source', 'Destination')) { $model = if ($side -eq 'Source') { $SourceModel } else { $DestinationModel } if ($null -eq $model -or $null -eq $model.Products) { continue } foreach ($prodName in $model.Products.Keys) { foreach ($catName in $model.Products[$prodName].Categories.Keys) { # Exclude Custom Indicators category entirely (noise — per-tenant unique data) if ($catName -match 'Custom Indicators') { continue } # Extract platform from category path for OS-level scoping # Category format: "Product / Platform / Category" or just "Category" $catSegments = $catName -split '\s*/\s*' $platform = if ($catSegments.Count -ge 2) { $catSegments[0].Trim() } else { 'All' } if ($platform -eq 'All') { $platform = 'Windows' } # Composite scope key: product + platform + category # Category scoping prevents false matches from different policy templates # within the same category that reuse generic DefinitionIds (e.g., macOS # plist "Rules > Comment" for Office Updates vs Office Experience). # A cross-category pass after Phase 2 detects true duplicates where the # same DefinitionId appears in different categories (e.g., ASR settings # delivered via Endpoint Security AND Settings Catalog profiles). $scopeKey = "$prodName`0$platform`0$catName`0$side" if (-not $scopedSettingMaps.Contains($scopeKey)) { $scopedSettingMaps[$scopeKey] = [ordered]@{} } $settingMap = $scopedSettingMaps[$scopeKey] foreach ($policy in @($model.Products[$prodName].Categories[$catName])) { if ($null -eq $policy -or $null -eq $policy.Basics) { continue } $paths = & $buildSettingPaths $policy.Settings # D-09: count definitionId occurrences within this policy $defsInPolicy = [System.Collections.Generic.Dictionary[string,int]]::new( [System.StringComparer]::OrdinalIgnoreCase ) foreach ($p in $paths) { if (-not [string]::IsNullOrEmpty($p.DefinitionId)) { $defKeyLower = $p.DefinitionId.ToLowerInvariant() if ($defsInPolicy.ContainsKey($defKeyLower)) { $defsInPolicy[$defKeyLower]++ } else { $defsInPolicy[$defKeyLower] = 1 } } } # Collect per DefinitionId, keeping only one entry per policy+side # Uses DefinitionId as key (not SettingPath) so same-named settings # with different IDs (e.g., "Allowed System Extensions" in OneDrive vs Antivirus) # are correctly treated as different settings. # The ProfileType is included in entries so Phase 2 can further scope # by template — different profile types reusing the same generic schema # (e.g., macOS plist "Rules > Comment" for Edge vs Office) are not duplicates. $profileType = if ($policy.Basics.ProfileType) { $policy.Basics.ProfileType } else { '' } $policyDefEntries = [ordered]@{} foreach ($p in $paths) { if ([string]::IsNullOrEmpty($p.DefinitionId)) { continue } if (& $isExcludedSetting $p.Name $p.Value) { continue } $defKeyLower = $p.DefinitionId.ToLowerInvariant() if ($defsInPolicy[$defKeyLower] -gt 1) { continue } # Overwrite: last entry wins (child choice overrides parent "Enabled") $policyDefEntries[$defKeyLower] = @{ Value = $p.Value PolicyName = $policy.Basics.Name PolicyId = if ($policy.Basics.Id) { $policy.Basics.Id } else { '' } SettingName = $p.Name SettingPath = $p.SettingPath Side = $side Category = "$prodName / $catName" } } foreach ($dupeKey in $policyDefEntries.Keys) { # Composite key: DefinitionId + ProfileType (+ PolicyId for macOS) # Different profile types reusing the same generic DefinitionId # (e.g., macOS plist "Rules > Comment" for Edge vs Office) are not duplicates. # macOS custom profiles share generic plist schema DefinitionIds across # unrelated policies (e.g., "Comment" in Edge Updater vs Office Licensing). # Since each profile targets a different PayloadType, they don't conflict # on the device — include PolicyId to prevent false duplicate matches. $policyDiscriminator = '' if ($platform -match 'macOS|iOS|iPadOS') { $policyDiscriminator = if ($policy.Basics.Id) { "`0$($policy.Basics.Id)" } else { "`0$($policy.Basics.Name)" } } $compositeKey = "$dupeKey`0$profileType$policyDiscriminator" if (-not $settingMap.Contains($compositeKey)) { $settingMap[$compositeKey] = [System.Collections.Generic.List[object]]::new() } [void]$settingMap[$compositeKey].Add($policyDefEntries[$dupeKey]) } } } } } # Build per-side DefinitionId index for opposite-side context in duplicate tables $sideDefIndex = @{ Source = @{}; Destination = @{} } foreach ($sk in $scopedSettingMaps.Keys) { $skSide = ($sk.Split([char]0))[3] foreach ($ck in $scopedSettingMaps[$sk].Keys) { $ni = $ck.IndexOf([char]0) $rd = if ($ni -ge 0) { $ck.Substring(0, $ni) } else { $ck } if (-not $sideDefIndex[$skSide].ContainsKey($rd)) { $sideDefIndex[$skSide][$rd] = [System.Collections.Generic.List[object]]::new() } foreach ($e in $scopedSettingMaps[$sk][$ck]) { [void]$sideDefIndex[$skSide][$rd].Add($e) } } } # Phase 2: Detect duplicates and build ManualReview entries $duplicateItems = [System.Collections.Generic.List[object]]::new() $processedPolicySides = [System.Collections.Generic.HashSet[string]]::new() # O(1) lookup for existing items by policyKey (avoids Where-Object O(n) per Pitfall 4) $itemLookup = @{} foreach ($scopeKey in $scopedSettingMaps.Keys) { $settingMap = $scopedSettingMaps[$scopeKey] foreach ($dupeKey in $settingMap.Keys) { $entries = $settingMap[$dupeKey] if ($entries.Count -lt 2) { continue } # Require 2+ distinct policies — use name+id to distinguish same-named policies # (e.g., two "ASR rules" policies with different IDs: one Block, one Audit) $uniquePolicies = [System.Collections.Generic.HashSet[string]]::new( [string[]]($entries | ForEach-Object { $id = if ($_.PolicyId) { $_.PolicyId } else { '' } "$($_.PolicyName)`0$id" }), [System.StringComparer]::OrdinalIgnoreCase ) if ($uniquePolicies.Count -lt 2) { continue } # Check for 2+ unique values (case-insensitive) $uniqueValues = [System.Collections.Generic.HashSet[string]]::new( [string[]]($entries | ForEach-Object { $_.Value }), [System.StringComparer]::OrdinalIgnoreCase ) if ($uniqueValues.Count -lt 2) { continue } # Enrich with opposite-side entries for context display $scopeParts2 = $scopeKey.Split([char]0) $oppSide = if ($scopeParts2[3] -eq 'Source') { 'Destination' } else { 'Source' } $dupeNullIdx = $dupeKey.IndexOf([char]0) $rawDefId2 = if ($dupeNullIdx -ge 0) { $dupeKey.Substring(0, $dupeNullIdx) } else { $dupeKey } $oppositeEntries = if ($sideDefIndex[$oppSide].ContainsKey($rawDefId2)) { $sideDefIndex[$oppSide][$rawDefId2] } else { @() } $allEntries = [System.Collections.Generic.List[object]]::new() foreach ($e in $entries) { [void]$allEntries.Add($e) } foreach ($e in $oppositeEntries) { [void]$allEntries.Add($e) } [void]$duplicateDefIds.Add($rawDefId2) # D-02: Build __DUPLICATE_TABLE__ encoded value # Pre-build policy name counts to avoid O(n^2) Where-Object lookups $polNameCounts = @{} foreach ($e in $allEntries) { if ($polNameCounts.ContainsKey($e.PolicyName)) { $polNameCounts[$e.PolicyName]++ } else { $polNameCounts[$e.PolicyName] = 1 } } # Disambiguate policy names when same-named policies exist (append short ID) $policyValues = $allEntries | ForEach-Object { $dispName = $_.PolicyName if ($_.PolicyId -and $polNameCounts[$dispName] -gt 1) { $shortId = $_.PolicyId; if ($shortId.Length -gt 8) { $shortId = $shortId.Substring(0, 8) } $dispName = "$dispName ($shortId)" } @{ Policy = $dispName; Value = $_.Value; Side = $_.Side; SettingName = $_.SettingName; SettingPath = $_.SettingPath; Category = $_.Category } } $tableJson = '__DUPLICATE_TABLE__' + ($policyValues | ConvertTo-Json -Depth 100 -Compress) foreach ($entry in $entries) { # Use name+id for grouping so same-named policies get separate cards $entryDispName = $entry.PolicyName if ($entry.PolicyId -and $polNameCounts[$entry.PolicyName] -gt 1) { $shortId = $entry.PolicyId; if ($shortId.Length -gt 8) { $shortId = $shortId.Substring(0, 8) } $entryDispName = "$($entry.PolicyName) ($shortId)" } $policyKey = "$($entry.Side)`0$entryDispName" $settingKey = if ($entry.SettingPath) { $entry.SettingPath } else { $entry.SettingName } if ($processedPolicySides.Contains($policyKey)) { # Add setting to existing item $existing = $itemLookup[$policyKey] if ($null -ne $existing) { $alreadyHas = $false foreach ($s in $existing.Settings) { if ($s.Name -eq $settingKey) { $alreadyHas = $true; break } } if (-not $alreadyHas) { [void]$existing.Settings.Add(@{ Name = $settingKey; Value = $tableJson }) # D-03: Recompute ProfileType with updated setting count $otherPolicies = [System.Collections.Generic.HashSet[string]]::new() foreach ($s in $existing.Settings) { $jsonPart = $s.Value -replace '^__DUPLICATE_TABLE__', '' try { $pairs = $jsonPart | ConvertFrom-Json -Depth 10 foreach ($pair in $pairs) { if ($pair.Policy -ne $entryDispName -or $pair.Side -ne $entry.Side) { [void]$otherPolicies.Add("$($pair.Policy) ($($pair.Side))") } } } catch { } } $existing.ProfileType = "$($existing.Settings.Count) duplicate settings `u{2014} also in: $($otherPolicies -join ', ')" } } continue } [void]$processedPolicySides.Add($policyKey) # D-03: Build "also in" list excluding current policy $otherPolicies = $policyValues | Where-Object { $_.Policy -ne $entryDispName -or $_.Side -ne $entry.Side } | ForEach-Object { "$($_.Policy) ($($_.Side))" } | Select-Object -Unique $settingsList = [System.Collections.Generic.List[object]]::new() [void]$settingsList.Add(@{ Name = $settingKey; Value = $tableJson }) # D-01: ManualReview entry shape $item = @{ PolicyName = $entryDispName Side = $entry.Side ProfileType = "1 duplicate settings `u{2014} also in: $($otherPolicies -join ', ')" Settings = $settingsList HasDeprecated = $false } [void]$duplicateItems.Add($item) $itemLookup[$policyKey] = $item } } } # Phase 3: Cross-category duplicate detection # Same DefinitionId appearing in different categories (same product+platform) # with different values indicates the same Intune setting delivered through # different policy types (e.g., Endpoint Security template vs Settings Catalog). # This is a real conflict — the device receives conflicting instructions. $globalDefIndex = @{} # DefinitionId -> List of entries from ALL scopes foreach ($scopeKey in $scopedSettingMaps.Keys) { $settingMap = $scopedSettingMaps[$scopeKey] foreach ($compositeKey in $settingMap.Keys) { foreach ($entry in $settingMap[$compositeKey]) { # Extract the raw DefinitionId (first part of composite key before the null char) $nullIdx = $compositeKey.IndexOf([char]0) $rawDefId = if ($nullIdx -ge 0) { $compositeKey.Substring(0, $nullIdx) } else { $compositeKey } # Build a cross-scope key: product + platform + side + defId (no category, no profileType) # For Apple platforms, include PolicyId to prevent false cross-category matches # between unrelated custom profiles sharing generic plist DefinitionIds $scopeParts = $scopeKey.Split([char]0) $applePlatformDisc = '' if ($scopeParts[1] -match 'macOS|iOS|iPadOS') { $polId = if ($entry.PolicyId) { $entry.PolicyId } else { $entry.PolicyName } $applePlatformDisc = "`0$polId" } $crossKey = "$($scopeParts[0])`0$($scopeParts[1])`0$($entry.Side)`0$rawDefId$applePlatformDisc" if (-not $globalDefIndex.ContainsKey($crossKey)) { $globalDefIndex[$crossKey] = [System.Collections.Generic.List[object]]::new() } [void]$globalDefIndex[$crossKey].Add($entry) } } } foreach ($crossKey in $globalDefIndex.Keys) { $entries = $globalDefIndex[$crossKey] if ($entries.Count -lt 2) { continue } # Only flag if entries come from 2+ different categories $uniqueCategories = [System.Collections.Generic.HashSet[string]]::new( [string[]]($entries | ForEach-Object { $_.Category }), [System.StringComparer]::OrdinalIgnoreCase ) if ($uniqueCategories.Count -lt 2) { continue } # Require 2+ distinct policies (name+id) and 2+ unique values $uniquePolicies = [System.Collections.Generic.HashSet[string]]::new( [string[]]($entries | ForEach-Object { $id = if ($_.PolicyId) { $_.PolicyId } else { '' } "$($_.PolicyName)`0$id" }), [System.StringComparer]::OrdinalIgnoreCase ) if ($uniquePolicies.Count -lt 2) { continue } $uniqueValues = [System.Collections.Generic.HashSet[string]]::new( [string[]]($entries | ForEach-Object { $_.Value }), [System.StringComparer]::OrdinalIgnoreCase ) if ($uniqueValues.Count -lt 2) { continue } # Enrich with opposite-side entries for context display $crossKeyParts = $crossKey.Split([char]0) $oppSide3 = if ($crossKeyParts[2] -eq 'Source') { 'Destination' } else { 'Source' } $rawDefId3 = $crossKeyParts[3] $oppositeEntries3 = if ($sideDefIndex[$oppSide3].ContainsKey($rawDefId3)) { $sideDefIndex[$oppSide3][$rawDefId3] } else { @() } $allEntries3 = [System.Collections.Generic.List[object]]::new() foreach ($e in $entries) { [void]$allEntries3.Add($e) } foreach ($e in $oppositeEntries3) { [void]$allEntries3.Add($e) } [void]$duplicateDefIds.Add($rawDefId3) # Pre-build policy name counts to avoid O(n^2) Where-Object lookups $polNameCounts3 = @{} foreach ($e in $allEntries3) { if ($polNameCounts3.ContainsKey($e.PolicyName)) { $polNameCounts3[$e.PolicyName]++ } else { $polNameCounts3[$e.PolicyName] = 1 } } # Build duplicate entries (same shape as Phase 2) with disambiguated names $policyValues = $allEntries3 | ForEach-Object { $dispName = $_.PolicyName if ($_.PolicyId -and $polNameCounts3[$dispName] -gt 1) { $shortId = $_.PolicyId; if ($shortId.Length -gt 8) { $shortId = $shortId.Substring(0, 8) } $dispName = "$dispName ($shortId)" } @{ Policy = $dispName; Value = $_.Value; Side = $_.Side; SettingName = $_.SettingName; SettingPath = $_.SettingPath; Category = $_.Category } } $tableJson = '__DUPLICATE_TABLE__' + ($policyValues | ConvertTo-Json -Depth 100 -Compress) foreach ($entry in $entries) { $entryDispName = $entry.PolicyName if ($entry.PolicyId -and $polNameCounts3[$entry.PolicyName] -gt 1) { $shortId = $entry.PolicyId; if ($shortId.Length -gt 8) { $shortId = $shortId.Substring(0, 8) } $entryDispName = "$($entry.PolicyName) ($shortId)" } $policyKey = "$($entry.Side)`0$entryDispName" $settingKey = if ($entry.SettingPath) { $entry.SettingPath } else { $entry.SettingName } if ($processedPolicySides.Contains($policyKey)) { $existing = $itemLookup[$policyKey] if ($null -ne $existing) { $alreadyHas = $false foreach ($s in $existing.Settings) { if ($s.Name -eq $settingKey) { $alreadyHas = $true; break } } if (-not $alreadyHas) { [void]$existing.Settings.Add(@{ Name = $settingKey; Value = $tableJson }) $otherPolicies = [System.Collections.Generic.HashSet[string]]::new() foreach ($s in $existing.Settings) { $jsonPart = $s.Value -replace '^__DUPLICATE_TABLE__', '' try { $pairs = $jsonPart | ConvertFrom-Json -Depth 10 foreach ($pair in $pairs) { if ($pair.Policy -ne $entryDispName -or $pair.Side -ne $entry.Side) { [void]$otherPolicies.Add("$($pair.Policy) ($($pair.Side))") } } } catch { } } $existing.ProfileType = "$($existing.Settings.Count) duplicate settings `u{2014} also in: $($otherPolicies -join ', ')" } } continue } [void]$processedPolicySides.Add($policyKey) $otherPolicies = $policyValues | Where-Object { $_.Policy -ne $entryDispName -or $_.Side -ne $entry.Side } | ForEach-Object { "$($_.Policy) ($($_.Side))" } | Select-Object -Unique $settingsList = [System.Collections.Generic.List[object]]::new() [void]$settingsList.Add(@{ Name = $settingKey; Value = $tableJson }) $item = @{ PolicyName = $entryDispName Side = $entry.Side ProfileType = "1 duplicate settings `u{2014} also in: $($otherPolicies -join ', ')" Settings = $settingsList HasDeprecated = $false } [void]$duplicateItems.Add($item) $itemLookup[$policyKey] = $item } } # D-01: Add to ManualReview under canonical key if ($duplicateItems.Count -gt 0) { $dupeCategory = 'Duplicate Settings (Different Values)' if ($manualReview.Contains($dupeCategory)) { foreach ($item in $duplicateItems) { [void]$manualReview[$dupeCategory].Add($item) } } else { $manualReview[$dupeCategory] = $duplicateItems } } } & $scanForDuplicates # ── BUG-04: Remove duplicate settings from ComparisonRows ────────── # Settings with within-side duplicates (same setting in 2+ policies with # different values on the same side) cannot be reliably compared cross-tenant. # They belong exclusively in the Duplicates tab. Match by DefinitionId # (stored as LookupKey on each row) for reliable identification. if ($duplicateDefIds.Count -gt 0) { foreach ($prodName in @($products.Keys)) { foreach ($catName in @($products[$prodName].Categories.Keys)) { $rows = $products[$prodName].Categories[$catName].ComparisonRows $toRemove = [System.Collections.Generic.List[object]]::new() foreach ($row in $rows) { if ($row.LookupKey -and $duplicateDefIds.Contains($row.LookupKey)) { [void]$toRemove.Add($row) } } foreach ($row in $toRemove) { [void]$rows.Remove($row) $counters[$row.Status]-- $products[$prodName].Counters[$row.Status]-- } } } } # manualReview now contains: script/remediation/custom compliance + deprecated policies + duplicate settings + ambiguous comparisons # ── Alignment score ─────────────────────────────────────────────────── $totalItems = $counters.Matched + $counters.Conflicting + $counters.SourceOnly + $counters.DestOnly $alignmentScore = if ($totalItems -eq 0) { 100 } else { [math]::Round(($counters.Matched / $totalItems) * 100, 1) } # ── Return ComparisonModel ──────────────────────────────────────────── @{ SourceName = $SourceModel.TenantName DestinationName = $DestinationModel.TenantName SourceBaselineName = $SourceBaselineName DestinationBaselineName = $DestinationBaselineName GeneratedAt = [datetime]::UtcNow AlignmentScore = $alignmentScore TotalItems = $totalItems Counters = $counters Products = $products ManualReview = $manualReview DuplicatePolicies = $duplicatePolicies IncludingAssignments = [bool]$IncludingAssignments } } |