tests/Test-Assessment.41034.ps1
|
<#
.SYNOPSIS Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions. #> function Test-Assessment-41034 { [ZtTest( Category = 'Email and collaboration security', CompatibleLicense = ('EXCHANGE_S_STANDARD'), ImplementationCost = 'Low', Pillar = 'SecOps', RiskLevel = 'Medium', Service = ('ExchangeOnline'), SfiPillar = 'Protect tenants and isolate production systems', TenantType = ('Workforce'), TestId = 41034, Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions', UserImpact = 'Medium' )] [CmdletBinding()] param() #region Data Collection Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose $activity = 'Checking anti-spam (hosted content filter) policy configuration' Write-ZtProgress -Activity $activity -Status 'Retrieving hosted content filter policies' # Q1a: Retrieve all hosted content filter policies from Exchange Online. $allPolicies = $null try { $allPolicies = @(Get-HostedContentFilterPolicy -ErrorAction Stop | Select-Object Identity, IsBuiltInProtection, IsDefault, BulkThreshold, MarkAsSpamBulkMail, SpamAction, HighConfidenceSpamAction, PhishSpamAction, HighConfidencePhishAction, BulkSpamAction, AllowedSenders, AllowedSenderDomains, HighConfidencePhishQuarantineTag, PhishZapEnabled, SpamZapEnabled, InlineSafetyTipsEnabled) } catch { Write-PSFMessage "Failed to retrieve hosted content filter policies: $_" -Tag Test -Level Warning $params = @{ TestId = '41034' Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions' Status = $false Result = '⚠️ Hosted content filter policies could not be retrieved; verify Exchange Online permissions and re-run.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } # Spec: The Default policy always exists in Exchange Online; zero rows indicates a cmdlet failure. if ($allPolicies.Count -eq 0) { $params = @{ TestId = '41034' Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions' Status = $false 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.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } # Q1b: Retrieve all hosted content filter rules to determine which policies are actively applied. Write-ZtProgress -Activity $activity -Status 'Retrieving hosted content filter rules' $allRules = $null try { $allRules = @(Get-HostedContentFilterRule -ErrorAction Stop | Select-Object Name, HostedContentFilterPolicy, Priority, State, RecipientDomainIs, SentTo, SentToMemberOf) } catch { Write-PSFMessage "Failed to retrieve hosted content filter rules: $_" -Tag Test -Level Warning # Spec: if Get-HostedContentFilterRule fails while policies succeeded, return Investigate — # the rule set needed to determine which policies are actively applied cannot be read. $params = @{ TestId = '41034' Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions' Status = $false 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.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } #endregion Data Collection #region Assessment Logic # Build case-insensitive identity lookup $policyByIdentity = @{} foreach ($policy in $allPolicies) { $policyByIdentity[$policy.Identity] = $policy } # Spec: zero rows from Get-HostedContentFilterRule is NOT an error — evaluate Default policy alone. $enabledRules = @($allRules | Where-Object { $_.State -eq 'Enabled' }) # Map: policy identity → all rule names that reference it $rulesForPolicy = @{} foreach ($rule in $enabledRules) { if (-not $rulesForPolicy.ContainsKey($rule.HostedContentFilterPolicy)) { $rulesForPolicy[$rule.HostedContentFilterPolicy] = [System.Collections.Generic.List[string]]::new() } $rulesForPolicy[$rule.HostedContentFilterPolicy].Add($rule.Name) } # Default policy (IsDefault == True) — always in-scope as the catch-all $defaultPolicy = $allPolicies | Where-Object { $_.IsDefault -eq $true } | Select-Object -First 1 # Spec: Default policy must be present; if policies were returned but none is marked IsDefault, # something is wrong with the data — return Investigate rather than a false Pass. if ($null -eq $defaultPolicy) { $params = @{ TestId = '41034' Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions' Status = $false 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.' CustomStatus = 'Investigate' } Add-ZtTestResultDetail @params return } # Collect in-scope policy identities: Default first, then all referenced by enabled rules (deduplicated). # Use OrdinalIgnoreCase HashSet — Exchange may return different casing between cmdlets. $inScopeIdentities = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase) $inScopeIdentities.Add($defaultPolicy.Identity) | Out-Null foreach ($policyName in $rulesForPolicy.Keys) { $inScopeIdentities.Add($policyName) | Out-Null } $passed = $true $hasInvestigate = $false $policyRows = [System.Collections.Generic.List[PSCustomObject]]::new() # Process in-scope policies foreach ($identity in $inScopeIdentities) { $policy = $policyByIdentity[$identity] $ruleName = if ($rulesForPolicy.ContainsKey($identity)) { $rulesForPolicy[$identity] -join ', ' } else { '' } if ($null -eq $policy) { # Orphan rule: enabled rule references a policy not found in Get-HostedContentFilterPolicy → Investigate $hasInvestigate = $true $policyRows.Add([PSCustomObject]@{ Identity = $identity IsDefault = $false IsOrphan = $true RuleName = $ruleName RowResult = 'Investigate' }) continue } # Evaluate each baseline property and collect named fail reasons $allowedSendersCount = ($policy.AllowedSenders | Measure-Object).Count $allowedSenderDomainsCount = ($policy.AllowedSenderDomains | Measure-Object).Count $failReasons = [System.Collections.Generic.List[string]]::new() # Spec-named reasons first (most impactful) if ($policy.HighConfidencePhishAction -ne 'Quarantine') { $failReasons.Add('HC-phish delivered') } if ($policy.PhishSpamAction -ne 'Quarantine') { $failReasons.Add('phish delivered') } if ($policy.HighConfidenceSpamAction -ne 'Quarantine') { $failReasons.Add('HC-spam delivered') } if ($policy.SpamAction -notin @('MoveToJmf', 'Quarantine')) { $failReasons.Add("spam action $($policy.SpamAction)") } if ($policy.BulkSpamAction -notin @('MoveToJmf', 'Quarantine')) { $failReasons.Add("bulk action $($policy.BulkSpamAction)") } if ($null -eq $policy.BulkThreshold) { $failReasons.Add('bulk threshold null (unexpected — verify policy data)') } elseif ($policy.BulkThreshold -gt 6) { $failReasons.Add("bulk threshold $($policy.BulkThreshold)") } if ($policy.MarkAsSpamBulkMail -ne 'On') { $failReasons.Add('MarkAsSpamBulkMail off') } if ($allowedSendersCount -gt 0) { $failReasons.Add('allowed senders non-empty') } if ($allowedSenderDomainsCount -gt 0) { $failReasons.Add('allowed domains non-empty') } if ($policy.HighConfidencePhishQuarantineTag -ne 'AdminOnlyAccessPolicy') { $failReasons.Add('HC-phish tag allows self-release') } if ($policy.PhishZapEnabled -ne $true) { $failReasons.Add('PhishZAP disabled') } if ($policy.SpamZapEnabled -ne $true) { $failReasons.Add('SpamZAP disabled') } if ($policy.InlineSafetyTipsEnabled -ne $true) { $failReasons.Add('SafetyTips disabled') } $rowFails = $failReasons.Count -gt 0 if ($rowFails) { $passed = $false } $rowResult = if ($rowFails) { 'Fail' } else { 'Pass' } # Default policy has no rule; rule name is blank for it. $displayRuleName = if ($policy.IsDefault) { '' } else { $ruleName } $policyRows.Add([PSCustomObject]@{ Identity = $identity IsDefault = ($policy.IsDefault -eq $true) IsOrphan = $false RuleName = $displayRuleName FailReasons = $failReasons RowResult = $rowResult SpamAction = $policy.SpamAction HighConfidenceSpamAction = $policy.HighConfidenceSpamAction PhishSpamAction = $policy.PhishSpamAction HighConfidencePhishAction = $policy.HighConfidencePhishAction BulkSpamAction = $policy.BulkSpamAction BulkThreshold = $policy.BulkThreshold MarkAsSpamBulkMail = $policy.MarkAsSpamBulkMail PhishZapEnabled = $policy.PhishZapEnabled SpamZapEnabled = $policy.SpamZapEnabled InlineSafetyTipsEnabled = $policy.InlineSafetyTipsEnabled AllowedSendersCount = $allowedSendersCount AllowedSenderDomainsCount = $allowedSenderDomainsCount HighConfidencePhishQuarantineTag = $policy.HighConfidencePhishQuarantineTag }) } # Aggregate verdict: Fail > Investigate > Pass $customStatus = $null if (-not $passed) { $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%" } elseif ($hasInvestigate) { $passed = $false $customStatus = 'Investigate' $testResultMarkdown = "⚠️ An enabled rule references a policy that does not exist in **Get-HostedContentFilterPolicy**; manual review is required.`n`n%TestResult%" } else { $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%" } #endregion Assessment Logic #region Report Generation $portalUrl = 'https://security.microsoft.com/antispam' $maxDisplay = 10 $totalCount = $policyRows.Count # Sort: worst verdict first (Fail > Investigate > Pass), then alphabetically by identity $statusPriority = @{ Fail = 0; Investigate = 1; Pass = 2 } $sortedRows = @($policyRows | Sort-Object { $statusPriority[$_.RowResult] }, Identity) $displayRows = @($sortedRows | Select-Object -First $maxDisplay) $tableRows = '' foreach ($row in $displayRows) { # Policy column: Identity with [default] suffix for the Default policy $policySuffix = if ($row.IsDefault) { ' [default]' } else { '' } $policyDisplay = "$(Get-SafeMarkdown $row.Identity)$policySuffix" # Scope column: show all rule names if multiple rules reference this policy $scopeDisplay = if ($row.IsOrphan -or $row.RuleName) { $ruleLabel = if (($row.RuleName -split ', ').Count -gt 1) { 'rules' } else { 'rule' } "Applied via $ruleLabel $(Get-SafeMarkdown $row.RuleName)" } else { 'Default (catch-all)' } # Compact columns — blank for orphan rows where policy data is unavailable if ($row.IsOrphan) { $filterActionsDisplay = '—' $bulkZapDisplay = '—' $allowListsDisplay = '—' } else { # Filter actions: Phish: <action>/HC:<action> • Spam: <action>/HC:<action> • Bulk: <action> $filterActionsDisplay = "Phish: $($row.PhishSpamAction)/HC:$($row.HighConfidencePhishAction) • Spam: $($row.SpamAction)/HC:$($row.HighConfidenceSpamAction) • Bulk: $($row.BulkSpamAction)" # Bulk & ZAP: Bulk: <n>/6 • MarkBulk: Y/N • ZAP: Y/N • SafetyTips: Y/N # ZAP is a single Y/N: Y only when both PhishZapEnabled AND SpamZapEnabled are true $zapDisplay = if ($row.PhishZapEnabled -eq $true -and $row.SpamZapEnabled -eq $true) { 'Y' } else { 'N' } $markBulkDisplay = if ($row.MarkAsSpamBulkMail -eq 'On') { 'Y' } else { 'N' } $safetyTipsDisplay = if ($row.InlineSafetyTipsEnabled -eq $true) { 'Y' } else { 'N' } $bulkZapDisplay = "Bulk: $($row.BulkThreshold)/6 • MarkBulk: $markBulkDisplay • ZAP: $zapDisplay • SafetyTips: $safetyTipsDisplay" # Allow lists & quarantine tag: Senders: N • Domains: N • HC-Phish tag: <tag> $allowListsDisplay = "Senders: $($row.AllowedSendersCount) • Domains: $($row.AllowedSenderDomainsCount) • HC-Phish tag: $(Get-SafeMarkdown -Text $row.HighConfidencePhishQuarantineTag)" } # Result column with named reasons per spec $resultDisplay = switch ($row.RowResult) { 'Pass' { '✅ Pass' } 'Investigate' { '⚠️ Investigate (orphan rule reference)' } 'Fail' { "❌ Fail ($($row.FailReasons -join '; '))" } } $tableRows += "| $policyDisplay | $scopeDisplay | $filterActionsDisplay | $bulkZapDisplay | $allowListsDisplay | $resultDisplay |`n" } if ($totalCount -gt $maxDisplay) { $tableRows += "| ... | ... | ... | ... | ... | ... |`n" } $preTableLines = '' if ($totalCount -gt $maxDisplay) { $preTableLines = "Showing $maxDisplay of $totalCount policies. [View all in Microsoft 365 Defender > Policies & rules > Threat policies > Anti-spam]($portalUrl)`n`n" } $formatTemplate = @' {0} ## [Anti-spam policy settings]({2}) | Policy | Scope | Filter actions | Bulk & ZAP | Allow lists & quarantine tag | Result | | :----- | :---- | :------------- | :--------- | :--------------------------- | :----- | {1} '@ $mdInfo = $formatTemplate -f $preTableLines, $tableRows, $portalUrl $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo #endregion Report Generation $params = @{ TestId = '41034' Title = 'Anti-spam (hosted content filter) policies are configured with recommended thresholds and actions' Status = $passed Result = $testResultMarkdown } if ($customStatus) { $params.CustomStatus = $customStatus } Add-ZtTestResultDetail @params } |