Checks/MDO/MET-MDO014-GroupReferenceAudit.ps1

$allMailboxes = $null
if ($METContext -and $METContext.AllMailboxes) {
    $allMailboxes = $METContext.AllMailboxes
} else {
    try {
        $allMailboxes = @(Get-METAssessableMailboxes)
        if ($METContext) { $METContext.AllMailboxes = $allMailboxes }
    }
    catch {
        New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
            -Result Fail -Severity High -AffectedObject 'All Mailboxes' `
            -Finding 'Unable to retrieve mailbox list to assess group references.' `
            -Recommendation 'Ensure the account has Exchange View-Only Recipients permission.' `
            -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365' -ErrorMessage $_.ToString()
        return
    }
}

if ($allMailboxes.Count -eq 0) {
    New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
        -Result NotApplicable -Severity Medium -AffectedObject 'All Mailboxes' `
        -Finding 'No mailboxes found in the tenant.' `
        -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365'
    return
}

if ($METContext -and $METContext.GroupMembers) {
    $groupCache = $METContext.GroupMembers
}
else {
    $groupCache = @{}
    if ($METContext) { $METContext.GroupMembers = $groupCache }
}
$retrievalErrors = [System.Collections.Generic.List[string]]::new()

$sources = @(
    @{ Label = 'EOP Preset';       Getter = { @(Get-EOPProtectionPolicyRule -ErrorAction Stop | Where-Object State -eq 'Enabled') } },
    @{ Label = 'MDO Preset';       Getter = { @(Get-ATPProtectionPolicyRule -ErrorAction Stop | Where-Object State -eq 'Enabled') } },
    @{ Label = 'Safe Links';       Getter = { @(Get-SafeLinksRule -ErrorAction Stop | Where-Object State -eq 'Enabled') } },
    @{ Label = 'Safe Attachments'; Getter = { @(Get-SafeAttachmentRule -ErrorAction Stop | Where-Object State -eq 'Enabled') } },
    @{ Label = 'Anti-Phishing';    Getter = { @(Get-AntiPhishRule -ErrorAction Stop | Where-Object { $_.State -eq 'Enabled' -and $_.Name -ne 'Office365 AntiPhish Default' }) } },
    @{ Label = 'Anti-Spam';        Getter = { @(Get-HostedContentFilterRule -ErrorAction Stop | Where-Object { $_.State -eq 'Enabled' -and $_.Name -ne 'Default' }) } }
)

$referencedBy = @{}

foreach ($source in $sources) {
    try { $rules = & $source.Getter }
    catch {
        $retrievalErrors.Add("Unable to retrieve $($source.Label) rules. $($_.ToString())")
        continue
    }
    foreach ($rule in $rules) {
        $groupIds = [System.Collections.Generic.List[string]]::new()
        if ($rule.SentToMemberOf) { foreach ($g in @($rule.SentToMemberOf)) { $groupIds.Add([string]$g) } }
        if ($rule.ExceptIfSentToMemberOf) { foreach ($g in @($rule.ExceptIfSentToMemberOf)) { $groupIds.Add([string]$g) } }

        foreach ($groupId in $groupIds) {
            if (-not $referencedBy.ContainsKey($groupId)) { $referencedBy[$groupId] = [System.Collections.Generic.List[string]]::new() }
            $referencedBy[$groupId].Add("$($source.Label) rule '$($rule.Name)'")
        }
    }
}

if ($retrievalErrors.Count -gt 0) {
    New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
        -Result Fail -Severity High -AffectedObject 'Policy Rule Data' `
        -Finding 'Unable to complete the group reference audit because one or more rule collections could not be retrieved.' `
        -Recommendation 'Verify Exchange Online permissions and retry the assessment.' `
        -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365' `
        -ErrorMessage ($retrievalErrors -join "`n")
    return
}

if ($referencedBy.Count -eq 0) {
    New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
        -Result NotApplicable -Severity Medium -AffectedObject 'All Threat Policies' `
        -Finding 'No enabled EOP or MDO policy rule targets recipients via group membership (SentToMemberOf/ExceptIfSentToMemberOf).' `
        -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365'
    return
}

$expansionErrors = [System.Collections.Generic.List[string]]::new()
# Errors already surfaced against a specific group, so the trailing summary
# does not report the same failure a second time.
$reportedErrors = [System.Collections.Generic.List[string]]::new()

foreach ($groupId in ($referencedBy.Keys | Sort-Object)) {
    $errorsBefore = $expansionErrors.Count
    $members = @(Expand-METGroupMembership -Identity $groupId -Cache $groupCache -RetrievalErrors $expansionErrors)
    $usedBy = $referencedBy[$groupId] -join '; '
    $newErrors = @(if ($expansionErrors.Count -gt $errorsBefore) { $expansionErrors[$errorsBefore..($expansionErrors.Count - 1)] })

    # An empty member list means "no members" only when resolution actually
    # succeeded - Expand-METGroupMembership returns @() for a failed lookup too,
    # appending the reason to $expansionErrors. A group that still yielded
    # members despite an error only lost a nested group, so it is reported as
    # Info here and the shortfall is summarised at the end.
    if ($members.Count -eq 0 -and $newErrors.Count -gt 0) {
        foreach ($e in $newErrors) { $reportedErrors.Add($e) }
        New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
            -Result Fail -Severity Medium -AffectedObject $groupId `
            -Finding "Group '$groupId' referenced by $usedBy could not be resolved to a member list, so its policy coverage could not be assessed." `
            -Recommendation "Verify Exchange Online and Microsoft Graph group-read permissions, and confirm '$groupId' still exists." `
            -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365' `
            -ErrorMessage ($newErrors -join "`n")
    }
    elseif ($members.Count -eq 0) {
        New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
            -Result Fail -Severity High -AffectedObject $groupId `
            -Finding "Group '$groupId' has 0 members but is referenced by: $usedBy. The policy condition matches nobody." `
            -Recommendation "Verify '$groupId' still exists and has members, or remove the stale reference from the rule(s) listed." `
            -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365'
    }
    else {
        $memberWord = if ($members.Count -eq 1) { 'member' } else { 'members' }
        New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
            -Result Info -Severity Informational -AffectedObject $groupId `
            -Finding "Group '$groupId' has $($members.Count) $memberWord. Referenced by: $usedBy." `
            -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365'
    }
}

# Only errors not already attributed to a specific group above - e.g. a nested
# group that failed to expand while its parent resolved successfully.
$unreported = @($expansionErrors | Where-Object { $reportedErrors -notcontains $_ })
if ($unreported.Count -gt 0) {
    New-METCheckResult -CheckId 'MET-MDO014' -Category MDO -Name 'Group Reference Audit' `
        -Result Fail -Severity Medium -AffectedObject 'Group Membership Data' `
        -Finding 'One or more nested groups could not be expanded to a member list, so the member counts reported above may be incomplete.' `
        -Recommendation 'Verify Exchange Online and Microsoft Graph group-read permissions and retry the assessment.' `
        -ReferenceUrl 'https://learn.microsoft.com/en-us/defender-office-365/recommended-settings-for-eop-and-office365' `
        -ErrorMessage ($unreported -join "`n")
}