public/Get-MsecDefenderOfficePolicy.ps1
|
function Get-MsecDefenderOfficePolicy { <# .SYNOPSIS The Defender for Office 365 and Exchange Online Protection policies that filter mail - anti-phishing, Safe Links, Safe Attachments, anti-spam, anti-malware, outbound spam - as one row per setting, with whether the policy applies to anyone. .DESCRIPTION msec already reads Exchange MAIL FLOW: transport rules, remote domains, outbound forwarding. This reads the protection stack sitting on top of it, which is where phishing and malware are actually caught or missed. A POLICY THAT APPLIES TO NOBODY IS THE POINT, not an empty result. In Exchange Online Protection a policy and the rule that applies it are separate objects, and a custom policy with no rule is inert however carefully it was written. Every row therefore carries IsApplied and AppliedBy, so `Where-Object { -not $_.IsApplied }` is the whole question. HOW A POLICY COMES TO APPLY, in the order this command tests: Default - IsDefault. Applies to everyone not matched by something above it. Never has a rule, and must not be reported as unapplied. Preset - named by an enabled EOP or ATP protection policy rule (the Standard and Strict preset security policies). These also have no rule of their own, for the same reason. Microsoft evaluates presets BEFORE custom policies, so a weak custom policy does not necessarily win just by existing. Built-in - Microsoft's Built-In Protection Policy, the floor for Safe Links and Safe Attachments. Rule - a custom policy named by its own *Rule. AppliedTo carries the rule's recipient conditions. (none) - a custom policy no rule references. Inert. PRESET POLICIES ARE NOT MISCONFIGURATION. Reporting 'Strict Preset Security Policy' as unapplied because Get-AntiPhishRule does not mention it would be crying wolf on the one configuration Microsoft most recommends. ADVANCED DELIVERY IS REPORTED AS UNREADABLE WHERE IT CANNOT BE READ. The phishing simulation and SecOps overrides fail server-side under an app-only session on at least some tenants, and 'could not read' must not render as 'not configured' - that is the difference between a tenant with a phishing-simulation exemption and one without. .PARAMETER PolicyType Which areas to read. Default is all of them. .PARAMETER UnappliedOnly Only policies that currently apply to nobody. .PARAMETER All Every property of every policy, not just the security-relevant projection. .EXAMPLE Get-MsecDefenderOfficePolicy -UnappliedOnly Policies someone wrote that are not in force. .EXAMPLE Get-MsecDefenderOfficePolicy -PolicyType AntiSpam | Where-Object Setting -eq 'AllowedSenderDomains' Domains exempted from spam filtering - an allow list here bypasses filtering entirely. .EXAMPLE Get-MsecDefenderOfficePolicy -PolicyType Preset Which preset security policies are on, and who they cover. #> [CmdletBinding()] [OutputType([PSCustomObject])] param( [ValidateSet('Preset', 'AntiPhish', 'SafeLinks', 'SafeAttachment', 'AntiSpam', 'AntiMalware', 'OutboundSpam', 'AdvancedDelivery')] [string[]] $PolicyType = @('Preset', 'AntiPhish', 'SafeLinks', 'SafeAttachment', 'AntiSpam', 'AntiMalware', 'OutboundSpam', 'AdvancedDelivery'), [switch] $UnappliedOnly, [switch] $All ) Initialize-MsecExoSession -Endpoint Exchange # Which settings matter, per area. Written out rather than derived: "which of these two # hundred properties is a security control" is a judgement the reader must be able to check. $projection = @{ AntiPhish = @{ Policy = 'Get-AntiPhishPolicy'; Rule = 'Get-AntiPhishRule'; Link = 'AntiPhishPolicy' Settings = @( 'Enabled' 'PhishThresholdLevel' # 1 is the least aggressive of four 'EnableSpoofIntelligence' 'AuthenticationFailAction' 'HonorDmarcPolicy' 'EnableFirstContactSafetyTips' 'EnableTargetedUserProtection' # impersonation of named people 'TargetedUserProtectionAction' # 'NoAction' detects and does nothing 'EnableTargetedDomainsProtection' 'EnableOrganizationDomainsProtection' 'TargetedDomainProtectionAction' 'EnableMailboxIntelligence' 'EnableMailboxIntelligenceProtection' 'MailboxIntelligenceProtectionAction' 'EnableUnauthenticatedSender' 'EnableViaTag' ) } SafeLinks = @{ Policy = 'Get-SafeLinksPolicy'; Rule = 'Get-SafeLinksRule'; Link = 'SafeLinksPolicy' Settings = @( 'EnableSafeLinksForEmail'; 'EnableSafeLinksForTeams'; 'EnableSafeLinksForOffice' 'ScanUrls' 'DeliverMessageAfterScan' # false delivers before the verdict 'EnableForInternalSenders' # a compromised insider is a sender too 'TrackClicks' 'AllowClickThrough' # true lets the user ignore the warning 'DisableUrlRewrite' 'DoNotRewriteUrls' # an allow list, so worth reading ) } SafeAttachment = @{ Policy = 'Get-SafeAttachmentPolicy'; Rule = 'Get-SafeAttachmentRule'; Link = 'SafeAttachmentPolicy' Settings = @('Enable'; 'Action'; 'QuarantineTag'; 'Redirect'; 'RedirectAddress') } AntiSpam = @{ Policy = 'Get-HostedContentFilterPolicy'; Rule = 'Get-HostedContentFilterRule'; Link = 'HostedContentFilterPolicy' Settings = @( 'SpamAction'; 'HighConfidenceSpamAction' 'PhishSpamAction'; 'HighConfidencePhishAction' 'BulkSpamAction'; 'BulkThreshold'; 'MarkAsSpamBulkMail' 'SpamZapEnabled'; 'PhishZapEnabled' # Allow lists here skip filtering for a whole domain and are trivially spoofed. 'AllowedSenderDomains'; 'AllowedSenders' 'BlockedSenderDomains'; 'BlockedSenders' 'InlineSafetyTipsEnabled'; 'EnableEndUserSpamNotifications' ) } AntiMalware = @{ Policy = 'Get-MalwareFilterPolicy'; Rule = 'Get-MalwareFilterRule'; Link = 'MalwareFilterPolicy' Settings = @( 'EnableFileFilter'; 'FileTypeAction'; 'FileTypes' 'ZapEnabled'; 'QuarantineTag' 'EnableInternalSenderAdminNotifications' ) } OutboundSpam = @{ Policy = 'Get-HostedOutboundSpamFilterPolicy'; Rule = 'Get-HostedOutboundSpamFilterRule'; Link = 'HostedOutboundSpamFilterPolicy' Settings = @( 'RecipientLimitExternalPerHour'; 'RecipientLimitInternalPerHour'; 'RecipientLimitPerDay' 'ActionWhenThresholdReached' 'AutoForwardingMode' # also surfaced by Get-MsecExchangeOrganizationSetting 'BccSuspiciousOutboundMail'; 'NotifyOutboundSpam' ) } } $read = { param($Cmdlet, $Label) try { ,@(& $Cmdlet -ErrorAction Stop) } catch { Write-Warning "Could not read $Label via $Cmdlet, so it is NOT covered by this output - which is not the same as it being absent. Exchange said: $($_.Exception.Message)" ,$null } } $unreadableRow = { param($Type, $Name) [PSCustomObject]@{ PSTypeName = 'MsecDefenderOfficePolicy' PolicyType = $Type; PolicyName = $Name Setting = $null; Value = $null IsDefault = $null; IsApplied = $null; AppliedBy = $null; AppliedTo = $null } } # The preset rules name the policies they apply, so one read answers "is this policy a # preset that is switched on" for every area at once. $presetFor = @{} $presetRules = @() foreach ($c in 'Get-EOPProtectionPolicyRule', 'Get-ATPProtectionPolicyRule') { $rules = & $read $c 'preset security policy rules' if ($null -eq $rules) { continue } $presetRules += @($rules) foreach ($rule in @($rules)) { foreach ($link in 'AntiPhishPolicy', 'HostedContentFilterPolicy', 'MalwareFilterPolicy', 'SafeLinksPolicy', 'SafeAttachmentPolicy') { $named = [string] $rule.$link if ($named) { $presetFor[$named] = [pscustomobject]@{ RuleName = [string] $rule.Name State = [string] $rule.State Scope = (@( foreach ($p in 'SentTo', 'SentToMemberOf', 'RecipientDomainIs') { if ($rule.$p -and @($rule.$p).Count) { "$p=$((@($rule.$p)) -join ';')" } }) -join ' ') } } } } } if ($PolicyType -contains 'Preset') { if (-not $presetRules) { & $unreadableRow 'Preset' 'Unreadable' } foreach ($rule in $presetRules) { # A preset rule with no recipient condition is NOT self-evidently tenant-wide, and # guessing either way would be worse than saying so. $scope = (@( foreach ($p in 'SentTo', 'SentToMemberOf', 'RecipientDomainIs') { if ($rule.$p -and @($rule.$p).Count) { "$p=$((@($rule.$p)) -join ';')" } }) -join ' ') foreach ($pair in @( @{ S = 'State'; V = [string] $rule.State } @{ S = 'Priority'; V = [string] $rule.Priority } @{ S = 'Scope'; V = if ($scope) { $scope } else { '(no recipient condition returned - confirm in the portal)' } } )) { [PSCustomObject]@{ PSTypeName = 'MsecDefenderOfficePolicy' PolicyType = 'Preset' PolicyName = [string] $rule.Name Setting = $pair.S Value = $pair.V IsDefault = $false IsApplied = ([string] $rule.State -eq 'Enabled') AppliedBy = 'Preset' AppliedTo = if ($scope) { $scope } else { $null } } } } } foreach ($type in @($PolicyType | Where-Object { $_ -notin 'Preset', 'AdvancedDelivery' })) { $spec = $projection[$type] $policies = & $read $spec.Policy "$type policies" if ($null -eq $policies) { & $unreadableRow $type 'Unreadable'; continue } # A missing rule list is not an empty one: without it, every custom policy would be # reported inert. $rules = & $read $spec.Rule "$type rules" $rulesRead = $null -ne $rules foreach ($policy in $policies) { $name = [string] $policy.Name if (-not $name) { $name = [string] $policy.Identity } $isDefault = [bool] $policy.IsDefault $preset = $presetFor[$name] $rule = if ($rulesRead) { @($rules | Where-Object { [string] $_.($spec.Link) -eq $name })[0] } else { $null } # Order matters: a default or preset policy legitimately has no rule. $appliedBy = $null; $appliedTo = $null; $isApplied = $null if ($isDefault) { $appliedBy = 'Default'; $isApplied = $true $appliedTo = 'everyone not matched by a higher-priority policy' } elseif ($preset) { $appliedBy = "Preset: $($preset.RuleName)" $isApplied = ($preset.State -eq 'Enabled') $appliedTo = if ($preset.Scope) { $preset.Scope } else { $null } } elseif ($name -like '*Built-In Protection*') { $appliedBy = 'Built-in'; $isApplied = $true $appliedTo = 'Microsoft baseline, minus any exclusions' } elseif ($rule) { $appliedBy = "Rule: $([string] $rule.Name)" $isApplied = ([string] $rule.State -eq 'Enabled') $appliedTo = (@( foreach ($p in 'SentTo', 'SentToMemberOf', 'RecipientDomainIs') { if ($rule.$p -and @($rule.$p).Count) { "$p=$((@($rule.$p)) -join ';')" } }) -join ' ') } elseif ($rulesRead) { # Custom, rules were read, nothing references it. Inert. $isApplied = $false } # else: rules unreadable - IsApplied stays $null rather than claiming inert. if ($UnappliedOnly -and $isApplied -ne $false) { continue } $settings = if ($All) { @($policy.PSObject.Properties.Name | Where-Object { $_ -notin 'Name', 'Identity' }) } else { # Only properties this object actually carries: the set moves between service # versions, and asking for an absent one emits a null that reads as "off". @($spec.Settings | Where-Object { $_ -in $policy.PSObject.Properties.Name }) } foreach ($setting in $settings) { $value = $policy.$setting if ($null -ne $value -and $value -isnot [string] -and $value -is [System.Collections.IEnumerable]) { $value = (@($value) | ForEach-Object { "$_" }) -join '; ' } [PSCustomObject]@{ PSTypeName = 'MsecDefenderOfficePolicy' PolicyType = $type PolicyName = $name Setting = $setting Value = [string] $value IsDefault = $isDefault IsApplied = $isApplied AppliedBy = $appliedBy AppliedTo = if ($appliedTo) { $appliedTo } else { $null } } } } } if ($PolicyType -contains 'AdvancedDelivery') { foreach ($pair in @( @{ C = 'Get-PhishSimOverridePolicy'; N = 'Phishing simulation override' } @{ C = 'Get-SecOpsOverridePolicy'; N = 'SecOps mailbox override' } )) { $got = & $read $pair.C $pair.N if ($null -eq $got) { & $unreadableRow 'AdvancedDelivery' $pair.N; continue } if (-not @($got).Count) { # Genuinely read and genuinely empty, which is a real and different answer. [PSCustomObject]@{ PSTypeName = 'MsecDefenderOfficePolicy' PolicyType = 'AdvancedDelivery'; PolicyName = $pair.N Setting = 'Configured'; Value = 'False' IsDefault = $false; IsApplied = $false; AppliedBy = $null; AppliedTo = $null } continue } foreach ($o in @($got)) { [PSCustomObject]@{ PSTypeName = 'MsecDefenderOfficePolicy' PolicyType = 'AdvancedDelivery'; PolicyName = $pair.N Setting = 'Enabled'; Value = [string] $o.Enabled IsDefault = $false IsApplied = [bool] $o.Enabled AppliedBy = 'AdvancedDelivery'; AppliedTo = $null } } } } } |