Private/Apps.ps1
|
function Get-M365SnapshotApps { param( [Parameter(Mandatory=$true)] [hashtable]$GraphHeaders, [Parameter(Mandatory=$true)] [hashtable]$SignInHeaders, [Parameter(Mandatory=$true)] [object]$Token, [Parameter(Mandatory=$true)] [int]$EffectiveMaxAppRegistrations, [Parameter(Mandatory=$true)] [switch]$LoadAllAppRegistrations, [Parameter(Mandatory=$true)] [int]$MaxAppRegistrations, [Parameter(Mandatory=$true)] [switch]$ReturnObjects ) $appRegistrations = @() $appsWithSecretRisk = @() try { $uri = "https://graph.microsoft.com/v1.0/applications?`$select=id,appId,displayName,signInAudience,createdDateTime,passwordCredentials,requiredResourceAccess&`$top=999" $allApps = @() do { $response = Invoke-RestMethod -Uri $uri ` -Headers $GraphHeaders ` -Method Get ` -ErrorAction Stop if ($response.value) { $remaining = $EffectiveMaxAppRegistrations - $allApps.Count if ($remaining -gt 0) { $appsPage = @($response.value) if ($appsPage.Count -gt $remaining) { $allApps += ($appsPage | Select-Object -First $remaining) } else { $allApps += $appsPage } } } if ($allApps.Count -ge $EffectiveMaxAppRegistrations) { $uri = $null } else { $uri = $response.'@odata.nextLink' } } while ($uri) $resourceAppIds = @($allApps | ForEach-Object { $_.requiredResourceAccess } | ForEach-Object { $_.resourceAppId } | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Sort-Object -Unique) $resourceMetadataByAppId = @{} foreach ($resourceAppId in $resourceAppIds) { try { $servicePrincipalUri = "https://graph.microsoft.com/v1.0/servicePrincipals?`$filter=appId eq '$resourceAppId'&`$select=appId,displayName,appRoles,oauth2PermissionScopes" $resourceResponse = Invoke-RestMethod -Uri $servicePrincipalUri ` -Headers $GraphHeaders ` -Method Get ` -ErrorAction Stop if ($resourceResponse.value -and $resourceResponse.value.Count -gt 0) { $resourceSp = $resourceResponse.value | Select-Object -First 1 $scopeById = @{} foreach ($scope in @($resourceSp.oauth2PermissionScopes)) { if ($scope.Id) { $scopeById[$scope.Id.ToString().ToLower()] = if ([string]::IsNullOrWhiteSpace($scope.Value)) { $scope.Id } else { $scope.Value } } } $appRoleById = @{} foreach ($appRole in @($resourceSp.appRoles)) { if ($appRole.Id) { $appRoleById[$appRole.Id.ToString().ToLower()] = if ([string]::IsNullOrWhiteSpace($appRole.Value)) { $appRole.Id } else { $appRole.Value } } } $resourceMetadataByAppId[$resourceAppId] = [PSCustomObject]@{ Name = if ([string]::IsNullOrWhiteSpace($resourceSp.displayName)) { $resourceAppId } else { $resourceSp.displayName } ScopeById = $scopeById AppRoleById = $appRoleById } } else { $resourceMetadataByAppId[$resourceAppId] = [PSCustomObject]@{ Name = $resourceAppId ScopeById = @{} AppRoleById = @{} } } } catch { $resourceMetadataByAppId[$resourceAppId] = [PSCustomObject]@{ Name = $resourceAppId ScopeById = @{} AppRoleById = @{} } } } $lastUsageByAppId = @{} $lastUsageSourceByAppId = @{} try { $activityUri = "https://graph.microsoft.com/beta/reports/servicePrincipalSignInActivities?`$top=999" do { $activityResponse = Invoke-RestMethod -Uri $activityUri ` -Headers $GraphHeaders ` -Method Get ` -ErrorAction Stop foreach ($activity in @($activityResponse.value)) { $activityAppId = [string]$activity.appId if ([string]::IsNullOrWhiteSpace($activityAppId)) { continue } $lastDate = $null if (-not [string]::IsNullOrWhiteSpace([string]$activity.lastSignInDateTime)) { $lastDate = [DateTime]$activity.lastSignInDateTime } elseif (-not [string]::IsNullOrWhiteSpace([string]$activity.lastNonInteractiveSignInDateTime)) { $lastDate = [DateTime]$activity.lastNonInteractiveSignInDateTime } if ($null -ne $lastDate) { $appIdKey = $activityAppId.ToLower() if (-not $lastUsageByAppId.ContainsKey($appIdKey) -or $lastDate -gt $lastUsageByAppId[$appIdKey]) { $lastUsageByAppId[$appIdKey] = $lastDate $lastUsageSourceByAppId[$appIdKey] = "ServicePrincipalActivity" } } } $activityUri = $activityResponse.'@odata.nextLink' } while ($activityUri) } catch { if (($_.Exception.Message -match '403') -or ($_.Exception.Message -match 'Forbidden')) { } elseif (-not $ReturnObjects) { Write-Host "[INFO] App last-usage data unavailable (requires additional Graph access such as AuditLog.Read.All): $($_.Exception.Message)" -ForegroundColor DarkGray } } $appUsagePermissionDeniedNoticeShown = $false $auditSignInLatestByAppId = @{} try { $targetAppIds = @{} foreach ($targetApp in $allApps) { if (-not [string]::IsNullOrWhiteSpace([string]$targetApp.appId)) { $targetAppIds[[string]$targetApp.appId.ToLower()] = $true } } $signInUri = "https://graph.microsoft.com/v1.0/auditLogs/signIns?`$select=appId,createdDateTime&`$filter=appId ne null&`$orderby=createdDateTime desc&`$top=1000" $signInPageCount = 0 do { $signInPageCount++ if (-not $ReturnObjects) { Write-Host " Loading audit sign-in page $signInPageCount..." -ForegroundColor DarkGray } $signInResponse = Invoke-RestMethod -Uri $signInUri ` -Headers $SignInHeaders ` -Method Get ` -ErrorAction Stop foreach ($signIn in @($signInResponse.value)) { $signInAppId = [string]$signIn.appId if ([string]::IsNullOrWhiteSpace($signInAppId)) { continue } $signInAppIdKey = $signInAppId.ToLower() if (-not $targetAppIds.ContainsKey($signInAppIdKey)) { continue } if ($auditSignInLatestByAppId.ContainsKey($signInAppIdKey)) { continue } if (-not [string]::IsNullOrWhiteSpace([string]$signIn.createdDateTime)) { $auditSignInLatestByAppId[$signInAppIdKey] = ([DateTime]$signIn.createdDateTime).ToUniversalTime() } } $signInUri = $signInResponse.'@odata.nextLink' } while ($signInUri -and $signInPageCount -lt 10) } catch { if (($_.Exception.Message -match '403') -or ($_.Exception.Message -match 'Forbidden')) { if (-not $ReturnObjects -and -not $appUsagePermissionDeniedNoticeShown) { Write-Host "[INFO] App last-usage enrichment unavailable (AuditLog.Read.All may be missing or not consented). LastUsageDate will be reported as '(unknown)'." -ForegroundColor DarkGray $appUsagePermissionDeniedNoticeShown = $true } } elseif (-not $ReturnObjects) { Write-Host "[INFO] Audit sign-in usage enrichment unavailable: $($_.Exception.Message)" -ForegroundColor DarkGray } } $nowUtc = (Get-Date).ToUniversalTime() $thresholdUtc = $nowUtc.AddDays(30) $totalAppsToProcess = $allApps.Count $currentAppIndex = 0 foreach ($app in $allApps) { $currentAppIndex++ $permissionEntries = @() foreach ($resourceAccessSet in @($app.requiredResourceAccess)) { if ($null -eq $resourceAccessSet) { continue } $resourceAppId = [string]$resourceAccessSet.resourceAppId if ([string]::IsNullOrWhiteSpace($resourceAppId)) { continue } $resourceMeta = $resourceMetadataByAppId[$resourceAppId] if ($null -eq $resourceMeta) { $resourceMeta = [PSCustomObject]@{ Name = $resourceAppId; ScopeById = @{}; AppRoleById = @{} } } foreach ($permission in @($resourceAccessSet.resourceAccess)) { $permissionId = [string]$permission.id $permissionType = [string]$permission.type $permissionName = $permissionId $permissionIdKey = $permissionId.ToLower() if ($permissionType -eq 'Scope' -and $resourceMeta.ScopeById.ContainsKey($permissionIdKey)) { $permissionName = $resourceMeta.ScopeById[$permissionIdKey] } elseif ($permissionType -eq 'Role' -and $resourceMeta.AppRoleById.ContainsKey($permissionIdKey)) { $permissionName = $resourceMeta.AppRoleById[$permissionIdKey] } $permissionEntries += "{0}: {1} [{2}]" -f $resourceMeta.Name, $permissionName, $permissionType } } $permissionEntries = @($permissionEntries | Sort-Object) $secretEndDates = @() foreach ($secret in @($app.passwordCredentials)) { if (-not [string]::IsNullOrWhiteSpace([string]$secret.endDateTime)) { try { $secretEndDates += ([DateTime]$secret.endDateTime).ToUniversalTime() } catch { } } } $expiredSecrets = @($secretEndDates | Where-Object { $_ -lt $nowUtc }) $expiringSoonSecrets = @($secretEndDates | Where-Object { $_ -ge $nowUtc -and $_ -le $thresholdUtc }) $secretStatus = if ($expiredSecrets.Count -gt 0) { "Expired" } elseif ($expiringSoonSecrets.Count -gt 0) { "ExpiringIn30Days" } elseif ($secretEndDates.Count -gt 0) { "Valid" } else { "NoSecrets" } $appIdKey = ([string]$app.appId).ToLower() $lastUsageDate = if ($lastUsageByAppId.ContainsKey($appIdKey)) { $lastUsageByAppId[$appIdKey] } else { $null } $lastUsageSource = if ($lastUsageSourceByAppId.ContainsKey($appIdKey)) { $lastUsageSourceByAppId[$appIdKey] } else { "(not available)" } if (-not $ReturnObjects) { Write-Host " [$currentAppIndex/$totalAppsToProcess] Checking audit log for $($app.displayName)." -ForegroundColor DarkGray } if ($null -eq $lastUsageDate -and -not [string]::IsNullOrWhiteSpace([string]$app.appId)) { if ($auditSignInLatestByAppId.ContainsKey($appIdKey)) { $lastUsageDate = $auditSignInLatestByAppId[$appIdKey] $lastUsageSource = "AuditSignIn" $lastUsageByAppId[$appIdKey] = $lastUsageDate $lastUsageSourceByAppId[$appIdKey] = $lastUsageSource } } $appObject = [PSCustomObject]@{ DisplayName = $app.displayName AppId = $app.appId SignInAudience = $app.signInAudience CreatedDateTime = $app.createdDateTime PermissionCount = $permissionEntries.Count Permissions = ($permissionEntries -join "; ") SecretCount = $secretEndDates.Count SecretExpiryDates = if ($secretEndDates.Count -gt 0) { (($secretEndDates | Sort-Object) | ForEach-Object { $_.ToString("yyyy-MM-dd") }) -join ", " } else { "(none)" } SecretStatus = $secretStatus ExpiredSecretCount = $expiredSecrets.Count ExpiringIn30DaysCount = $expiringSoonSecrets.Count LastUsageDate = if ($null -ne $lastUsageDate) { $lastUsageDate.ToString("yyyy-MM-dd") } else { "(unknown)" } LastUsageDateTime = $lastUsageDate LastUsageSource = $lastUsageSource PrivilegedPermissionCount = 0 HighRiskPermissionCount = 0 SensitivePermissionCount = 0 SensitivePermissions = "(none)" IsSensitive = $false SensitiveReason = "(none)" } $appRegistrations += $appObject if ($secretStatus -eq "Expired" -or $secretStatus -eq "ExpiringIn30Days") { $appsWithSecretRisk += $appObject } } if (-not $ReturnObjects) { Write-Host "[OK] Found $($appRegistrations.Count) app registrations ($($appsWithSecretRisk.Count) with secret expiry risk)`n" -ForegroundColor Green if (-not $LoadAllAppRegistrations -and $appRegistrations.Count -ge $MaxAppRegistrations) { Write-Host "[INFO] App registration collection limited to $MaxAppRegistrations items. Use -LoadAllAppRegistrations to remove this limit.`n" -ForegroundColor DarkGray } } } catch { if (-not $ReturnObjects) { Write-Host "[WARNING] Could not collect app registrations (Application.Read.All may be missing): $($_.Exception.Message)`n" -ForegroundColor Yellow } } return [PSCustomObject]@{ AppRegistrations = @($appRegistrations) AppsWithSecretRisk = @($appsWithSecretRisk) } } |