public/Get-MsecPurviewDlpPolicy.ps1
|
function Get-MsecPurviewDlpPolicy { <# .SYNOPSIS Data Loss Prevention policies, one row each, with where they apply and whether they actually enforce. .DESCRIPTION CONFIGURED IS NOT THE SAME AS ENFORCING, and the count people quote is the configured one. A DLP policy has a Mode independent of its Enabled flag: 'Disable' means it does nothing, 'TestWithNotifications' means it reports without blocking, and only 'Enable' stops anything. IsEnforcing collapses that into the answer most questions actually want, while Mode and Enabled stay on the row so nothing is hidden behind the derivation. WHERE A POLICY APPLIES IS NOT A BOOLEAN. Each workload gets a Scope of All, Named or None, plus a count of named locations. 'All' and 'one location happening to be called All' are indistinguishable in the raw data until you inspect the collection, which is the sort of thing that turns an estate-wide policy into a footnote. NB a Named count is not coverage - two named SharePoint sites out of nine hundred is technically 'Named'. DO NOT BELIEVE THE Workload PROPERTY. It is declarative, not derived: measured live, every policy on one tenant listed "Exchange" in Workload while every single Exchange targeting property - ExchangeLocation, ExchangeSender, ExchangeSenderMemberOf, ExchangeAdaptiveScopes - was empty, which per Microsoft's own parameter reference means email is NOT included. ("If you don't want to include email messages in the policy, don't use this parameter.") Workload is surfaced here anyway, because reading it and believing email was covered is exactly the mistake this column exists to expose - WorkloadClaims is the list it asserts, and the *Scope columns are what is actually targeted. Where they disagree, the scopes are right. EXCHANGE IS THE ONE TO CHECK. Its location is empty on a policy that covers nothing in mail, and because every other workload can look healthy at the same time, an uncovered Exchange is easy to miss - measured on one tenant, every enforcing policy had an empty Exchange location. A RENAMED POLICY HAS TWO NAMES, AND THE PORTAL SHOWS THE ONE THIS DID NOT REPORT. Renaming a DLP policy changes its DisplayName and leaves Name at whatever it was created as, so the two drift apart the moment anyone tidies a name up. Measured live: a policy the portal calls "DLP - Confidential document shared" is still Name "TEST - Label-based DLP (pilot)" underneath. Name here is therefore the DISPLAY name - the one a reader can find in the portal - and InternalName carries the original, which is what Set-DlpCompliancePolicy -Identity and the rule join both need. -Name matches either. Rules are summarised on the policy row (how many, how many block, the highest severity) because a policy with no blocking rule enforces nothing regardless of its mode. Pass -IncludeRule for the rules themselves; see Get-MsecPurviewDlpRule for the detail. .PARAMETER Name Limit to policies whose display name OR internal name matches. Wildcards allowed - both are checked, because a renamed policy is findable under either. .PARAMETER IncludeRule Attach the policy's rules as a Rules property, in full. .EXAMPLE Connect-Msec -KeyVaultName kv-msec Get-MsecPurviewDlpPolicy | Format-Table Name, Mode, IsEnforcing, ExchangeScope, SharePointScope .EXAMPLE # The policies that CLAIM email coverage in Workload but target no mailboxes. Get-MsecPurviewDlpPolicy | Where-Object ClaimsEmailWithoutTarget | Format-Table Name, IsEnforcing, ExchangeScope, WorkloadClaims .EXAMPLE # Policies with no rule that blocks - on paper enforcing, in practice reporting. Get-MsecPurviewDlpPolicy | Where-Object { $_.IsEnforcing -and $_.BlockingRuleCount -eq 0 } .OUTPUTS One PSCustomObject per policy, PSTypeName 'MsecPurviewDlpPolicy'. .NOTES Needs Connect-Msec. The compliance session is opened automatically on first use - that handshake takes a few seconds and imports a few hundred cmdlets, so it is reported rather than done silently. Call Connect-MsecPurview yourself to control -Organization, or to choose when those 102 cmdlet names land in your runspace. Read-only. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [Parameter()] [string] $Name, [Parameter()] [switch] $IncludeRule ) Initialize-MsecExoSession -Endpoint Compliance Assert-MsecExoCmdlet -Name 'Get-DlpCompliancePolicy' -Feature 'Data Loss Prevention policies' $policies = @(Get-DlpCompliancePolicy -ErrorAction Stop) # Either name: someone reading the portal knows the display name, someone reading a script # knows the internal one, and a renamed policy makes those different strings. if ($Name) { $policies = @($policies | Where-Object { $_.DisplayName -like $Name -or $_.Name -like $Name }) } # One call for every rule, then grouped in memory: the per-policy call costs a round trip # each and this endpoint is slow enough for that to be felt on a tenant with many policies. $rulesByPolicy = @{} $ruleError = $null try { foreach ($rule in @(Get-DlpComplianceRule -ErrorAction Stop)) { $key = [string] $rule.ParentPolicyName if (-not $rulesByPolicy.ContainsKey($key)) { $rulesByPolicy[$key] = @() } $rulesByPolicy[$key] += $rule } } catch { # Rule counts become $null rather than 0 - see below. $ruleError = $_ Write-Warning "Could not read DLP rules, so the rule columns are null rather than zero: $($_.Exception.Message)" } $severityRank = @{ 'Low' = 1; 'Medium' = 2; 'High' = 3 } foreach ($policy in $policies) { $rules = @($rulesByPolicy[[string] $policy.Name]) $exchange = Resolve-MsecPurviewLocation $policy.ExchangeLocation $sharePoint = Resolve-MsecPurviewLocation $policy.SharePointLocation $oneDrive = Resolve-MsecPurviewLocation $policy.OneDriveLocation $teams = Resolve-MsecPurviewLocation $policy.TeamsLocation $endpoint = Resolve-MsecPurviewLocation $policy.EndpointDlpLocation $maxSeverity = $null if (-not $ruleError -and $rules.Count) { $ranked = @($rules | ForEach-Object { $severityRank[[string] $_.ReportSeverityLevel] } | Where-Object { $_ }) if ($ranked.Count) { $top = ($ranked | Measure-Object -Maximum).Maximum $maxSeverity = @($severityRank.Keys | Where-Object { $severityRank[$_] -eq $top })[0] } } $row = [PSCustomObject]@{ PSTypeName = 'MsecPurviewDlpPolicy' # What the portal shows. The rule join above deliberately uses $policy.Name instead - # ParentPolicyName tracks the internal name, never the display one. Name = [string] $(if ($policy.DisplayName) { $policy.DisplayName } else { $policy.Name }) InternalName = [string] $policy.Name Renamed = ([string] $policy.DisplayName -and [string] $policy.DisplayName -ne [string] $policy.Name) Mode = [string] $policy.Mode Enabled = [bool] $policy.Enabled # Only 'Enable' stops anything. TestWithNotifications reports and permits. IsEnforcing = ([string] $policy.Mode -eq 'Enable') # Declarative, and routinely contradicts the scopes below - see the help. WorkloadClaims = @(([string] $policy.Workload) -split ',\s*' | Where-Object { $_ }) # True when the policy ASSERTS email coverage it does not actually have. This is the # single most misleading thing about a DLP policy read from PowerShell. ClaimsEmailWithoutTarget = (([string] $policy.Workload) -match 'Exchange' -and $exchange.Scope -eq 'None') ExchangeScope = $exchange.Scope SharePointScope = $sharePoint.Scope OneDriveScope = $oneDrive.Scope TeamsScope = $teams.Scope EndpointScope = $endpoint.Scope ExchangeCount = $exchange.Count SharePointCount = $sharePoint.Count OneDriveCount = $oneDrive.Count TeamsCount = $teams.Count EndpointCount = $endpoint.Count ExchangeNames = $exchange.Names SharePointNames = $sharePoint.Names OneDriveNames = $oneDrive.Names TeamsNames = $teams.Names EndpointNames = $endpoint.Names # $null, not 0, when the rules could not be read: "no blocking rule" and "could not # tell" must not look alike on a control question. RuleCount = $(if ($ruleError) { $null } else { $rules.Count }) BlockingRuleCount = $(if ($ruleError) { $null } else { @($rules | Where-Object { $_.BlockAccess }).Count }) MaxRuleSeverity = $maxSeverity RuleNames = $(if ($ruleError) { $null } else { @($rules | ForEach-Object { [string] $_.Name }) }) CreatedBy = [string] $policy.CreatedBy WhenCreatedUtc = $policy.WhenCreated WhenChangedUtc = $policy.WhenChanged Comment = [string] $policy.Comment } if ($IncludeRule) { $row | Add-Member -NotePropertyName Rules -NotePropertyValue $rules } $row } } |