Checks/Test-HygieneRiskyApplication.ps1

<#
.SYNOPSIS
    Flags service principals that hold high-impact permissions on Microsoft Graph.

.DESCRIPTION
    Application permissions are the quietest way to own a tenant. They belong to a
    non-human identity, they are not challenged by MFA, they survive every password
    reset, and nobody receives a sign-in notification when they are used.

    The check looks at two distinct grant types, because they fail differently:

      Application permissions (app roles) act with no user present and apply to the
      whole tenant. Mail.ReadWrite here means every mailbox, always.

      Delegated permissions granted with consentType 'AllPrincipals' act on behalf of
      any user who uses the app, tenant-wide, without each user consenting.

    Permissions are graded by what they let the holder become, not by how alarming
    the name sounds. Anything that can assign roles or credentials -- RoleManagement,
    AppRoleAssignment, Application.ReadWrite.All, Directory.ReadWrite.All -- is
    Critical, because a holder can promote itself to Global Administrator. Bulk access
    to mail, files or sites is High: total data loss, but no privilege escalation.

.PARAMETER Context
    Shared audit context from New-HygieneAuditContext.

.OUTPUTS
    M365TenantHygiene.Finding objects.

.NOTES
    Graph endpoints: GET /servicePrincipals
                     GET /servicePrincipals(appId='00000003-0000-0000-c000-000000000000')
                     GET /servicePrincipals(appId='...')/appRoleAssignedTo
                     GET /oauth2PermissionGrants
    Scopes required: Application.Read.All, Directory.Read.All (delegated)

    Scoped to permissions granted on the Microsoft Graph API. That is one call rather
    than one per service principal, and it is where the highest-impact application
    permissions in a Microsoft 365 tenant live -- but permissions granted on other
    resource APIs, including the legacy Exchange Online API, are outside this check.
#>

