Checks/Teams/MET-Teams003-MeetingProtection.ps1

[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', 'METCheckInfo',
    Justification = 'Check metadata. Read from the AST by Get-METCheck and never executed.')]
param()

$METCheckInfo = @{
    Name           = 'Meeting Protection'
    Severity       = 'Medium'
    Description    = 'Reviews external access settings, anonymous join policy, and lobby bypass settings (AllowPSTNUsersToBypassLobby) across all Get-CsTeamsMeetingPolicy instances.'
    RequiresModule = @('MicrosoftTeams')
}

$issues = [System.Collections.Generic.List[string]]::new()
$retrievalErrors = [System.Collections.Generic.List[string]]::new()
$unobservedSettingClauses = [System.Collections.Generic.List[string]]::new()
$unobservedSettingNames = [System.Collections.Generic.List[string]]::new()

# Check Teams external access settings via EXO/Graph
try {
    $tenantConfig = Get-CsTenantFederationConfiguration -ErrorAction Stop
    # Federation being disabled is the hardened end state - MET-Teams006 recommends
    # Set-CsTenantFederationConfiguration -AllowFederatedUsers $false as its remediation.
    # Adding it to $issues made the two checks penalise each other's recommended
    # configuration, and turned the most locked-down tenant into a Warning. Usability
    # commentary does not belong in the issue list of a posture check.
    if ($tenantConfig.AllowFederatedUsers -eq $false) {
        Write-Verbose 'MET-Teams003: Teams federation is fully disabled (hardened; see MET-Teams006).'
    }
    if ($tenantConfig.AllowPublicUsers -eq $true) {
        $issues.Add('Access from Skype consumer users is allowed - consider disabling if not needed')
    }
}
catch {
    $retrievalErrors.Add("Could not retrieve tenant federation configuration: $($_.Exception.Message)")
    Write-Verbose "Could not retrieve tenant federation configuration: $_"
}

# Check Teams meeting policies for anonymous join and lobby settings. Every
# policy instance is evaluated, not just Global, since per-user/group policy
# assignments can silently apply weaker settings than the tenant default.
try {
    $meetingPolicies = @(Get-CsTeamsMeetingPolicy -ErrorAction Stop)

    $anonymousJoinPolicies = @($meetingPolicies | Where-Object { $_.AllowAnonymousUsersToJoinMeeting -eq $true })
    if ($anonymousJoinPolicies.Count -gt 0) {
        $names = ($anonymousJoinPolicies | Select-Object -ExpandProperty Identity) -join ', '
        $issues.Add("Anonymous users are allowed to join meetings without being admitted from the lobby in the following meeting policy/policies: $names")
    }

    $everyoneAdmittedPolicies = @($meetingPolicies | Where-Object { $_.AutoAdmittedUsers -eq 'Everyone' })
    if ($everyoneAdmittedPolicies.Count -gt 0) {
        $names = ($everyoneAdmittedPolicies | Select-Object -ExpandProperty Identity) -join ', '
        $issues.Add("AutoAdmittedUsers is 'Everyone' in the following meeting policy/policies: $names - all users bypass the lobby; recommended: 'EveryoneInSameAndFederatedCompany' or stricter")
    }

    $externalChatPolicies = @($meetingPolicies | Where-Object { $_.AllowExternalNonTrustedMeetingChat -eq $true })
    if ($externalChatPolicies.Count -gt 0) {
        $names = ($externalChatPolicies | Select-Object -ExpandProperty Identity) -join ', '
        $issues.Add("External non-trusted participants are allowed to use meeting chat in the following meeting policy/policies: $names")
    }

    $pstnLobbyBypassPolicies = @($meetingPolicies | Where-Object { $_.AllowPSTNUsersToBypassLobby -eq $true })
    if ($pstnLobbyBypassPolicies.Count -gt 0) {
        $names = ($pstnLobbyBypassPolicies | Select-Object -ExpandProperty Identity) -join ', '
        $issues.Add("PSTN callers bypass the lobby in the following meeting policy/policies: $names - phone participants are automatically admitted, a meeting-invite-lure risk")
    }

    $giveControlPolicies = @($meetingPolicies | Where-Object { $_.AllowExternalParticipantGiveRequestControl -eq $true })
    if ($giveControlPolicies.Count -gt 0) {
        $names = ($giveControlPolicies | Select-Object -ExpandProperty Identity) -join ', '
        $issues.Add("External participants can request and be granted control of a shared screen in the following meeting policy/policies: $names - the screen-control handoff used in helpdesk-impersonation and remote-access social engineering")
    }

    $anonymousStartPolicies = @($meetingPolicies | Where-Object { $_.AllowAnonymousUsersToStartMeeting -eq $true })
    if ($anonymousStartPolicies.Count -gt 0) {
        $names = ($anonymousStartPolicies | Select-Object -ExpandProperty Identity) -join ', '
        $issues.Add("Anonymous (unauthenticated) participants can start a meeting with no organiser present in the following meeting policy/policies: $names - this defeats lobby controls that assume an organiser is there to admit attendees")
    }

    # A property present but $null is treated the same as absent: neither observation
    # confirms whether the setting is actually secure, and the six -eq $true filters
    # above only ever catch an explicit insecure value, never silence on this collection.
    $meetingSettingNames = @(
        'AllowAnonymousUsersToJoinMeeting',
        'AutoAdmittedUsers',
        'AllowExternalNonTrustedMeetingChat',
        'AllowPSTNUsersToBypassLobby',
        'AllowExternalParticipantGiveRequestControl',
        'AllowAnonymousUsersToStartMeeting'
    )

    foreach ($settingName in $meetingSettingNames) {
        $unobservedPolicies = @(
            $meetingPolicies | Where-Object {
                -not $_.PSObject.Properties[$settingName] -or $null -eq $_.$settingName
            }
        )
        if ($unobservedPolicies.Count -gt 0) {
            $unobservedSettingNames.Add($settingName)
            $shownNames = @($unobservedPolicies | Select-Object -First 5 -ExpandProperty Identity)
            $shownNamesText = $shownNames -join ', '
            $truncationSuffix = if ($unobservedPolicies.Count -gt $shownNames.Count) { " (showing first $($shownNames.Count) of $($unobservedPolicies.Count))" } else { '' }
            $unobservedSettingClauses.Add("$settingName was not returned for the following meeting policy/policies: $shownNamesText$truncationSuffix")
        }
    }
}
catch {
    $retrievalErrors.Add("Could not retrieve Teams meeting policies: $($_.Exception.Message)")
    Write-Verbose "Could not retrieve Teams meeting policies: $_"
}

