SHELL/APP.ROLES.STATUS.ps1

$CheckId = "APP.ROLES.STATUS"
$Title = "Supplemental - Application permissions and critical app role assignments"
$Level = "L1"
$BenchmarkType = "Automated"

function Get-PropValue {
    param(
        [AllowNull()]$Object,
        [string]$Name
    )

    if ($null -eq $Object) { return $null }

    if ($Object -is [System.Collections.IDictionary]) {
        foreach ($Key in $Object.Keys) {
            if ([string]$Key -ieq $Name) { return $Object[$Key] }
        }
    }

    if ($Object.PSObject -and $Object.PSObject.Properties) {
        foreach ($Property in $Object.PSObject.Properties) {
            if ([string]$Property.Name -ieq $Name) { return $Property.Value }
        }
    }

    return $null
}

function Convert-ToStringArray {
    param([AllowNull()]$Value)

    if ($null -eq $Value) { return @() }
    if ($Value -is [string]) { return @($Value) }
    if ($Value -is [System.Collections.IEnumerable]) {
        return @($Value | ForEach-Object { [string]$_ } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) })
    }

    return @([string]$Value)
}

function Invoke-GraphPaged {
    param(
        [Parameter(Mandatory = $true)][string]$Uri,
        [hashtable]$Headers
    )

    $Items = [System.Collections.Generic.List[object]]::new()
    $Next = $Uri

    while (-not [string]::IsNullOrWhiteSpace($Next)) {
        if ($Headers) {
            $Response = Invoke-MgGraphRequest -Method GET -Uri $Next -Headers $Headers -ErrorAction Stop
        }
        else {
            $Response = Invoke-MgGraphRequest -Method GET -Uri $Next -ErrorAction Stop
        }

        foreach ($Item in @(Get-PropValue -Object $Response -Name "value")) {
            $Items.Add($Item)
        }

        $Next = [string](Get-PropValue -Object $Response -Name "@odata.nextLink")
    }

    return @($Items)
}

function Load-PermissionLookup {
    param([string]$Path)

    $Lookup = @{}
    if (-not (Test-Path $Path)) {
        return $Lookup
    }

    foreach ($Line in (Get-Content -Path $Path -ErrorAction SilentlyContinue)) {
        if ($Line -match '"(?<id>[0-9a-fA-F-]{36})"\s*=\s*"(?<name>[^"]+)"') {
            $Lookup[[string]$Matches.id] = [string]$Matches.name
        }
    }

    return $Lookup
}

function Resolve-OwnerLabel {
    param(
        [AllowNull()]$Owners
    )

    if ($null -eq $Owners) { return "N/A" }

    $Names = [System.Collections.Generic.List[string]]::new()
    foreach ($Owner in @($Owners)) {
        $Candidate = [string](Get-PropValue -Object $Owner -Name "userPrincipalName")
        if ([string]::IsNullOrWhiteSpace($Candidate)) {
            $Candidate = [string](Get-PropValue -Object $Owner -Name "displayName")
        }
        if ([string]::IsNullOrWhiteSpace($Candidate)) {
            $Candidate = [string](Get-PropValue -Object $Owner -Name "id")
        }
        if (-not [string]::IsNullOrWhiteSpace($Candidate)) {
            $Names.Add($Candidate)
        }
    }

    if ($Names.Count -eq 0) { return "N/A" }
    return (@($Names | Select-Object -Unique) -join "/")
}

function Is-CriticalPermission {
    param(
        [string]$PermissionName,
        [string]$PermissionType
    )

    if ([string]::IsNullOrWhiteSpace($PermissionName)) { return $false }

    $ExactCritical = @(
        "RoleManagement.ReadWrite.Directory",
        "Directory.ReadWrite.All",
        "Directory.AccessAsUser.All",
        "Application.ReadWrite.All",
        "AppRoleAssignment.ReadWrite.All",
        "DelegatedPermissionGrant.ReadWrite.All",
        "Policy.ReadWrite.ConditionalAccess",
        "PrivilegedAccess.ReadWrite.AzureAD",
        "PrivilegedAccess.ReadWrite.AzureADGroup",
        "UserAuthenticationMethod.ReadWrite.All",
        "Group.ReadWrite.All",
        "User.ReadWrite.All",
        "Organization.ReadWrite.All",
        "Domain.ReadWrite.All"
    )

    if ($ExactCritical -contains $PermissionName) {
        return $true
    }

    # High-privilege wildcards that should always be reviewed when app-level consent is granted.
    $PatternCritical = @(
        '^.*\.ReadWrite\.All$',
        '^.*\.FullControl\.All$',
        '^.*\.Manage\.All$'
    )

    if ($PermissionType -eq "Application") {
        foreach ($Pattern in $PatternCritical) {
            if ($PermissionName -match $Pattern) {
                return $true
            }
        }
    }

    return $false
}

