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. .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. .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 Requires: Microsoft.Graph PowerShell module 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 ) 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'" } # 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.Generic.List[PSCustomObject]]::new() $inactiveThreshold = (Get-Date).AddDays(-$DaysInactive) } process { Write-Verbose "Retrieving delegated permission grants (user consents)..." try { # Get all OAuth2PermissionGrants (user consents) $grants = Get-MgOauth2PermissionGrant -All -ErrorAction Stop } catch { throw "Failed to retrieve permission grants: $_" } Write-Verbose "Found $($grants.Count) permission grants" # Group by ClientId (application) $grantsByApp = $grants | Group-Object -Property ClientId foreach ($appGrants in $grantsByApp) { $clientId = $appGrants.Name # Get service principal details try { $sp = Get-MgServicePrincipal -ServicePrincipalId $clientId -ErrorAction Stop } catch { Write-Verbose "Skipping app $clientId - not found" continue } # Filter Microsoft apps if requested if (-not $IncludeMicrosoftApps -and $sp.AppOwnerOrganizationId -eq '72f988bf-86f1-41af-91ab-2d7cd011db47') { Write-Verbose "Skipping Microsoft app: $($sp.DisplayName)" continue } # Count user consents (PrincipalId not null = user consent) $userConsents = $appGrants.Group | Where-Object { $_.PrincipalId } $userConsentCount = ($userConsents | Measure-Object).Count if ($userConsentCount -eq 0) { continue # Skip app-only permissions } # Get consenting users $consentingUsers = @() foreach ($consent in $userConsents) { if ($consent.PrincipalId) { try { $user = Get-MgUser -UserId $consent.PrincipalId -Property DisplayName, UserPrincipalName -ErrorAction SilentlyContinue if ($user) { $consentingUsers += $user.UserPrincipalName } } catch { $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) $lastSignIn = $null $usageStatus = 'Unknown' try { # Query sign-ins for this app (limited to last 30 days due to API limits) $signIns = Get-MgAuditLogSignIn -Filter "appId eq '$($sp.AppId)'" -Top 1 -OrderBy "createdDateTime DESC" -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 }) } } end { Write-Verbose "Found $($results.Count) user-consented applications" # Summary $critical = ($results | Where-Object { $_.RiskLevel -eq 'CRITICAL' }).Count $high = ($results | Where-Object { $_.RiskLevel -eq 'HIGH' }).Count $dormant = ($results | Where-Object { $_.UsageStatus -eq 'Dormant' }).Count Write-Host "`n=== User-Consented Applications (Shadow IT) ===" -ForegroundColor Yellow Write-Host "Total user-consented apps: $($results.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 "================================================`n" -ForegroundColor Yellow # Export if requested if ($ExportPath) { try { $results | Export-Csv -Path $ExportPath -NoTypeInformation -Encoding UTF8 Write-Host "Results exported to: $ExportPath" -ForegroundColor Green } catch { Write-Error "Failed to export results: $_" } } return $results } } Export-ModuleMember -Function Get-UserConsentedApplications -ErrorAction SilentlyContinue |