Modules/Providers/ProviderHelpers/AADRiskyPermissionsHelper.psm1

Import-Module -Name $PSScriptRoot/../../Utility/Utility.psm1 -Function Invoke-GraphDirectly, ConvertFrom-GraphHashtable, Invoke-GraphBatchRequest

# Module-scoped cache for RiskyAppPermissions.json - loaded once, reused across all function calls
$script:CachedRiskyAppPermissionsJson = $null
$script:CachedPermissionLookup = $null

function Get-ResourcePermissions {
    param(
        [ValidateNotNullOrEmpty()]
        [string]
        $M365Environment,

        [hashtable]
        $ResourcePermissionCache,

        [string]
        $ResourceAppId
    )
    try {
        if ($null -eq $ResourcePermissionCache) {
            $ResourcePermissionCache = @{}
        }

        if (-not $ResourcePermissionCache.ContainsKey($ResourceAppId)) {
            # v1.0 Graph endpoint is used here because it contains the oauth2PermissionScopes property
            $result = (
                Invoke-GraphDirectly `
                    -Commandlet "Get-MgServicePrincipal" `
                    -M365Environment $M365Environment `
                    -QueryParams @{
                        '$filter' = "appId eq '$ResourceAppId'"
                        '$select' = "appRoles,oauth2PermissionScopes"
                    }
            ).Value

            $ResourcePermissionCache[$ResourceAppId] = $result
        }
        return $ResourcePermissionCache[$ResourceAppId]
    }
    catch {
        Write-Warning "An error occurred in Get-ResourcePermissions: $($_.Exception.Message)"
        Write-Warning "Stack trace: $($_.ScriptStackTrace)"
        throw $_
    }
}

function Get-RiskyAppPermissionsJson {
    <#
    .Description
    Returns the parsed RiskyAppPermissions.json data. Uses a module-scoped cache to avoid
    redundant file reads and JSON parsing on subsequent calls.
    .Functionality
    Internal
    #>

    process {
        if ($null -eq $script:CachedRiskyAppPermissionsJson) {
            try {
                $SchemasPath = Join-Path -Path ((Get-Item -Path $PSScriptRoot).Parent.Parent.Parent.FullName) -ChildPath "schemas"
                $script:CachedRiskyAppPermissionsJson = Get-Content -Path (
                    Join-Path -Path (Get-Item -Path $SchemasPath) -ChildPath "RiskyAppPermissions.json"
                ) -Raw | ConvertFrom-Json
                # Build the hashtable lookup on first load
                $script:CachedPermissionLookup = New-PermissionLookup -Json $script:CachedRiskyAppPermissionsJson
            }
            catch {
                Write-Warning "An error occurred in Get-RiskyAppPermissionsJson: $($_.Exception.Message)"
                Write-Warning "Stack trace: $($_.ScriptStackTrace)"
                throw $_
            }
        }
        return $script:CachedRiskyAppPermissionsJson
    }
}

function New-PermissionLookup {
    <#
    .Description
    Builds a nested hashtable from the RiskyAppPermissions.json PSObject for O(1) permission lookups.
    Structure: $Lookup[$ResourceDisplayName][$RoleType][$Guid] = @{ Name; RiskLevel }
    .Functionality
    Internal
    #>

    param (
        [ValidateNotNullOrEmpty()]
        [PSCustomObject]
        $Json
    )

    $Lookup = @{}
    foreach ($Resource in $Json.permissions.PSObject.Properties) {
        $ResourceName = $Resource.Name
        $Lookup[$ResourceName] = @{}
        foreach ($RoleType in $Resource.Value.PSObject.Properties) {
            # Skip internal keys like _excludedDelegated
            if ($RoleType.Name.StartsWith("_")) { continue }
            $RoleTypeName = $RoleType.Name
            $Lookup[$ResourceName][$RoleTypeName] = @{}
            foreach ($Perm in $RoleType.Value.PSObject.Properties) {
                $Lookup[$ResourceName][$RoleTypeName][$Perm.Name] = @{
                    Name = $Perm.Value.Name
                    RiskLevel = $Perm.Value.RiskLevel
                }
            }
        }
    }
    return $Lookup
}

function Get-PermissionLookup {
    <#
    .Description
    Returns the cached hashtable lookup for risky permissions. Builds it if not yet initialized.
    .Functionality
    Internal
    #>

    param (
        [PSCustomObject]
        $RiskyAppPermissionsJson
    )

    if ($null -ne $script:CachedPermissionLookup) {
        return $script:CachedPermissionLookup
    }

    if ($null -eq $RiskyAppPermissionsJson) {
        $RiskyAppPermissionsJson = Get-RiskyAppPermissionsJson
    }

    $script:CachedPermissionLookup = New-PermissionLookup -Json $RiskyAppPermissionsJson
    return $script:CachedPermissionLookup
}

function New-RiskyAppResourceLookup {
    <#
    .Description
    Builds a pair of hashtable lookups for risky resource mappings.
    .Functionality
    Internal
    #>

    param (
        [ValidateNotNullOrEmpty()]
        [PSCustomObject]
        $RiskyAppPermissionsJson
    )

    $Lookup = @{
        AppIdToName = @{}
        NameToAppId = @{}
    }

    foreach ($Property in $RiskyAppPermissionsJson.resources.PSObject.Properties) {
        $Lookup.AppIdToName[$Property.Name] = $Property.Value
        $Lookup.NameToAppId[$Property.Value] = $Property.Name
    }

    return $Lookup
}

function Get-PermissionTypeDetails {
    <#
    .Description
    Resolves role type, display name, and consent requirements using cached per-resource lookups.
    .Functionality
    Internal
    #>

    param (
        [ValidateNotNullOrEmpty()]
        [object]
        $ResourceAppPermissions,

        [ValidateNotNull()]
        [hashtable]
        $ResourcePermissionTypeCache,

        [ValidateNotNullOrEmpty()]
        [string]
        $ResourceAppId,

        [ValidateNotNullOrEmpty()]
        [string]
        $RoleId,

        [string]
        $DeclaredRoleType
    )

    if (-not $ResourcePermissionTypeCache.ContainsKey($ResourceAppId)) {
        $RoleLookup = @{}
        foreach ($Role in @($ResourceAppPermissions.appRoles)) {
            if ($null -ne $Role -and $null -ne $Role.id) {
                $RoleLookup[[string]$Role.id] = $Role
            }
        }

        $ScopeLookup = @{}
        foreach ($Scope in @($ResourceAppPermissions.oauth2PermissionScopes)) {
            if ($null -ne $Scope -and $null -ne $Scope.id) {
                $ScopeLookup[[string]$Scope.id] = $Scope
            }
        }

        $ResourcePermissionTypeCache[$ResourceAppId] = @{
            AppRoles = $RoleLookup
            Scopes   = $ScopeLookup
        }
    }

    $PermissionTypeLookup = $ResourcePermissionTypeCache[$ResourceAppId]
    $Role = $PermissionTypeLookup.AppRoles[$RoleId]
    if ($null -ne $Role) {
        return @{
            ReadableRoleType     = "Application"
            RoleDisplayName      = $Role.value
            RequiresAdminConsent = $true
        }
    }

    $Scope = $PermissionTypeLookup.Scopes[$RoleId]
    if ($null -ne $Scope) {
        return @{
            ReadableRoleType     = "Delegated"
            RoleDisplayName      = $Scope.value
            RequiresAdminConsent = $Scope.type -eq "Admin"
        }
    }

    # Preserve role type semantics when the caller explicitly declared delegated/role and Graph object is missing.
    if ($DeclaredRoleType -eq "Role") {
        return @{
            ReadableRoleType     = "Application"
            RoleDisplayName      = $null
            RequiresAdminConsent = $true
        }
    }

    return @{
        ReadableRoleType     = "Delegated"
        RoleDisplayName      = $null
        RequiresAdminConsent = $false
    }
}

function Format-Permission {
    <#
    .Description
    Returns an API permission from either application/service principal which maps
    to the list of permissions declared in RiskyAppPermissions.json
    .Functionality
    #Internal
    ##>

    param (
        [ValidateNotNullOrEmpty()]
        [PSCustomObject]
        $Json,

        [ValidateNotNullOrEmpty()]
        [string]
        $AppDisplayName,

        [ValidateNotNullOrEmpty()]
        [string]
        $Id,

        [string]
        $RoleType,

        [string]
        $RoleDisplayName,

        [ValidateNotNullOrEmpty()]
        [boolean]
        $IsAdminConsented,

        [ValidateNotNullOrEmpty()]
        [boolean]
        $RequiresAdminConsent
    )
    $Map = @()
    if ($null -ne $RoleType) {
        $Lookup = Get-PermissionLookup -RiskyAppPermissionsJson $Json
        $IsRisky = $false
        $RiskLevel = $null

        if ($Lookup.ContainsKey($AppDisplayName) -and
            $Lookup[$AppDisplayName].ContainsKey($RoleType) -and
            $Lookup[$AppDisplayName][$RoleType].ContainsKey($Id)) {
            $IsRisky = $true
            $RiskLevel = $Lookup[$AppDisplayName][$RoleType][$Id].RiskLevel
        }

        $Map += [PSCustomObject]@{
            RoleId                 = $Id
            RoleType               = if ($null -ne $RoleType) { $RoleType } else { $null }
            RoleDisplayName        = if ($null -ne $RoleDisplayName) { $RoleDisplayName } else { $null }
            ApplicationDisplayName = $AppDisplayName
            IsAdminConsented       = $IsAdminConsented
            RequiresAdminConsent   = $RequiresAdminConsent
            IsRisky                = $IsRisky
            RiskLevel              = $RiskLevel
        }
    }
    return $Map
}

function Format-Credentials {
    <#
    .Description
    Returns an array of valid/expired credentials
    .Functionality
    #Internal
    ##>

    [Diagnostics.CodeAnalysis.SuppressMessageAttribute(
        "PSReviewUnusedParameter", "IsFromApplication", Justification = "False positive due to variable scoping"
    )]
    param (
        [Object[]]
        $AccessKeys,

        [ValidateNotNullOrEmpty()]
        [boolean]
        $IsFromApplication,

        [switch]
        $IsFederated
    )

    process {
        $ValidCredentials = @()

        if ($IsFederated) {
            $RequiredKeys = @("Id", "Name", "Description", "Issuer", "Subject", "Audiences")
        }
        else {
            $RequiredKeys = @("KeyId", "DisplayName", "StartDateTime", "EndDateTime")
        }

        foreach ($Credential in $AccessKeys) {
            # Only format credentials with the correct keys
            $MissingKeys = $RequiredKeys | Where-Object { -not ($Credential.PSObject.Properties.Name -contains $_) }
            if ($MissingKeys.Count -eq 0) {
                if ($IsFederated) {
                    # $Credential is of type PSCredential which is immutable, create a copy
                    $CredentialCopy = $Credential | Select-Object -Property `
                        Id, Name, Description, Issuer, Subject, Audiences,`
                        @{ Name = "IsFromApplication"; Expression = { $IsFromApplication }}
                }
                else {
                    $CredentialCopy = $Credential | Select-Object -Property `
                        KeyId, DisplayName, StartDateTime, EndDateTime, `
                        @{ Name = "IsFromApplication"; Expression = { $IsFromApplication }}
                }
                $ValidCredentials += $CredentialCopy
            }
        }

        if ($null -eq $AccessKeys -or $AccessKeys.Count -eq 0 -or $ValidCredentials.Count -eq 0) {
            return $null
        }
        return $ValidCredentials
    }
}

function Merge-Credentials {
    <#
    .Description
    Merge credentials from multiple resources into a single resource
    .Functionality
    #Internal
    ##>

    param (
        [Object[]]
        $ApplicationAccessKeys,

        [Object[]]
        $ServicePrincipalAccessKeys
    )

    # Both application/sp objects have key and federated credentials.
    # Conditionally merge the two together, select only application/service principal creds, or none.
    $MergedCredentials = @()
    if ($null -ne $ServicePrincipalAccessKeys -and $null -ne $ApplicationAccessKeys) {
        # Both objects valid
        $MergedCredentials = @($ServicePrincipalAccessKeys) + @($ApplicationAccessKeys)
    }
    elseif ($null -eq $ServicePrincipalAccessKeys -and $null -ne $ApplicationAccessKeys) {
        # Only application credentials valid
        $MergedCredentials = @($ApplicationAccessKeys)
    }
    elseif ($null -ne $ServicePrincipalAccessKeys -and $null -eq $ApplicationAccessKeys) {
        # Only service principal credentials valid
        $MergedCredentials = @($ServicePrincipalAccessKeys)
    }
    else {
        # Neither credentials are valid
        $MergedCredentials = $null
    }
    return $MergedCredentials
}

function Get-ApplicationsWithRiskyPermissions {
    <#
    .Description
    Returns an array of applications where each item contains its Object ID, App ID, Display Name,
    Key/Password/Federated Credentials, and risky API permissions.
    .Functionality
    #Internal
    ##>

    param (
        [ValidateNotNullOrEmpty()]
        [string]
        $M365Environment,

        [hashtable]
        $ResourcePermissionCache,

        [PSCustomObject]
        $RiskyAppPermissionsJson
    )
    process {
        try {
            if ($null -eq $RiskyAppPermissionsJson) {
                $RiskyAppPermissionsJson = Get-RiskyAppPermissionsJson
            }
            $ResourceLookup = New-RiskyAppResourceLookup -RiskyAppPermissionsJson $RiskyAppPermissionsJson
            $ResourcePermissionTypeCache = @{}

            # Get all applications in the tenant with only required fields to reduce payload size.
            $Applications = (
                Invoke-GraphDirectly `
                    -commandlet "Get-MgBetaApplication" `
                    -M365Environment $M365Environment `
                    -QueryParams @{
                        '$select' = "id,appId,displayName,signInAudience,requiredResourceAccess,keyCredentials,passwordCredentials"
                    }
            ).Value
            $ApplicationResults = [System.Collections.Generic.List[object]]::new()

            foreach ($App in $Applications) {
                # `AzureADMyOrg` = single tenant; `AzureADMultipleOrgs` = multi tenant
                $IsMultiTenantEnabled = $false
                if ($App.SignInAudience -eq "AzureADMultipleOrgs") { $IsMultiTenantEnabled = $true }

                # Map application permissions against RiskyAppPermissions.json
                $MappedPermissions = [System.Collections.Generic.List[object]]::new()
                foreach ($Resource in $App.RequiredResourceAccess) {
                    # Returns both application and delegated permissions
                    $Roles = $Resource.ResourceAccess
                    $ResourceAppId = $Resource.ResourceAppId

                    if (-not $ResourceLookup.AppIdToName.ContainsKey($ResourceAppId)) {
                        continue
                    }
                    $ResourceDisplayName = $ResourceLookup.AppIdToName[$ResourceAppId]

                    $ResourceAppPermissions = Get-ResourcePermissions `
                        -M365Environment $M365Environment `
                        -ResourcePermissionCache $ResourcePermissionCache `
                        -ResourceAppId $ResourceAppId

                    if ($null -eq $ResourceAppPermissions) {
                        Write-Warning "No permissions found for resource app ID: $ResourceAppId"
                        continue
                    }

                    # Additional processing is required to determine if a permission is admin consented.
                    # Initially assume admin consent is false since we reference the application's manifest,
                    # then update the value later when its compared to service principal permissions.
                    $IsAdminConsented = $false

                    foreach ($Role in $Roles) {
                        $RoleId = [string]$Role.Id
                        $PermissionTypeDetails = Get-PermissionTypeDetails `
                            -ResourceAppPermissions $ResourceAppPermissions `
                            -ResourcePermissionTypeCache $ResourcePermissionTypeCache `
                            -ResourceAppId $ResourceAppId `
                            -RoleId $RoleId `
                            -DeclaredRoleType $Role.Type

                        [void]$MappedPermissions.AddRange(@(
                            Format-Permission `
                                -Json $RiskyAppPermissionsJson `
                                -AppDisplayName $ResourceDisplayName `
                                -Id $RoleId `
                                -RoleType $PermissionTypeDetails.ReadableRoleType `
                                -RoleDisplayName $PermissionTypeDetails.RoleDisplayName `
                                -IsAdminConsented $IsAdminConsented `
                                -RequiresAdminConsent $PermissionTypeDetails.RequiresAdminConsent
                        ))
                    }
                }

                $RiskyPermissions = @($MappedPermissions | Where-Object { $_.IsRisky -eq $true })

                # Exclude applications without risky permissions
                if ($RiskyPermissions.Count -gt 0) {
                    # Fetch federated credentials only for applications that are confirmed risky.
                    $FederatedCredentials = (Invoke-GraphDirectly -commandlet "Get-MgBetaApplicationFederatedIdentityCredential" -M365Environment $M365Environment -Id $App.Id).Value
                    $FederatedCredentialsResults = @()

                    if ($FederatedCredentials -is [System.Collections.IEnumerable] -and $FederatedCredentials.Count -gt 0) {
                        foreach ($FederatedCredential in $FederatedCredentials) {
                            $FederatedCredentialsResults += [PSCustomObject]@{
                                Id          = $FederatedCredential.Id
                                Name        = $FederatedCredential.Name
                                Description = $FederatedCredential.Description
                                Issuer      = $FederatedCredential.Issuer
                                Subject     = $FederatedCredential.Subject
                                Audiences   = $FederatedCredential.Audiences | Out-String
                            }
                        }
                    }
                    else {
                        $FederatedCredentialsResults = $null
                    }

                    [void]$ApplicationResults.Add([PSCustomObject]@{
                        ObjectId             = $App.Id
                        AppId                = $App.AppId
                        DisplayName          = $App.DisplayName
                        IsMultiTenantEnabled = $IsMultiTenantEnabled
                        # Credentials from application and service principal objects may get merged in other cmdlets.
                        # Differentiate between the two by setting IsFromApplication=$true
                        KeyCredentials       = Format-Credentials -AccessKeys $App.KeyCredentials -IsFromApplication $true
                        PasswordCredentials  = Format-Credentials -AccessKeys $App.PasswordCredentials -IsFromApplication $true
                        FederatedCredentials = Format-Credentials -AccessKeys $FederatedCredentialsResults -IsFromApplication $true -IsFederated
                        Permissions          = $MappedPermissions.ToArray()
                    })
                }
            }
        } catch {
            Write-Warning "An error occurred in Get-ApplicationsWithRiskyPermissions: $($_.Exception.Message)"
            Write-Warning "Stack trace: $($_.ScriptStackTrace)"
            throw $_
        }
        return $ApplicationResults.ToArray()
    }
}

function Get-ServicePrincipalsWithRiskyPermissions {
    <#
    .Description
    Returns an array of service principals where each item contains its Object ID, App ID, Display Name,
    Key/Password Credentials, and risky API permissions.
    .Functionality
    #Internal
    ##>

    param (
        [ValidateNotNullOrEmpty()]
        [string]
        $M365Environment,

        [hashtable]
        $ResourcePermissionCache,

        [PSCustomObject]
        $RiskyAppPermissionsJson
    )
    process {
        try {
            if ($null -eq $RiskyAppPermissionsJson) {
                $RiskyAppPermissionsJson = Get-RiskyAppPermissionsJson
            }
            $ServicePrincipalResults = [System.Collections.Generic.List[object]]::new()
            $ResourceLookup = New-RiskyAppResourceLookup -RiskyAppPermissionsJson $RiskyAppPermissionsJson
            $ResourcePermissionTypeCache = @{}

            # Get all service principals with only required fields to reduce payload size.
            $ServicePrincipals = (
                Invoke-GraphDirectly `
                    -commandlet "Get-MgBetaServicePrincipal" `
                    -M365Environment $M365Environment `
                    -QueryParams @{
                        '$select' = "id,appId,displayName,signInAudience,keyCredentials,passwordCredentials,federatedIdentityCredentials,appOwnerOrganizationId"
                    }
            ).Value

            $ServicePrincipalById = @{}
            foreach ($ServicePrincipal in @($ServicePrincipals)) {
                $ServicePrincipalById[[string]$ServicePrincipal.Id] = $ServicePrincipal
            }

            $BatchRequests = @(
                foreach ($ServicePrincipalId in @($ServicePrincipals.Id)) {
                    @{
                        id = [string]$ServicePrincipalId
                        method = "GET"
                        url = "/servicePrincipals/$ServicePrincipalId/appRoleAssignments?`$select=appRoleId,resourceDisplayName"
                    }
                }
            )
            $BatchResponses = Invoke-GraphBatchRequest -Requests $BatchRequests -M365Environment $M365Environment -ApiVersion "beta" `
                -RetriableStatusCodes @(429, 500, 502, 503, 504) -UseExponentialBackoffFallback -MaxRetries 6 -FallbackBaseDelaySeconds 1

            foreach ($ServicePrincipalId in @($ServicePrincipals.Id)) {
                $Result = $BatchResponses[[string]$ServicePrincipalId]
                $ServicePrincipal = $ServicePrincipalById[[string]$ServicePrincipalId]
                if ($null -eq $ServicePrincipal) {
                    continue
                }

                $MappedPermissions = [System.Collections.Generic.List[object]]::new()
                if ($null -ne $Result -and [int]$Result.status -eq 200 -and $null -ne $Result.body) {
                    $AppRoleAssignments = @($Result.body.value)
                    foreach ($Role in $AppRoleAssignments) {
                        $ResourceDisplayName = [string]$Role.ResourceDisplayName
                        $RoleId = [string]$Role.AppRoleId

                        if (-not $ResourceLookup.NameToAppId.ContainsKey($ResourceDisplayName)) {
                            continue
                        }

                        # Default to true,
                        # `Get-MgBetaServicePrincipalAppRoleAssignment` only returns admin consented permissions
                        $IsAdminConsented = $true
                        $ResourceAppId = $ResourceLookup.NameToAppId[$ResourceDisplayName]

                        $ResourceAppPermissions = Get-ResourcePermissions `
                            -M365Environment $M365Environment `
                            -ResourcePermissionCache $ResourcePermissionCache `
                            -ResourceAppId $ResourceAppId

                        if ($null -eq $ResourceAppPermissions) {
                            Write-Warning "No permissions found for resource app ID: $ResourceAppId"
                            continue
                        }

                        $PermissionTypeDetails = Get-PermissionTypeDetails `
                            -ResourceAppPermissions $ResourceAppPermissions `
                            -ResourcePermissionTypeCache $ResourcePermissionTypeCache `
                            -ResourceAppId $ResourceAppId `
                            -RoleId $RoleId

                        [void]$MappedPermissions.AddRange(@(
                            Format-Permission `
                                -Json $RiskyAppPermissionsJson `
                                -AppDisplayName $ResourceDisplayName `
                                -Id $RoleId `
                                -RoleType $PermissionTypeDetails.ReadableRoleType `
                                -RoleDisplayName $PermissionTypeDetails.RoleDisplayName `
                                -IsAdminConsented $IsAdminConsented `
                                -RequiresAdminConsent $PermissionTypeDetails.RequiresAdminConsent
                        ))
                    }
                }
                elseif ($null -ne $Result) {
                    Write-Warning "Error for service principal ${ServicePrincipalId}: $($Result.status)"
                }

                $RiskyPermissions = @($MappedPermissions | Where-Object { $_.IsRisky -eq $true })

                # Exclude service principals without risky permissions
                if ($RiskyPermissions.Count -gt 0) {
                    [void]$ServicePrincipalResults.Add([PSCustomObject]@{
                        ObjectId                = $ServicePrincipal.Id
                        AppId                   = $ServicePrincipal.AppId
                        DisplayName             = $ServicePrincipal.DisplayName
                        SignInAudience          = $ServicePrincipal.SignInAudience
                        # Credentials from application and service principal objects may get merged in other cmdlets.
                        # Differentiate between the two by setting IsFromApplication=$false
                        KeyCredentials          = Format-Credentials -AccessKeys $ServicePrincipal.KeyCredentials -IsFromApplication $false
                        PasswordCredentials     = Format-Credentials -AccessKeys $ServicePrincipal.PasswordCredentials -IsFromApplication $false
                        FederatedCredentials    = Format-Credentials -AccessKeys $ServicePrincipal.FederatedIdentityCredentials -IsFromApplication $false -IsFederated
                        Permissions             = $MappedPermissions.ToArray()
                        AppOwnerOrganizationId  = $ServicePrincipal.AppOwnerOrganizationId
                    })
                }
            }
        } catch {
            Write-Warning "An error occurred in Get-ServicePrincipalsWithRiskyPermissions: $($_.Exception.Message)"
            Write-Warning "Stack trace: $($_.ScriptStackTrace)"
            throw $_
        }
        return $ServicePrincipalResults.ToArray()
    }
}

function Get-ServicePrincipalsWithRiskyDelegatedPermissionClassifications {
    <#
    .Description
    Returns an array of service principals where each item contains its Object ID, App ID, Display Name,
    Key/Password Credentials, and risky API permissions.
    .Functionality
    #Internal
    ##>

    param (
        [ValidateNotNullOrEmpty()]
        [string]
        $M365Environment,

        [PSCustomObject]
        $RiskyAppPermissionsJson
    )
    process {
        try {
            if ($null -eq $RiskyAppPermissionsJson) {
                $RiskyAppPermissionsJson = Get-RiskyAppPermissionsJson
            }
            $Resources = $RiskyAppPermissionsJson.resources.PSObject.Properties
            $ResourceIds = @($Resources | ForEach-Object { [string]$_.Name })

            # Resolve all risky resource service principals in one filtered query instead of per-resource lookups.
            $ServicePrincipalByAppId = @{}
            if ($ResourceIds.Count -gt 0) {
                $FilterValues = @($ResourceIds | ForEach-Object { "appId eq '$_'" })
                $FilterClause = $FilterValues -join " or "

                $ResolvedServicePrincipals = (
                    Invoke-GraphDirectly `
                        -Commandlet "Get-MgServicePrincipal" `
                        -M365Environment $M365Environment `
                        -QueryParams @{
                            '$filter' = $FilterClause
                            '$select' = "id,appId,displayName"
                        }
                ).Value

                foreach ($ResolvedServicePrincipal in @($ResolvedServicePrincipals)) {
                    $ServicePrincipalByAppId[[string]$ResolvedServicePrincipal.appId] = $ResolvedServicePrincipal
                }
            }

            $ResourceByServicePrincipalId = @{}
            foreach ($Resource in $Resources) {
                $ResourceId = $Resource.Name
                $ResourceName = $Resource.Value

                $ServicePrincipal = $ServicePrincipalByAppId[$ResourceId]
                if ($null -eq $ServicePrincipal -or $null -eq $ServicePrincipal.id) {
                    continue
                }

                $ResourceByServicePrincipalId[[string]$ServicePrincipal.id] = [PSCustomObject]@{
                    ResourceId = $ResourceId
                    ResourceName = $ResourceName
                    ServicePrincipal = $ServicePrincipal
                }
            }

            $BatchRequests = @(
                foreach ($ServicePrincipalId in @($ResourceByServicePrincipalId.Keys)) {
                    @{
                        id = [string]$ServicePrincipalId
                        method = "GET"
                        url = "/servicePrincipals/$ServicePrincipalId/delegatedPermissionClassifications?`$select=id,permissionId,permissionName,classification"
                    }
                }
            )
            $BatchResponses = Invoke-GraphBatchRequest -Requests $BatchRequests -M365Environment $M365Environment -ApiVersion "beta" `
                -RetriableStatusCodes @(429, 500, 502, 503, 504) -UseExponentialBackoffFallback -MaxRetries 6 -FallbackBaseDelaySeconds 1


            $RiskyDelegatedPermissionClassificationResults = @()
            foreach ($ServicePrincipalId in @($ResourceByServicePrincipalId.Keys)) {
                $ResourceContext = $ResourceByServicePrincipalId[$ServicePrincipalId]
                $ResourceId = $ResourceContext.ResourceId
                $ResourceName = $ResourceContext.ResourceName
                $ServicePrincipal = $ResourceContext.ServicePrincipal

                $RiskyDelegatedPermissions = $RiskyAppPermissionsJson.permissions.$ResourceName.Delegated.PSObject.Properties
                $Result = $BatchResponses[[string]$ServicePrincipalId]
                if ($null -eq $Result -or [int]$Result.status -ne 200 -or $null -eq $Result.body) {
                    if ($null -ne $Result) {
                        Write-Warning "Error for service principal ${ServicePrincipalId}: $($Result.status)"
                    }
                    continue
                }

                $PermClassifications = @($Result.body.value)

                $RiskyPermClassifications = @()
                foreach ($PermClassification in $PermClassifications) {
                    if ($PermClassification.Classification -eq "low" -and $RiskyDelegatedPermissions.Name -contains $PermClassification.PermissionId) {
                        $RiskyPermClassifications += [PSCustomObject]@{
                            id                = $PermClassification.id
                            permissionId      = $PermClassification.permissionId
                            permissionName    = $PermClassification.permissionName
                            classification    = $PermClassification.classification
                        }
                    }
                }

                if ($RiskyPermClassifications.Count -gt 0) {
                    $RiskyDelegatedPermissionClassificationResults += [PSCustomObject]@{
                        ObjectId                        = $ServicePrincipalId
                        AppId                           = $ResourceId
                        DisplayName                     = $ServicePrincipal.DisplayName
                        RiskyPermClassifications        = $RiskyPermClassifications.permissionName
                    }
                }
            }
            return $RiskyDelegatedPermissionClassificationResults
        } catch {
            Write-Warning "An error occurred in Get-ServicePrincipalsWithRiskyDelegatedPermissionClassifications: $($_.Exception.Message)"
            Write-Warning "Stack trace: $($_.ScriptStackTrace)"
            throw $_
        }
    }
}

function Format-RiskyApplications {
    <#
    .Description
    Returns an aggregated JSON dataset of application objects, combining data from both applications and
    service principal objects. Key/Password/Federated credentials are combined into a single array, and
    admin consent is reflected in each object's list of associated risky permissions.
    .Functionality
    #Internal
    ##>

    param (
        [ValidateNotNullOrEmpty()]
        [Object[]]
        $RiskyApps,

        [ValidateNotNullOrEmpty()]
        [Object[]]
        $RiskySPs
    )
    process {
        try {
            $Applications = @()
            foreach ($App in $RiskyApps) {
                $MatchedServicePrincipal = $RiskySPs | Where-Object { $_.AppId -eq $App.AppId }

                # Merge objects if an application and service principal exist with the same AppId
                $MergedObject = @{}
                if ($MatchedServicePrincipal) {
                    $ServicePrincipalRoleIds = @($MatchedServicePrincipal.Permissions | Select-Object -ExpandProperty RoleId)

                    # Determine if each risky permission was admin consented or not
                    foreach ($Permission in $App.Permissions) {
                        if ($ServicePrincipalRoleIds -contains $Permission.RoleId) {
                            $Permission.IsAdminConsented = $true
                        }
                    }

                    $ObjectIds = [PSCustomObject]@{
                        Application      = $App.ObjectId
                        ServicePrincipal = $MatchedServicePrincipal.ObjectId
                    }

                    $MergedKeyCredentials = Merge-Credentials `
                        -ApplicationAccessKeys $App.KeyCredentials `
                        -ServicePrincipalAccessKeys $MatchedServicePrincipal.KeyCredentials

                    $MergedPasswordCredentials = Merge-Credentials `
                        -ApplicationAccessKeys $App.PasswordCredentials `
                        -ServicePrincipalAccessKeys $MatchedServicePrincipal.PasswordCredentials

                    $MergedFederatedCredentials = Merge-Credentials `
                        -ApplicationAccessKeys $App.FederatedCredentials `
                        -ServicePrincipalAccessKeys $MatchedServicePrincipal.FederatedCredentials

                    $MergedObject = [PSCustomObject]@{
                        ObjectId                 = $ObjectIds
                        AppId                    = $App.AppId
                        DisplayName              = $App.DisplayName
                        IsMultiTenantEnabled     = $App.IsMultiTenantEnabled
                        KeyCredentials           = $MergedKeyCredentials
                        PasswordCredentials      = $MergedPasswordCredentials
                        FederatedCredentials     = $MergedFederatedCredentials
                        Permissions              = $App.Permissions
                    }
                }
                else {
                    $MergedObject = $App
                }

                # Calculate severity score after admin consent for permissions has been determined
                $SeverityInfo = Set-SeverityScore -Object $MergedObject

                # Add severity info to the merged object
                $MergedObject | Add-Member -MemberType NoteProperty -Name "SeverityScore" -Value $SeverityInfo.SeverityScore
                $MergedObject | Add-Member -MemberType NoteProperty -Name "ScoreBreakdown" -Value $SeverityInfo.ScoreBreakdown

                $Applications += $MergedObject
            }
        }
        catch {
            Write-Warning "An error occurred in Format-RiskyApplications: $($_.Exception.Message)"
            Write-Warning "Stack trace: $($_.ScriptStackTrace)"
            throw $_
        }
        return $Applications
    }
}

function Format-RiskyThirdPartyServicePrincipals {
    <#
    .Description
    Returns a JSON dataset of service principal objects owned by external organizations.
    .Functionality
    #Internal
    ##>

    param (
        [ValidateNotNullOrEmpty()]
        [Object[]]
        $RiskySPs,

        [ValidateNotNullOrEmpty()]
        [string]
        $M365Environment,

        # Raw hashtable containing privileged service principals which is keyed by ServicePrincipalId (object id)
        [hashtable]
        $PrivilegedServicePrincipals = @{}
    )
    process {
        try {
            $ServicePrincipals = @()
            $OrgInfo = (Invoke-GraphDirectly -Commandlet "Get-MgBetaOrganization" -M365Environment $M365Environment).Value

            foreach ($ServicePrincipal in $RiskySPs) {
                if ($null -eq $ServicePrincipal) {
                    continue
                }

                # A null value indicates the owner organization is unknown (e.g., agent service principal)
                # and should not be treated as a third-party service principal.
                if ($null -eq $ServicePrincipal.AppOwnerOrganizationId) {
                    Write-Warning "Service principal $($ServicePrincipal.DisplayName) with AppId $($ServicePrincipal.AppId) does not have an AppOwnerOrganizationId. Skipping."
                    continue
                }

                # If the service principal's owner id is not the same as this tenant then it is a 3rd party principal
                if ($ServicePrincipal.AppOwnerOrganizationId -ne $OrgInfo.Id) {
                    $PrivilegedRoles = @()
                    if ($PrivilegedServicePrincipals.ContainsKey($ServicePrincipal.ObjectId)) {
                        $PrivilegedRoles = $PrivilegedServicePrincipals[$ServicePrincipal.ObjectId].roles
                    }

                    # Calculate severity score after admin consent for permissions has been determined
                    $SeverityInfo = Set-SeverityScore `
                        -Object $ServicePrincipal `
                        -IsThirdPartyServicePrincipal `
                        -PrivilegedRoles $PrivilegedRoles

                    # Add severity info to the merged object
                    $ServicePrincipal | Add-Member -MemberType NoteProperty -Name "SeverityScore" -Value $SeverityInfo.SeverityScore
                    $ServicePrincipal | Add-Member -MemberType NoteProperty -Name "ScoreBreakdown" -Value $SeverityInfo.ScoreBreakdown
                    $ServicePrincipal | Add-Member -MemberType NoteProperty -Name "PrivilegedRoles" -Value $PrivilegedRoles

                    $ServicePrincipals += $ServicePrincipal
                }
            }
        }
        catch {
            Write-Warning "An error occurred in Format-RiskyThirdPartyServicePrincipals: $($_.Exception.Message)"
            Write-Warning "Stack trace: $($_.ScriptStackTrace)"
            throw $_
        }

        return $ServicePrincipals
    }
}

function Get-SeverityScoreWeights {
    <#
    .Description
    Returns the weight factors used in severity score calculation.
    The priority score is determined by the sum of all weight factors. A higher value indicates higher risk.
 
    Weight Factor Notes
    -------------------------------------------------------------------------------------------------------------------------------------------
    Permission risk level weights | Each risky permission adds its RiskLevel weight: critical = 50, high = 15, medium = 5, low = 2
    -------------------------------------------------------------------------------------------------------------------------------------------
    Permission volume | +1 per 10 total permissions (both risky and non-risky)
    -------------------------------------------------------------------------------------------------------------------------------------------
    Multi-tenant | +10 for applications with multi-tenant enabled
    -------------------------------------------------------------------------------------------------------------------------------------------
    Third-party service principal | +20 for externally-owned service principals
    -------------------------------------------------------------------------------------------------------------------------------------------
    Privileged roles | +8 per privileged role assigned to a service principal
    -------------------------------------------------------------------------------------------------------------------------------------------
    Credential context weights | Base points are added to a credential based on the highest level permission assigned to the application.
                                  | +50/cred for critical, +35/cred for high, +15/cred for medium, +5/cred for low
    -------------------------------------------------------------------------------------------------------------------------------------------
    Credential type discounts | Key credentials are discounted by 50% and federated credentials are discounted by 75%
    -------------------------------------------------------------------------------------------------------------------------------------------
    Credential lifetime tiers | Bonus points for credentials with long lifetimes (excludes federated credentials):
                                  | +5 points for password creds valid for 2+ years, +3 points for 1-2 years, +2 points for 6 months - 1 year
                                  | +5 points for key creds valid for 3+ years, +3 points for 2-3 years, +2 points for 1-2 years
    -------------------------------------------------------------------------------------------------------------------------------------------
    .Functionality
    #Internal
    #>

    return [PSCustomObject]@{
        PermissionRiskLevelWeights = @{
            Critical = 50
            High = 15
            Medium = 5
            Low = 2
            Description = "Risk level weights are assigned based on the level of access granted by each permission."
        }

        PermissionVolume = @{
            PointsPer10Permissions = 1
            Description = "Over-permissioned applications/service principals represent an increased attack surface regardless of individual permission risk level."
        }

        MultiTenant = @{
            Points = 10
            Description = "Multi-tenant applications can be used across multiple organizations, increasing their attack surface."
        }

        ThirdPartyServicePrincipal = @{
            Points = 20
            Description = "Third-party service principals are owned by external organizations and do not fall under the same security policies as internal service principals."
        }

        PrivilegedRoles = @{
            PointsPerRole = 8
            Description = "Service principals with privileged roles (e.g., Global Administrator) have elevated permissions and pose a higher risk."
        }

        CredentialContextWeights = @{
            Critical = 50
            High = 35
            Medium = 15
            Low = 5
            Description = "Credential base points dynamically scale by the highest risk level permission on the app/SP."
        }

        # Discount applied to credential base points.
        # Key and federated credentials are discounted since key (certificate) credentials are more difficult to steal, and federated credentials contain no shared secret.
        CredentialTypeDiscounts = @{
            Password = 1.0
            Key = 0.5
            Federated = 0.25
            Description = "Multiplier applied to credential and base points by credential type. Passwords hold the highest risk, then certificates, and federated creds with the least."
        }

        PasswordCredentialLifetimeTiers = @(
            @{ MinDays = 730; Points = 5 }  # 2+ years
            @{ MinDays = 365; Points = 3 }  # 1 - 2 years
            @{ MinDays = 180; Points = 2 }  # 6 months - 1 year
                                            # <= 180 days is valid, no bonus points
        )

        KeyCredentialLifetimeTiers = @(
            @{ MinDays = 1095; Points = 5 } # 3+ years
            @{ MinDays = 730;  Points = 3 } # 2 - 3 years
            @{ MinDays = 365;  Points = 2 } # 1 - 2 years
                                            # <= 365 days is valid, no bonus points
        )

        CredentialVolume = @{
            PointsPerCredentialAfterFirst = 5
            Description = "Multiple active credentials increase the authentication attack surface. Each active credential beyond the first adds bonus points."
        }

        # Used in Entra ID HTML report to generate risk indicators.
        CredentialRiskIndicatorTiers = @{
            Critical = 0.75
            High = 0.50
            Medium = 0.25
            # Below 0.25 is considered low risk
        }
    }
}

function ConvertFrom-DotNetDate {
    param(
        [string]
        $DateString
    )

    if ([string]::IsNullOrEmpty($DateString)) {
        return $null
    }

    # Dates are returned from Graph as .NET JSON dates: /Date(1675800895000)/
    if ($DateString -match '\\?/Date\((\d+)\)\\?/') {
        $EpochMs = $Matches[1]
        return [System.DateTimeOffset]::FromUnixTimeMilliseconds($EpochMs).UtcDateTime
    }

    return [Datetime]::Parse($DateString)
}

function Set-CredentialScore {
    <#
    .Description
    Calculates the severity score for credentials; handles password, key, and federated credentials.
    .Functionality
    #Internal
    #>

    param (
        [Object[]]
        $AccessKeys,

        # Base points per credential are derived by:
        # - multiplying the credential context weight (determined by the app/SP's highest risk level permission)
        # - credential type discount (password credentials have no discount, key credentials have a 50% discount,
        # and federated credentials have a 75% discount)
        [ValidateNotNullOrEmpty()]
        [int]
        $BasePointsPerCredential,

        # Bonus poinst are added to a credential's score if the credential's duration exceeds a certain time-bound threshold.
        [array]
        $LifetimeTiers,

        # Only check lifetime for password/key credentials, not required for federated credentials.
        [switch]
        $CheckLifetime
    )

    $CredentialPoints = 0
    $CredentialCount = 0
    $LongLivedCredentialCount = 0

    if ($null -eq $AccessKeys -or @($AccessKeys).Count -eq 0) {
        return @{
            CredentialCount = $CredentialCount
            LongLivedCredentialCount = $LongLivedCredentialCount
            TotalPoints = $CredentialPoints
        }
    }

    foreach ($Credential in $AccessKeys) {
        $CurrentCredentialPoints = 0

        # Skip expired credentials since they can't be used for authentication
        if ($CheckLifetime -and $null -ne $Credential.EndDateTime) {
            $End = ConvertFrom-DotNetDate -DateString $Credential.EndDateTime
            if ($null -ne $End -and $End -lt (Get-Date)) {
                continue
            }
        }

        $CredentialCount++

        # Base points are determined by the app/SP's highest permission risk level
        $CurrentCredentialPoints += $BasePointsPerCredential

        # Add additional points for long-lived credentials (excludes federated)
        if ($CheckLifetime -and $null -ne $Credential.StartDateTime -and $null -ne $Credential.EndDateTime) {
            $Start = ConvertFrom-DotNetDate -DateString $Credential.StartDateTime
            $End = ConvertFrom-DotNetDate -DateString $Credential.EndDateTime
            $Duration = (New-TimeSpan -Start $Start -End $End).Days

            if ($LifetimeTiers) {
                foreach ($Tier in $LifetimeTiers) {
                    if ($Duration -gt $Tier.MinDays) {
                        $CurrentCredentialPoints += $Tier.Points
                        $LongLivedCredentialCount++
                        break
                    }
                }
            }
        }

        $CredentialPoints += $CurrentCredentialPoints
    }

    return @{
        CredentialCount = $CredentialCount
        LongLivedCredentialCount = $LongLivedCredentialCount
        TotalPoints = $CredentialPoints
    }
}

function Set-SeverityScore {
    <#
    .Description
    Calculates a severity score for each risky application/service principal based on multiple risk factors:
    - Number of admin consented risky permissions
    - Number of non-admin consented risky permissions
    - Multi-tenant enabled/disabled
    - Third-party service principal (owned externally)
    - Privileged roles assigned to risky service principals
    - Existence of password/key/federated credentials (considers Long-lived credentials)
 
    The total severity score is normalized to 100 to factor in different weight distributions for each of the above risk factors.
    .Functionality
    #Internal
    #>

    param (
        [ValidateNotNullOrEmpty()]
        [Object[]]
        $Object,

        [switch]
        $IsThirdPartyServicePrincipal,

        [string[]]
        $PrivilegedRoles = @()
    )
    try {
        $Weights = Get-SeverityScoreWeights

        $Score = 0
        $ScoreBreakdown = @{}

        # 1. Determine admin consented risky permission weight factor
        $AdminConsentedRiskyPermissions = @($Object.Permissions | Where-Object {
            $_.IsRisky -eq $true -and $_.IsAdminConsented -eq $true
        })
        $AdminConsentedPoints = ($AdminConsentedRiskyPermissions | ForEach-Object {
            $Weights.PermissionRiskLevelWeights[$_.RiskLevel]
        } | Measure-Object -Sum).Sum

        $Score += $AdminConsentedPoints
        $ScoreBreakdown.AdminConsentedRiskyPermissions = [PSCustomObject]@{
            PermissionCount = $AdminConsentedRiskyPermissions.Count
            TotalPoints = $AdminConsentedPoints
        }

        # 2. Determine non-admin consented risky permission weight factor
        $NonAdminConsentedRiskyPermissions = @($Object.Permissions | Where-Object {
            $_.IsRisky -eq $true -and $_.IsAdminConsented -eq $false
        })
        $NonAdminConsentedPoints = ($NonAdminConsentedRiskyPermissions | ForEach-Object {
            $Weights.PermissionRiskLevelWeights[$_.RiskLevel]
        } | Measure-Object -Sum).Sum

        $Score += $NonAdminConsentedPoints
        $ScoreBreakdown.NonAdminConsentedRiskyPermissions = [PSCustomObject]@{
            PermissionCount = $NonAdminConsentedRiskyPermissions.Count
            TotalPoints = $NonAdminConsentedPoints
        }

        # 3. Determine privileged roles weight factor (used only for service principals)
        $PrivilegedRolesPoints = 0
        if ($PrivilegedRoles.Count -gt 0) {
            $PrivilegedRolesPoints = $PrivilegedRoles.Count * $Weights.PrivilegedRoles.PointsPerRole
            $Score += $PrivilegedRolesPoints

            $ScoreBreakdown.PrivilegedRoles = [PSCustomObject]@{
                RoleCount = $PrivilegedRoles.Count
                TotalPoints = $PrivilegedRolesPoints
                Roles = $PrivilegedRoles
            }
        }

        # 4. Determine multi-tenant weight factor (used only for applications)
        $MultiTenantPoints = 0
        if ($Object.IsMultiTenantEnabled -eq $true) {
            $MultiTenantPoints = $Weights.MultiTenant.Points
            $Score += $MultiTenantPoints

            $ScoreBreakdown.MultiTenant = [PSCustomObject]@{
                IsMultiTenantEnabled = $Object.IsMultiTenantEnabled
                TotalPoints = $MultiTenantPoints
            }
        }

        # 5. Determine third-party service principal weight factor (used only for service principals)
        $ThirdPartyServicePrincipalPoints = 0
        if ($IsThirdPartyServicePrincipal -eq $true) {
            $ThirdPartyServicePrincipalPoints = $Weights.ThirdPartyServicePrincipal.Points
            $Score += $ThirdPartyServicePrincipalPoints

            $ScoreBreakdown.ThirdPartyServicePrincipal = [PSCustomObject]@{
                IsThirdPartyServicePrincipal = $true
                TotalPoints = $ThirdPartyServicePrincipalPoints
            }
        }

        # 6. Determine the credential base points by the highest risk level permission on the app/SP
        $AllRiskyPermissions = @($Object.Permissions | Where-Object { $_.IsRisky -eq $true })
        $RiskLevelPriority = @{ Critical = 4; High = 3; Medium = 2; Low = 1 }
        $HighestRiskLevel = "None"
        $HighestPriority = 0

        foreach ($Permission in $AllRiskyPermissions) {
            $Priority = $RiskLevelPriority[$Permission.RiskLevel]
            if ($null -ne $Priority -and $Priority -gt $HighestPriority) {
                $HighestPriority = $Priority
                $HighestRiskLevel = $Permission.RiskLevel
            }
        }

        $CredentialBasePoints = if ($HighestRiskLevel -ne "None") { $Weights.CredentialContextWeights[$HighestRiskLevel] } else { 0 }
        $ScoreBreakdown.HighestRiskLevel = $HighestRiskLevel

        # 7. Calculate password credential weight factor
        $PasswordBasePoints = [Math]::Ceiling($CredentialBasePoints * $Weights.CredentialTypeDiscounts.Password)
        $AllPasswordCredentials = @($Object.PasswordCredentials | Where-Object { $null -ne $_ })
        $PasswordScore = Set-CredentialScore `
            -AccessKeys $AllPasswordCredentials `
            -BasePointsPerCredential $PasswordBasePoints `
            -LifetimeTiers $Weights.PasswordCredentialLifetimeTiers `
            -CheckLifetime

        $Score += $PasswordScore.TotalPoints
        $ScoreBreakdown.PasswordCredentials = [PSCustomObject]@{
            CredentialCount = $PasswordScore.CredentialCount
            LongLivedCredentialCount = $PasswordScore.LongLivedCredentialCount
            TotalPoints = $PasswordScore.TotalPoints
        }

        # 8. Calculate key credential weight factor
        $KeyBasePoints = [Math]::Ceiling($CredentialBasePoints * $Weights.CredentialTypeDiscounts.Key)
        $AllKeyCredentials = @($Object.KeyCredentials | Where-Object { $null -ne $_})
        $KeyScore = Set-CredentialScore `
            -AccessKeys $AllKeyCredentials `
            -BasePointsPerCredential $KeyBasePoints `
            -LifetimeTiers $Weights.KeyCredentialLifetimeTiers `
            -CheckLifetime

        $Score += $KeyScore.TotalPoints
        $ScoreBreakdown.KeyCredentials = [PSCustomObject]@{
            CredentialCount = $KeyScore.CredentialCount
            LongLivedCredentialCount = $KeyScore.LongLivedCredentialCount
            TotalPoints = $KeyScore.TotalPoints
        }

        # 9. Calculate federated credential weight factor
        $FederatedBasePoints = [Math]::Ceiling($CredentialBasePoints * $Weights.CredentialTypeDiscounts.Federated)
        $AllFederatedCredentials = @($Object.FederatedCredentials | Where-Object { $null -ne $_})
        $FederatedScore = Set-CredentialScore `
            -AccessKeys $AllFederatedCredentials `
            -BasePointsPerCredential $FederatedBasePoints `

        $Score += $FederatedScore.TotalPoints
        $ScoreBreakdown.FederatedCredentials = [PSCustomObject]@{
            CredentialCount = $FederatedScore.CredentialCount
            TotalPoints = $FederatedScore.TotalPoints
        }

        # 10. Credential volume factor
        $TotalActiveCredentials = $PasswordScore.CredentialCount + $KeyScore.CredentialCount + $FederatedScore.CredentialCount
        $CredentialVolumePoints = 0

        if ($TotalActiveCredentials -gt 1) {
            # Subtract 1 because we're already taking into account the first credential.
            $CredentialVolumePoints = ($TotalActiveCredentials - 1) * $Weights.CredentialVolume.PointsPerCredentialAfterFirst
            $Score += $CredentialVolumePoints
        }

        $ScoreBreakdown.CredentialVolume = [PSCustomObject]@{
            TotalActiveCredentials = $TotalActiveCredentials
            TotalPoints = $CredentialVolumePoints
        }

        # 11. Permission volume factor
        $TotalPermissionCount = @($Object.Permissions).Count
        # Use Math.floor() so the integer division truncates rounds down.
        # For example:
        # - 5 permissions / 10 = 0.5 x 1 (0 points)
        # - 15 permissions / 10 = 1.5 x 1 (1 point)
        $PermissionVolumePoints = [Math]::Floor($TotalPermissionCount / 10) * $Weights.PermissionVolume.PointsPer10Permissions
        $Score += $PermissionVolumePoints
        $ScoreBreakdown.PermissionVolume = [PSCustomObject]@{
            TotalPermissions = $TotalPermissionCount
            TotalPoints = $PermissionVolumePoints
        }

        return [PSCustomObject]@{
            SeverityScore  = $Score
            ScoreBreakdown = $ScoreBreakdown
        }
    }
    catch {
        Write-Warning "An error occurred in Set-SeverityScore: $($_.Exception.Message)"
        Write-Warning "Stack trace: $($_.ScriptStackTrace)"
        throw $_
    }
}

# Keep Get-RiskyAppPermissionsJson exported for cross-module usage in AADHybridExchangeHelper.
Export-ModuleMember -Function @(
    "Get-RiskyAppPermissionsJson",
    "Format-Credentials",
    "Get-ApplicationsWithRiskyPermissions",
    "Get-ServicePrincipalsWithRiskyPermissions",
    "Format-RiskyApplications",
    "Format-RiskyThirdPartyServicePrincipals",
    "Get-SeverityScoreWeights",
    "Get-ServicePrincipalsWithRiskyDelegatedPermissionClassifications"
)

# SIG # Begin signature block
# MIIu9wYJKoZIhvcNAQcCoIIu6DCCLuQCAQExDzANBglghkgBZQMEAgEFADB5Bgor
# BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG
# KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCB2f1pF1xFyfkLu
# hXbAq0F0XpwFHY7EFNHv+hJ+meacD6CCE6MwggWQMIIDeKADAgECAhAFmxtXno4h
# MuI5B72nd3VcMA0GCSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQK
# EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNV
# BAMTGERpZ2lDZXJ0IFRydXN0ZWQgUm9vdCBHNDAeFw0xMzA4MDExMjAwMDBaFw0z
# ODAxMTUxMjAwMDBaMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIB
# AL/mkHNo3rvkXUo8MCIwaTPswqclLskhPfKK2FnC4SmnPVirdprNrnsbhA3EMB/z
# G6Q4FutWxpdtHauyefLKEdLkX9YFPFIPUh/GnhWlfr6fqVcWWVVyr2iTcMKyunWZ
# anMylNEQRBAu34LzB4TmdDttceItDBvuINXJIB1jKS3O7F5OyJP4IWGbNOsFxl7s
# Wxq868nPzaw0QF+xembud8hIqGZXV59UWI4MK7dPpzDZVu7Ke13jrclPXuU15zHL
# 2pNe3I6PgNq2kZhAkHnDeMe2scS1ahg4AxCN2NQ3pC4FfYj1gj4QkXCrVYJBMtfb
# BHMqbpEBfCFM1LyuGwN1XXhm2ToxRJozQL8I11pJpMLmqaBn3aQnvKFPObURWBf3
# JFxGj2T3wWmIdph2PVldQnaHiZdpekjw4KISG2aadMreSx7nDmOu5tTvkpI6nj3c
# AORFJYm2mkQZK37AlLTSYW3rM9nF30sEAMx9HJXDj/chsrIRt7t/8tWMcCxBYKqx
# YxhElRp2Yn72gLD76GSmM9GJB+G9t+ZDpBi4pncB4Q+UDCEdslQpJYls5Q5SUUd0
# viastkF13nqsX40/ybzTQRESW+UQUOsxxcpyFiIJ33xMdT9j7CFfxCBRa2+xq4aL
# T8LWRV+dIPyhHsXAj6KxfgommfXkaS+YHS312amyHeUbAgMBAAGjQjBAMA8GA1Ud
# EwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgGGMB0GA1UdDgQWBBTs1+OC0nFdZEzf
# Lmc/57qYrhwPTzANBgkqhkiG9w0BAQwFAAOCAgEAu2HZfalsvhfEkRvDoaIAjeNk
# aA9Wz3eucPn9mkqZucl4XAwMX+TmFClWCzZJXURj4K2clhhmGyMNPXnpbWvWVPjS
# PMFDQK4dUPVS/JA7u5iZaWvHwaeoaKQn3J35J64whbn2Z006Po9ZOSJTROvIXQPK
# 7VB6fWIhCoDIc2bRoAVgX+iltKevqPdtNZx8WorWojiZ83iL9E3SIAveBO6Mm0eB
# cg3AFDLvMFkuruBx8lbkapdvklBtlo1oepqyNhR6BvIkuQkRUNcIsbiJeoQjYUIp
# 5aPNoiBB19GcZNnqJqGLFNdMGbJQQXE9P01wI4YMStyB0swylIQNCAmXHE/A7msg
# dDDS4Dk0EIUhFQEI6FUy3nFJ2SgXUE3mvk3RdazQyvtBuEOlqtPDBURPLDab4vri
# RbgjU2wGb2dVf0a1TD9uKFp5JtKkqGKX0h7i7UqLvBv9R0oN32dmfrJbQdA75PQ7
# 9ARj6e/CVABRoIoqyc54zNXqhwQYs86vSYiv85KZtrPmYQ/ShQDnUBrkG5WdGaG5
# nLGbsQAe79APT0JsyQq87kP6OnGlyE0mpTX9iV28hWIdMtKgK1TtmlfB2/oQzxm3
# i0objwG2J5VT6LaJbVu8aNQj6ItRolb58KaAoNYes7wPD1N1KarqE3fk3oyBIa0H
# EEcRrYc9B9F1vM/zZn4wggawMIIEmKADAgECAhAIrUCyYNKcTJ9ezam9k67ZMA0G
# CSqGSIb3DQEBDAUAMGIxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2VydCBJ
# bmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xITAfBgNVBAMTGERpZ2lDZXJ0
# IFRydXN0ZWQgUm9vdCBHNDAeFw0yMTA0MjkwMDAwMDBaFw0zNjA0MjgyMzU5NTla
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwggIiMA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQDVtC9C
# 0CiteLdd1TlZG7GIQvUzjOs9gZdwxbvEhSYwn6SOaNhc9es0JAfhS0/TeEP0F9ce
# 2vnS1WcaUk8OoVf8iJnBkcyBAz5NcCRks43iCH00fUyAVxJrQ5qZ8sU7H/Lvy0da
# E6ZMswEgJfMQ04uy+wjwiuCdCcBlp/qYgEk1hz1RGeiQIXhFLqGfLOEYwhrMxe6T
# SXBCMo/7xuoc82VokaJNTIIRSFJo3hC9FFdd6BgTZcV/sk+FLEikVoQ11vkunKoA
# FdE3/hoGlMJ8yOobMubKwvSnowMOdKWvObarYBLj6Na59zHh3K3kGKDYwSNHR7Oh
# D26jq22YBoMbt2pnLdK9RBqSEIGPsDsJ18ebMlrC/2pgVItJwZPt4bRc4G/rJvmM
# 1bL5OBDm6s6R9b7T+2+TYTRcvJNFKIM2KmYoX7BzzosmJQayg9Rc9hUZTO1i4F4z
# 8ujo7AqnsAMrkbI2eb73rQgedaZlzLvjSFDzd5Ea/ttQokbIYViY9XwCFjyDKK05
# huzUtw1T0PhH5nUwjewwk3YUpltLXXRhTT8SkXbev1jLchApQfDVxW0mdmgRQRNY
# mtwmKwH0iU1Z23jPgUo+QEdfyYFQc4UQIyFZYIpkVMHMIRroOBl8ZhzNeDhFMJlP
# /2NPTLuqDQhTQXxYPUez+rbsjDIJAsxsPAxWEQIDAQABo4IBWTCCAVUwEgYDVR0T
# AQH/BAgwBgEB/wIBADAdBgNVHQ4EFgQUaDfg67Y7+F8Rhvv+YXsIiGX0TkIwHwYD
# VR0jBBgwFoAU7NfjgtJxXWRM3y5nP+e6mK4cD08wDgYDVR0PAQH/BAQDAgGGMBMG
# A1UdJQQMMAoGCCsGAQUFBwMDMHcGCCsGAQUFBwEBBGswaTAkBggrBgEFBQcwAYYY
# aHR0cDovL29jc3AuZGlnaWNlcnQuY29tMEEGCCsGAQUFBzAChjVodHRwOi8vY2Fj
# ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRUcnVzdGVkUm9vdEc0LmNydDBDBgNV
# HR8EPDA6MDigNqA0hjJodHRwOi8vY3JsMy5kaWdpY2VydC5jb20vRGlnaUNlcnRU
# cnVzdGVkUm9vdEc0LmNybDAcBgNVHSAEFTATMAcGBWeBDAEDMAgGBmeBDAEEATAN
# BgkqhkiG9w0BAQwFAAOCAgEAOiNEPY0Idu6PvDqZ01bgAhql+Eg08yy25nRm95Ry
# sQDKr2wwJxMSnpBEn0v9nqN8JtU3vDpdSG2V1T9J9Ce7FoFFUP2cvbaF4HZ+N3HL
# IvdaqpDP9ZNq4+sg0dVQeYiaiorBtr2hSBh+3NiAGhEZGM1hmYFW9snjdufE5Btf
# Q/g+lP92OT2e1JnPSt0o618moZVYSNUa/tcnP/2Q0XaG3RywYFzzDaju4ImhvTnh
# OE7abrs2nfvlIVNaw8rpavGiPttDuDPITzgUkpn13c5UbdldAhQfQDN8A+KVssIh
# dXNSy0bYxDQcoqVLjc1vdjcshT8azibpGL6QB7BDf5WIIIJw8MzK7/0pNVwfiThV
# 9zeKiwmhywvpMRr/LhlcOXHhvpynCgbWJme3kuZOX956rEnPLqR0kq3bPKSchh/j
# wVYbKyP/j7XqiHtwa+aguv06P0WmxOgWkVKLQcBIhEuWTatEQOON8BUozu3xGFYH
# Ki8QxAwIZDwzj64ojDzLj4gLDb879M4ee47vtevLt/B3E+bnKD+sEq6lLyJsQfmC
# XBVmzGwOysWGw/YmMwwHS6DTBwJqakAwSEs0qFEgu60bhQjiWQ1tygVQK+pKHJ6l
# /aCnHwZ05/LWUpD9r4VIIflXO7ScA+2GRfS0YW6/aOImYIbqyK+p/pQd52MbOoZW
# eE4wggdXMIIFP6ADAgECAhAMM6tnPejLgA9WVhXroQvSMA0GCSqGSIb3DQEBCwUA
# MGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2VydCwgSW5jLjFBMD8GA1UE
# AxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBDb2RlIFNpZ25pbmcgUlNBNDA5NiBTSEEz
# ODQgMjAyMSBDQTEwHhcNMjYwMTE0MDAwMDAwWhcNMjcwMTEzMjM1OTU5WjBfMQsw
# CQYDVQQGEwJVUzEdMBsGA1UECBMURGlzdHJpY3Qgb2YgQ29sdW1iaWExEzARBgNV
# BAcTCldhc2hpbmd0b24xDTALBgNVBAoTBENJU0ExDTALBgNVBAMTBENJU0EwggIi
# MA0GCSqGSIb3DQEBAQUAA4ICDwAwggIKAoICAQCuXYolNHqlh6smLTE592waXheZ
# 8VHzxeds4pMaepGuwmjf8d1jG9wUNuJX9/qb0a1dgGz5D/EAz5NRTIin4SZYQEE8
# qvdl2yQJ5uWxXIjsFbrOyc1fWscUXw0Kt7OPLOafcEkdDoe8K0tO4h2GL3RWRzjp
# uLfQhhnAmD6NT1l+ughnfmarV/ODgIn/RFR4YORlu4YP2xQX6KRxeTDslg7F+z6X
# +t87/U8m8gQ9XTm5kBmteP4GcE/ytnyI+ScIxNRybzGomWIBm848XDE5yYhlYQ2R
# SnCoo6M4CRqp9WFGVyoLkoPP0OlxzryKWaE1/nuPbYG/kf/rUB1OhqxvSSGwmNhs
# vkkjsC0Z9H5Jy6heFdoxOu/+ZQksKoP/fMvHxuCCtkIJbV8tk0oT6MQ8EJbgsWDZ
# TKhui1wxW6JIZyBOMPWoZUOouOzo2h5Cz7LBPKME5FkcUzcs47lpRlDkJco4PLcj
# wJSo4XPnx3G/2DIjNEFNyfKCWfH8uW6nJjmDBiveFZ2j0YvgdQ+7MOjQnw7R/MAD
# DTagrKl3rLV60+X2TY6/onKhCUuU3pMAjVbOwZ3PkzDLZnsEGRfm6hgp6014aXml
# t8h4nu+uC41U8vUSGHl0vqKuzvmShLmnI+Iv0l95pmnqomuCZzRDrjEoaLPx7OxL
# Dy/Id9E7yQDip4jBdQIDAQABo4ICAzCCAf8wHwYDVR0jBBgwFoAUaDfg67Y7+F8R
# hvv+YXsIiGX0TkIwHQYDVR0OBBYEFN30sVU+fpfQQfPQWMhkO1qd+MxoMD4GA1Ud
# IAQ3MDUwMwYGZ4EMAQQBMCkwJwYIKwYBBQUHAgEWG2h0dHA6Ly93d3cuZGlnaWNl
# cnQuY29tL0NQUzAOBgNVHQ8BAf8EBAMCB4AwEwYDVR0lBAwwCgYIKwYBBQUHAwMw
# gbUGA1UdHwSBrTCBqjBToFGgT4ZNaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0Rp
# Z2lDZXJ0VHJ1c3RlZEc0Q29kZVNpZ25pbmdSU0E0MDk2U0hBMzg0MjAyMUNBMS5j
# cmwwU6BRoE+GTWh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0
# ZWRHNENvZGVTaWduaW5nUlNBNDA5NlNIQTM4NDIwMjFDQTEuY3JsMIGUBggrBgEF
# BQcBAQSBhzCBhDAkBggrBgEFBQcwAYYYaHR0cDovL29jc3AuZGlnaWNlcnQuY29t
# MFwGCCsGAQUFBzAChlBodHRwOi8vY2FjZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNl
# cnRUcnVzdGVkRzRDb2RlU2lnbmluZ1JTQTQwOTZTSEEzODQyMDIxQ0ExLmNydDAJ
# BgNVHRMEAjAAMA0GCSqGSIb3DQEBCwUAA4ICAQA77T42YiOx5wWPItgo+kvB+Gzb
# ZFCRHRFfTAZZNQt9o/0CqNmyA2xFklX4t5Z9VNdiIOx14AnmJdQkcRdk3vsU5gby
# jKEup7LTWtvcWrl6hQwGNt3l892BgUbPKsPBE+AriktVqn5yMSXVVzeboqsqAG8e
# Syei0B54/QdgR4whfHvQ/qpCACsJTlJgAykXVgDPJNKnQ7wc17loLQutqF1JUcbO
# XKWt1AA9Zas5q30LDZzZeK4B56yojK68CTQXN7toSRFIuZDMKKDfIZpCX8cmbaaO
# DFOOu44/QWMv+Xc6+ISYGkrTTzqWhOqiXgLVBeXGn/WrOJJ8R29mZMneCpBesCLs
# YII1gCFOo7Vt6mvOKxAPQ3KhJYBFEHkp+GI65koaQkO2xv50iLS0+/j2YC66uviU
# MFe0JEOdXuE7Rn/OmWNSzQ+6kPNYDJQASQ974C3wUejJoMtGZEzoTbly/HufQTrd
# rhcL2aC69CxSN+idTXPLC9UT3xo4sFdOw+hXkmbXtoB1GDsd5p1TWFgRbnTXDkbM
# YMBWYVB6/Tk1bzwj4iTp4g0YrtB628FXPX/ko+JWZlv0Ea865S23w1uGlnDNVxIi
# +8oi74G5DM66Q6ENt6+3WoRGRrdoyE6uCh1haY+oPYSgumb0ozzrp8tw89TRKVrK
# KSXGBxlExEvjQ6dYwjGCGqowghqmAgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNV
# BAoTDkRpZ2lDZXJ0LCBJbmMuMUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0
# IENvZGUgU2lnbmluZyBSU0E0MDk2IFNIQTM4NCAyMDIxIENBMQIQDDOrZz3oy4AP
# VlYV66EL0jANBglghkgBZQMEAgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgACh
# AoAAMBkGCSqGSIb3DQEJAzEMBgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAM
# BgorBgEEAYI3AgEVMC8GCSqGSIb3DQEJBDEiBCBGPoptD3ZuCOrdi3h59NGFYm0D
# CGUCS8IoY4smUesixjANBgkqhkiG9w0BAQEFAASCAgBTlHEqHD7C/B+ImulhLfMA
# WsX+LhVy9VBzP8Z1w3mqsSfh9WuBC5oTrz7lpM46HJC9p94K5AJFFjEN3VJ51Ts5
# qRx8I3E30sSEnMbbc8+JfEfe8CxvXW6jfYNdTj3Ey4nv3pvHU2frAkZmQm5QGfBB
# 3VNYQeW5zlPstVGpxgA+fo1WEl5O6jzJAlPN+7XxkWYn+CyRAoAo6zf48fu54oLt
# 1egUhePeSu/a2726i4y7ia5xj9mXyoyp8mnFpQV5d5+yjWyterDLwBR0WPp9/lyL
# QHr9r7O8tHck41WPxDAaKbyTdAUtgMNiwlJ4h9qt0BQYaC85tU+Rgx+XFY0FfBfh
# ZyLLftSScy6sS8/Fi6/3LgGygzEVp+LOyZsMecY9d7f+NULkK1K6ShOKe7paqTp4
# Sr6E0a/TtHw2tV/l9dfg4iwMgS9B7n6ITGx6A0rzJGvEn1BL6tl9TreyjqI5X3eO
# Hq/4qv1ZC4XMZ2yksOp7ZI6f+xEzCW+DvitSpPpUGG4A30Jz/XQIIc3auOGeQMta
# KaFWWLOP1Fa2xzXn2UurKKk9NSqkAuYf/V7Q+mQv+7QYcL309Tahd19KQ13BcXak
# q/X0k5a6x3dcwIJUpd2ex3NpP8DCIJVfP0wTbz17Jj2xuVo4/C1DYUHGotk6HW3N
# CXWFigrLTc7ooM74HBnNRqGCF3cwghdzBgorBgEEAYI3AwMBMYIXYzCCF18GCSqG
# SIb3DQEHAqCCF1AwghdMAgEDMQ8wDQYJYIZIAWUDBAIBBQAweAYLKoZIhvcNAQkQ
# AQSgaQRnMGUCAQEGCWCGSAGG/WwHATAxMA0GCWCGSAFlAwQCAQUABCDS21a+SQ8b
# mx7u+5lZrcWpLHsgGHkH8FT8ysHrxz3ksQIRAK6wfa/pYsjxbj+KtscntnkYDzIw
# MjYxMDA4MjMyOTQ4WqCCEzowggbtMIIE1aADAgECAhAIT9wzT35FTtvDD4/5khg1
# MA0GCSqGSIb3DQEBCwUAMGkxCzAJBgNVBAYTAlVTMRcwFQYDVQQKEw5EaWdpQ2Vy
# dCwgSW5jLjFBMD8GA1UEAxM4RGlnaUNlcnQgVHJ1c3RlZCBHNCBUaW1lU3RhbXBp
# bmcgUlNBNDA5NiBTSEEyNTYgMjAyNSBDQTEwHhcNMjYwODA1MDAwMDAwWhcNMzcx
# MTA0MjM1OTU5WjBjMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIElu
# Yy4xOzA5BgNVBAMTMkRpZ2lDZXJ0IFNIQTI1NiBSU0E0MDk2IFRpbWVzdGFtcCBS
# ZXNwb25kZXIgMjAyNiAxMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
# tnum8sn+zUr41JtMZbP9OMYw+HwJDpG5xkIu/lqcfNYmMX81YmsUiHLbh9ykpeWB
# GKTLhYBrAN9Tdg/QEzG32XcObmgIblnr0CoQ3WSAeDZ6nH6X6VkFyYkJw3QBJREw
# vm4UhLzSxmwPA7cFKRTEOMsmEEj6qJk/dqLEAL+oQYuOwE2UuiX1Vnul8YReIyWd
# 4kgLn9gq6LNXM0UplkR6jL/QHxmb6fMoGBJYbnaUI7XD6cKDpekK2SVMld4iDbze
# HDtOaaxldH5IxuNusQ69nd8/ZXEiB5Hbxj3RlK13cX1W4DlFXKdv/CEhM8Cj1vvl
# mvhNroyPdRGbbpBlgyf8Wdu5N6ByhFwURn0U6ozlPoxN22v+fviUhP+6DR547OZn
# pBMWDfei1f5sVGwiiW/KQTWOK97g+4RJpPzPNV4VYMAwO2jM2Aty2QYPVmOQTJm0
# msuXnJrSbl2gf9JylpkJlWXqk1Q4LJsxz+TELoQCZIljbgvTJgoPU2R12ydv8i1U
# qL/adelA0y7U9Pmmtbze9Xx3rtajC5SzQd1jgfwAwsa90v9YcSPdmeoyoBBA/27c
# CL237l5DTYYPDLQ4ON3OLTGWnvRb6jDrf/T75gMRfUzSLCBQfBusm9+mSWRlC/Df
# 6S/e9Q8i13CuhzOT2Jx+V/nlbXM4QoBwlUAhelwwJT0CAwEAAaOCAZUwggGRMAwG
# A1UdEwEB/wQCMAAwHQYDVR0OBBYEFBTJY4owLtRK+26U8+bjQH717M3iMB8GA1Ud
# IwQYMBaAFO9vU0rp5AZ8esrikFb2L9RJ7MtOMA4GA1UdDwEB/wQEAwIHgDAWBgNV
# HSUBAf8EDDAKBggrBgEFBQcDCDCBlQYIKwYBBQUHAQEEgYgwgYUwJAYIKwYBBQUH
# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBdBggrBgEFBQcwAoZRaHR0cDov
# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZEc0VGltZVN0YW1w
# aW5nUlNBNDA5NlNIQTI1NjIwMjVDQTEuY3J0MF8GA1UdHwRYMFYwVKBSoFCGTmh0
# dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFRpbWVTdGFt
# cGluZ1JTQTQwOTZTSEEyNTYyMDI1Q0ExLmNybDAgBgNVHSAEGTAXMAgGBmeBDAEE
# AjALBglghkgBhv1sBwEwDQYJKoZIhvcNAQELBQADggIBAI3FOmEenVIK35msCYB+
# fShAsWvSYvLBItoNdAgQ2jIqrGsVsluXMJU/+mRebBc52s6lbKAvOVPXaizmKkML
# LflEEKDZQx4CkS2t8aHPjkXha3hYZ010htFa3dhNgmalH5vuWvh3tTCf4frTS7gP
# tGc4Z/xaPhQ2AB1mR8eEe/WbH0RWHvVIl6VwQ3+g5FKNfN2N/DWJkf13w2H+2Gfq
# Efbd35Ww8CvoYBjLNIDTadcPWdgsjsiOaK/7EsKJgLjUNIVgvcaFOLLQ/GlrA+0Z
# HJoFUbOr5SJN8zykPspXIXlpDJY/gqFUZRROeab9GVgmhbdOJcD/63RhxPahFUGb
# ckRONqMe6DYAv6/mOG0pWd3cPStsdcS7buj5DyniwRY8yooMH6ptx5vpP/pZzBPB
# eZD2U4IsthyxB5Jaa8qrOkB5z160TXiM5ADMspZ0TfD9MJoq0tFpFPssKRFhWeED
# YPvcUuN7U7lvcdHl4ezQ3NT/7Ffs1sR1yh/LRbdZ3B3Vc6q2WmD8mDC0p9kzl2o7
# 3iVtS946IkEj7FkRsZGww1teYxERROC745xrtjvcw9ZyyUjHZWGRIpJeMNsPquCD
# f0fkyHtB+J4AiNZqCQk23rxh+KbpyMTNVKItJ5l92Svl20U9NbqMBOVYl1h54NEY
# LJq1/xHWFKPNK903zJZA9P2DMIIGtDCCBJygAwIBAgIQDcesVwX/IZkuQEMiDDpJ
# hjANBgkqhkiG9w0BAQsFADBiMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNl
# cnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSEwHwYDVQQDExhEaWdp
# Q2VydCBUcnVzdGVkIFJvb3QgRzQwHhcNMjUwNTA3MDAwMDAwWhcNMzgwMTE0MjM1
# OTU5WjBpMQswCQYDVQQGEwJVUzEXMBUGA1UEChMORGlnaUNlcnQsIEluYy4xQTA/
# BgNVBAMTOERpZ2lDZXJ0IFRydXN0ZWQgRzQgVGltZVN0YW1waW5nIFJTQTQwOTYg
# U0hBMjU2IDIwMjUgQ0ExMIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEA
# tHgx0wqYQXK+PEbAHKx126NGaHS0URedTa2NDZS1mZaDLFTtQ2oRjzUXMmxCqvkb
# sDpz4aH+qbxeLho8I6jY3xL1IusLopuW2qftJYJaDNs1+JH7Z+QdSKWM06qchUP+
# AbdJgMQB3h2DZ0Mal5kYp77jYMVQXSZH++0trj6Ao+xh/AS7sQRuQL37QXbDhAkt
# VJMQbzIBHYJBYgzWIjk8eDrYhXDEpKk7RdoX0M980EpLtlrNyHw0Xm+nt5pnYJU3
# Gmq6bNMI1I7Gb5IBZK4ivbVCiZv7PNBYqHEpNVWC2ZQ8BbfnFRQVESYOszFI2Wv8
# 2wnJRfN20VRS3hpLgIR4hjzL0hpoYGk81coWJ+KdPvMvaB0WkE/2qHxJ0ucS638Z
# xqU14lDnki7CcoKCz6eum5A19WZQHkqUJfdkDjHkccpL6uoG8pbF0LJAQQZxst7V
# vwDDjAmSFTUms+wV/FbWBqi7fTJnjq3hj0XbQcd8hjj/q8d6ylgxCZSKi17yVp2N
# L+cnT6Toy+rN+nM8M7LnLqCrO2JP3oW//1sfuZDKiDEb1AQ8es9Xr/u6bDTnYCTK
# IsDq1BtmXUqEG1NqzJKS4kOmxkYp2WyODi7vQTCBZtVFJfVZ3j7OgWmnhFr4yUoz
# ZtqgPrHRVHhGNKlYzyjlroPxul+bgIspzOwbtmsgY1MCAwEAAaOCAV0wggFZMBIG
# A1UdEwEB/wQIMAYBAf8CAQAwHQYDVR0OBBYEFO9vU0rp5AZ8esrikFb2L9RJ7MtO
# MB8GA1UdIwQYMBaAFOzX44LScV1kTN8uZz/nupiuHA9PMA4GA1UdDwEB/wQEAwIB
# hjATBgNVHSUEDDAKBggrBgEFBQcDCDB3BggrBgEFBQcBAQRrMGkwJAYIKwYBBQUH
# MAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBBBggrBgEFBQcwAoY1aHR0cDov
# L2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0VHJ1c3RlZFJvb3RHNC5jcnQw
# QwYDVR0fBDwwOjA4oDagNIYyaHR0cDovL2NybDMuZGlnaWNlcnQuY29tL0RpZ2lD
# ZXJ0VHJ1c3RlZFJvb3RHNC5jcmwwIAYDVR0gBBkwFzAIBgZngQwBBAIwCwYJYIZI
# AYb9bAcBMA0GCSqGSIb3DQEBCwUAA4ICAQAXzvsWgBz+Bz0RdnEwvb4LyLU0pn/N
# 0IfFiBowf0/Dm1wGc/Do7oVMY2mhXZXjDNJQa8j00DNqhCT3t+s8G0iP5kvN2n7J
# d2E4/iEIUBO41P5F448rSYJ59Ib61eoalhnd6ywFLerycvZTAz40y8S4F3/a+Z1j
# EMK/DMm/axFSgoR8n6c3nuZB9BfBwAQYK9FHaoq2e26MHvVY9gCDA/JYsq7pGdog
# P8HRtrYfctSLANEBfHU16r3J05qX3kId+ZOczgj5kjatVB+NdADVZKON/gnZruMv
# NYY2o1f4MXRJDMdTSlOLh0HCn2cQLwQCqjFbqrXuvTPSegOOzr4EWj7PtspIHBld
# NE2K9i697cvaiIo2p61Ed2p8xMJb82Yosn0z4y25xUbI7GIN/TpVfHIqQ6Ku/qjT
# Y6hc3hsXMrS+U0yy+GWqAXam4ToWd2UQ1KYT70kZjE4YtL8Pbzg0c1ugMZyZZd/B
# dHLiRu7hAWE6bTEm4XYRkA6Tl4KSFLFk43esaUeqGkH/wyW4N7OigizwJWeukcyI
# PbAvjSabnf7+Pu0VrFgoiovRDiyx3zEdmcif/sYQsfch28bZeUz2rtY/9TCA6TD8
# dC3JE3rYkrhLULy7Dc90G6e8BlqmyIjlgp2+VqsS9/wQD7yFylIz0scmbKvFoW2j
# NrbM1pD2T7m3XDCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI
# hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ
# MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz
# dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow
# YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ
# d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290
# IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww
# IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5
# 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH
# hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6
# Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ
# ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b
# A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9
# WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU
# tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo
# ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J
# vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP
# orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB
# Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr
# oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt
# MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF
# BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl
# ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp
# Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw
# BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH
# vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8
# UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn
# f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU
# jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j
# LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDGCA3ww
# ggN4AgEBMH0waTELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0LCBJbmMu
# MUEwPwYDVQQDEzhEaWdpQ2VydCBUcnVzdGVkIEc0IFRpbWVTdGFtcGluZyBSU0E0
# MDk2IFNIQTI1NiAyMDI1IENBMQIQCE/cM09+RU7bww+P+ZIYNTANBglghkgBZQME
# AgEFAKCB0TAaBgkqhkiG9w0BCQMxDQYLKoZIhvcNAQkQAQQwHAYJKoZIhvcNAQkF
# MQ8XDTI2MTAwODIzMjk0OFowKwYLKoZIhvcNAQkQAgwxHDAaMBgwFgQUUdmr2gNJ
# c9hPQmaspIJI5rNpxDkwLwYJKoZIhvcNAQkEMSIEICC4sagP6cV+vW5jj+W7Yp1Q
# 36//lY1N7ZVpB5fHxXkHMDcGCyqGSIb3DQEJEAIvMSgwJjAkMCIEIC2gnaf0Ex+f
# 5y22xebpyWVnVa8EPx6nQswNISDhQev8MA0GCSqGSIb3DQEBAQUABIICAC8tinRV
# ZObp70yge5RKMWFy0HpdU6SNHBFms7Q/SpsUulzvyTL8YX2Sv0UmHiy1CVbH+dHX
# 8IirooaUgUPmtyahN9xToidHyJFZiO6a3Vx4ytnp+DMEfmYXEEyuBEHHOaPMrJO4
# w21WnYsIEOdZGochNmQUFKV+XUBKfhwQQbpPnWYEXIIMg7Vw/V8GS2x4DEtfM6kz
# XZIsJSPuzS2CIatMH34VJ1z1hLhqWwy+aDawGu1upGdppaTUHtFTJpIpevBjYPUE
# BSRSOPePIUwyJWs4F+tK2Z/BiKK3mzKoJYiiKHpFMfGE87IA2KO9mnn2e6aoO/aW
# sa8I09h8OHBAWaEcu4H+x33F9fsVqxRAAV4E85uCAAz9kwSweIWa2lBimihDEMUr
# j5l6XpL1rpxQVUgz6PbtHjFANa0tF3SO+dWtlbsq/bEMqIleNTQ/hIxf+3Ro9IMl
# a96cRtqhWctYrAS9/pAec53M7SspjsCzHTr6mM9s3Ic4ic56ZY12nTNjRHPYmTLQ
# qJavFBWFhcTVYM2MHlVGa0rIHeGp23ldYp6dlUU3gBSp1NF/OSS78lGW56gzyonX
# 4BHdMJVuFGd285NHC9qF7eO20Q3BK/3O1Q/C6gOiCkhFW08TLtSOJzZZlwfI3O/6
# ocliytvvxsa8lVszcBLxM1IPJnZ88HDSPh+V
# SIG # End signature block