# Check Teams channel meeting policy
try {
    $channelMeetingPolicy = Get-CsTeamsChannelsPolicy -ErrorAction Stop | Where-Object { $_.Identity -eq 'Global' }
    if ($channelMeetingPolicy -and $channelMeetingPolicy.AllowSharedChannelCreation -eq $true) {
        # Shared channels bypass some external access controls - informational
        Write-Verbose 'Shared channel creation is enabled - ensure external sharing is reviewed in shared channels.'
    }
}
catch {
    $retrievalErrors.Add("Could not retrieve Teams channel policy: $($_.Exception.Message)")
    Write-Verbose "Could not retrieve Teams channel policy: $_"
}

if ($issues.Count -gt 0) {
    $result = if ($issues | Where-Object { $_ -match 'Anonymous' -or $_ -match 'Everyone' -or $_ -match 'PSTN callers bypass the lobby' }) { 'Fail' } else { 'Warning' }
    $findingParts = [System.Collections.Generic.List[string]]::new($issues)
    if ($unobservedSettingClauses.Count -gt 0) {
        $findingParts.AddRange($unobservedSettingClauses)
        $findingParts.Add('An unconfirmed state is reported alongside the confirmed issues above rather than folded into a pass, because nothing here distinguishes a tenant with an unreturned setting switched on from one with it switched off.')
    }
    New-METCheckResult -CheckId 'MET-Teams003' -Category Teams -Name 'Meeting Protection' `
        -Result $result -Severity Medium -AffectedObject 'Teams Meeting Policies' `
        -Finding ($findingParts -join '; ') `
        -Recommendation "Disable anonymous meeting join, set AutoAdmittedUsers to 'EveryoneInSameAndFederatedCompany' or 'OrganizerOnly', and review external chat permissions. Use the lobby as a security control." `
        -ReferenceUrl 'https://aka.ms/teams-meeting-security' `
        -ErrorMessage ($retrievalErrors -join "`n")
}
elseif ($unobservedSettingClauses.Count -gt 0) {
    New-METCheckResult -CheckId 'MET-Teams003' -Category Teams -Name 'Meeting Protection' `
        -Result Warning -Severity Medium -AffectedObject 'Teams Meeting Policies' `
        -Finding (($unobservedSettingClauses -join '; ') + '. An unconfirmed state is reported as unassessed rather than a pass, because nothing here distinguishes a tenant with the setting on from one with it switched off.') `
        -Recommendation 'Confirm these settings directly with: Get-CsTeamsMeetingPolicy | Format-List <PropertyName>. An absent or null property usually means a MicrosoftTeams module version that does not expose it, or a value never explicitly set on that policy - update the module and rerun the assessment.' `
        -ReferenceUrl 'https://aka.ms/teams-meeting-security' `
        -ErrorMessage "The following properties were not returned by Get-CsTeamsMeetingPolicy for one or more meeting policies: $($unobservedSettingNames -join ', ')"
}
elseif ($retrievalErrors.Count -gt 0) {
    New-METCheckResult -CheckId 'MET-Teams003' -Category Teams -Name 'Meeting Protection' `
        -Result Warning -Severity Medium -AffectedObject 'Teams Meeting Policies' `
        -Finding 'Teams meeting protection state could not be read in full, so anonymous join, lobby admission and external meeting chat exposure were not assessed.' `
        -Recommendation 'Ensure the MicrosoftTeams module is installed and the session has permission to read Teams meeting, federation and channel policies, then rerun the assessment.' `
        -ReferenceUrl 'https://aka.ms/teams-meeting-security' `
        -ErrorMessage ($retrievalErrors -join "`n")
}
else {
    New-METCheckResult -CheckId 'MET-Teams003' -Category Teams -Name 'Meeting Protection' `
        -Result Pass -Severity Medium -AffectedObject 'Teams Meeting Policies' `
        -Finding 'Teams meeting protection settings are correctly configured' `
        -ReferenceUrl 'https://aka.ms/teams-meeting-security'
}