tests/Test-Assessment.41040.ps1

<#
.SYNOPSIS
    Tenant Allow/Block List allow entries are reviewed for expiration, justification, and staleness.
 
.NOTES
    Test ID: 41040
    Workshop Task: SECOPS-040
    Pillar: SecOps
    Category: Email and collaboration security
    Required Module: ExchangeOnlineManagement
    Required Connection: Exchange Online (Security Reader or View-Only Configuration role)
#>


function Test-Assessment-41040 {
    [ZtTest(
        Category = 'Email and collaboration security',
        CompatibleLicense = ('EXCHANGE_S_STANDARD'),
        ImplementationCost = 'Low',
        Pillar = 'SecOps',
        RiskLevel = 'High',
        Service = ('ExchangeOnline'),
        SfiPillar = 'Protect tenants and isolate production systems',
        TenantType = ('Workforce'),
        TestId = 41040,
        Title = 'Tenant Allow/Block List allow entries are reviewed for expiration, justification, and staleness',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose

    $activity        = 'Checking Tenant Allow/Block List hygiene'
    $allEntries      = @()
    $failedListTypes = @()

    # Q1a: Enumerate Sender entries.
    Write-ZtProgress -Activity $activity -Status 'Querying Sender entries'
    try {
        $senderEntries = @(Get-TenantAllowBlockListItems -ListType Sender -ErrorAction Stop |
            Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
        foreach ($entry in $senderEntries) {
            $entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'Sender' -Force
            $allEntries += $entry
        }
        Write-PSFMessage "Q1a: retrieved $($senderEntries.Count) Sender entries" -Tag Test -Level VeryVerbose
    }
    catch {
        Write-PSFMessage "Failed to query Sender TABL entries: $_" -Tag Test -Level Warning
        $failedListTypes += 'Sender'
    }

    # Q1b: Enumerate URL entries.
    # AdvancedDelivery is a documented ListSubType for phishing-simulation URLs
    # (see advanced-delivery-policy-configure). Those entries are intentionally excluded from the verdict.
    Write-ZtProgress -Activity $activity -Status 'Querying URL entries'
    try {
        $urlEntries = @(Get-TenantAllowBlockListItems -ListType Url -ErrorAction Stop |
            Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
        foreach ($entry in $urlEntries) {
            $entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'Url' -Force
            $allEntries += $entry
        }
        Write-PSFMessage "Q1b: retrieved $($urlEntries.Count) URL entries" -Tag Test -Level VeryVerbose
    }
    catch {
        Write-PSFMessage "Failed to query URL TABL entries: $_" -Tag Test -Level Warning
        $failedListTypes += 'Url'
    }

    # Q1c: Enumerate file hash entries.
    Write-ZtProgress -Activity $activity -Status 'Querying FileHash entries'
    try {
        $fileHashEntries = @(Get-TenantAllowBlockListItems -ListType FileHash -ErrorAction Stop |
            Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
        foreach ($entry in $fileHashEntries) {
            $entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'FileHash' -Force
            $allEntries += $entry
        }
        Write-PSFMessage "Q1c: retrieved $($fileHashEntries.Count) FileHash entries" -Tag Test -Level VeryVerbose
    }
    catch {
        Write-PSFMessage "Failed to query FileHash TABL entries: $_" -Tag Test -Level Warning
        $failedListTypes += 'FileHash'
    }

    # Q1d: Enumerate IP entries.
    Write-ZtProgress -Activity $activity -Status 'Querying IP entries'
    try {
        $ipEntries = @(Get-TenantAllowBlockListItems -ListType IP -ErrorAction Stop |
            Select-Object Value, Action, ExpirationDate, Notes, ListSubType, LastModifiedDateTime)
        foreach ($entry in $ipEntries) {
            $entry | Add-Member -NotePropertyName ListTypeName -NotePropertyValue 'IP' -Force
            $allEntries += $entry
        }
        Write-PSFMessage "Q1d: retrieved $($ipEntries.Count) IP entries" -Tag Test -Level VeryVerbose
    }
    catch {
        Write-PSFMessage "Failed to query IP TABL entries: $_" -Tag Test -Level Warning
        $failedListTypes += 'IP'
    }
    #endregion Data Collection

    #region Assessment Logic

    if ($failedListTypes.Count -eq 4) {
        $params = @{
            TestId       = '41040'
            Title        = 'Tenant Allow/Block List allow entries are reviewed for expiration, justification, and staleness'
            Status       = $false
            Result       = '⚠️ All four Tenant Allow/Block List queries failed (Sender, Url, FileHash, and IP). Verify the assessment account has Security Reader or View-Only Configuration access via Exchange Online RBAC and that the ExchangeOnline connection is active.'
            CustomStatus = 'Investigate'
        }
        Add-ZtTestResultDetail @params
        return
    }

    $now              = Get-Date
    $staleThreshold   = $now.AddDays(-90)
    $listTypePriority = @{ 'FileHash' = 1; 'Url' = 2; 'Sender' = 3; 'IP' = 4 }

    $allAllow = @($allEntries | Where-Object { $_.Action -eq 'Allow' })
    $allBlock = @($allEntries | Where-Object { $_.Action -eq 'Block' })

    $exemptEntries = @($allAllow | Where-Object {
        $_.ListSubType -in @('AdvancedDelivery', 'Submission')
    })
    $adminControlledAllow = @($allAllow | Where-Object {
        $_.ListSubType -eq 'Tenant'
    })

    $classifiedEntries = foreach ($entry in $adminControlledAllow) {
        $expirationDate  = $entry.ExpirationDate
        $lastModifiedDate = $entry.LastModifiedDateTime
        $isUnbounded     = $null -eq $expirationDate

        $hasNotes = -not [string]::IsNullOrWhiteSpace([string]$entry.Notes)
        $isActive = $isUnbounded -or ($null -ne $expirationDate -and $expirationDate -gt $now)
        $isStale = $isActive -and $null -ne $lastModifiedDate -and $lastModifiedDate -lt $staleThreshold
        $isFailEntry = $isUnbounded -and (-not $hasNotes)
        $isFlagged = $isFailEntry -or ($isUnbounded -and $hasNotes) -or $isStale

        $flags = @()
        if ($isUnbounded) { $flags += 'unbounded' }
        if (-not $hasNotes) { $flags += 'no-notes' }
        if ($isStale) { $flags += 'stale' }

        [PSCustomObject]@{
            ListTypeName         = $entry.ListTypeName
            Value                = $entry.Value
            Action               = $entry.Action
            ExpirationDate       = $expirationDate
            LastModifiedDateTime = $lastModifiedDate
            Notes                = $entry.Notes
            IsUnbounded          = $isUnbounded
            HasNotes             = $hasNotes
            IsStale              = $isStale
            IsFailEntry          = $isFailEntry
            IsFlagged            = $isFlagged
            Flags                = $flags -join ', '
            SortDate             = $lastModifiedDate
            ListTypePriority     = $listTypePriority[$entry.ListTypeName]
        }
    }
    $classifiedEntries = @($classifiedEntries)

    # Drift metrics (CISO scoreboard).
    $totalAdminControlledAllowsCount = $classifiedEntries.Count
    $unboundedAdminControlledCount   = @($classifiedEntries | Where-Object { $_.IsUnbounded }).Count
    $unboundedWithoutNotesCount      = @($classifiedEntries | Where-Object { $_.IsFailEntry }).Count
    $staleAdminControlledCount       = @($classifiedEntries | Where-Object { $_.IsStale }).Count
    $unboundedRatio                  = if ($totalAdminControlledAllowsCount -gt 0) {
        [math]::Round($unboundedAdminControlledCount / $totalAdminControlledAllowsCount * 100, 1)
    } else { $null }

    $totalAllowCount  = $allAllow.Count
    $totalBlockCount  = $allBlock.Count
    $totalExemptCount = $exemptEntries.Count

    $passed       = $false
    $customStatus = $null
    $partialDataCaveat = if ($failedListTypes.Count -gt 0) {
        " Results reflect partial data only (failed: $($failedListTypes -join ', ')). Verify permissions and re-run."
    } else { '' }

    if ($unboundedWithoutNotesCount -gt 0) {
        $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.$partialDataCaveat`n`n%TestResult%"
    }
    elseif ($staleAdminControlledCount -gt 0 -or $unboundedAdminControlledCount -gt 0) {
        $customStatus       = 'Investigate'
        $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.$partialDataCaveat`n`n%TestResult%"
    }
    elseif ($failedListTypes.Count -gt 0) {
        $customStatus       = 'Investigate'
        $testResultMarkdown = "⚠️ One or more Tenant Allow/Block List queries failed; results reflect partial data only (failed: $($failedListTypes -join ', ')). Verify permissions and re-run.`n`n%TestResult%"
    }
    else {
        $passed             = $true
        $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%"
    }
    #endregion Assessment Logic

    #region Report Generation
    $tablPortalUrl = 'https://security.microsoft.com/tenantAllowBlockList'
    $maxDisplay    = 10

    $unboundedCountDisplay = if ($unboundedAdminControlledCount -gt 0) { "⚠️ $unboundedAdminControlledCount" } else { '0' }
    $undocumentedCountDisplay = if ($unboundedWithoutNotesCount -gt 0) { "❌ $unboundedWithoutNotesCount" } else { '0' }
    $staleCountDisplay = if ($staleAdminControlledCount -gt 0) { "⚠️ $staleAdminControlledCount" } else { '0' }
    $unboundedRatioDisplay = if ($null -eq $unboundedRatio) { '—' } else { "$unboundedRatio%" }
    $partialDataNote = if ($failedListTypes.Count -gt 0) { " — ⚠️ partial data (failed: $($failedListTypes -join ', '))" } else { '' }

    $driftRows  = "| Unbounded admin-controlled allow entries | $unboundedCountDisplay | 0 |`n"
    $driftRows += "| Unbounded entries lacking documented justification | $undocumentedCountDisplay | 0 |`n"
    $driftRows += "| Stale allow entries (last modified >90 days) | $staleCountDisplay | 0 |`n"
    $driftRows += "| Unbounded ratio | $unboundedRatioDisplay | <5% (informational) |`n"
    $driftRows += "| Total allow entries (all categories) | $totalAllowCount | — |`n"
    $driftRows += "| Total block entries (not evaluated) | $totalBlockCount | — |`n"
    $driftRows += "| Excluded entries (AdvancedDelivery + Submission) | $totalExemptCount | — |`n"

    $driftSection = @"
 
## [Tenant Allow/Block Lists]($tablPortalUrl) — drift summary$partialDataNote
 
| Metric | Value | Target |
| :----- | ----: | :----- |
$driftRows
 
Excluded entries (AdvancedDelivery, Submission) are represented by the excluded count row above and never affect the verdict.
"@


    $actionSection = ''
    if (-not $passed -or $null -ne $customStatus) {
        $flaggedEntries = @($classifiedEntries | Where-Object { $_.IsFlagged })

        if ($flaggedEntries.Count -gt 0) {
            $sortedFlagged = @(
                $flaggedEntries | Sort-Object `
                    @{ Expression = { if ($_.IsFailEntry) { 0 } else { 1 } } },
                    @{ Expression = { $_.SortDate } },
                    @{ Expression = { $_.ListTypePriority } },
                    @{ Expression = { if ($_.IsStale) { 0 } else { 1 } } },
                    @{ Expression = { [string]$_.Value } }
            )
            $hasMoreRows  = $sortedFlagged.Count -gt $maxDisplay
            $displayRows  = if ($hasMoreRows) { @($sortedFlagged | Select-Object -First $maxDisplay) } else { $sortedFlagged }

            $tableRows = ''
            foreach ($row in $displayRows) {
                $valueRaw = ([string]$row.Value) -replace '[\r\n]+', ' '
                $valueDisplay = if ($valueRaw.Length -gt 50) { $valueRaw.Substring(0, 47) + '...' } else { $valueRaw }
                $valueDisplay = Get-SafeMarkdown -Text $valueDisplay

                $notesRaw = if ($null -ne $row.Notes) { (([string]$row.Notes).Trim() -replace '[\r\n]+', ' ') } else { '' }
                $notesDisplay = if ([string]::IsNullOrWhiteSpace($notesRaw)) { '—' } `
                    elseif ($notesRaw.Length -gt 80) { $notesRaw.Substring(0, 77) + '...' } `
                    else { $notesRaw }
                if ($notesDisplay -ne '—') {
                    $notesDisplay = Get-SafeMarkdown -Text $notesDisplay
                }

                $expirationDisplay = if ($row.IsUnbounded) {
                    'No expiration'
                } else {
                    Get-FormattedDate -DateString ($row.ExpirationDate.ToString('o'))
                }
                $lastModDisplay = if ($null -ne $row.LastModifiedDateTime) {
                    Get-FormattedDate -DateString ($row.LastModifiedDateTime.ToString('o'))
                } else { '—' }

                $tableRows += "| $($row.ListTypeName) | $valueDisplay | $($row.Action) | $expirationDisplay | $lastModDisplay | $notesDisplay | $($row.Flags) |`n"
            }

            if ($hasMoreRows) {
                $tableRows += "| ... | ... | ... | ... | ... | ... | ... |`n"
            }

            $inventoryLink = if ($hasMoreRows) {
                "`n[Microsoft Defender portal > Tenant Allow/Block Lists]($tablPortalUrl)`n"
            } else { '' }

            $actionSection = @"
 
## Action required
 
| List type | Value | Action | Expiration date | Last modified | Notes | Flags |
| :-------- | :---- | :----- | :-------------- | :------------ | :---- | :----- |
$tableRows
$inventoryLink
"@

        }
    }

    $formatTemplate = @'
{0}
{1}
'@

    $mdInfo             = $formatTemplate -f $driftSection, $actionSection
    $testResultMarkdown = $testResultMarkdown -replace '%TestResult%', $mdInfo
    #endregion Report Generation

    $params = @{
        TestId = '41040'
        Title  = 'Tenant Allow/Block List allow entries are reviewed for expiration, justification, and staleness'
        Status = $passed
        Result = $testResultMarkdown
    }
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params
}