Find-EwsApps.ps1

<#PSScriptInfo
 
.VERSION 1.0.1
 
.GUID 93bd44b9-a886-4025-a1c1-49e59102898d
 
.AUTHOR Sunsetless
 
.COMPANYNAME Sunsetless
 
.COPYRIGHT (c) Sunsetless. MIT License.
 
.TAGS EWS ExchangeOnline Exchange EntraID MicrosoftGraph EwsAllowedAppIDs
 
.LICENSEURI https://github.com/sunset-less/sunsetless-ews-samples/blob/main/LICENSE
 
.PROJECTURI https://sunsetless.com/guides/find-apps-using-ews
 
.RELEASENOTES
Signs out of the Microsoft Graph and Exchange Online sessions it opened. Accepts only whole GUIDs as application
IDs. Writes values that start with =, +, -, @, a tab or a carriage return to ews-apps.csv with an apostrophe in front.
Requires ExchangeOnlineManagement 3.2.0 or later.
 
#>


#Requires -Modules Microsoft.Graph.Authentication, Microsoft.Graph.Applications, Microsoft.Graph.Identity.SignIns, @{ ModuleName = "ExchangeOnlineManagement"; ModuleVersion = "3.2.0" }

<#
.SYNOPSIS
Lists the applications that use EWS in Exchange Online or hold access to it.
 
.DESCRIPTION
Collects application IDs from four places: the EWS usage report (optional), the EwsAllowedAppIDs list, EWS
permissions in Microsoft Entra ID (full_access_as_app and EWS.AccessAsUser.All) and RBAC for Applications in
Exchange Online. For each application it adds the name, whether it is Microsoft's, a vendor's or your own, the
verified publisher and the owners. The result goes to the screen and to ews-apps.csv in the current folder.
 
The script changes nothing in the tenant. It signs in to Microsoft Graph with Directory.Read.All and to Exchange Online
PowerShell, and signs out of the sessions it opened when it ends.
 
.PARAMETER UsageReport
The CSV exported from the EWS usage report in the Microsoft 365 admin center. Leave it out where the report is
not available.
 
.EXAMPLE
Find-EwsApps.ps1
 
.EXAMPLE
Find-EwsApps.ps1 -UsageReport .\EWSUsage.csv
 
.LINK
https://sunsetless.com/guides/find-apps-using-ews
#>

param(
    # The CSV exported from the EWS usage report. Leave it out where the report is not available.
    [string]$UsageReport
)
$ErrorActionPreference = "Stop"

