public/Get-MsecEntraAppConsent.ps1

function Get-MsecEntraAppConsent {
    <#
    .SYNOPSIS
        Which applications have been granted access to your tenant's data, what they can do,
        and who agreed to it - one row per permission.

    .DESCRIPTION
        Get-MsecEntraAppCredential says which apps hold a key. This says what those apps are
        ALLOWED TO DO, which is the half that decides whether a key matters. Illicit consent
        is one of the most common routes into a Microsoft 365 tenant precisely because it needs
        no password, survives a password reset, and leaves the attacker holding a token rather
        than an account.

        TWO DIFFERENT GRANTS, REPORTED TOGETHER.
          Delegated - oauth2PermissionGrants. The app acts AS A USER and is limited to what
                        that user can reach. ConsentType 'AllPrincipals' means an administrator
                        consented on behalf of EVERYONE; 'Principal' means one user consented
                        for themselves.
          Application - appRoleAssignments. The app acts AS ITSELF, with no user and no user's
                        limits. Mail.Read here is every mailbox in the tenant, not one.
        An Application grant is almost always the more serious of the two for the same
        permission name, so PermissionType belongs in any review that sorts by risk.

        ONE ROW PER PERMISSION, NOT PER GRANT. A single delegated grant carries a whole
        space-separated scope string; left whole it cannot be filtered or compared. Split out,
        'which apps can read mail' is one Where-Object.

        APP ROLE ASSIGNMENTS ARE READ FROM THE RESOURCE SIDE, deliberately. The obvious route -
        expanding appRoleAssignments on each service principal - SILENTLY TRUNCATES at one page
        and does not paginate: measured on one tenant it returned 203 assignments where the
        resource-side read returned 410, and 20 of msec's own 24. A security command that
        under-reports permissions by half is worse than no command, so this one pays for ~200
        extra calls and takes about half a minute.

        ASSIGNMENTS TO USERS AND GROUPS ARE NOT CONSENT AND ARE EXCLUDED. An app role assigned
        to a user or a group says who may USE an app; only an assignment to a service principal
        is an API permission the app holds over your data. Mixing them would inflate every
        count with something that answers a different question.

        HIGH RISK IS A JUDGEMENT AND IS WRITTEN DOWN IN THE SOURCE, not inferred. See
        $highRisk below and argue with it - the three escalation permissions at the top of that
        list let an app grant ITSELF more access, up to and including Global Administrator.

        UNREADABLE IS NOT EMPTY. A resource whose assignments cannot be read is reported as a
        row saying so, rather than omitted - an app with permissions nobody could enumerate
        must not read as an app with none.

    .PARAMETER PermissionType
        Delegated, Application, or both. Default is both.

    .PARAMETER HighRiskOnly
        Only permissions on the curated high-risk list.

    .PARAMETER ThirdPartyOnly
        Exclude applications published by Microsoft. Convenience for review, not a default:
        a first-party app with a surprising permission is still worth seeing.

    .EXAMPLE
        Get-MsecEntraAppConsent -HighRiskOnly | Where-Object PermissionType -eq 'Application'

        Apps that can act on the whole tenant without a user, holding a sensitive permission.

    .EXAMPLE
        Get-MsecEntraAppConsent -ThirdPartyOnly |
            Where-Object ConsentType -eq 'AllPrincipals'

        Third-party apps an administrator consented to on behalf of every user.

    .EXAMPLE
        Get-MsecEntraAppConsent | Group-Object ClientDisplayName |
            Sort-Object Count -Descending | Select-Object -First 20

        The apps holding the most permissions.
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [ValidateSet('Delegated', 'Application')]
        [string[]] $PermissionType = @('Delegated', 'Application'),

        [switch] $HighRiskOnly,

        [switch] $ThirdPartyOnly
    )

    if (-not $script:MsecSession) {
        throw 'No msec session. Run Connect-Msec first.'
    }

    # Permissions worth a second look. Grouped by why, because "high risk" with no reason
    # attached is not reviewable. This list is the command's opinion and is meant to be edited.
    $highRisk = @(
        # Escalation - these let an app increase its OWN access, so they outrank everything else
        'AppRoleAssignment.ReadWrite.All'     # grant itself any application permission
        'RoleManagement.ReadWrite.Directory'  # put itself in a directory role, incl. Global Admin
        'Application.ReadWrite.All'           # add credentials to any app, then become it
        'Application.ReadWrite.OwnedBy'
        'PrivilegedAccess.ReadWrite.AzureADGroup'

        # Mail - read, send as, or quietly reconfigure where mail goes
        'Mail.Read'; 'Mail.ReadBasic'; 'Mail.ReadBasic.All'; 'Mail.ReadWrite'; 'Mail.Send'
        'MailboxSettings.ReadWrite'           # sets forwarding without touching a rule
        'full_access_as_app'                  # EWS: every mailbox, everything
        'Exchange.ManageAsApp'

        # Content at tenant scope
        'Files.Read.All'; 'Files.ReadWrite.All'
        'Sites.Read.All'; 'Sites.ReadWrite.All'; 'Sites.FullControl.All'; 'Sites.Manage.All'
        'Chat.Read.All'; 'ChatMessage.Read.All'; 'ChannelMessage.Read.All'
        'Calendars.Read'; 'Calendars.ReadWrite'

        # Directory - reconnaissance and modification
        'Directory.Read.All'; 'Directory.ReadWrite.All'
        'User.Read.All'; 'User.ReadWrite.All'
        'Group.Read.All'; 'Group.ReadWrite.All'; 'GroupMember.ReadWrite.All'
        'User.EnableDisableAccount.All'
        'UserAuthenticationMethod.ReadWrite.All'  # reset someone's MFA
    )

    # First-party applications are owned by one of these two tenants. Checked against the OWNER
    # tenant rather than the publisher name, which is free text and absent on many apps.
    $microsoftTenants = @(
        'f8cdef31-a31e-4b4a-93e4-5f571e91255a'
        '72f988bf-86f1-41af-91ab-2d7cd011db47'
    )

    Write-Verbose 'Reading service principals'
    $servicePrincipals = @(Invoke-MsecGraphRequest -All -Path (
        '/v1.0/servicePrincipals?$select=id,appId,displayName,appRoles,appOwnerOrganizationId,' +
        'publisherName,verifiedPublisher,servicePrincipalType,accountEnabled,signInAudience&$top=999'))

    $spById = @{}
    foreach ($sp in $servicePrincipals) { $spById[$sp.id] = $sp }

    # appRoleId is a GUID that only means anything against the RESOURCE's own appRoles list.
    $roleName = @{}
    foreach ($sp in $servicePrincipals) {
        foreach ($role in @($sp.appRoles)) {
            if ($role.id) { $roleName["$($sp.id)/$($role.id)"] = [string] $role.value }
        }
    }

    # Describes a client once, so every row for it agrees.
    $describe = {
        param($sp, $fallbackId, $fallbackName)
        $owner = if ($sp) { [string] $sp.appOwnerOrganizationId } else { '' }
        $publisher = if (-not $sp) { $null }
                     elseif ($sp.verifiedPublisher -and $sp.verifiedPublisher.displayName) { [string] $sp.verifiedPublisher.displayName }
                     elseif ($sp.publisherName) { [string] $sp.publisherName }
                     else { $null }
        [PSCustomObject]@{
            Name       = if ($sp) { [string] $sp.displayName } else { $fallbackName }
            AppId      = if ($sp) { [string] $sp.appId } else { $null }
            ObjectId   = if ($sp) { [string] $sp.id } else { $fallbackId }
            Publisher  = $publisher
            # $null, not $false, when the owning tenant is unknown - an app msec could not
            # resolve is not evidence of a third-party app.
            IsMicrosoft = if ($owner) { $owner -in $microsoftTenants } else { $null }
            Enabled    = if ($sp) { $sp.accountEnabled } else { $null }
            Type       = if ($sp) { [string] $sp.servicePrincipalType } else { $null }
        }
    }

    $emit = {
        param($row)
        if ($HighRiskOnly -and -not $row.IsHighRisk) { return }
        # -ThirdPartyOnly drops only apps KNOWN to be Microsoft's. An unresolved owner stays in.
        if ($ThirdPartyOnly -and $row.ClientIsMicrosoft -eq $true) { return }
        $row
    }

    # ---------- Delegated ----------
    if ($PermissionType -contains 'Delegated') {
        Write-Verbose 'Reading delegated permission grants'
        $grants = $null
        try {
            $grants = @(Invoke-MsecGraphRequest -All -Path '/v1.0/oauth2PermissionGrants?$top=999')
        }
        catch {
            Write-Warning "Could not read delegated permission grants (/oauth2PermissionGrants), so NO delegated consent is covered by this output - this is not the same as a tenant with none. Graph said: $($_.Exception.Message)"
        }

        if ($null -ne $grants) {
            # One batched lookup instead of one call per user: a tenant can easily hold
            # hundreds of individual-user consents, and resolving them singly is the
            # difference between seconds and minutes.
            $principalName = @{}
            $ids = @($grants | Where-Object { $_.principalId } | ForEach-Object { [string] $_.principalId } | Select-Object -Unique)
            for ($i = 0; $i -lt $ids.Count; $i += 1000) {
                $chunk = @($ids[$i..([math]::Min($i + 999, $ids.Count - 1))])
                try {
                    $resolved = Invoke-MsecGraphRequest -Method POST -Path '/v1.0/directoryObjects/getByIds' -Body @{
                        ids   = $chunk
                        types = @('user')
                    }
                    foreach ($o in @($resolved.value)) {
                        $principalName[[string] $o.id] = [pscustomobject]@{
                            DisplayName = [string] $o.displayName
                            Upn         = [string] $o.userPrincipalName
                        }
                    }
                }
                catch {
                    Write-Warning "Could not resolve $($chunk.Count) consenting user(s) to names; PrincipalDisplayName and PrincipalUpn are null for those rows rather than wrong. Graph said: $($_.Exception.Message)"
                }
            }

            foreach ($grant in $grants) {
                $client   = & $describe $spById[[string] $grant.clientId]   ([string] $grant.clientId)   $null
                $resource = & $describe $spById[[string] $grant.resourceId] ([string] $grant.resourceId) $null

                $who = if ($grant.principalId) { $principalName[[string] $grant.principalId] } else { $null }

                # A space-separated scope string, sometimes with leading whitespace.
                foreach ($scope in @(([string] $grant.scope) -split '\s+' | Where-Object { $_ })) {
                    & $emit ([PSCustomObject]@{
                        PSTypeName           = 'MsecEntraAppConsent'
                        ClientDisplayName    = $client.Name
                        ClientAppId          = $client.AppId
                        ClientObjectId       = $client.ObjectId
                        ClientPublisher      = $client.Publisher
                        ClientIsMicrosoft    = $client.IsMicrosoft
                        ClientEnabled        = $client.Enabled
                        ResourceDisplayName  = $resource.Name
                        ResourceAppId        = $resource.AppId
                        Permission           = $scope
                        PermissionType       = 'Delegated'
                        # AllPrincipals = an admin agreed for the whole tenant.
                        ConsentType          = [string] $grant.consentType
                        PrincipalId          = [string] $grant.principalId
                        PrincipalDisplayName = if ($who) { $who.DisplayName } else { $null }
                        PrincipalUpn         = if ($who) { $who.Upn } else { $null }
                        IsHighRisk           = ($scope -in $highRisk)
                        GrantId              = [string] $grant.id
                        CreatedDateTime      = $null   # not exposed on oauth2PermissionGrant
                    })
                }
            }
        }
    }

    # ---------- Application ----------
    if ($PermissionType -contains 'Application') {
        # Only a service principal that PUBLISHES app roles can be the resource of one, so the
        # sweep is over those rather than over every principal in the tenant.
        $resources = @($servicePrincipals | Where-Object { @($_.appRoles).Count -gt 0 })
        Write-Verbose "Reading app role assignments from $($resources.Count) resource service principal(s)"

        foreach ($resource in $resources) {
            $assignments = $null
            try {
                $assignments = @(Invoke-MsecGraphRequest -All -Path "/v1.0/servicePrincipals/$($resource.id)/appRoleAssignedTo?`$top=999")
            }
            catch {
                Write-Warning "Could not read app role assignments on '$($resource.displayName)', so any application permission granted on it is MISSING from this output. Graph said: $($_.Exception.Message)"
                & $emit ([PSCustomObject]@{
                    PSTypeName           = 'MsecEntraAppConsent'
                    ClientDisplayName    = 'Unreadable'
                    ClientAppId          = $null
                    ClientObjectId       = $null
                    ClientPublisher      = $null
                    ClientIsMicrosoft    = $null
                    ClientEnabled        = $null
                    ResourceDisplayName  = [string] $resource.displayName
                    ResourceAppId        = [string] $resource.appId
                    Permission           = $null
                    PermissionType       = 'Application'
                    ConsentType          = 'Application'
                    PrincipalId          = $null
                    PrincipalDisplayName = $null
                    PrincipalUpn         = $null
                    IsHighRisk           = $null
                    GrantId              = $null
                    CreatedDateTime      = $null
                })
                continue
            }

            foreach ($assignment in $assignments) {
                # User and Group assignments say who may USE the app. Not consent.
                if ([string] $assignment.principalType -ne 'ServicePrincipal') { continue }

                $client = & $describe $spById[[string] $assignment.principalId] `
                                      ([string] $assignment.principalId) `
                                      ([string] $assignment.principalDisplayName)

                $permission = $roleName["$($resource.id)/$($assignment.appRoleId)"]

                & $emit ([PSCustomObject]@{
                    PSTypeName           = 'MsecEntraAppConsent'
                    ClientDisplayName    = $client.Name
                    ClientAppId          = $client.AppId
                    ClientObjectId       = $client.ObjectId
                    ClientPublisher      = $client.Publisher
                    ClientIsMicrosoft    = $client.IsMicrosoft
                    ClientEnabled        = $client.Enabled
                    ResourceDisplayName  = [string] $resource.displayName
                    ResourceAppId        = [string] $resource.appId
                    # Null when the resource no longer publishes the role the grant names -
                    # a real state after an app is updated, and not the same as no permission.
                    Permission           = $permission
                    PermissionType       = 'Application'
                    # No user is involved at all, which is the point.
                    ConsentType          = 'Application'
                    PrincipalId          = $null
                    PrincipalDisplayName = $null
                    PrincipalUpn         = $null
                    IsHighRisk           = if ($permission) { $permission -in $highRisk } else { $null }
                    GrantId              = [string] $assignment.id
                    CreatedDateTime      = $assignment.createdDateTime
                })
            }
        }
    }
}