|
| 1 | +function ConvertTo-CIPPComparableString { |
| 2 | + <# |
| 3 | + .SYNOPSIS |
| 4 | + Produce an order-independent canonical string for a value, for equality comparison. |
| 5 | + .DESCRIPTION |
| 6 | + Recursively serializes scalars, dictionaries/objects (keys sorted), and arrays (elements sorted) |
| 7 | + into a deterministic string. Two values are equal iff their canonical strings match - independent |
| 8 | + of property order or array order, which is the right semantics for DLP locations and the set of |
| 9 | + sensitive information types (order is not meaningful for matching). |
| 10 | + .FUNCTIONALITY |
| 11 | + Internal |
| 12 | + #> |
| 13 | + [CmdletBinding()] |
| 14 | + param($Value) |
| 15 | + |
| 16 | + if ($null -eq $Value) { return 'null' } |
| 17 | + if ($Value -is [string]) { return '"' + $Value + '"' } |
| 18 | + if ($Value -is [bool] -or $Value -is [int] -or $Value -is [long] -or $Value -is [double] -or $Value -is [decimal]) { |
| 19 | + return [string]$Value |
| 20 | + } |
| 21 | + if ($Value -is [System.Collections.IDictionary]) { |
| 22 | + $parts = foreach ($k in (@($Value.Keys) | Sort-Object)) { '"' + $k + '":' + (ConvertTo-CIPPComparableString -Value $Value[$k]) } |
| 23 | + return '{' + ($parts -join ',') + '}' |
| 24 | + } |
| 25 | + if ($Value -is [System.Management.Automation.PSCustomObject]) { |
| 26 | + $parts = foreach ($p in ($Value.PSObject.Properties | Sort-Object Name)) { '"' + $p.Name + '":' + (ConvertTo-CIPPComparableString -Value $p.Value) } |
| 27 | + return '{' + ($parts -join ',') + '}' |
| 28 | + } |
| 29 | + if ($Value -is [System.Collections.IEnumerable]) { |
| 30 | + $items = @(foreach ($item in $Value) { ConvertTo-CIPPComparableString -Value $item }) | Sort-Object |
| 31 | + return '[' + ($items -join ',') + ']' |
| 32 | + } |
| 33 | + return '"' + ([string]$Value) + '"' |
| 34 | +} |
| 35 | + |
| 36 | +function ConvertTo-CIPPDlpComparable { |
| 37 | + <# |
| 38 | + .SYNOPSIS |
| 39 | + Normalize a DLP policy source (template or live policy) + its rules into a comparable param map. |
| 40 | + .DESCRIPTION |
| 41 | + Runs the source through the exact same normalization the deploy path uses - allowlist filtering, |
| 42 | + location normalization, sensitive-information-type conversion (which also strips output-only |
| 43 | + rulePackId), and IncidentReportContent string->array - so a template and the live policy it was |
| 44 | + deployed from collapse to identical structures when nothing has actually drifted. |
| 45 | + .PARAMETER PolicySource |
| 46 | + The policy-level object (a stored template, or a Get-DlpCompliancePolicy result). |
| 47 | + .PARAMETER RuleSource |
| 48 | + The rule collection (template RuleParams, or Get-DlpComplianceRule results). |
| 49 | + .OUTPUTS |
| 50 | + PSCustomObject with Policy (hashtable of normalized policy params) and Rules (ordered map of |
| 51 | + rule name -> hashtable of normalized rule params). |
| 52 | + .FUNCTIONALITY |
| 53 | + Internal |
| 54 | + #> |
| 55 | + [CmdletBinding()] |
| 56 | + param($PolicySource, $RuleSource) |
| 57 | + |
| 58 | + $Fields = Get-CIPPDlpComplianceFieldList |
| 59 | + |
| 60 | + $Policy = Format-CIPPCompliancePolicyParams -Source $PolicySource -AllowedFields $Fields.Policy -LocationFields $Fields.Location |
| 61 | + $Policy.Remove('Name') | Out-Null # identity, not a comparable setting |
| 62 | + # Mirror deploy: an invalid/transient Mode (e.g. PendingDeletion) is never deployed, so it must not |
| 63 | + # register as drift either. |
| 64 | + if ($Policy.ContainsKey('Mode') -and $Policy['Mode'] -notin $Fields.ValidPolicyModes) { |
| 65 | + $Policy.Remove('Mode') | Out-Null |
| 66 | + } |
| 67 | + |
| 68 | + $Rules = [ordered]@{} |
| 69 | + foreach ($Rule in @($RuleSource) | Where-Object { $_ }) { |
| 70 | + $RuleParams = Format-CIPPCompliancePolicyParams -Source $Rule -AllowedFields $Fields.Rule |
| 71 | + $RuleName = [string]$RuleParams['Name'] |
| 72 | + $RuleParams.Remove('Policy') | Out-Null |
| 73 | + $RuleParams.Remove('Name') | Out-Null |
| 74 | + foreach ($SitField in @('ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation')) { |
| 75 | + if ($RuleParams.ContainsKey($SitField)) { |
| 76 | + $RuleParams[$SitField] = @(ConvertTo-CIPPSensitiveInformationType -SensitiveInformation $RuleParams[$SitField]) |
| 77 | + } |
| 78 | + } |
| 79 | + if ($RuleParams.ContainsKey('IncidentReportContent') -and $RuleParams['IncidentReportContent'] -is [string]) { |
| 80 | + $RuleParams['IncidentReportContent'] = @($RuleParams['IncidentReportContent'] -split ',' | ForEach-Object { $_.Trim() } | Where-Object { $_ }) |
| 81 | + } |
| 82 | + if (-not [string]::IsNullOrWhiteSpace($RuleName)) { $Rules[$RuleName] = $RuleParams } |
| 83 | + } |
| 84 | + |
| 85 | + return [pscustomobject]@{ Policy = $Policy; Rules = $Rules } |
| 86 | +} |
| 87 | + |
| 88 | +function Compare-CIPPDlpCompliancePolicy { |
| 89 | + <# |
| 90 | + .SYNOPSIS |
| 91 | + Compare a stored DLP template against the live policy + rules in a tenant and report drift. |
| 92 | + .DESCRIPTION |
| 93 | + Normalizes both sides through ConvertTo-CIPPDlpComparable and diffs them field by field |
| 94 | + (policy-level and per-rule, matched by rule name). Returns the overall state and the specific |
| 95 | + differing fields with their expected (template) and current (tenant) values, so callers can |
| 96 | + decide whether to remediate and can surface exactly what differs. |
| 97 | + .PARAMETER TenantFilter |
| 98 | + Target tenant. |
| 99 | + .PARAMETER Template |
| 100 | + The stored template object (already ConvertFrom-Json'd). |
| 101 | + .OUTPUTS |
| 102 | + PSCustomObject: Name, State ('Missing' | 'PendingDeletion' | 'InSync' | 'Drift'), and Differences |
| 103 | + (array of { Scope, Field, Expected, Current }). |
| 104 | + .FUNCTIONALITY |
| 105 | + Internal |
| 106 | + #> |
| 107 | + [CmdletBinding()] |
| 108 | + param( |
| 109 | + [Parameter(Mandatory)] [string] $TenantFilter, |
| 110 | + [Parameter(Mandatory)] $Template |
| 111 | + ) |
| 112 | + |
| 113 | + $PolicyName = $Template.Name ?? $Template.name |
| 114 | + |
| 115 | + $LivePolicy = try { |
| 116 | + New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpCompliancePolicy' -Compliance | |
| 117 | + Where-Object { $_.Name -eq $PolicyName } | Select-Object -First 1 |
| 118 | + } catch { $null } |
| 119 | + |
| 120 | + if (-not $LivePolicy) { |
| 121 | + return [pscustomobject]@{ Name = $PolicyName; State = 'Missing'; Differences = @() } |
| 122 | + } |
| 123 | + if ($LivePolicy.Mode -eq 'PendingDeletion') { |
| 124 | + return [pscustomobject]@{ Name = $PolicyName; State = 'PendingDeletion'; Differences = @() } |
| 125 | + } |
| 126 | + |
| 127 | + $LiveRules = try { |
| 128 | + @(New-ExoRequest -tenantid $TenantFilter -cmdlet 'Get-DlpComplianceRule' -Compliance | |
| 129 | + Where-Object { $_.ParentPolicyName -eq $PolicyName }) |
| 130 | + } catch { @() } |
| 131 | + |
| 132 | + $Want = ConvertTo-CIPPDlpComparable -PolicySource $Template -RuleSource $Template.RuleParams |
| 133 | + $Have = ConvertTo-CIPPDlpComparable -PolicySource $LivePolicy -RuleSource $LiveRules |
| 134 | + |
| 135 | + $Differences = [System.Collections.Generic.List[object]]::new() |
| 136 | + |
| 137 | + # Policy-level diff |
| 138 | + foreach ($Key in (@($Want.Policy.Keys) + @($Have.Policy.Keys) | Select-Object -Unique)) { |
| 139 | + $Expected = if ($Want.Policy.ContainsKey($Key)) { $Want.Policy[$Key] } else { $null } |
| 140 | + $Current = if ($Have.Policy.ContainsKey($Key)) { $Have.Policy[$Key] } else { $null } |
| 141 | + if ((ConvertTo-CIPPComparableString -Value $Expected) -ne (ConvertTo-CIPPComparableString -Value $Current)) { |
| 142 | + $Differences.Add([pscustomobject]@{ Scope = 'Policy'; Field = $Key; Expected = $Expected; Current = $Current }) |
| 143 | + } |
| 144 | + } |
| 145 | + |
| 146 | + # Rule-level diff (only rules the template defines; matched by name) |
| 147 | + foreach ($RuleName in @($Want.Rules.Keys)) { |
| 148 | + if (@($Have.Rules.Keys) -notcontains $RuleName) { |
| 149 | + $Differences.Add([pscustomobject]@{ Scope = "Rule '$RuleName'"; Field = '(entire rule)'; Expected = 'present'; Current = 'missing' }) |
| 150 | + continue |
| 151 | + } |
| 152 | + $WantRule = $Want.Rules[$RuleName] |
| 153 | + $HaveRule = $Have.Rules[$RuleName] |
| 154 | + foreach ($Key in (@($WantRule.Keys) + @($HaveRule.Keys) | Select-Object -Unique)) { |
| 155 | + $Expected = if ($WantRule.ContainsKey($Key)) { $WantRule[$Key] } else { $null } |
| 156 | + $Current = if ($HaveRule.ContainsKey($Key)) { $HaveRule[$Key] } else { $null } |
| 157 | + if ((ConvertTo-CIPPComparableString -Value $Expected) -ne (ConvertTo-CIPPComparableString -Value $Current)) { |
| 158 | + $Differences.Add([pscustomobject]@{ Scope = "Rule '$RuleName'"; Field = $Key; Expected = $Expected; Current = $Current }) |
| 159 | + } |
| 160 | + } |
| 161 | + } |
| 162 | + |
| 163 | + $State = if ($Differences.Count -eq 0) { 'InSync' } else { 'Drift' } |
| 164 | + return [pscustomobject]@{ Name = $PolicyName; State = $State; Differences = @($Differences) } |
| 165 | +} |
0 commit comments