Private/Entra/Checks/Invoke-EntraAppChecks.ps1
|
# PSGuerrilla - Jim Tyler, Microsoft MVP - CC BY 4.0 # https://github.com/jimrtyler/PSGuerrilla | https://creativecommons.org/licenses/by/4.0/ # AI/LLM use: see AI-USAGE.md for required attribution function Invoke-EntraAppChecks { [CmdletBinding()] param( [Parameter(Mandatory)] [hashtable]$AuditData ) $checkDefs = Get-AuditCategoryDefinitions -Category 'EntraAppChecks' $findings = [System.Collections.Generic.List[PSCustomObject]]::new() foreach ($check in $checkDefs.checks) { $funcName = "Test-Infiltration$($check.id -replace '-', '')" if (Get-Command $funcName -ErrorAction SilentlyContinue) { try { $finding = & $funcName -AuditData $AuditData -CheckDefinition $check if ($finding) { $findings.Add($finding) } } catch { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'ERROR' ` -CurrentValue "Check failed: $_")) } } else { $findings.Add((New-AuditFinding -CheckDefinition $check -Status 'SKIP' ` -CurrentValue 'Check not yet implemented')) } } return @($findings) } # ── EIDAPP-001: Application Registration Inventory ────────────────────── function Test-InfiltrationEIDAPP001 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $apps = $AuditData.Applications.AppRegistrations $sps = $AuditData.Applications.ServicePrincipals $appCount = if ($apps) { $apps.Count } else { 0 } $spCount = if ($sps) { $sps.Count } else { 0 } if ($appCount -eq 0 -and $spCount -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'No application registrations or service principals found' ` -Details @{ AppRegistrationCount = 0; ServicePrincipalCount = 0 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "$appCount app registrations, $spCount service principals" ` -Details @{ AppRegistrationCount = $appCount ServicePrincipalCount = $spCount Apps = @($apps | Select-Object -First 100 | ForEach-Object { @{ AppId = $_.appId DisplayName = $_.displayName SignInAudience = $_.signInAudience CreatedDateTime = $_.createdDateTime } }) } } # ── EIDAPP-002: High-Risk API Permissions ──────────────────────────────── function Test-InfiltrationEIDAPP002 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $apps = $AuditData.Applications.AppRegistrations if (-not $apps -or $apps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No application registrations available' } # Dangerous application permission IDs (Microsoft Graph) $dangerousPermissions = @{ '1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9' = 'Application.ReadWrite.All' '9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8' = 'RoleManagement.ReadWrite.Directory' 'e2a3a72e-5f79-4c64-b1b1-878b674786c9' = 'Mail.ReadWrite' '06b708a9-e830-4db3-a914-8e69da51d44f' = 'AppRoleAssignment.ReadWrite.All' '19dbc75e-c2e2-444c-a770-ec596d67b7e4' = 'Directory.ReadWrite.All' '741f803b-c850-494e-b5df-cde7c675a1ca' = 'User.ReadWrite.All' '62a82d76-70ea-41e2-9197-370581804d09' = 'Group.ReadWrite.All' '9492366f-7969-46a4-8d15-ed1a20078fff' = 'Sites.ReadWrite.All' 'ef54d2bf-783f-4e0f-bca1-3210c0444d99' = 'Files.ReadWrite.All' '01d4f7ba-0ac5-41b9-838e-02e68906e5c8' = 'Mail.Send' } # Microsoft Graph resource app ID $graphResourceId = '00000003-0000-0000-c000-000000000000' $riskyApps = [System.Collections.Generic.List[hashtable]]::new() foreach ($app in $apps) { if (-not $app.requiredResourceAccess) { continue } foreach ($resource in $app.requiredResourceAccess) { if ($resource.resourceAppId -ne $graphResourceId) { continue } foreach ($perm in @($resource.resourceAccess)) { # Only check Application permissions (type = 'Role') if ($perm.type -eq 'Role' -and $dangerousPermissions.ContainsKey($perm.id)) { $riskyApps.Add(@{ AppId = $app.appId DisplayName = $app.displayName PermissionId = $perm.id PermissionName = $dangerousPermissions[$perm.id] }) } } } } if ($riskyApps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No applications request high-risk API permissions' ` -Details @{ RiskyAppCount = 0 } } $uniqueApps = @($riskyApps | ForEach-Object { $_.AppId } | Select-Object -Unique).Count return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$uniqueApps apps request $($riskyApps.Count) high-risk API permissions" ` -Details @{ RiskyAppCount = $uniqueApps TotalHighRiskPerms = $riskyApps.Count RiskyApps = @($riskyApps) } } # ── EIDAPP-003: Apps with Credentials ──────────────────────────────────── function Test-InfiltrationEIDAPP003 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $apps = $AuditData.Applications.AppRegistrations if (-not $apps -or $apps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No application registrations available' } $appsWithPasswords = @($apps | Where-Object { $_.passwordCredentials -and $_.passwordCredentials.Count -gt 0 }) $appsWithCerts = @($apps | Where-Object { $_.keyCredentials -and $_.keyCredentials.Count -gt 0 }) $appsWithAnyCredential = @($apps | Where-Object { ($_.passwordCredentials -and $_.passwordCredentials.Count -gt 0) -or ($_.keyCredentials -and $_.keyCredentials.Count -gt 0) }) $totalCredentials = 0 foreach ($app in $appsWithAnyCredential) { $totalCredentials += @($app.passwordCredentials).Count + @($app.keyCredentials).Count } $status = if ($appsWithAnyCredential.Count -eq 0) { 'PASS' } elseif ($appsWithAnyCredential.Count -le 10) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($appsWithAnyCredential.Count) apps have credentials ($($appsWithPasswords.Count) with passwords, $($appsWithCerts.Count) with certificates)" ` -Details @{ AppsWithCredentials = $appsWithAnyCredential.Count AppsWithPasswords = $appsWithPasswords.Count AppsWithCerts = $appsWithCerts.Count TotalCredentials = $totalCredentials Apps = @($appsWithAnyCredential | Select-Object -First 50 | ForEach-Object { @{ AppId = $_.appId DisplayName = $_.displayName PasswordCount = @($_.passwordCredentials).Count CertCount = @($_.keyCredentials).Count } }) } } # ── EIDAPP-004: First-Party Service Principals with Credentials ───────── function Test-InfiltrationEIDAPP004 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $sps = $AuditData.Applications.ServicePrincipals if (-not $sps -or $sps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No service principals available' } # Microsoft's tenant ID for first-party apps $microsoftTenantId = 'f8cdef31-a31e-4b4a-93e4-5f571e91255a' $firstPartySPsWithCreds = @($sps | Where-Object { $_.appOwnerOrganizationId -eq $microsoftTenantId -and (($_.passwordCredentials -and $_.passwordCredentials.Count -gt 0) -or ($_.keyCredentials -and $_.keyCredentials.Count -gt 0)) }) if ($firstPartySPsWithCreds.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No first-party Microsoft service principals have custom credentials' ` -Details @{ FirstPartyWithCredsCount = 0 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($firstPartySPsWithCreds.Count) first-party Microsoft service principals have custom credentials — potential backdoor" ` -Details @{ FirstPartyWithCredsCount = $firstPartySPsWithCreds.Count ServicePrincipals = @($firstPartySPsWithCreds | ForEach-Object { @{ Id = $_.id AppId = $_.appId DisplayName = $_.displayName PasswordCount = @($_.passwordCredentials).Count CertCount = @($_.keyCredentials).Count } }) } } # ── EIDAPP-005: High-Privilege Service Principals with Credentials ────── function Test-InfiltrationEIDAPP005 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $apps = $AuditData.Applications.AppRegistrations $sps = $AuditData.Applications.ServicePrincipals if (-not $apps -or -not $sps) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Application or service principal data not available' } # Dangerous permission IDs (Application-level roles) $dangerousPermissionIds = @( '1bfefb4e-e0b5-418b-a88f-73c46d2cc8e9' # Application.ReadWrite.All '9e3f62cf-ca93-4989-b6ce-bf83c28f9fe8' # RoleManagement.ReadWrite.Directory '19dbc75e-c2e2-444c-a770-ec596d67b7e4' # Directory.ReadWrite.All '06b708a9-e830-4db3-a914-8e69da51d44f' # AppRoleAssignment.ReadWrite.All ) $graphResourceId = '00000003-0000-0000-c000-000000000000' # Build set of appIds with high-priv permissions $highPrivAppIds = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase) foreach ($app in $apps) { if (-not $app.requiredResourceAccess) { continue } foreach ($resource in $app.requiredResourceAccess) { if ($resource.resourceAppId -ne $graphResourceId) { continue } foreach ($perm in @($resource.resourceAccess)) { if ($perm.type -eq 'Role' -and $perm.id -in $dangerousPermissionIds) { [void]$highPrivAppIds.Add($app.appId) } } } } # Find SPs that match those appIds AND have credentials $highPrivSPsWithCreds = @($sps | Where-Object { $highPrivAppIds.Contains($_.appId) -and (($_.passwordCredentials -and $_.passwordCredentials.Count -gt 0) -or ($_.keyCredentials -and $_.keyCredentials.Count -gt 0)) }) if ($highPrivSPsWithCreds.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No high-privilege service principals have credentials' ` -Details @{ HighPrivSPsWithCredsCount = 0 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($highPrivSPsWithCreds.Count) high-privilege service principals have credentials attached" ` -Details @{ HighPrivSPsWithCredsCount = $highPrivSPsWithCreds.Count ServicePrincipals = @($highPrivSPsWithCreds | ForEach-Object { @{ Id = $_.id AppId = $_.appId DisplayName = $_.displayName PasswordCount = @($_.passwordCredentials).Count CertCount = @($_.keyCredentials).Count } }) } } # ── EIDAPP-006: Excessive Graph Permissions ────────────────────────────── function Test-InfiltrationEIDAPP006 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $consentGrants = $AuditData.Applications.ConsentGrants if (-not $consentGrants -or $consentGrants.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No consent grants available for analysis' } # Broad scope patterns indicating excessive permissions $broadScopePatterns = @('.ReadWrite.All', '.FullControl', 'Directory.ReadWrite', 'Sites.FullControl', 'Mail.ReadWrite', 'Files.ReadWrite.All', 'User.ReadWrite.All', 'Group.ReadWrite.All') $excessiveGrants = [System.Collections.Generic.List[hashtable]]::new() foreach ($grant in $consentGrants) { if (-not $grant.scope) { continue } $scopes = $grant.scope -split ' ' $broadScopes = @($scopes | Where-Object { $scope = $_ ($broadScopePatterns | Where-Object { $scope -like "*$_*" }).Count -gt 0 }) if ($broadScopes.Count -gt 0) { $excessiveGrants.Add(@{ ClientId = $grant.clientId ConsentType = $grant.consentType ResourceId = $grant.resourceId BroadScopes = @($broadScopes) AllScopes = @($scopes) }) } } if ($excessiveGrants.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No consent grants with excessive broad permissions detected' } $status = if ($excessiveGrants.Count -le 5) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($excessiveGrants.Count) consent grants with excessive broad permissions" ` -Details @{ ExcessiveGrantCount = $excessiveGrants.Count Grants = @($excessiveGrants | Select-Object -First 50) } } # ── EIDAPP-007: Apps with Azure IAM Roles ──────────────────────────────── function Test-InfiltrationEIDAPP007 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # Azure IAM role assignments require ARM data which is not part of the Entra data collection return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Azure IAM role assignment analysis requires ARM data collection (not available in Entra-only audit)' } # ── EIDAPP-008: Expiring Credentials ───────────────────────────────────── function Test-InfiltrationEIDAPP008 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $apps = $AuditData.Applications.AppRegistrations if (-not $apps -or $apps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No application registrations available' } $now = [datetime]::UtcNow $thirtyDaysFromNow = $now.AddDays(30) $expiringSoon = [System.Collections.Generic.List[hashtable]]::new() $alreadyExpired = [System.Collections.Generic.List[hashtable]]::new() foreach ($app in $apps) { foreach ($cred in @($app.passwordCredentials)) { if (-not $cred.endDateTime) { continue } $endDate = [datetime]::Parse($cred.endDateTime) if ($endDate -lt $now) { $alreadyExpired.Add(@{ AppId = $app.appId DisplayName = $app.displayName CredType = 'Password' EndDate = $cred.endDateTime KeyId = $cred.keyId }) } elseif ($endDate -le $thirtyDaysFromNow) { $expiringSoon.Add(@{ AppId = $app.appId DisplayName = $app.displayName CredType = 'Password' EndDate = $cred.endDateTime DaysLeft = [Math]::Ceiling(($endDate - $now).TotalDays) KeyId = $cred.keyId }) } } foreach ($cred in @($app.keyCredentials)) { if (-not $cred.endDateTime) { continue } $endDate = [datetime]::Parse($cred.endDateTime) if ($endDate -lt $now) { $alreadyExpired.Add(@{ AppId = $app.appId DisplayName = $app.displayName CredType = 'Certificate' EndDate = $cred.endDateTime KeyId = $cred.keyId }) } elseif ($endDate -le $thirtyDaysFromNow) { $expiringSoon.Add(@{ AppId = $app.appId DisplayName = $app.displayName CredType = 'Certificate' EndDate = $cred.endDateTime DaysLeft = [Math]::Ceiling(($endDate - $now).TotalDays) KeyId = $cred.keyId }) } } } if ($expiringSoon.Count -eq 0 -and $alreadyExpired.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No credentials expiring within 30 days or already expired' } $status = if ($alreadyExpired.Count -gt 0) { 'FAIL' } elseif ($expiringSoon.Count -gt 0) { 'WARN' } else { 'PASS' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($expiringSoon.Count) credentials expiring within 30 days, $($alreadyExpired.Count) already expired" ` -Details @{ ExpiringSoonCount = $expiringSoon.Count AlreadyExpiredCount = $alreadyExpired.Count ExpiringSoon = @($expiringSoon | Select-Object -First 50) AlreadyExpired = @($alreadyExpired | Select-Object -First 50) } } # ── EIDAPP-009: Stale Applications ─────────────────────────────────────── function Test-InfiltrationEIDAPP009 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $sps = $AuditData.Applications.ServicePrincipals if (-not $sps -or $sps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No service principals available for sign-in activity analysis' } # Check if any SP has signInActivity data (requires premium licensing) $spsWithActivity = @($sps | Where-Object { $_.signInActivity }) if ($spsWithActivity.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Service principal sign-in activity data not available (requires Entra ID P1/P2)' } $now = [datetime]::UtcNow $ninetyDaysAgo = $now.AddDays(-90) $staleApps = @($spsWithActivity | Where-Object { $lastSignIn = $_.signInActivity.lastSignInDateTime if ($lastSignIn) { [datetime]::Parse($lastSignIn) -lt $ninetyDaysAgo } else { $true } }) $status = if ($staleApps.Count -eq 0) { 'PASS' } elseif ($staleApps.Count -le 10) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($staleApps.Count) service principals have not signed in for 90+ days" ` -Details @{ StaleAppCount = $staleApps.Count TotalAnalyzed = $spsWithActivity.Count StaleApps = @($staleApps | Select-Object -First 50 | ForEach-Object { @{ Id = $_.id AppId = $_.appId DisplayName = $_.displayName LastSignIn = $_.signInActivity.lastSignInDateTime } }) } } # ── EIDAPP-010: Multi-Tenant Application Analysis ─────────────────────── function Test-InfiltrationEIDAPP010 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $apps = $AuditData.Applications.AppRegistrations if (-not $apps -or $apps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No application registrations available' } $multiTenantApps = @($apps | Where-Object { $_.signInAudience -eq 'AzureADMultipleOrgs' -or $_.signInAudience -eq 'AzureADandPersonalMicrosoftAccount' -or $_.signInAudience -eq 'PersonalMicrosoftAccount' }) $singleTenantApps = @($apps | Where-Object { $_.signInAudience -eq 'AzureADMyOrg' }) if ($multiTenantApps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "All $($apps.Count) app registrations are single-tenant" ` -Details @{ MultiTenantCount = 0; SingleTenantCount = $singleTenantApps.Count } } $status = if ($multiTenantApps.Count -le 3) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($multiTenantApps.Count) multi-tenant app registrations found — review for necessity" ` -Details @{ MultiTenantCount = $multiTenantApps.Count SingleTenantCount = $singleTenantApps.Count MultiTenantApps = @($multiTenantApps | ForEach-Object { @{ AppId = $_.appId DisplayName = $_.displayName SignInAudience = $_.signInAudience CreatedDateTime = $_.createdDateTime } }) } } # ── EIDAPP-011: Consent Grant Analysis ─────────────────────────────────── function Test-InfiltrationEIDAPP011 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $consentGrants = $AuditData.Applications.ConsentGrants if (-not $consentGrants -or $consentGrants.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No OAuth2 consent grants found' ` -Details @{ TotalGrants = 0 } } $adminConsent = @($consentGrants | Where-Object { $_.consentType -eq 'AllPrincipals' }) $userConsent = @($consentGrants | Where-Object { $_.consentType -eq 'Principal' }) # User consent grants are higher risk as they may indicate consent phishing $status = if ($userConsent.Count -eq 0) { 'PASS' } elseif ($userConsent.Count -le 20) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($consentGrants.Count) consent grants: $($adminConsent.Count) admin consent, $($userConsent.Count) user consent" ` -Details @{ TotalGrants = $consentGrants.Count AdminConsent = $adminConsent.Count UserConsent = $userConsent.Count AdminGrants = @($adminConsent | Select-Object -First 30 | ForEach-Object { @{ ClientId = $_.clientId; ResourceId = $_.resourceId; Scope = $_.scope } }) UserGrants = @($userConsent | Select-Object -First 30 | ForEach-Object { @{ ClientId = $_.clientId; PrincipalId = $_.principalId; ResourceId = $_.resourceId; Scope = $_.scope } }) } } # ── EIDAPP-012: User Consent Settings ──────────────────────────────────── function Test-InfiltrationEIDAPP012 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # Check AuthorizationPolicy for user consent configuration $authzPolicy = $AuditData.TenantConfig.AuthorizationPolicy if (-not $authzPolicy) { $authzPolicy = $AuditData.AuthMethods.AuthorizationPolicy } if (-not $authzPolicy) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Authorization policy not available to check user consent settings' } $permissionGrantPolicies = $authzPolicy.permissionGrantPolicyIdsAssignedToDefaultUserRole $allowUserConsent = $permissionGrantPolicies -and $permissionGrantPolicies.Count -gt 0 # Check if user consent is unrestricted $hasManagePermissionGrants = $permissionGrantPolicies -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-legacy' $hasLowRiskOnly = $permissionGrantPolicies -contains 'ManagePermissionGrantsForSelf.microsoft-user-default-low' $status = if (-not $allowUserConsent) { 'PASS' } elseif ($hasLowRiskOnly -and -not $hasManagePermissionGrants) { 'WARN' } else { 'FAIL' } $currentValue = if (-not $allowUserConsent) { 'User consent is disabled — users cannot consent to apps' } elseif ($hasLowRiskOnly) { 'User consent limited to low-risk permissions from verified publishers' } else { 'User consent is enabled — users can consent to apps without admin approval' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue $currentValue ` -Details @{ AllowUserConsent = $allowUserConsent PermissionGrantPolicies = @($permissionGrantPolicies ?? @()) HasLegacyUserConsent = $hasManagePermissionGrants HasLowRiskOnly = $hasLowRiskOnly } } # ── EIDAPP-013: Admin Consent Workflow ─────────────────────────────────── function Test-InfiltrationEIDAPP013 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $adminConsentPolicy = $AuditData.TenantConfig.AdminConsentRequestPolicy if (-not $adminConsentPolicy) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'WARN' ` -CurrentValue 'Admin consent request policy not available' ` -Details @{ PolicyAvailable = $false } } $isEnabled = $adminConsentPolicy.isEnabled if ($isEnabled) { $reviewers = $adminConsentPolicy.reviewers $reviewerCount = if ($reviewers) { $reviewers.Count } else { 0 } $status = if ($reviewerCount -gt 0) { 'PASS' } else { 'WARN' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "Admin consent workflow enabled with $reviewerCount reviewer(s)" ` -Details @{ IsEnabled = $true ReviewerCount = $reviewerCount Reviewers = @($reviewers | ForEach-Object { @{ Query = $_.query; QueryType = $_.queryType; QueryRoot = $_.queryRoot } }) RequestExpiresInDays = $adminConsentPolicy.requestExpiresInDays NotificationsEnabled = $adminConsentPolicy.notificationsEnabled RemindersEnabled = $adminConsentPolicy.remindersEnabled } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue 'Admin consent workflow is not enabled — users cannot request admin consent for blocked apps' ` -Details @{ IsEnabled = $false } } # ── EIDAPP-014: Application Impersonation Permissions ──────────────────── function Test-InfiltrationEIDAPP014 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # Full EWS/EXO impersonation analysis requires Exchange Online data # Check for known impersonation permission IDs in app registrations $apps = $AuditData.Applications.AppRegistrations if (-not $apps -or $apps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Application impersonation check requires Exchange Online data for complete analysis' } # Exchange Online (Office 365 Exchange Online) resource app ID $exchangeResourceId = '00000002-0000-0ff1-ce00-000000000000' # full_access_as_app permission ID $fullAccessAsApp = 'dc890d15-9560-4a4c-9b7f-a736ec74ec40' $impersonationApps = [System.Collections.Generic.List[hashtable]]::new() foreach ($app in $apps) { if (-not $app.requiredResourceAccess) { continue } foreach ($resource in $app.requiredResourceAccess) { if ($resource.resourceAppId -ne $exchangeResourceId) { continue } foreach ($perm in @($resource.resourceAccess)) { if ($perm.type -eq 'Role' -and $perm.id -eq $fullAccessAsApp) { $impersonationApps.Add(@{ AppId = $app.appId DisplayName = $app.displayName }) } } } } if ($impersonationApps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No apps have Exchange full_access_as_app impersonation permission' ` -Details @{ ImpersonationAppCount = 0 } } return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'FAIL' ` -CurrentValue "$($impersonationApps.Count) apps have Exchange full_access_as_app impersonation permission" ` -Details @{ ImpersonationAppCount = $impersonationApps.Count Apps = @($impersonationApps) } } # ── EIDAPP-015: OAuth2 Permission Grants Detail ───────────────────────── function Test-InfiltrationEIDAPP015 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $consentGrants = $AuditData.Applications.ConsentGrants if (-not $consentGrants -or $consentGrants.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No OAuth2 permission grants to analyze' } # Analyze scope distributions $allScopes = [System.Collections.Generic.Dictionary[string, int]]::new([StringComparer]::OrdinalIgnoreCase) $readWriteScopes = [System.Collections.Generic.List[string]]::new() foreach ($grant in $consentGrants) { if (-not $grant.scope) { continue } foreach ($scope in ($grant.scope -split ' ')) { $scope = $scope.Trim() if (-not $scope) { continue } if ($allScopes.ContainsKey($scope)) { $allScopes[$scope]++ } else { $allScopes[$scope] = 1 } if ($scope -match '\.ReadWrite' -or $scope -match '\.FullControl') { if (-not $readWriteScopes.Contains($scope)) { $readWriteScopes.Add($scope) } } } } $topScopes = @($allScopes.GetEnumerator() | Sort-Object -Property Value -Descending | Select-Object -First 20 | ForEach-Object { @{ Scope = $_.Key; Count = $_.Value } }) $status = if ($readWriteScopes.Count -eq 0) { 'PASS' } elseif ($readWriteScopes.Count -le 10) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($allScopes.Count) unique scopes across $($consentGrants.Count) grants ($($readWriteScopes.Count) read-write scopes)" ` -Details @{ TotalGrants = $consentGrants.Count UniqueScopes = $allScopes.Count ReadWriteScopeCount = $readWriteScopes.Count ReadWriteScopes = @($readWriteScopes) TopScopes = $topScopes } } # ── EIDAPP-016: Managed Identity Inventory ─────────────────────────────── function Test-InfiltrationEIDAPP016 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $sps = $AuditData.Applications.ServicePrincipals if (-not $sps -or $sps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No service principals available' } $managedIdentities = @($sps | Where-Object { $_.servicePrincipalType -eq 'ManagedIdentity' }) $systemAssigned = @($managedIdentities | Where-Object { $_.displayName -match '^[a-f0-9]{8}-' -or $_.tags -contains 'WindowsAzureActiveDirectoryIntegratedApp' }) $userAssigned = @($managedIdentities | Where-Object { $_.displayName -notmatch '^[a-f0-9]{8}-' }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "$($managedIdentities.Count) managed identities found" ` -Details @{ TotalManagedIdentities = $managedIdentities.Count ManagedIdentities = @($managedIdentities | Select-Object -First 100 | ForEach-Object { @{ Id = $_.id AppId = $_.appId DisplayName = $_.displayName AccountEnabled = $_.accountEnabled } }) } } # ── EIDAPP-017: Service Principal Sign-In Activity ────────────────────── function Test-InfiltrationEIDAPP017 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $sps = $AuditData.Applications.ServicePrincipals if (-not $sps -or $sps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No service principals available' } # Check if sign-in activity data is present on any SP $spsWithActivity = @($sps | Where-Object { $_.signInActivity }) if ($spsWithActivity.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Service principal sign-in activity data not available (may require Entra ID P1/P2 or additional data collection)' } $now = [datetime]::UtcNow $active = @($spsWithActivity | Where-Object { $_.signInActivity.lastSignInDateTime -and ([datetime]::Parse($_.signInActivity.lastSignInDateTime)) -ge $now.AddDays(-30) }) $inactive = @($spsWithActivity | Where-Object { -not $_.signInActivity.lastSignInDateTime -or ([datetime]::Parse($_.signInActivity.lastSignInDateTime)) -lt $now.AddDays(-30) }) return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue "$($active.Count) active (30d), $($inactive.Count) inactive out of $($spsWithActivity.Count) SPs with activity data" ` -Details @{ ActiveCount = $active.Count InactiveCount = $inactive.Count TotalAnalyzed = $spsWithActivity.Count } } # ── EIDAPP-018: Application Change Tracking ────────────────────────────── function Test-InfiltrationEIDAPP018 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) # Application change tracking requires audit log data return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'Application change tracking requires audit log data collection (not available in current data set)' } # ── EIDAPP-019: Dangling Reply URLs ────────────────────────────────────── function Test-InfiltrationEIDAPP019 { [CmdletBinding()] param([hashtable]$AuditData, [hashtable]$CheckDefinition) $apps = $AuditData.Applications.AppRegistrations if (-not $apps -or $apps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'SKIP' ` -CurrentValue 'No application registrations available' } # Suspicious patterns in redirect URIs: localhost, non-HTTPS, IP addresses, wildcards $suspiciousPatterns = @( 'http://' 'localhost' '127.0.0.1' '0.0.0.0' 'urn:ietf:wg:oauth:2.0:oob' ) $danglingApps = [System.Collections.Generic.List[hashtable]]::new() foreach ($app in $apps) { $redirectUris = @() if ($app.web -and $app.web.redirectUris) { $redirectUris += @($app.web.redirectUris) } if ($app.spa -and $app.spa.redirectUris) { $redirectUris += @($app.spa.redirectUris) } if ($app.publicClient -and $app.publicClient.redirectUris) { $redirectUris += @($app.publicClient.redirectUris) } if ($redirectUris.Count -eq 0) { continue } $suspiciousUris = @($redirectUris | Where-Object { $uri = $_ if (-not $uri) { return $false } ($suspiciousPatterns | Where-Object { $uri -match [regex]::Escape($_) }).Count -gt 0 }) if ($suspiciousUris.Count -gt 0) { $danglingApps.Add(@{ AppId = $app.appId DisplayName = $app.displayName SuspiciousUris = @($suspiciousUris) TotalUris = $redirectUris.Count }) } } if ($danglingApps.Count -eq 0) { return New-AuditFinding -CheckDefinition $CheckDefinition -Status 'PASS' ` -CurrentValue 'No applications with suspicious or dangling reply URLs detected' } $status = if ($danglingApps.Count -le 5) { 'WARN' } else { 'FAIL' } return New-AuditFinding -CheckDefinition $CheckDefinition -Status $status ` -CurrentValue "$($danglingApps.Count) apps with suspicious reply URLs (localhost, HTTP, IP addresses)" ` -Details @{ DanglingAppCount = $danglingApps.Count Apps = @($danglingApps | Select-Object -First 50) } } |