Skip to content

Commit a9b3e40

Browse files
committed
Purview DLP Policy and Standard implementation and fixes
1 parent 6192717 commit a9b3e40

6 files changed

Lines changed: 478 additions & 75 deletions

File tree

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
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+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
function ConvertTo-CIPPSensitiveInformationType {
2+
<#
3+
.SYNOPSIS
4+
Normalize a DLP rule's ContentContainsSensitiveInformation value into clean input objects.
5+
.DESCRIPTION
6+
Get-DlpComplianceRule returns ContentContainsSensitiveInformation (and the ExceptIf variant)
7+
in an output-only @odata serialization that New-/Set-DlpComplianceRule will not accept as input.
8+
Two shapes occur:
9+
10+
- Flat list: an array of SITs, each SIT being an array of '{ _key, _value }' GenericHashTable
11+
pairs - e.g. { _key = 'name'; _value = 'Credit Card Number' }.
12+
13+
- Grouped: an array containing a single wrapper '{ groups = (...); operator = 'And' }', where
14+
each group is an array of pairs carrying 'name', 'operator', and a nested 'sensitivetypes'
15+
value (itself a flat list of SITs). Used by templates that AND/OR several named groups
16+
together (e.g. HIPAA Enhanced). NOTE the wrapper is delivered inside an array, so the
17+
top-level value is an array in BOTH shapes.
18+
19+
This collapses every '{ _key, _value }' pair group into a single flat object and recurses through
20+
the grouped / groups / sensitivetypes nesting, producing a structure the New-/Set-* cmdlets accept.
21+
22+
The function is idempotent: a value already in the clean shape (no '_key' pairs) is returned
23+
unchanged, so it is safe to call at both template-build time and deploy time.
24+
.PARAMETER SensitiveInformation
25+
The ContentContainsSensitiveInformation value to normalize.
26+
.FUNCTIONALITY
27+
Internal
28+
#>
29+
[CmdletBinding()]
30+
param($SensitiveInformation)
31+
32+
if ($null -eq $SensitiveInformation) { return $null }
33+
34+
# Output-only SIT properties that Get-DlpComplianceRule emits but New-/Set-DlpComplianceRule reject
35+
# as input (matched case-insensitively against the lower-cased key).
36+
$script:InvalidSitProperties = @('rulepackid')
37+
38+
# Recursively normalize a single entry. An entry is one of:
39+
# - a raw array of { _key, _value } pairs (a SIT, or a group) -> collapse to a flat object
40+
# - a grouped wrapper object { groups, operator } -> recurse each group
41+
# - an already-clean object with a nested sensitivetypes list -> recurse that list
42+
# - anything else -> pass through unchanged
43+
# 'groups' and 'sensitivetypes' are recursed on BOTH the raw and the already-clean paths so the
44+
# conversion is correct whether the wrapper arrives bare or (as the cmdlets deliver it) array-wrapped,
45+
# and so re-running on an already-converted value is a no-op.
46+
function Convert-Entry {
47+
param($Entry)
48+
49+
if ($null -eq $Entry) { return $null }
50+
51+
$first = @($Entry) | Where-Object { $null -ne $_ } | Select-Object -First 1
52+
$isRawPairs = ($Entry -isnot [string]) -and $null -ne $first -and ($first.PSObject.Properties.Name -contains '_key')
53+
54+
if ($isRawPairs) {
55+
$ht = [ordered]@{}
56+
foreach ($pair in @($Entry)) {
57+
if ($null -eq $pair -or ($pair.PSObject.Properties.Name -notcontains '_key')) { continue }
58+
$key = [string]$pair._key
59+
# Skip output-only properties the New-/Set-* cmdlets reject as input (e.g. rulePackId,
60+
# which Get-DlpComplianceRule emits on every SIT).
61+
if ($key -in $script:InvalidSitProperties) { continue }
62+
if ($key -in @('sensitivetypes', 'groups')) {
63+
$ht[$key] = @(foreach ($child in @($pair._value)) { Convert-Entry -Entry $child })
64+
} else {
65+
$ht[$key] = $pair._value
66+
}
67+
}
68+
return [pscustomobject]$ht
69+
}
70+
71+
# Already clean (or partially clean) object - recurse the nested collections, strip invalid
72+
# properties, pass the rest through. Rebuild when there is anything to recurse or strip.
73+
$propNames = @($Entry.PSObject.Properties.Name)
74+
$needsRebuild = ($propNames | Where-Object { $_ -in @('groups', 'sensitivetypes') -or $_ -in $script:InvalidSitProperties }).Count -gt 0
75+
if ($needsRebuild) {
76+
$clone = [ordered]@{}
77+
foreach ($prop in $Entry.PSObject.Properties) {
78+
if ($prop.Name -in $script:InvalidSitProperties) { continue }
79+
if ($prop.Name -in @('groups', 'sensitivetypes')) {
80+
$clone[$prop.Name] = @(foreach ($child in @($prop.Value)) { Convert-Entry -Entry $child })
81+
} else {
82+
$clone[$prop.Name] = $prop.Value
83+
}
84+
}
85+
return [pscustomobject]$clone
86+
}
87+
88+
return $Entry
89+
}
90+
91+
# Grouped form: a bare wrapper object exposing a 'groups' collection. (When array-wrapped, the
92+
# branch below handles it via Convert-Entry on each element.)
93+
if ($SensitiveInformation -isnot [System.Collections.IEnumerable] -and
94+
($SensitiveInformation.PSObject.Properties.Name -contains 'groups')) {
95+
# Callers MUST wrap the result in @(...) so this lands as a PswsHashtable[] array on the wire -
96+
# PowerShell unwraps a single-element return to a bare object, which is rejected server-side.
97+
return @(Convert-Entry -Entry $SensitiveInformation)
98+
}
99+
100+
# Array form (the normal case): flat list of SITs, OR an array carrying the grouped wrapper.
101+
# Callers must wrap in @(...) - see the note above.
102+
return @(foreach ($entry in @($SensitiveInformation)) { Convert-Entry -Entry $entry })
103+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
function Get-CIPPDlpComplianceFieldList {
2+
<#
3+
.SYNOPSIS
4+
Single source of truth for the DLP compliance policy/rule cmdlet parameter allowlists.
5+
.DESCRIPTION
6+
The New-/Set-DlpCompliancePolicy and New-/Set-DlpComplianceRule cmdlets accept only a subset of
7+
the (much larger) set of properties Get-* returns. These allowlists are shared by every code path
8+
that builds or compares DLP policy params - template creation, deploy, and drift comparison - so
9+
the accepted fields never diverge between them (divergence here previously caused 'Mode'/'Priority'
10+
being sent where invalid, etc.).
11+
12+
Priority is intentionally excluded: Microsoft assigns it per tenant from existing policy ordering,
13+
so it varies between tenants and must not be captured into, deployed from, or drift-compared.
14+
.OUTPUTS
15+
PSCustomObject with Policy, Rule, and Location (subset of Policy) string arrays.
16+
.FUNCTIONALITY
17+
Internal
18+
#>
19+
[CmdletBinding()]
20+
param()
21+
22+
$Policy = @(
23+
'Name', 'Comment', 'Mode',
24+
'ExchangeLocation', 'ExchangeLocationException',
25+
'SharePointLocation', 'SharePointLocationException',
26+
'OneDriveLocation', 'OneDriveLocationException',
27+
'TeamsLocation', 'TeamsLocationException',
28+
'EndpointDlpLocation', 'EndpointDlpLocationException',
29+
'OnPremisesScannerDlpLocation', 'OnPremisesScannerDlpLocationException',
30+
'ThirdPartyAppDlpLocation', 'ThirdPartyAppDlpLocationException',
31+
'PowerBIDlpLocation', 'PowerBIDlpLocationException',
32+
'ModernGroupLocation', 'ModernGroupLocationException'
33+
)
34+
35+
# Note: DLP rules have no 'Mode' parameter (that is policy-level). 'Policy' is the parent reference
36+
# added at deploy time; it is not a comparable setting.
37+
$Rule = @(
38+
'Name', 'Policy', 'Comment', 'Disabled',
39+
'ContentContainsSensitiveInformation', 'ExceptIfContentContainsSensitiveInformation',
40+
'ContentPropertyContainsWords', 'BlockAccess', 'BlockAccessScope',
41+
'NotifyUser', 'NotifyEmailCustomText', 'NotifyEmailCustomSubject',
42+
'NotifyPolicyTipCustomText', 'GenerateAlert', 'AlertProperties',
43+
'GenerateIncidentReport', 'IncidentReportContent',
44+
'AccessScope', 'From', 'FromMemberOf', 'FromAddressContainsWords',
45+
'FromAddressMatchesPatterns', 'SentTo', 'SentToMemberOf',
46+
'RecipientDomainIs', 'AnyOfRecipientAddressContainsWords',
47+
'AnyOfRecipientAddressMatchesPatterns', 'AnyOfRecipientAddressDomainIs',
48+
'ExceptIfFrom', 'ExceptIfFromMemberOf', 'ExceptIfFromAddressContainsWords',
49+
'ExceptIfFromAddressMatchesPatterns',
50+
'AddRecipients', 'BlockMessage', 'GenerateAlertOn', 'IncidentReportTo',
51+
'ReportSeverityLevel', 'RuleErrorAction',
52+
'ContentExtensionMatchesWords', 'DocumentNameMatchesPatterns',
53+
'DocumentNameMatchesWords', 'DocumentSizeOver',
54+
'ContentCharacterSetContainsWords', 'ContentFileTypeMatches'
55+
)
56+
57+
return [pscustomobject]@{
58+
Policy = $Policy
59+
Rule = $Rule
60+
Location = @($Policy | Where-Object { $_ -like '*Location*' })
61+
# Valid -Mode input values for New-/Set-DlpCompliancePolicy. Transient/output-only states such as
62+
# 'PendingDeletion' are NOT accepted as input and must be dropped before deploy.
63+
ValidPolicyModes = @('Enable', 'TestWithNotifications', 'TestWithoutNotifications', 'Disable')
64+
}
65+
}

0 commit comments

Comments
 (0)