function Test-HygieneRiskyApplication {
    [CmdletBinding()]
    [OutputType([pscustomobject])]
    param([Parameter(Mandatory)][hashtable] $Context)

    $meta = (Get-HygieneCheckRegistry)['RiskyApplication']

    # The Microsoft Graph resource application. This identifier is a well-known
    # constant, the same in every tenant.
    $graphAppId = '00000003-0000-0000-c000-000000000000'

    # Permissions that let the holder grant itself or another principal more access.
    # Holding any of these is equivalent to holding Global Administrator.
    $escalation = @(
        'RoleManagement.ReadWrite.Directory'
        'AppRoleAssignment.ReadWrite.All'
        'Application.ReadWrite.All'
        'Directory.ReadWrite.All'
        'PrivilegedAccess.ReadWrite.AzureAD'
        'RoleManagement.ReadWrite.All'
        'Directory.AccessAsUser.All'
    )

    # Permissions that expose or alter organisation-wide data without escalating.
    $bulkData = @(
        'Mail.Read'
        'Mail.ReadWrite'
        'Mail.Send'
        'MailboxSettings.ReadWrite'
        'Files.Read.All'
        'Files.ReadWrite.All'
        'Sites.Read.All'
        'Sites.ReadWrite.All'
        'Sites.FullControl.All'
        'User.ReadWrite.All'
        'Group.ReadWrite.All'
        'ChannelMessage.Read.All'
        'Chat.Read.All'
        'full_access_as_app'
    )

    $servicePrincipals = Invoke-HygieneGraphRequest `
        -Uri 'servicePrincipals?$select=id,appId,displayName,servicePrincipalType,accountEnabled,appOwnerOrganizationId&$top=999' `
        -MaxPages $Context.MaxPages

    $spById = @{}
    foreach ($sp in $servicePrincipals) { $spById[$sp.id] = $sp }
    Write-HygieneLog -Message "Read $($servicePrincipals.Count) service principal(s)."

    $graphSp = @(Invoke-HygieneGraphRequest `
        -Uri "servicePrincipals(appId='$graphAppId')?`$select=id,appId,displayName,appRoles" `
        -MaxPages 1)

    if ($graphSp.Count -eq 0 -or -not $graphSp[0].id) {
        return New-HygieneCheckResult `
            -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
            -Status 'Failed' `
            -RequiredScopes $meta.RequiredScopes `
            -ErrorMessage 'The Microsoft Graph service principal could not be read, so application permissions could not be resolved to permission names.'
    }

    $graphSp = $graphSp[0]

    # appRoleAssignedTo returns an opaque appRoleId. Without this map the report would
    # list GUIDs, which nobody can act on.
    $roleValueById = @{}
    foreach ($role in $graphSp.appRoles) { $roleValueById[$role.id] = $role.value }

    $tenantId = if ($Context.Tenant) { $Context.Tenant.Id } else { $null }
    $findings = [System.Collections.Generic.List[object]]::new()
    $evaluated = 0

    # --- Application permissions on Microsoft Graph -------------------------------

    $appGrants = Invoke-HygieneGraphRequest `
        -Uri "servicePrincipals(appId='$graphAppId')/appRoleAssignedTo" `
        -MaxPages $Context.MaxPages

    Write-HygieneLog -Message "Read $($appGrants.Count) application permission grant(s) on Microsoft Graph."

    $byClient = $appGrants | Group-Object -Property principalId
    foreach ($group in $byClient) {

        $evaluated++
        $clientId   = $group.Name
        $clientName = $group.Group[0].principalDisplayName
        $client     = $spById[$clientId]

        $permissions = @(
            $group.Group |
                ForEach-Object { $roleValueById[$_.appRoleId] } |
                Where-Object { $_ } |
                Sort-Object -Unique
        )

        $hasEscalation = @($permissions | Where-Object { $escalation -contains $_ })
        $hasBulk       = @($permissions | Where-Object { $bulkData -contains $_ })

        if ($hasEscalation.Count -eq 0 -and $hasBulk.Count -eq 0) { continue }

        $external = $false
        if ($client -and $client.appOwnerOrganizationId -and $tenantId) {
            $external = ($client.appOwnerOrganizationId -ne $tenantId)
        }

        if ($hasEscalation.Count -gt 0) {
            $severity = 'Critical'
            $title    = 'Application can escalate its own privileges'
            $detail   = "This application holds $($hasEscalation -join ', ') as an application permission. Permissions in this group let the holder assign roles or credentials, so whoever controls this application's secret can make themselves a Global Administrator without ever signing in as a user."
        }
        else {
            $severity = 'High'
            $title    = 'Application has tenant-wide access to organisation data'
            $detail   = "This application holds $($hasBulk -join ', ') as an application permission. Application permissions are not scoped to a user and are not subject to MFA, so this grant covers every mailbox, site or file in the tenant, permanently."
        }

        if ($external) {
            $detail += ' The application is published by another tenant, so the credential that uses this grant is held outside your organisation.'
        }
        if ($client -and $client.accountEnabled -eq $false) {
            $detail += ' The service principal is currently disabled, which suspends but does not remove the grant.'
        }

        $findings.Add((New-HygieneFinding `
            -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
            -Severity $severity `
            -Title $title `
            -ObjectType 'ServicePrincipal' `
            -ObjectId $clientId `
            -ObjectName $clientName `
            -Detail $detail `
            -Recommendation 'Identify the workload behind this application and confirm it needs tenant-wide access. Where it does not, replace the grant with a narrower permission, or scope mail and file access with an application access policy. Remove grants belonging to applications nobody can account for.' `
            -Reference 'https://learn.microsoft.com/entra/identity-platform/permissions-consent-overview' `
            -Evidence @{
                grantType            = 'Application permission (app role)'
                resource             = 'Microsoft Graph'
                permissions          = $permissions
                escalationPermissions = $hasEscalation
                bulkDataPermissions  = $hasBulk
                appId                = if ($client) { $client.appId } else { $null }
                publishedExternally  = $external
                appOwnerOrganizationId = if ($client) { $client.appOwnerOrganizationId } else { $null }
                accountEnabled       = if ($client) { $client.accountEnabled } else { $null }
            }))
    }

    # --- Tenant-wide delegated grants ---------------------------------------------

    $delegatedGrants = Invoke-HygieneGraphRequest -Uri 'oauth2PermissionGrants' -MaxPages $Context.MaxPages
    Write-HygieneLog -Message "Read $($delegatedGrants.Count) delegated permission grant(s)."

    foreach ($grant in $delegatedGrants) {

        if ($grant.consentType -ne 'AllPrincipals') { continue }
        $evaluated++

        $scopes = @(($grant.scope -split '\s+') | Where-Object { $_ })
        $hasEscalation = @($scopes | Where-Object { $escalation -contains $_ })
        $hasBulk       = @($scopes | Where-Object { $bulkData -contains $_ })

        if ($hasEscalation.Count -eq 0 -and $hasBulk.Count -eq 0) { continue }

        $client       = $spById[$grant.clientId]
        $clientName   = if ($client) { $client.displayName } else { $grant.clientId }
        $resource     = $spById[$grant.resourceId]
        $resourceName = if ($resource) { $resource.displayName } else { $grant.resourceId }

        $severity = if ($hasEscalation.Count -gt 0) { 'High' } else { 'Medium' }
        $risky    = @($hasEscalation + $hasBulk | Sort-Object -Unique)

        $findings.Add((New-HygieneFinding `
            -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
            -Severity $severity `
            -Title 'Sensitive delegated permission granted for all users' `
            -ObjectType 'ServicePrincipal' `
            -ObjectId $grant.clientId `
            -ObjectName $clientName `
            -Detail "An administrator consented to $($risky -join ', ') on $resourceName for every user in the tenant. Any user of this application acts with these permissions without being asked, so the grant is only as trustworthy as the application itself." `
            -Recommendation 'Confirm the application is one your organisation deliberately adopted. Where tenant-wide consent is not required, revoke it and let individual users consent, or restrict the app to an assigned group.' `
            -Reference 'https://learn.microsoft.com/entra/identity/enterprise-apps/manage-consent-requests' `
            -Evidence @{
                grantType      = 'Delegated permission (AllPrincipals)'
                resource       = $resourceName
                scopes         = $scopes
                riskyScopes    = $risky
                grantId        = $grant.id
                clientAppId    = if ($client) { $client.appId } else { $null }
            }))
    }

    New-HygieneCheckResult `
        -CheckId $meta.Id -CheckName $meta.Name -Category $meta.Category `
        -Status 'Completed' `
        -Findings @($findings) `
        -ObjectsEvaluated $evaluated `
        -RequiredScopes $meta.RequiredScopes
}