tests/Test-Assessment.41032.ps1

<#
.SYNOPSIS
    Checks that Safe Links policies in Microsoft Defender for Office 365 are configured to scan and block malicious URLs.
 
.DESCRIPTION
    Validates that Safe Links protection in Microsoft Defender for Office 365 checks URLs against
    Microsoft's reputation service at time of click across email, Teams, and Office apps. The check
    reads Safe Links policies and rules from Exchange Online and evaluates every policy referenced by
    an enabled rule against the Standard/Strict recommended baseline. Built-in Protection is reported
    for transparency but is intentionally weaker than Standard/Strict and does not meet the baseline
    on its own.
 
.NOTES
    Test ID: 41032
    Category: Email and collaboration security
    Pillar: SecOps
    Required Module: ExchangeOnlineManagement
    Required Connection: Connect-ExchangeOnline
#>


function Test-Assessment-41032 {
    [ZtTest(
        Category = 'Email and collaboration security',
        CompatibleLicense = ('ATP_ENTERPRISE'),
        ImplementationCost = 'Low',
        Pillar = 'SecOps',
        RiskLevel = 'High',
        Service = ('ExchangeOnline'),
        SfiPillar = 'Protect tenants and isolate production systems',
        TenantType = ('Workforce'),
        TestId = 41032,
        Title = 'Safe Links policies in Microsoft Defender for Office 365 are configured to scan and block malicious URLs',
        UserImpact = 'Low'
    )]
    [CmdletBinding()]
    param()

    # Invariant result metadata — TestId and Title are defined once here. Each return path only sets
    # the varying fields (Status, Result, and optionally CustomStatus) before Add-ZtTestResultDetail.
    $params = @{
        TestId = '41032'
        Title  = 'Safe Links policies in Microsoft Defender for Office 365 are configured to scan and block malicious URLs'
    }

    #region Data Collection
    Write-PSFMessage '🟦 Start' -Tag Test -Level VeryVerbose
    $activity = 'Checking Safe Links policies in Microsoft Defender for Office 365'
    Write-ZtProgress -Activity $activity -Status 'Retrieving Safe Links policies'

    # Q1a: Retrieve all Safe Links policies from Exchange Online (no Graph/REST API exists for Safe Links).
    $allPolicies = $null
    try {
        $allPolicies = @(Get-SafeLinksPolicy -ErrorAction Stop | Select-Object Identity, IsBuiltInProtection, IsDefault, EnableSafeLinksForEmail, EnableSafeLinksForTeams, EnableSafeLinksForOffice, ScanUrls, EnableForInternalSenders, DeliverMessageAfterScan, DisableUrlRewrite, AllowClickThrough, TrackClicks)
    }
    catch {
        Write-PSFMessage "Failed to retrieve Safe Links policies: $_" -Tag Test -Level Warning
        # Spec: ATP_ENTERPRISE gates this test, so a cmdlet failure points to an Exchange Online
        # permission or connectivity issue rather than an unlicensed tenant.
        $params.Status       = $false
        $params.Result       = '⚠️ Safe Links policies could not be retrieved (no policies were returned, the cmdlet is unavailable, or the Exchange Online rule set cannot be read); because MDO licensing gates this test, an empty or failed result points to an Exchange Online permission or connectivity issue rather than absence of protection — verify Exchange Online access and re-run.'
        $params.CustomStatus = 'Investigate'
        Add-ZtTestResultDetail @params
        Write-PSFMessage 'Test-Assessment-41032: INVESTIGATE — Get-SafeLinksPolicy failed' -Tag Test -Level VeryVerbose
        return
    }

    if ($allPolicies.Count -eq 0) {
        # Spec: Built-in Protection is always present in every MDO-licensed tenant, so zero rows
        # indicates an Exchange Online permission or connectivity anomaly, not an unlicensed tenant.
        $params.Status       = $false
        $params.Result       = '⚠️ Safe Links policies could not be retrieved (no policies were returned, the cmdlet is unavailable, or the Exchange Online rule set cannot be read); because MDO licensing gates this test, an empty or failed result points to an Exchange Online permission or connectivity issue rather than absence of protection — verify Exchange Online access and re-run.'
        $params.CustomStatus = 'Investigate'
        Add-ZtTestResultDetail @params
        Write-PSFMessage 'Test-Assessment-41032: INVESTIGATE — Get-SafeLinksPolicy returned zero rows' -Tag Test -Level VeryVerbose
        return
    }

    Write-ZtProgress -Activity $activity -Status 'Retrieving Safe Links rules'

    # Q1b: Retrieve all Safe Links rules to determine which policies are actively applied.
    $allRules = $null
    try {
        $allRules = @(Get-SafeLinksRule -ErrorAction Stop | Select-Object Name, SafeLinksPolicy, State)
    }
    catch {
        Write-PSFMessage "Failed to retrieve Safe Links rules: $_" -Tag Test -Level Warning
        # Spec: if Get-SafeLinksRule fails while policies succeeded, return Investigate —
        # the rule set needed to determine which policies are actively applied cannot be read.
        $params.Status       = $false
        $params.Result       = '⚠️ Safe Links 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.'
        $params.CustomStatus = 'Investigate'
        Add-ZtTestResultDetail @params
        Write-PSFMessage 'Test-Assessment-41032: INVESTIGATE — Get-SafeLinksRule failed' -Tag Test -Level VeryVerbose
        return
    }
    #endregion Data Collection

    #region Assessment Logic
    # Standard/Strict recommended baseline. Built-in Protection is intentionally weaker on three of
    # these (EnableForInternalSenders, DisableUrlRewrite, AllowClickThrough) and does not meet it.
    $baseline = [ordered]@{
        EnableSafeLinksForEmail  = $true
        EnableSafeLinksForTeams  = $true
        EnableSafeLinksForOffice = $true
        ScanUrls                 = $true
        DeliverMessageAfterScan  = $true
        EnableForInternalSenders = $true
        DisableUrlRewrite        = $false
        AllowClickThrough        = $false
        TrackClicks              = $true
    }

    # Lookup: policy Identity → policy object
    $policyByIdentity = @{}
    foreach ($policy in $allPolicies) {
        $policyByIdentity[$policy.Identity] = $policy
    }

    # Enabled rules and the distinct policies they reference (these drive the verdict).
    $enabledRules  = @($allRules | Where-Object { $_.State -eq 'Enabled' })
    $anyRuleEnabled = $enabledRules.Count -gt 0

    # Map: policy identity → enabled rule name (join key: rule.SafeLinksPolicy == policy.Identity)
    $rulesForPolicy = @{}
    foreach ($rule in $enabledRules) {
        $rulesForPolicy[$rule.SafeLinksPolicy] = $rule.Name
    }

    # Built-in Protection policy (IsBuiltInProtection == True) — reported for transparency only.
    $builtInPolicy = $allPolicies | Where-Object { $_.IsBuiltInProtection -eq $true } | Select-Object -First 1

    # In-scope policy identities: built-in first (transparency), then all referenced by enabled rules.
    $inScopeIdentities = [System.Collections.Generic.List[string]]::new()
    if ($builtInPolicy) {
        $inScopeIdentities.Add($builtInPolicy.Identity)
    }
    foreach ($policyName in $rulesForPolicy.Keys) {
        if (-not $inScopeIdentities.Contains($policyName)) {
            $inScopeIdentities.Add($policyName)
        }
    }

    # Spec verdict:
    # Pass = at least one enabled rule AND every rule-referenced policy fully meets the baseline.
    # Fail = no enabled rule, or any rule-referenced policy diverges from the baseline.
    # Investigate = an enabled rule references a policy that no longer exists (orphan rule).
    $passed         = $anyRuleEnabled
    $hasInvestigate = $false

    $policyRows = [System.Collections.Generic.List[PSCustomObject]]::new()

    foreach ($identity in $inScopeIdentities) {
        $policy       = $policyByIdentity[$identity]
        $isRuleScoped = $rulesForPolicy.ContainsKey($identity)
        $ruleName     = if ($isRuleScoped) { $rulesForPolicy[$identity] } else { '' }

        if ($null -eq $policy) {
            # Orphan rule: enabled rule references a policy deleted from Get-SafeLinksPolicy → Investigate.
            $hasInvestigate = $true
            $policyRows.Add([PSCustomObject]@{
                Identity       = $identity
                IsBuiltIn      = $false
                AppliedViaRule = $ruleName
                NonCompliant   = 'Referenced policy not found in Get-SafeLinksPolicy'
                RowResult      = 'Investigate'
            })
            continue
        }

        # Evaluate the nine baseline properties.
        $nonCompliant = [System.Collections.Generic.List[string]]::new()
        foreach ($prop in $baseline.Keys) {
            if ($policy.$prop -ne $baseline[$prop]) {
                $nonCompliant.Add("$prop=$($policy.$prop)")
            }
        }

        $rowResult = if ($nonCompliant.Count -eq 0) { 'Pass' } else { 'Fail' }

        # Only rule-referenced policies drive the overall verdict; Built-in is transparency only.
        if ($isRuleScoped -and $rowResult -eq 'Fail') {
            $passed = $false
        }

        $policyRows.Add([PSCustomObject]@{
            Identity       = $identity
            IsBuiltIn      = [bool]$policy.IsBuiltInProtection
            # Built-in Protection applies as a preset with no explicit rule; Applied via Rule is blank.
            AppliedViaRule = if ($policy.IsBuiltInProtection) { '' } else { $ruleName }
            NonCompliant   = ($nonCompliant -join ', ')
            RowResult      = $rowResult
        })
    }

    $customStatus = $null
    if (-not $passed) {
        $testResultMarkdown = "❌ Either no custom or preset Safe Links rule is enabled (Built-in Protection alone does not meet the Standard/Strict baseline) or one or more enabled rule-referenced policies disables a scanning surface, allows click-through, skips internal senders, or disables URL rewriting; users can encounter weaponized URLs in collaboration surfaces.`n`n%TestResult%"
    }
    elseif ($hasInvestigate) {
        $passed       = $false
        $customStatus = 'Investigate'
        $testResultMarkdown = "⚠️ An enabled Safe Links rule references a policy that does not exist in Get-SafeLinksPolicy; manual review is required.`n`n%TestResult%"
    }
    else {
        $testResultMarkdown = "✅ Every Safe Links policy in use scans URLs for email, Teams, and Office apps at time of click; rewrites URLs in email; covers internal senders; and prevents users from clicking through warnings.`n`n%TestResult%"
    }
    #endregion Assessment Logic

    #region Report Generation
    $portalUrl  = 'https://security.microsoft.com/safelinksv2'
    $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) {
        $resultDisplay = switch ($row.RowResult) {
            'Pass'        { '✅ Pass' }
            'Fail'        { '❌ Fail' }
            'Investigate' { '⚠️ Investigate' }
        }
        $policyName = if ($row.IsBuiltIn) { "(Built-in) $($row.Identity)" } else { $row.Identity }
        $policyMd   = Get-SafeMarkdown $policyName
        $ruleMd     = if ($row.AppliedViaRule) { Get-SafeMarkdown $row.AppliedViaRule } else { '' }
        $issuesMd   = if ($row.NonCompliant) { Get-SafeMarkdown $row.NonCompliant } else { '' }
        $tableRows += "| $resultDisplay | $policyMd | $ruleMd | $issuesMd |`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 > Safe Links]($portalUrl)`n`n"
    }

    $formatTemplate = @'
{0}
| Result | Policy | Applied via rule | Non-compliant settings |
| :----- | :----- | :--------------- | :--------------------- |
{1}
'@


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

    $params.Status = $passed
    $params.Result = $testResultMarkdown
    if ($customStatus) {
        $params.CustomStatus = $customStatus
    }
    Add-ZtTestResultDetail @params

    if ($customStatus -eq 'Investigate') {
        Write-PSFMessage "Test-Assessment-41032: INVESTIGATE — orphan enabled rule references a missing policy ($totalCount policies in scope)" -Tag Test -Level VeryVerbose
    }
    elseif ($passed) {
        Write-PSFMessage "Test-Assessment-41032: PASS — all $totalCount in-scope policies meet the Standard/Strict baseline" -Tag Test -Level VeryVerbose
    }
    else {
        Write-PSFMessage "Test-Assessment-41032: FAIL — no enabled rule or a rule-referenced policy diverges from the baseline ($totalCount policies in scope)" -Tag Test -Level VeryVerbose
    }
}