Public/Get-UserConsentedApplications.ps1
|
<# .SYNOPSIS Identifies "Shadow IT" by auditing user-consented applications. .DESCRIPTION Discovers applications where individual users have granted permissions (user consent), bypassing formal IT approval processes. This is a primary source of "Shadow IT" and security risks. The function analyzes delegated permissions, usage patterns, and flags high-risk applications. v2.2.0: Performance optimizations - parallel processing, batched user lookups, progress tracking. .PARAMETER IncludeMicrosoftApps Include Microsoft first-party applications in the audit. Default is $false. .PARAMETER DaysInactive Number of days without sign-ins to consider an app "dormant". Default is 90. .PARAMETER ExportPath Optional path to export results to CSV. .PARAMETER ThrottleLimit Maximum parallel threads for service principal lookups. Default is 10. .EXAMPLE Get-UserConsentedApplications Returns all user-consented third-party applications with risk assessment. .EXAMPLE Get-UserConsentedApplications -IncludeMicrosoftApps $true Includes Microsoft apps in the audit. .EXAMPLE Get-UserConsentedApplications | Where-Object { $_.HasHighRiskPermissions } Shows only apps with high-risk delegated permissions. .EXAMPLE Get-UserConsentedApplications | Where-Object { $_.UsageStatus -eq 'Dormant' } Finds dormant apps that users consented to but aren't using. .NOTES Author: Kent Agent (kentagent-ai) Created: 2026-03-11 Updated: 2026-03-12 (v2.2.0 performance optimizations) Requires: Microsoft.Graph PowerShell module, PowerShell 7.0+ Permissions: Application.Read.All, Directory.Read.All, AuditLog.Read.All, DelegatedPermissionGrant.Read.All .LINK https://github.com/kentagent-ai/EntraIDSecurityScripts #> function Get-UserConsentedApplications { [CmdletBinding()] param( [Parameter(Mandatory = $false)] [bool]$IncludeMicrosoftApps = $false, [Parameter(Mandatory = $false)] [ValidateRange(1, 365)] [int]$DaysInactive = 90, [Parameter(Mandatory = $false)] [string]$ExportPath, [Parameter(Mandatory = $false)] [ValidateRange(1, 50)] [int]$ThrottleLimit = 10 ) begin { # Verify Graph connection $context = Get-MgContext if (-not $context) { throw "Not connected to Microsoft Graph. Run: Connect-MgGraph -Scopes 'Application.Read.All', 'Directory.Read.All', 'AuditLog.Read.All', 'DelegatedPermissionGrant.Read.All'" } # Verify PowerShell 7+ for parallel processing if ($PSVersionTable.PSVersion.Major -lt 7) { Write-Warning "PowerShell 7+ recommended for best performance. Current version: $($PSVersionTable.PSVersion)" } # High-risk delegated permissions $highRiskPermissions = @( 'Mail.ReadWrite' 'Mail.ReadWrite.All' 'Mail.Send' 'Files.ReadWrite.All' 'Sites.ReadWrite.All' 'User.ReadWrite.All' 'Directory.ReadWrite.All' 'RoleManagement.ReadWrite.Directory' 'AppRoleAssignment.ReadWrite.All' 'GroupMember.ReadWrite.All' 'Application.ReadWrite.All' 'Domain.ReadWrite.All' 'IdentityRiskEvent.ReadWrite.All' ) $results = [System.Collections.Concurrent.ConcurrentBag[PSCustomObject]]::new() $inactiveThreshold = (Get-Date).AddDays(-$DaysInactive) } process { Write-Verbose "Retrieving delegated permission grants (user consents)..." try { # Get all OAuth2PermissionGrants (user consents) - only fetch needed properties $grants = Get-MgOauth2PermissionGrant -All -Property ClientId, PrincipalId, Scope -ErrorAction Stop } catch { throw "Failed to retrieve permission grants: $_" } Write-Verbose "Found $($grants.Count) permission grants" # Group by ClientId (application) and filter to only user consents $grantsByApp = $grants | Where-Object { $_.PrincipalId } | Group-Object -Property ClientId if ($grantsByApp.Count -eq 0) { Write-Warning "No user-consented applications found." return } Write-Host "Processing $($grantsByApp.Count) applications..." -ForegroundColor Cyan # Batch collect all unique user IDs for lookup Write-Verbose "Collecting unique user IDs for batch lookup..." $allUserIds = $grants | Where-Object { $_.PrincipalId } | Select-Object -ExpandProperty PrincipalId -Unique # Batch fetch users - Graph API supports filtering with 'in' operator (max 15 per filter) Write-Verbose "Batch fetching $($allUserIds.Count) users..." $userLookup = @{} $userBatchSize = 15 $userBatchCount = [Math]::Ceiling($allUserIds.Count / $userBatchSize) for ($i = 0; $i -lt $allUserIds.Count; $i += $userBatchSize) { $batchNum = [Math]::Floor($i / $userBatchSize) + 1 Write-Progress -Activity "Fetching user details" -Status "Batch $batchNum of $userBatchCount" -PercentComplete (($i / $allUserIds.Count) * 100) $batch = $allUserIds[$i..([Math]::Min($i + $userBatchSize - 1, $allUserIds.Count - 1))] $filter = "id in ('" + ($batch -join "','") + "')" try { $batchUsers = Get-MgUser -Filter $filter -Property Id, UserPrincipalName -ErrorAction SilentlyContinue foreach ($user in $batchUsers) { $userLookup[$user.Id] = $user.UserPrincipalName } } catch { Write-Verbose "Batch user lookup failed: $_" } } Write-Progress -Activity "Fetching user details" -Completed Write-Verbose "User lookup table built with $($userLookup.Count) entries" # Process apps in parallel (PowerShell 7+) $appIndex = 0 $totalApps = $grantsByApp.Count if ($PSVersionTable.PSVersion.Major -ge 7) { Write-Verbose "Using parallel processing with throttle limit: $ThrottleLimit" $grantsByApp | ForEach-Object -Parallel { $appGrants = $_ $clientId = $appGrants.Name $highRiskPermissions = $using:highRiskPermissions $IncludeMicrosoftApps = $using:IncludeMicrosoftApps $inactiveThreshold = $using:inactiveThreshold $userLookup = $using:userLookup $results = $using:results $appIndex = $using:appIndex $totalApps = $using:totalApps # Thread-safe progress (approximate) $currentIndex = [System.Threading.Interlocked]::Increment([ref]$appIndex) if ($currentIndex % 5 -eq 0) { Write-Progress -Activity "Processing applications" -Status "$currentIndex of $totalApps" -PercentComplete (($currentIndex / $totalApps) * 100) -Id 1 } # Get service principal details - only needed properties try { $sp = Get-MgServicePrincipal -ServicePrincipalId $clientId -Property Id, DisplayName, AppId, PublisherName, Homepage, AppOwnerOrganizationId -ErrorAction Stop } catch { Write-Verbose "Skipping app $clientId - not found" return } # Filter Microsoft apps if requested if (-not $IncludeMicrosoftApps -and $sp.AppOwnerOrganizationId -eq '72f988bf-86f1-41af-91ab-2d7cd011db47') { Write-Verbose "Skipping Microsoft app: $($sp.DisplayName)" return } # Count user consents $userConsents = $appGrants.Group | Where-Object { $_.PrincipalId } $userConsentCount = ($userConsents | Measure-Object).Count if ($userConsentCount -eq 0) { return # Skip app-only permissions } # Get consenting users from lookup table $consentingUsers = @() foreach ($consent in $userConsents) { if ($consent.PrincipalId -and $userLookup.ContainsKey($consent.PrincipalId)) { $consentingUsers += $userLookup[$consent.PrincipalId] } elseif ($consent.PrincipalId) { $consentingUsers += $consent.PrincipalId } } # Collect all delegated permissions $allPermissions = $userConsents.Scope | ForEach-Object { $_ -split ' ' } | Select-Object -Unique | Where-Object { $_ } # Check for high-risk permissions $hasHighRisk = $false $highRiskPerms = @() foreach ($perm in $allPermissions) { if ($perm -in $highRiskPermissions) { $hasHighRisk = $true $highRiskPerms += $perm } } # Get last sign-in (if available) - limit to 1 result $lastSignIn = $null $usageStatus = 'Unknown' try { $signIns = Get-MgAuditLogSignIn -Filter "appId eq '$($sp.AppId)'" -Top 1 -Property CreatedDateTime -ErrorAction SilentlyContinue if ($signIns) { $lastSignIn = $signIns[0].CreatedDateTime if ($lastSignIn -lt $inactiveThreshold) { $usageStatus = 'Dormant' } else { $usageStatus = 'Active' } } else { $usageStatus = 'No Recent Sign-ins' } } catch { Write-Verbose "Could not retrieve sign-ins for $($sp.DisplayName): $_" } # Determine risk level $riskLevel = if ($hasHighRisk -and $usageStatus -eq 'Dormant') { 'CRITICAL' } elseif ($hasHighRisk) { 'HIGH' } elseif ($usageStatus -eq 'Dormant') { 'MEDIUM' } else { 'LOW' } $recommendation = switch ($riskLevel) { 'CRITICAL' { 'High-risk dormant app - Review and revoke consents immediately' } 'HIGH' { 'Active app with high-risk permissions - Verify business justification' } 'MEDIUM' { 'Dormant app - Consider revoking unused consents' } 'LOW' { 'Monitor for unusual activity' } } $results.Add([PSCustomObject]@{ DisplayName = $sp.DisplayName AppId = $sp.AppId ServicePrincipalId = $sp.Id UserConsentsCount = $userConsentCount ConsentingUsers = ($consentingUsers -join '; ') HasHighRiskPermissions = $hasHighRisk HighRiskPermissions = ($highRiskPerms -join ', ') AllDelegatedPermissions = ($allPermissions -join ', ') LastSignInUTC = $lastSignIn UsageStatus = $usageStatus RiskLevel = $riskLevel Recommendation = $recommendation Publisher = $sp.PublisherName Homepage = $sp.Homepage }) } -ThrottleLimit $ThrottleLimit Write-Progress -Activity "Processing applications" -Completed -Id 1 } else { # Fallback to sequential processing for PowerShell 5.1 Write-Verbose "Using sequential processing (PowerShell 7+ recommended for parallel processing)" foreach ($appGrants in $grantsByApp) { $appIndex++ Write-Progress -Activity "Processing applications" -Status "$appIndex of $totalApps" -PercentComplete (($appIndex / $totalApps) * 100) $clientId = $appGrants.Name try { $sp = Get-MgServicePrincipal -ServicePrincipalId $clientId -Property Id, DisplayName, AppId, PublisherName, Homepage, AppOwnerOrganizationId -ErrorAction Stop } catch { Write-Verbose "Skipping app $clientId - not found" continue } if (-not $IncludeMicrosoftApps -and $sp.AppOwnerOrganizationId -eq '72f988bf-86f1-41af-91ab-2d7cd011db47') { Write-Verbose "Skipping Microsoft app: $($sp.DisplayName)" continue } $userConsents = $appGrants.Group | Where-Object { $_.PrincipalId } $userConsentCount = ($userConsents | Measure-Object).Count if ($userConsentCount -eq 0) { continue } $consentingUsers = @() foreach ($consent in $userConsents) { if ($consent.PrincipalId -and $userLookup.ContainsKey($consent.PrincipalId)) { $consentingUsers += $userLookup[$consent.PrincipalId] } elseif ($consent.PrincipalId) { $consentingUsers += $consent.PrincipalId } } $allPermissions = $userConsents.Scope | ForEach-Object { $_ -split ' ' } | Select-Object -Unique | Where-Object { $_ } $hasHighRisk = $false $highRiskPerms = @() foreach ($perm in $allPermissions) { if ($perm -in $highRiskPermissions) { $hasHighRisk = $true $highRiskPerms += $perm } } $lastSignIn = $null $usageStatus = 'Unknown' try { $signIns = Get-MgAuditLogSignIn -Filter "appId eq '$($sp.AppId)'" -Top 1 -Property CreatedDateTime -ErrorAction SilentlyContinue if ($signIns) { $lastSignIn = $signIns[0].CreatedDateTime if ($lastSignIn -lt $inactiveThreshold) { $usageStatus = 'Dormant' } else { $usageStatus = 'Active' } } else { $usageStatus = 'No Recent Sign-ins' } } catch { Write-Verbose "Could not retrieve sign-ins for $($sp.DisplayName): $_" } $riskLevel = if ($hasHighRisk -and $usageStatus -eq 'Dormant') { 'CRITICAL' } elseif ($hasHighRisk) { 'HIGH' } elseif ($usageStatus -eq 'Dormant') { 'MEDIUM' } else { 'LOW' } $recommendation = switch ($riskLevel) { 'CRITICAL' { 'High-risk dormant app - Review and revoke consents immediately' } 'HIGH' { 'Active app with high-risk permissions - Verify business justification' } 'MEDIUM' { 'Dormant app - Consider revoking unused consents' } 'LOW' { 'Monitor for unusual activity' } } $results.Add([PSCustomObject]@{ DisplayName = $sp.DisplayName AppId = $sp.AppId ServicePrincipalId = $sp.Id UserConsentsCount = $userConsentCount ConsentingUsers = ($consentingUsers -join '; ') HasHighRiskPermissions = $hasHighRisk HighRiskPermissions = ($highRiskPerms -join ', ') AllDelegatedPermissions = ($allPermissions -join ', ') LastSignInUTC = $lastSignIn UsageStatus = $usageStatus RiskLevel = $riskLevel Recommendation = $recommendation Publisher = $sp.PublisherName Homepage = $sp.Homepage }) } Write-Progress -Activity "Processing applications" -Completed } } end { # Convert ConcurrentBag to array for output $resultArray = @($results) Write-Verbose "Found $($resultArray.Count) user-consented applications" # Summary $critical = ($resultArray | Where-Object { $_.RiskLevel -eq 'CRITICAL' }).Count $high = ($resultArray | Where-Object { $_.RiskLevel -eq 'HIGH' }).Count $dormant = ($resultArray | Where-Object { $_.UsageStatus -eq 'Dormant' }).Count Write-Host "`n=== User-Consented Applications (Shadow IT) ===" -ForegroundColor Yellow Write-Host "Total user-consented apps: $($resultArray.Count)" -ForegroundColor White Write-Host "CRITICAL risk: $critical" -ForegroundColor $(if ($critical -gt 0) { 'Red' } else { 'Green' }) Write-Host "HIGH risk: $high" -ForegroundColor $(if ($high -gt 0) { 'Red' } else { 'Yellow' }) Write-Host "Dormant apps: $dormant" -ForegroundColor $(if ($dormant -gt 0) { 'Yellow' } else { 'Green' }) Write-Host "================================================" -ForegroundColor Yellow # Export if requested if ($ExportPath) { try { $resultArray | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8 Write-Host "Results exported to: $ExportPath" -ForegroundColor Green } catch { Write-Error "Failed to export results: $_" } } return $resultArray } } Export-ModuleMember -Function Get-UserConsentedApplications -ErrorAction SilentlyContinue |