Skip to content

Commit 52a7aae

Browse files
authored
SecOps - 41034 - Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions (#1407)
[Spec-41034](https://github.com/microsoft/ztspecs/blob/main/specs/secops/41034.md) [Issue-906](microsoft/ztspecs#906)
2 parents 71200d6 + ac7506b commit 52a7aae

2 files changed

Lines changed: 324 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
Anti-spam policies (also called hosted content filter policies) decide what Exchange Online Protection does with inbound messages that the spam filter classifies as spam, high-confidence spam, phishing, high-confidence phishing, or bulk — quarantine the message, move it to the user's Junk Email folder, or deliver it untouched. Threat actors abuse over-permissive anti-spam configurations in two predictable ways. They slip credential-harvesting and malware-laden messages below a loose bulk-complaint threshold so the messages reach inboxes that should have rejected them; and they exploit the per-policy allowed-sender list — a single domain added to fix a one-time deliverability complaint bypasses spam, spoofing, and most phishing checks for every future message from that domain, so when the domain is later compromised or impersonated, phishing flows in without inspection. Microsoft's documented recommendation is to quarantine high-confidence verdicts with admin-only release, keep the bulk threshold low enough that genuine bulk traffic is filtered, and never use the per-policy allow lists for routine exceptions — narrow, time-bounded entries belong in the Tenant Allow/Block List, where they can be reviewed and expired. Some teams worry that strict quarantine actions will lose legitimate vendor mail; the recommended quarantine policies route everything to admin review first, so messages can be released without the recipient ever seeing them.
2+
3+
**Remediation action**
4+
5+
- [Anti-spam protection in EOP](https://learn.microsoft.com/en-us/defender-office-365/anti-spam-protection-about)
6+
- [Configure anti-spam policies in EOP](https://learn.microsoft.com/en-us/defender-office-365/anti-spam-policies-configure)
7+
- [Set-HostedContentFilterPolicy](https://learn.microsoft.com/en-us/powershell/module/exchangepowershell/set-hostedcontentfilterpolicy?view=exchange-ps)
8+
- [Bulk complaint level (BCL) values](https://learn.microsoft.com/en-us/defender-office-365/anti-spam-bulk-complaint-level-bcl-about)
9+
- [Recommended anti-spam settings](https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365#anti-spam-policy-settings)
10+
11+
<!--- Results --->
12+
%TestResult%
Lines changed: 312 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,312 @@
1+
<#
2+
.SYNOPSIS
3+
Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions.
4+
#>
5+
6+
function Test-Assessment-41034 {
7+
[ZtTest(
8+
Category = 'Email and collaboration security',
9+
CompatibleLicense = ('EXCHANGE_S_STANDARD'),
10+
ImplementationCost = 'Low',
11+
Pillar = 'SecOps',
12+
RiskLevel = 'Medium',
13+
Service = ('ExchangeOnline'),
14+
SfiPillar = 'Protect tenants and isolate production systems',
15+
TenantType = ('Workforce'),
16+
TestId = 41034,
17+
Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions',
18+
UserImpact = 'Medium'
19+
)]
20+
[CmdletBinding()]
21+
param()
22+
23+
#region Data Collection
24+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
25+
$activity = 'Checking anti-spam (hosted content filter) policy configuration'
26+
Write-ZtProgress -Activity $activity -Status 'Retrieving hosted content filter policies'
27+
28+
# Q1a: Retrieve all hosted content filter policies from Exchange Online.
29+
$allPolicies = $null
30+
try {
31+
$allPolicies = @(Get-HostedContentFilterPolicy -ErrorAction Stop |
32+
Select-Object Identity, IsBuiltInProtection, IsDefault,
33+
BulkThreshold, MarkAsSpamBulkMail,
34+
SpamAction, HighConfidenceSpamAction, PhishSpamAction,
35+
HighConfidencePhishAction, BulkSpamAction,
36+
AllowedSenders, AllowedSenderDomains,
37+
HighConfidencePhishQuarantineTag,
38+
PhishZapEnabled, SpamZapEnabled, InlineSafetyTipsEnabled)
39+
}
40+
catch {
41+
Write-PSFMessage "Failed to retrieve hosted content filter policies: $_" -Tag Test -Level Warning
42+
$params = @{
43+
TestId = '41034'
44+
Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions'
45+
Status = $false
46+
Result = '⚠️ Hosted content filter policies could not be retrieved; verify Exchange Online permissions and re-run.'
47+
CustomStatus = 'Investigate'
48+
}
49+
Add-ZtTestResultDetail @params
50+
return
51+
}
52+
53+
# Spec: The Default policy always exists in Exchange Online; zero rows indicates a cmdlet failure.
54+
if ($allPolicies.Count -eq 0) {
55+
$params = @{
56+
TestId = '41034'
57+
Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions'
58+
Status = $false
59+
Result = '⚠️ **Get-HostedContentFilterPolicy** returned no results. The Default policy is always present in Exchange Online; an empty result indicates an Exchange Online permission or connectivity issue rather than absence of protection — verify access and re-run.'
60+
CustomStatus = 'Investigate'
61+
}
62+
Add-ZtTestResultDetail @params
63+
return
64+
}
65+
66+
# Q1b: Retrieve all hosted content filter rules to determine which policies are actively applied.
67+
Write-ZtProgress -Activity $activity -Status 'Retrieving hosted content filter rules'
68+
$allRules = $null
69+
try {
70+
$allRules = @(Get-HostedContentFilterRule -ErrorAction Stop |
71+
Select-Object Name, HostedContentFilterPolicy, Priority, State,
72+
RecipientDomainIs, SentTo, SentToMemberOf)
73+
}
74+
catch {
75+
Write-PSFMessage "Failed to retrieve hosted content filter rules: $_" -Tag Test -Level Warning
76+
# Spec: if Get-HostedContentFilterRule fails while policies succeeded, return Investigate —
77+
# the rule set needed to determine which policies are actively applied cannot be read.
78+
$params = @{
79+
TestId = '41034'
80+
Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions'
81+
Status = $false
82+
Result = '⚠️ Hosted content filter policies were retrieved but the associated rules could not be read; the set of actively applied policies cannot be determined. Verify Exchange Online permissions and re-run.'
83+
CustomStatus = 'Investigate'
84+
}
85+
Add-ZtTestResultDetail @params
86+
return
87+
}
88+
#endregion Data Collection
89+
90+
#region Assessment Logic
91+
# Build case-insensitive identity lookup
92+
$policyByIdentity = @{}
93+
foreach ($policy in $allPolicies) {
94+
$policyByIdentity[$policy.Identity] = $policy
95+
}
96+
97+
# Spec: zero rows from Get-HostedContentFilterRule is NOT an error — evaluate Default policy alone.
98+
$enabledRules = @($allRules | Where-Object { $_.State -eq 'Enabled' })
99+
100+
# Map: policy identity → all rule names that reference it
101+
$rulesForPolicy = @{}
102+
foreach ($rule in $enabledRules) {
103+
if (-not $rulesForPolicy.ContainsKey($rule.HostedContentFilterPolicy)) {
104+
$rulesForPolicy[$rule.HostedContentFilterPolicy] = [System.Collections.Generic.List[string]]::new()
105+
}
106+
$rulesForPolicy[$rule.HostedContentFilterPolicy].Add($rule.Name)
107+
}
108+
109+
# Default policy (IsDefault == True) — always in-scope as the catch-all
110+
$defaultPolicy = $allPolicies | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1
111+
112+
# Spec: Default policy must be present; if policies were returned but none is marked IsDefault,
113+
# something is wrong with the data — return Investigate rather than a false Pass.
114+
if ($null -eq $defaultPolicy) {
115+
$params = @{
116+
TestId = '41034'
117+
Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions'
118+
Status = $false
119+
Result = '⚠️ Policies were returned by **Get-HostedContentFilterPolicy** but none has **IsDefault = $true**. The Default policy is always present in Exchange Online; this indicates unexpected data — verify Exchange Online access and re-run.'
120+
CustomStatus = 'Investigate'
121+
}
122+
Add-ZtTestResultDetail @params
123+
return
124+
}
125+
126+
# Collect in-scope policy identities: Default first, then all referenced by enabled rules (deduplicated).
127+
# Use OrdinalIgnoreCase HashSet — Exchange may return different casing between cmdlets.
128+
$inScopeIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
129+
$inScopeIdentities.Add($defaultPolicy.Identity) | Out-Null
130+
foreach ($policyName in $rulesForPolicy.Keys) {
131+
$inScopeIdentities.Add($policyName) | Out-Null
132+
}
133+
134+
$passed = $true
135+
$hasInvestigate = $false
136+
137+
$policyRows = [System.Collections.Generic.List[PSCustomObject]]::new()
138+
139+
# Process in-scope policies
140+
foreach ($identity in $inScopeIdentities) {
141+
$policy = $policyByIdentity[$identity]
142+
$ruleName = if ($rulesForPolicy.ContainsKey($identity)) { $rulesForPolicy[$identity] -join ', ' } else { '' }
143+
144+
if ($null -eq $policy) {
145+
# Orphan rule: enabled rule references a policy not found in Get-HostedContentFilterPolicy → Investigate
146+
$hasInvestigate = $true
147+
$policyRows.Add([PSCustomObject]@{
148+
Identity = $identity
149+
IsDefault = $false
150+
IsOrphan = $true
151+
RuleName = $ruleName
152+
RowResult = 'Investigate'
153+
})
154+
continue
155+
}
156+
157+
# Evaluate each baseline property and collect named fail reasons
158+
$allowedSendersCount = ($policy.AllowedSenders | Measure-Object).Count
159+
$allowedSenderDomainsCount = ($policy.AllowedSenderDomains | Measure-Object).Count
160+
161+
$failReasons = [System.Collections.Generic.List[string]]::new()
162+
# Spec-named reasons first (most impactful)
163+
if ($policy.HighConfidencePhishAction -ne 'Quarantine') { $failReasons.Add('HC-phish delivered') }
164+
if ($policy.PhishSpamAction -ne 'Quarantine') { $failReasons.Add('phish delivered') }
165+
if ($policy.HighConfidenceSpamAction -ne 'Quarantine') { $failReasons.Add('HC-spam delivered') }
166+
if ($policy.SpamAction -notin @('MoveToJmf', 'Quarantine')) { $failReasons.Add("spam action $($policy.SpamAction)") }
167+
if ($policy.BulkSpamAction -notin @('MoveToJmf', 'Quarantine')) { $failReasons.Add("bulk action $($policy.BulkSpamAction)") }
168+
if ($null -eq $policy.BulkThreshold) { $failReasons.Add('bulk threshold null (unexpected — verify policy data)') }
169+
elseif ($policy.BulkThreshold -gt 6) { $failReasons.Add("bulk threshold $($policy.BulkThreshold)") }
170+
if ($policy.MarkAsSpamBulkMail -ne 'On') { $failReasons.Add('MarkAsSpamBulkMail off') }
171+
if ($allowedSendersCount -gt 0) { $failReasons.Add('allowed senders non-empty') }
172+
if ($allowedSenderDomainsCount -gt 0) { $failReasons.Add('allowed domains non-empty') }
173+
if ($policy.HighConfidencePhishQuarantineTag -ne 'AdminOnlyAccessPolicy') { $failReasons.Add('HC-phish tag allows self-release') }
174+
if ($policy.PhishZapEnabled -ne $true) { $failReasons.Add('PhishZAP disabled') }
175+
if ($policy.SpamZapEnabled -ne $true) { $failReasons.Add('SpamZAP disabled') }
176+
if ($policy.InlineSafetyTipsEnabled -ne $true) { $failReasons.Add('SafetyTips disabled') }
177+
178+
$rowFails = $failReasons.Count -gt 0
179+
if ($rowFails) {
180+
$passed = $false
181+
}
182+
183+
$rowResult = if ($rowFails) { 'Fail' } else { 'Pass' }
184+
# Default policy has no rule; rule name is blank for it.
185+
$displayRuleName = if ($policy.IsDefault) { '' } else { $ruleName }
186+
187+
$policyRows.Add([PSCustomObject]@{
188+
Identity = $identity
189+
IsDefault = ($policy.IsDefault -eq $true)
190+
IsOrphan = $false
191+
RuleName = $displayRuleName
192+
FailReasons = $failReasons
193+
RowResult = $rowResult
194+
SpamAction = $policy.SpamAction
195+
HighConfidenceSpamAction = $policy.HighConfidenceSpamAction
196+
PhishSpamAction = $policy.PhishSpamAction
197+
HighConfidencePhishAction = $policy.HighConfidencePhishAction
198+
BulkSpamAction = $policy.BulkSpamAction
199+
BulkThreshold = $policy.BulkThreshold
200+
MarkAsSpamBulkMail = $policy.MarkAsSpamBulkMail
201+
PhishZapEnabled = $policy.PhishZapEnabled
202+
SpamZapEnabled = $policy.SpamZapEnabled
203+
InlineSafetyTipsEnabled = $policy.InlineSafetyTipsEnabled
204+
AllowedSendersCount = $allowedSendersCount
205+
AllowedSenderDomainsCount = $allowedSenderDomainsCount
206+
HighConfidencePhishQuarantineTag = $policy.HighConfidencePhishQuarantineTag
207+
})
208+
}
209+
210+
# Aggregate verdict: Fail > Investigate > Pass
211+
$customStatus = $null
212+
if (-not $passed) {
213+
$testResultMarkdown = "❌ One or more anti-spam policies allow high-confidence phishing to be delivered (action is not Quarantine), set a permissive bulk threshold, or contain entries in the per-policy allowed-sender list that bypass spam, spoofing, and most phishing filtering for those senders. Operational exceptions for legitimate vendors belong in the **[Tenant Allow/Block List](https://security.microsoft.com/tenantAllowBlockList)**, where they can be reviewed and expired.`n`n%TestResult%"
214+
}
215+
elseif ($hasInvestigate) {
216+
$passed = $false
217+
$customStatus = 'Investigate'
218+
$testResultMarkdown = "⚠️ An enabled rule references a policy that does not exist in **Get-HostedContentFilterPolicy**; manual review is required.`n`n%TestResult%"
219+
}
220+
else {
221+
$testResultMarkdown = "✅ Anti-spam policies route high-confidence phishing to admin-only quarantine, use a strict bulk threshold, and contain no per-policy allow-sender entries that bypass filtering.`n`n%TestResult%"
222+
}
223+
#endregion Assessment Logic
224+
225+
#region Report Generation
226+
$portalUrl = 'https://security.microsoft.com/antispam'
227+
$maxDisplay = 10
228+
$totalCount = $policyRows.Count
229+
230+
# Sort: worst verdict first (Fail > Investigate > Pass), then alphabetically by identity
231+
$statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 }
232+
$sortedRows = @($policyRows | Sort-Object { $statusPriority[$_.RowResult] }, Identity)
233+
$displayRows = @($sortedRows | Select-Object -First $maxDisplay)
234+
235+
$tableRows = ''
236+
foreach ($row in $displayRows) {
237+
# Policy column: Identity with [default] suffix for the Default policy
238+
$policySuffix = if ($row.IsDefault) { ' [default]' } else { '' }
239+
$policyDisplay = "$(Get-SafeMarkdown $row.Identity)$policySuffix"
240+
241+
# Scope column: show all rule names if multiple rules reference this policy
242+
$scopeDisplay = if ($row.IsOrphan -or $row.RuleName) {
243+
$ruleLabel = if (($row.RuleName -split ', ').Count -gt 1) { 'rules' } else { 'rule' }
244+
"Applied via $ruleLabel $(Get-SafeMarkdown $row.RuleName)"
245+
} else {
246+
'Default (catch-all)'
247+
}
248+
249+
# Compact columns — blank for orphan rows where policy data is unavailable
250+
if ($row.IsOrphan) {
251+
$filterActionsDisplay = ''
252+
$bulkZapDisplay = ''
253+
$allowListsDisplay = ''
254+
}
255+
else {
256+
# Filter actions: Phish: <action>/HC:<action> • Spam: <action>/HC:<action> • Bulk: <action>
257+
$filterActionsDisplay = "Phish: $($row.PhishSpamAction)/HC:$($row.HighConfidencePhishAction) • Spam: $($row.SpamAction)/HC:$($row.HighConfidenceSpamAction) • Bulk: $($row.BulkSpamAction)"
258+
259+
# Bulk & ZAP: Bulk: <n>/6 • MarkBulk: Y/N • ZAP: Y/N • SafetyTips: Y/N
260+
# ZAP is a single Y/N: Y only when both PhishZapEnabled AND SpamZapEnabled are true
261+
$zapDisplay = if ($row.PhishZapEnabled -eq $true -and $row.SpamZapEnabled -eq $true) { 'Y' } else { 'N' }
262+
$markBulkDisplay = if ($row.MarkAsSpamBulkMail -eq 'On') { 'Y' } else { 'N' }
263+
$safetyTipsDisplay = if ($row.InlineSafetyTipsEnabled -eq $true) { 'Y' } else { 'N' }
264+
$bulkZapDisplay = "Bulk: $($row.BulkThreshold)/6 • MarkBulk: $markBulkDisplay • ZAP: $zapDisplay • SafetyTips: $safetyTipsDisplay"
265+
266+
# Allow lists & quarantine tag: Senders: N • Domains: N • HC-Phish tag: <tag>
267+
$allowListsDisplay = "Senders: $($row.AllowedSendersCount) • Domains: $($row.AllowedSenderDomainsCount) • HC-Phish tag: $(Get-SafeMarkdown -Text $row.HighConfidencePhishQuarantineTag)"
268+
}
269+
270+
# Result column with named reasons per spec
271+
$resultDisplay = switch ($row.RowResult) {
272+
'Pass' { '✅ Pass' }
273+
'Investigate' { '⚠️ Investigate (orphan rule reference)' }
274+
'Fail' { "❌ Fail ($($row.FailReasons -join '; '))" }
275+
}
276+
277+
$tableRows += "| $policyDisplay | $scopeDisplay | $filterActionsDisplay | $bulkZapDisplay | $allowListsDisplay | $resultDisplay |`n"
278+
}
279+
280+
if ($totalCount -gt $maxDisplay) {
281+
$tableRows += "| ... | ... | ... | ... | ... | ... |`n"
282+
}
283+
284+
$preTableLines = ''
285+
if ($totalCount -gt $maxDisplay) {
286+
$preTableLines = "Showing $maxDisplay of $totalCount policies. [View all in Microsoft 365 Defender > Policies & rules > Threat policies > Anti-spam]($portalUrl)`n`n"
287+
}
288+
289+
$formatTemplate = @'
290+
{0}
291+
## [Anti-spam policy settings]({2})
292+
293+
| Policy | Scope | Filter actions | Bulk & ZAP | Allow lists & quarantine tag | Result |
294+
| :----- | :---- | :------------- | :--------- | :--------------------------- | :----- |
295+
{1}
296+
'@
297+
298+
$mdInfo = $formatTemplate -f $preTableLines, $tableRows, $portalUrl
299+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
300+
#endregion Report Generation
301+
302+
$params = @{
303+
TestId = '41034'
304+
Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions'
305+
Status = $passed
306+
Result = $testResultMarkdown
307+
}
308+
if ($customStatus) {
309+
$params.CustomStatus = $customStatus
310+
}
311+
Add-ZtTestResultDetail @params
312+
}

0 commit comments

Comments
 (0)