# Sessions that are already open stay open.
$graphWasConnected = [bool](Get-MgContext)
$exchangeConnections = @(Get-ConnectionInformation).ConnectionId
try {
    Connect-MgGraph -Scopes "Directory.Read.All" -ContextScope Process -NoWelcome
    Connect-ExchangeOnline -ShowBanner:$false

    $exchangeOnlineAppId = "00000002-0000-0ff1-ce00-000000000000"
    $microsoftGraphAppId = "00000003-0000-0000-c000-000000000000"
    $microsoftTenantIds = "f8cdef31-a31e-4b4a-93e4-5f571e91255a", "72f988bf-86f1-41af-91ab-2d7cd011db47"
    $tenantId = (Get-MgContext).TenantId
    $guid = "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$"
    $apps = @{}

    function Add-App($AppId, $FoundIn) {
        $id = "$AppId".Trim().ToLower()
        if (-not $apps[$id]) {
            $apps[$id] = [pscustomobject]@{
                AppId = $id; Name = ""; Kind = ""; Publisher = ""; Owners = ""
                FoundIn = @(); Calls = 0; SoapActions = @()
            }
        }
        if ($apps[$id].FoundIn -notcontains $FoundIn) { $apps[$id].FoundIn += $FoundIn }
        $apps[$id]
    }

    # The usage report
    if ($UsageReport) {
        $report = Import-Csv $UsageReport
        $columns = $report[0].PSObject.Properties.Name
        $idColumn = $columns -match "app.*id" | Select-Object -First 1
        $actionColumn = $columns -match "action" | Select-Object -First 1
        $callsColumn = $columns -match "call" | Select-Object -First 1
        foreach ($row in $report) {
            $id = "$($row.$idColumn)".Trim()
            if ($id -notmatch $guid) { continue }
            $app = Add-App $id "usage report"
            $app.Calls += [long]($row.$callsColumn -replace "\D", "")
            $action = $row.$actionColumn -replace ".*/", ""
            if ($app.SoapActions -notcontains $action) { $app.SoapActions += $action }
        }
    }

    # The allow list
    $config = Get-OrganizationConfig -RetrieveEwsOperationAccessPolicy
    "EwsEnabled: $($config.EwsEnabled)"
    foreach ($id in $config.EwsAllowedAppIDs -split ",") {
        if ($id.Trim() -match $guid) { $null = Add-App $id "allow list" }
    }

    # EWS permissions in Microsoft Entra ID
    $exchange = Get-MgServicePrincipal -Filter "appId eq '$exchangeOnlineAppId'"
    $graph = Get-MgServicePrincipal -Filter "appId eq '$microsoftGraphAppId'"
    $fullAccess = ($exchange.AppRoles | Where-Object Value -eq "full_access_as_app").Id

    foreach ($assignment in Get-MgServicePrincipalAppRoleAssignedTo -ServicePrincipalId $exchange.Id -All) {
        if ($assignment.AppRoleId -eq $fullAccess) {
            $null = Add-App (Get-MgServicePrincipal -ServicePrincipalId $assignment.PrincipalId).AppId "full_access_as_app"
        }
    }
    foreach ($resource in $exchange, $graph) {
        foreach ($grant in Get-MgOauth2PermissionGrant -Filter "resourceId eq '$($resource.Id)'" -All) {
            if (($grant.Scope -split " ") -contains "EWS.AccessAsUser.All") {
                $null = Add-App (Get-MgServicePrincipal -ServicePrincipalId $grant.ClientId).AppId "EWS.AccessAsUser.All"
            }
        }
    }

    # RBAC for Applications in Exchange Online
    foreach ($principal in Get-ServicePrincipal) {
        $roles = Get-ManagementRoleAssignment -RoleAssignee $principal.Identity | Where-Object Role -like "*EWS*"
        if ($roles) { $null = Add-App $principal.AppId "Exchange RBAC" }
    }

    # A name, a kind and an owner for each application
    foreach ($app in $apps.Values) {
        $sp = Get-MgServicePrincipal -Filter "appId eq '$($app.AppId)'"
        if (-not $sp) { $app.Kind = "Not in your directory"; continue }
        $app.Name = $sp.DisplayName
        if ($sp.ServicePrincipalType -eq "ManagedIdentity" -or $sp.AppOwnerOrganizationId -eq $tenantId) {
            $app.Kind = "Your own"
            $registration = Get-MgApplication -Filter "appId eq '$($app.AppId)'"
            if ($registration) {
                $app.Owners = (Get-MgApplicationOwnerAsUser -ApplicationId $registration.Id).UserPrincipalName -join "; "
            }
        } elseif ($microsoftTenantIds -contains $sp.AppOwnerOrganizationId) {
            $app.Kind = "Microsoft"
        } else {
            $app.Kind = "Vendor"
            $app.Publisher = $sp.VerifiedPublisher.DisplayName
        }
    }

    $result = $apps.Values | Sort-Object Kind, Name | Select-Object AppId, Name, Kind, Publisher, Owners,
        @{ Name = "FoundIn"; Expression = { $_.FoundIn -join "; " } }, Calls,
        @{ Name = "SoapActions"; Expression = { $_.SoapActions -join " " } }
    $result | ForEach-Object {
        $row = [ordered]@{}
        foreach ($property in $_.PSObject.Properties) {
            $value = $property.Value
            if ("$value" -match "^[=+\-@\t\r]") { $value = "'$value" }
            $row[$property.Name] = $value
        }
        [pscustomobject]$row
    } | Export-Csv .\ews-apps.csv -NoTypeInformation -Encoding UTF8
    $result | Format-Table AppId, Name, Kind, FoundIn -AutoSize
} finally {
    if (-not $graphWasConnected -and (Get-MgContext)) { $null = Disconnect-MgGraph }
    $opened = Get-ConnectionInformation | Where-Object { $exchangeConnections -notcontains $_.ConnectionId }
    if ($opened) { Disconnect-ExchangeOnline -ConnectionId $opened.ConnectionId -Confirm:$false }
}