try {
    $RunStamp = Get-Date -Format "yyyyMMdd_HHmmss"
    $ProjectRoot = Split-Path -Path $PSScriptRoot -Parent
    $ManualReviewDir = Join-Path $ProjectRoot "manual_review"
    if (-not (Test-Path $ManualReviewDir)) {
        New-Item -Path $ManualReviewDir -ItemType Directory -Force | Out-Null
    }

    $CsvExportPath = Join-Path $ManualReviewDir "app_roles_analyzer_$RunStamp.csv"
    $CriticalCsvExportPath = Join-Path $ManualReviewDir "app_roles_critical_$RunStamp.csv"
    $PermissionMapPath = Join-Path $ProjectRoot "app-id.txt"

    $PermissionLookup = Load-PermissionLookup -Path $PermissionMapPath

    $ServicePrincipals = @(Get-MgServicePrincipal -All -Property "id,appId,displayName,appRoles,oauth2PermissionScopes" -ErrorAction Stop)
    $RegisteredApps = @(Get-MgApplication -All -Property "id,appId,displayName,createdDateTime,requiredResourceAccess,accountEnabled" -ErrorAction Stop)

    $ServicePrincipalById = @{}
    $ServicePrincipalByAppId = @{}
    foreach ($Sp in $ServicePrincipals) {
        if ($Sp.Id) { $ServicePrincipalById[[string]$Sp.Id] = $Sp }
        if ($Sp.AppId) { $ServicePrincipalByAppId[[string]$Sp.AppId] = $Sp }
    }

    $Results = [System.Collections.Generic.List[object]]::new()
    $OwnerCache = @{}

    foreach ($Sp in $ServicePrincipals) {
        $SpId = [string]$Sp.Id
        $SpDisplayName = [string]$Sp.DisplayName

        if ([string]::IsNullOrWhiteSpace($SpId)) { continue }

        if (-not $OwnerCache.ContainsKey($SpId)) {
            try {
                $Owners = Invoke-GraphPaged -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SpId/owners?`$select=id,displayName,userPrincipalName" 
                $OwnerCache[$SpId] = Resolve-OwnerLabel -Owners $Owners
            }
            catch {
                $OwnerCache[$SpId] = "N/A"
            }
        }
        $OwnerLabel = [string]$OwnerCache[$SpId]

        try {
            $DelegatedGrants = @(Invoke-GraphPaged -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SpId/oauth2PermissionGrants?`$top=999")
        }
        catch {
            $DelegatedGrants = @()
        }

        foreach ($Grant in $DelegatedGrants) {
            $RawScope = [string](Get-PropValue -Object $Grant -Name "scope")
            $Scopes = if ([string]::IsNullOrWhiteSpace($RawScope)) { @() } else { @($RawScope -split '\s+' | Where-Object { -not [string]::IsNullOrWhiteSpace($_) }) }
            $ResourceId = [string](Get-PropValue -Object $Grant -Name "resourceId")
            $ResourceSp = if ($ResourceId -and $ServicePrincipalById.ContainsKey($ResourceId)) { $ServicePrincipalById[$ResourceId] } else { $null }
            $ResourceName = if ($ResourceSp) { [string]$ResourceSp.DisplayName } else { "Unknown" }

            foreach ($Scope in $Scopes) {
                if ([string]::IsNullOrWhiteSpace($Scope)) { continue }
                $IsCritical = Is-CriticalPermission -PermissionName $Scope -PermissionType "Delegated"
                $Risk = if ($IsCritical) { "Critical" } else { "Low" }

                $Results.Add([pscustomobject]@{
                    AppName = $SpDisplayName
                    AppObjectId = $SpId
                    AppClientId = [string]$Sp.AppId
                    AssignmentSource = "EnterpriseApplicationConsent"
                    PermissionType = "Delegated"
                    ResourceAppName = $ResourceName
                    ResourceAppId = if ($ResourceSp) { [string]$ResourceSp.AppId } else { $null }
                    PermissionId = $null
                    PermissionName = $Scope
                    IsCritical = $IsCritical
                    Risk = $Risk
                    Owners = $OwnerLabel
                    CreatedDateTime = $null
                    Disabled = $null
                    Reason = if ($IsCritical) { "High-privilege delegated permission detected." } else { $null }
                })
            }
        }

        try {
            $AppRoleAssignments = @(Invoke-GraphPaged -Uri "https://graph.microsoft.com/v1.0/servicePrincipals/$SpId/appRoleAssignments?`$top=999")
        }
        catch {
            $AppRoleAssignments = @()
        }

        foreach ($Assignment in $AppRoleAssignments) {
            $ResourceId = [string](Get-PropValue -Object $Assignment -Name "resourceId")
            $ResourceDisplayName = [string](Get-PropValue -Object $Assignment -Name "resourceDisplayName")
            $ResourceSp = if ($ResourceId -and $ServicePrincipalById.ContainsKey($ResourceId)) { $ServicePrincipalById[$ResourceId] } else { $null }
            if ([string]::IsNullOrWhiteSpace($ResourceDisplayName) -and $ResourceSp) {
                $ResourceDisplayName = [string]$ResourceSp.DisplayName
            }

            $PermissionId = [string](Get-PropValue -Object $Assignment -Name "appRoleId")
            $PermissionName = $null
            if ($PermissionId -and $PermissionLookup.ContainsKey($PermissionId)) {
                $PermissionName = [string]$PermissionLookup[$PermissionId]
            }
            if ([string]::IsNullOrWhiteSpace($PermissionName) -and $ResourceSp -and $ResourceSp.AppRoles) {
                $PermissionName = [string](
                    $ResourceSp.AppRoles |
                    Where-Object { ([string]$_.Id) -eq $PermissionId } |
                    Select-Object -First 1 -ExpandProperty Value
                )
            }
            if ([string]::IsNullOrWhiteSpace($PermissionName)) {
                $PermissionName = "UnknownAppRole($PermissionId)"
            }

            $IsCritical = Is-CriticalPermission -PermissionName $PermissionName -PermissionType "Application"
            $Risk = if ($IsCritical) { "Critical" } else { "Low" }

            $Results.Add([pscustomobject]@{
                AppName = $SpDisplayName
                AppObjectId = $SpId
                AppClientId = [string]$Sp.AppId
                AssignmentSource = "EnterpriseApplicationConsent"
                PermissionType = "Application"
                ResourceAppName = $ResourceDisplayName
                ResourceAppId = if ($ResourceSp) { [string]$ResourceSp.AppId } else { $null }
                PermissionId = $PermissionId
                PermissionName = $PermissionName
                IsCritical = $IsCritical
                Risk = $Risk
                Owners = $OwnerLabel
                CreatedDateTime = $null
                Disabled = $null
                Reason = if ($IsCritical) { "Critical application permission assigned to enterprise application." } else { $null }
            })
        }
    }

    foreach ($App in $RegisteredApps) {
        $AppId = [string]$App.Id
        $OwnerLabel = "N/A"

        try {
            $Owners = Invoke-GraphPaged -Uri "https://graph.microsoft.com/v1.0/applications/$AppId/owners?`$select=id,displayName,userPrincipalName" 
            $OwnerLabel = Resolve-OwnerLabel -Owners $Owners
        }
        catch {
            $OwnerLabel = "N/A"
        }

        foreach ($ResourceAccess in @($App.RequiredResourceAccess)) {
            $ResourceAppId = [string](Get-PropValue -Object $ResourceAccess -Name "resourceAppId")
            $ResourceSp = if ($ResourceAppId -and $ServicePrincipalByAppId.ContainsKey($ResourceAppId)) { $ServicePrincipalByAppId[$ResourceAppId] } else { $null }
            $ResourceAppName = if ($ResourceSp) { [string]$ResourceSp.DisplayName } else { "Unknown" }

            foreach ($Access in @(Get-PropValue -Object $ResourceAccess -Name "resourceAccess")) {
                $PermissionId = [string](Get-PropValue -Object $Access -Name "id")
                $AccessType = [string](Get-PropValue -Object $Access -Name "type")
                $PermissionType = if ($AccessType -eq "Role") { "Application" } else { "Delegated" }
                $PermissionName = $null

                if ($PermissionId -and $PermissionLookup.ContainsKey($PermissionId)) {
                    $PermissionName = [string]$PermissionLookup[$PermissionId]
                }

                if ([string]::IsNullOrWhiteSpace($PermissionName) -and $ResourceSp) {
                    if ($PermissionType -eq "Application" -and $ResourceSp.AppRoles) {
                        $PermissionName = [string](
                            $ResourceSp.AppRoles |
                            Where-Object { ([string]$_.Id) -eq $PermissionId } |
                            Select-Object -First 1 -ExpandProperty Value
                        )
                    }
                    elseif ($PermissionType -eq "Delegated" -and $ResourceSp.Oauth2PermissionScopes) {
                        $PermissionName = [string](
                            $ResourceSp.Oauth2PermissionScopes |
                            Where-Object { ([string]$_.Id) -eq $PermissionId } |
                            Select-Object -First 1 -ExpandProperty Value
                        )
                    }
                }

                if ([string]::IsNullOrWhiteSpace($PermissionName)) {
                    $PermissionName = "UnknownPermission($PermissionId)"
                }

                $IsCritical = Is-CriticalPermission -PermissionName $PermissionName -PermissionType $PermissionType
                $Risk = if ($IsCritical) { "Critical" } else { "Low" }

                $Results.Add([pscustomobject]@{
                    AppName = [string]$App.DisplayName
                    AppObjectId = [string]$App.Id
                    AppClientId = [string]$App.AppId
                    AssignmentSource = "AppRegistrationRequested"
                    PermissionType = $PermissionType
                    ResourceAppName = $ResourceAppName
                    ResourceAppId = $ResourceAppId
                    PermissionId = $PermissionId
                    PermissionName = $PermissionName
                    IsCritical = $IsCritical
                    Risk = $Risk
                    Owners = $OwnerLabel
                    CreatedDateTime = [string]$App.CreatedDateTime
                    Disabled = $null
                    Reason = if ($IsCritical -and $PermissionType -eq "Application") { "Critical application permission requested by app registration." } elseif ($IsCritical) { "High-privilege delegated permission requested by app registration." } else { $null }
                })
            }
        }
    }

    $AllRows = @($Results)
    $CriticalRows = @($AllRows | Where-Object { [bool]$_.IsCritical -eq $true -and [string]$_.PermissionType -eq "Application" })

    $AllRows | Sort-Object AppName, PermissionType, PermissionName | Export-Csv -Path $CsvExportPath -NoTypeInformation -Encoding UTF8
    $CriticalRows | Sort-Object AppName, PermissionName | Export-Csv -Path $CriticalCsvExportPath -NoTypeInformation -Encoding UTF8

    $Pass = ($CriticalRows.Count -eq 0)
    $ErrorMessage = if ($Pass) { $null } else { "Critical application permissions were identified for one or more applications." }

    [pscustomobject]@{
        CheckId = $CheckId
        Title = $Title
        Level = $Level
        BenchmarkType = $BenchmarkType
        Status = if ($Pass) { "PASS" } else { "FAIL" }
        Pass = $Pass
        Evidence = [pscustomobject]@{
            PermissionLookupSource = if (Test-Path $PermissionMapPath) { $PermissionMapPath } else { "NotFound" }
            TotalServicePrincipals = @($ServicePrincipals).Count
            TotalRegisteredApplications = @($RegisteredApps).Count
            TotalPermissionsDiscovered = @($AllRows).Count
            CriticalApplicationPermissionCount = @($CriticalRows).Count
            CriticalUniqueApplications = @($CriticalRows | Select-Object -ExpandProperty AppObjectId -Unique).Count
            CsvExportPath = $CsvExportPath
            CriticalCsvExportPath = $CriticalCsvExportPath
            PreviewCritical = @($CriticalRows | Select-Object -First 50)
            SourceDocument = "Supplemental application permissions and app role assignment analysis"
        }
        Error = $ErrorMessage
        Timestamp = Get-Date
    }
}
catch {
    [pscustomobject]@{
        CheckId = $CheckId
        Title = $Title
        Level = $Level
        BenchmarkType = $BenchmarkType
        Status = "ERROR"
        Pass = $null
        Evidence = [pscustomobject]@{
            SourceDocument = "Supplemental application permissions and app role assignment analysis"
        }
        Error = $_.Exception.Message
        Timestamp = Get-Date
    }
}