Skip to content

Commit 635a3c8

Browse files
committed
initial commit
1 parent 5c22ac1 commit 635a3c8

2 files changed

Lines changed: 343 additions & 0 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
The Tenant Allow/Block List is an admin-controlled override of Microsoft's filtering verdicts in Microsoft Defender for Office 365. Each allow entry is a deliberate exception that bypasses spam, bulk, and non-high-confidence phishing filters for a specific sender, URL, or file hash. The risk is drift: an allow entry created for a legitimate business reason — a vendor that fails strict SPF, a URL the filter false-positives on — is not removed after that reason ends. The partner account is later compromised, the vendor changes hands, or the admin who created the entry leaves the company, and the entry remains as a permanent filter bypass. A threat actor who reuses the allowed sender, registers a lookalike under the allowed domain, or replays the allowed file hash delivers spam, bulk, or phishing messages with the false legitimacy of a tenant-trusted entry, and those messages do not face the filter verdicts that would otherwise stop them. Direct allow entries cannot override malware or high-confidence phishing verdicts, so the blast radius is bounded — but credential-harvesting and business email compromise payloads do not require malware to succeed. This check identifies admin-controlled allow entries that are unbounded, undocumented, or stale, so that drift in this high-trust override does not become a permanent gap.
2+
3+
**Remediation action**
4+
5+
- [Manage the Tenant Allow/Block List](https://learn.microsoft.com/en-us/defender-office-365/tenant-allow-block-list-about)
6+
- [Allow or block emails using the Tenant Allow/Block List](https://learn.microsoft.com/en-us/defender-office-365/tenant-allow-block-list-email-spoof-configure)
7+
- [Allow or block URLs using the Tenant Allow/Block List](https://learn.microsoft.com/en-us/defender-office-365/tenant-allow-block-list-urls-configure)
8+
- [Submit messages and files to Microsoft for analysis](https://learn.microsoft.com/en-us/defender-office-365/submissions-admin)
9+
- [Configure the advanced delivery policy for third-party phishing simulations](https://learn.microsoft.com/en-us/defender-office-365/advanced-delivery-policy-configure)
10+
11+
<!--- Results --->
12+
%TestResult%
Lines changed: 331 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,331 @@
1+
<#
2+
.SYNOPSIS
3+
Tenant Allow/Block List entries are scoped, time-bounded, and free of broad allow rules.
4+
5+
.NOTES
6+
Test ID: 41040
7+
Workshop Task: SECOPS-040
8+
Pillar: SecOps
9+
Category: Email and collaboration security
10+
Required Module: ExchangeOnlineManagement
11+
Required Connection: Exchange Online (Security Reader or View-Only Configuration role)
12+
#>
13+
14+
function Test-Assessment-41040 {
15+
[ZtTest(
16+
Category = 'Email and collaboration security',
17+
CompatibleLicense = ('EXCHANGE_S_STANDARD'),
18+
ImplementationCost = 'Low',
19+
Pillar = 'SecOps',
20+
RiskLevel = 'High',
21+
Service = ('ExchangeOnline'),
22+
SfiPillar = 'Protect tenants and isolate production systems',
23+
TenantType = ('Workforce'),
24+
TestId = 41040,
25+
Title = 'Tenant Allow/Block List entries are scoped, time-bounded, and free of broad allow rules',
26+
UserImpact = 'Low'
27+
)]
28+
[CmdletBinding()]
29+
param()
30+
31+
#region Data Collection
32+
Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
33+
34+
$activity = 'Checking Tenant Allow/Block List hygiene'
35+
$allEntries = @()
36+
$queryErrors = @()
37+
38+
# Q1a: Enumerate Sender entries.
39+
Write-ZtProgress -Activity $activity -Status 'Querying Sender entries'
40+
try {
41+
$senderEntries = @(Get-TenantAllowBlockListItems -ListType Sender -ErrorAction Stop |
42+
Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
43+
foreach ($entry in $senderEntries) {
44+
$entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'Sender' -Force
45+
$allEntries += $entry
46+
}
47+
Write-PSFMessage "Q1a: retrieved $($senderEntries.Count) Sender entries" -Tag Test -Level VeryVerbose
48+
}
49+
catch {
50+
Write-PSFMessage "Failed to query Sender TABL entries: $_" -Tag Test -Level Warning
51+
$queryErrors += 'Sender'
52+
}
53+
54+
# Q1b: Enumerate URL entries.
55+
# AdvancedDelivery is a documented ListSubType for phishing-simulation URLs
56+
# (see advanced-delivery-policy-configure). Those entries are intentionally excluded from the verdict.
57+
Write-ZtProgress -Activity $activity -Status 'Querying URL entries'
58+
try {
59+
$urlEntries = @(Get-TenantAllowBlockListItems -ListType Url -ErrorAction Stop |
60+
Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
61+
foreach ($entry in $urlEntries) {
62+
$entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'Url' -Force
63+
$allEntries += $entry
64+
}
65+
Write-PSFMessage "Q1b: retrieved $($urlEntries.Count) URL entries" -Tag Test -Level VeryVerbose
66+
}
67+
catch {
68+
Write-PSFMessage "Failed to query URL TABL entries: $_" -Tag Test -Level Warning
69+
$queryErrors += 'Url'
70+
}
71+
72+
# Q1c: Enumerate file hash entries.
73+
Write-ZtProgress -Activity $activity -Status 'Querying FileHash entries'
74+
try {
75+
$fileHashEntries = @(Get-TenantAllowBlockListItems -ListType FileHash -ErrorAction Stop |
76+
Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
77+
foreach ($entry in $fileHashEntries) {
78+
$entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'FileHash' -Force
79+
$allEntries += $entry
80+
}
81+
Write-PSFMessage "Q1c: retrieved $($fileHashEntries.Count) FileHash entries" -Tag Test -Level VeryVerbose
82+
}
83+
catch {
84+
Write-PSFMessage "Failed to query FileHash TABL entries: $_" -Tag Test -Level Warning
85+
$queryErrors += 'FileHash'
86+
}
87+
88+
# Q1d: Enumerate IP entries.
89+
Write-ZtProgress -Activity $activity -Status 'Querying IP entries'
90+
try {
91+
$ipEntries = @(Get-TenantAllowBlockListItems -ListType IP -ErrorAction Stop |
92+
Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
93+
foreach ($entry in $ipEntries) {
94+
$entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'IP' -Force
95+
$allEntries += $entry
96+
}
97+
Write-PSFMessage "Q1d: retrieved $($ipEntries.Count) IP entries" -Tag Test -Level VeryVerbose
98+
}
99+
catch {
100+
Write-PSFMessage "Failed to query IP TABL entries: $_" -Tag Test -Level Warning
101+
$queryErrors += 'IP'
102+
}
103+
#endregion Data Collection
104+
105+
#region Assessment Logic
106+
107+
if ($queryErrors.Count -eq 4) {
108+
$params = @{
109+
TestId = '41040'
110+
Title = 'Tenant Allow/Block List entries are scoped, time-bounded, and free of broad allow rules'
111+
Status = $false
112+
Result = '⚠️ All four Tenant Allow/Block List queries failed. Verify the assessment account has Security Reader or View-Only Configuration access via Exchange Online RBAC and that the ExchangeOnline connection is active.'
113+
CustomStatus = 'Investigate'
114+
}
115+
Add-ZtTestResultDetail @params
116+
return
117+
}
118+
119+
$now = Get-Date
120+
$staleThreshold = $now.AddDays(-90)
121+
$listTypePriority = @{ 'FileHash' = 1; 'Url' = 2; 'Sender' = 3; 'IP' = 4 }
122+
123+
$allAllow = @($allEntries | Where-Object { $_.Action -eq 'Allow' })
124+
$allBlock = @($allEntries | Where-Object { $_.Action -eq 'Block' })
125+
126+
$exemptEntries = @($allAllow | Where-Object {
127+
$_.ListSubType -in @('AdvancedDelivery', 'Submission')
128+
})
129+
$adminControlledAllow = @($allAllow | Where-Object {
130+
$_.ListSubType -eq 'Tenant'
131+
})
132+
133+
$classifiedEntries = foreach ($entry in $adminControlledAllow) {
134+
$expirationValue = $entry.ExpirationDate
135+
$expirationText = [string]$expirationValue
136+
$isUnbounded = [string]::IsNullOrWhiteSpace($expirationText)
137+
$expirationDate = $null
138+
if (-not $isUnbounded) {
139+
if ($expirationValue -is [datetime]) {
140+
$expirationDate = $expirationValue
141+
}
142+
else {
143+
$parsedExpirationDate = [datetime]::MinValue
144+
if ([datetime]::TryParse($expirationText, [ref]$parsedExpirationDate)) {
145+
$expirationDate = $parsedExpirationDate
146+
}
147+
}
148+
}
149+
150+
$lastModifiedDate = $null
151+
$lastModifiedValue = $entry.LastModifiedDateTime
152+
if ($lastModifiedValue -is [datetime]) {
153+
$lastModifiedDate = $lastModifiedValue
154+
}
155+
else {
156+
$parsedLastModifiedDate = [datetime]::MinValue
157+
if ([datetime]::TryParse([string]$lastModifiedValue, [ref]$parsedLastModifiedDate)) {
158+
$lastModifiedDate = $parsedLastModifiedDate
159+
}
160+
}
161+
162+
$hasNotes = -not [string]::IsNullOrWhiteSpace([string]$entry.Notes)
163+
$isActive = $isUnbounded -or ($null -ne $expirationDate -and $expirationDate -gt $now)
164+
$isStale = $isActive -and $null -ne $lastModifiedDate -and $lastModifiedDate -lt $staleThreshold
165+
$isFailEntry = $isUnbounded -and (-not $hasNotes)
166+
$isFlagged = $isFailEntry -or ($isUnbounded -and $hasNotes) -or $isStale
167+
168+
$flags = @()
169+
if ($isUnbounded) { $flags += 'unbounded' }
170+
if (-not $hasNotes) { $flags += 'no-notes' }
171+
if ($isStale) { $flags += 'stale' }
172+
173+
[PSCustomObject]@{
174+
ListTypeName = $entry.ListTypeName
175+
Value = $entry.Value
176+
Action = $entry.Action
177+
ExpirationDate = $expirationDate
178+
LastModifiedDateTime = $lastModifiedDate
179+
Notes = $entry.Notes
180+
IsUnbounded = $isUnbounded
181+
HasNotes = $hasNotes
182+
IsStale = $isStale
183+
IsFailEntry = $isFailEntry
184+
IsFlagged = $isFlagged
185+
Flags = $flags -join ', '
186+
SortDate = $lastModifiedDate
187+
ListTypePriority = $listTypePriority[$entry.ListTypeName]
188+
}
189+
}
190+
$classifiedEntries = @($classifiedEntries)
191+
192+
# Drift metrics (CISO scoreboard).
193+
$totalAdminControlledAllowsCount = $classifiedEntries.Count
194+
$unboundedAdminControlledCount = @($classifiedEntries | Where-Object { $_.IsUnbounded }).Count
195+
$unboundedWithoutNotesCount = @($classifiedEntries | Where-Object { $_.IsFailEntry }).Count
196+
$staleAdminControlledCount = @($classifiedEntries | Where-Object { $_.IsStale }).Count
197+
$unboundedRatio = if ($totalAdminControlledAllowsCount -gt 0) {
198+
[math]::Round($unboundedAdminControlledCount / $totalAdminControlledAllowsCount * 100, 1)
199+
} else { $null }
200+
201+
$totalAllowCount = $allAllow.Count
202+
$totalBlockCount = $allBlock.Count
203+
$totalExemptCount = $exemptEntries.Count
204+
205+
$passed = $false
206+
$customStatus = $null
207+
208+
if ($queryErrors.Count -gt 0) {
209+
$customStatus = 'Investigate'
210+
$testResultMarkdown = "⚠️ One or more list-type queries failed ($($queryErrors -join ', ')); results below reflect partial data only. Verify permissions and re-run.`n`n%TestResult%"
211+
}
212+
elseif ($unboundedWithoutNotesCount -gt 0) {
213+
$testResultMarkdown = "❌ One or more admin-controlled allow entries are unbounded and have no documented business justification in the Notes field. Each is a permanent filter bypass with no recorded reason for its existence. A threat actor who reuses an allowed sender, registers a lookalike under an allowed domain, or replays an allowed file hash will bypass Microsoft's spam, bulk, and phishing verdicts for as long as the entry remains.`n`n%TestResult%"
214+
}
215+
elseif ($staleAdminControlledCount -gt 0 -or $unboundedAdminControlledCount -gt 0) {
216+
$customStatus = 'Investigate'
217+
$testResultMarkdown = "⚠️ Unbounded allow entries exist but all have populated Notes, or stale entries (not modified in more than 90 days) exist. The customer is using the Tenant Allow/Block List responsibly but should confirm that each unbounded entry's business justification is still current and prune any entries that no longer apply.`n`n%TestResult%"
218+
}
219+
else {
220+
$passed = $true
221+
$testResultMarkdown = "✅ All admin-controlled allow entries in the Tenant Allow/Block List are either time-bounded or have a documented business justification, and no entry has been left untouched for more than 90 days.`n`n%TestResult%"
222+
}
223+
#endregion Assessment Logic
224+
225+
#region Report Generation
226+
$tablPortalUrl = 'https://security.microsoft.com/tenantAllowBlockList'
227+
$maxDisplay = 10
228+
229+
$unboundedRatioDisplay = if ($null -eq $unboundedRatio) { '' } else { "$unboundedRatio%" }
230+
$partialDataNote = if ($queryErrors.Count -gt 0) { " — ⚠️ partial data (failed: $($queryErrors -join ', '))" } else { '' }
231+
232+
$driftRows = "| Unbounded admin-controlled allow entries | $unboundedAdminControlledCount | 0 |`n"
233+
$driftRows += "| Unbounded entries lacking documented justification | $unboundedWithoutNotesCount | 0 |`n"
234+
$driftRows += "| Stale allow entries (last modified >90 days) | $staleAdminControlledCount | 0 |`n"
235+
$driftRows += "| Unbounded ratio | $unboundedRatioDisplay | <5% (informational) |`n"
236+
$driftRows += "| Total allow entries (all categories) | $totalAllowCount | — |`n"
237+
$driftRows += "| Total block entries (not evaluated) | $totalBlockCount | — |`n"
238+
$driftRows += "| Excluded entries (AdvancedDelivery + Submission) | $totalExemptCount | — |`n"
239+
240+
$driftSection = @"
241+
242+
## [Tenant Allow/Block Lists]($tablPortalUrl) — drift summary$partialDataNote
243+
244+
| Metric | Value | Target |
245+
| :----- | ----: | :----- |
246+
$driftRows
247+
248+
AdvancedDelivery and Submission entries, plus spoofed-sender allows governed separately through Get-TenantAllowBlockListSpoofItems, are represented by the excluded count above and never affect the verdict.
249+
"@
250+
251+
$actionSection = ''
252+
if (-not $passed -or $null -ne $customStatus) {
253+
$flaggedEntries = @($classifiedEntries | Where-Object { $_.IsFlagged })
254+
255+
if ($flaggedEntries.Count -gt 0) {
256+
$sortedFlagged = @(
257+
$flaggedEntries | Sort-Object `
258+
@{ Expression = { if ($_.IsFailEntry) { 0 } else { 1 } } },
259+
@{ Expression = { $_.SortDate } },
260+
@{ Expression = { $_.ListTypePriority } },
261+
@{ Expression = { if ($_.IsStale) { 0 } else { 1 } } },
262+
@{ Expression = { [string]$_.Value } }
263+
)
264+
$hasMoreRows = $sortedFlagged.Count -gt $maxDisplay
265+
$displayRows = if ($hasMoreRows) { @($sortedFlagged | Select-Object -First $maxDisplay) } else { $sortedFlagged }
266+
267+
$tableRows = ''
268+
foreach ($row in $displayRows) {
269+
$valueRaw = ([string]$row.Value) -replace '[\r\n]+', ' '
270+
$valueDisplay = if ($valueRaw.Length -gt 50) { $valueRaw.Substring(0, 47) + '...' } else { $valueRaw }
271+
$valueDisplay = Get-SafeMarkdown -Text $valueDisplay
272+
273+
$notesRaw = if ($null -ne $row.Notes) { (([string]$row.Notes).Trim() -replace '[\r\n]+', ' ') } else { '' }
274+
$notesDisplay = if ([string]::IsNullOrWhiteSpace($notesRaw)) { '' } `
275+
elseif ($notesRaw.Length -gt 80) { $notesRaw.Substring(0, 77) + '...' } `
276+
else { $notesRaw }
277+
if ($notesDisplay -ne '') {
278+
$notesDisplay = Get-SafeMarkdown -Text $notesDisplay
279+
}
280+
281+
$expirationDisplay = if ($row.IsUnbounded) {
282+
'No expiration'
283+
} else {
284+
Get-FormattedDate -DateString ($row.ExpirationDate.ToString('o'))
285+
}
286+
$lastModDisplay = if ($null -ne $row.LastModifiedDateTime) {
287+
Get-FormattedDate -DateString ($row.LastModifiedDateTime.ToString('o'))
288+
} else { '' }
289+
290+
$tableRows += "| $($row.ListTypeName) | $valueDisplay | $($row.Action) | $expirationDisplay | $lastModDisplay | $notesDisplay | $($row.Flags) |`n"
291+
}
292+
293+
if ($hasMoreRows) {
294+
$tableRows += "| ... | ... | ... | ... | ... | ... | ... |`n"
295+
}
296+
297+
$inventoryLink = if ($hasMoreRows) {
298+
"`n[Microsoft Defender portal > Tenant Allow/Block Lists]($tablPortalUrl)`n"
299+
} else { '' }
300+
301+
$actionSection = @"
302+
303+
## Action required
304+
305+
| List type | Value | Action | Expiration date | Last modified | Notes | Flags |
306+
| :-------- | :---- | :----- | :-------------- | :------------ | :---- | :----- |
307+
$tableRows
308+
$inventoryLink
309+
"@
310+
}
311+
}
312+
313+
$formatTemplate = @'
314+
{0}
315+
{1}
316+
'@
317+
$mdInfo = $formatTemplate -f $driftSection, $actionSection
318+
$testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
319+
#endregion Report Generation
320+
321+
$params = @{
322+
TestId = '41040'
323+
Title = 'Tenant Allow/Block List entries are scoped, time-bounded, and free of broad allow rules'
324+
Status = $passed
325+
Result = $testResultMarkdown
326+
}
327+
if ($customStatus) {
328+
$params.CustomStatus = $customStatus
329+
}
330+
Add-ZtTestResultDetail @params
331+
}

0 commit comments

Comments
 (0)