Invoke-EntraAppReport.ps1
<#PSScriptInfo
.VERSION 0.2.1 .GUID 175aa966-47dc-4a76-bbd1-b7ab11cd3069 .AUTHOR Daniel Bradley .COMPANYNAME ourcloudnetwork.co.uk .COPYRIGHT .TAGS ourcloudnetwork Microsoft Entra Microsoft Graph .LICENSEURI .PROJECTURI https://ourcloudnetwork.com/create-a-free-enterprise-app-permissions-report-in-microsoft-entra/ .ICONURI .EXTERNALMODULEDEPENDENCIES Microsoft.Graph.Authentication .RELEASENOTES v0.1 - Initial release v0.2 - Added app registration owners. #> <# .DESCRIPTION This script, created by Daniel Bradley at ourcloudnetwork.co.uk, generates a report of all applications in your Microsoft Entra tenant, including their permissions, credentials, and sign-in activity. The report includes a summary of the total number of applications, applications with delegated permissions, applications with application permissions, third-party applications, and inactive applications. The report also includes a list of applications with insecure sign-in methods, applications with application permissions, and applications with no sign-in activity. The report is generated in HTML format and can be saved to a file for further analysis. .PARAMETER outpath Specified the output path of the report file. .EXAMPLE PS> Invoke-EntraAppReport -outpath "C:\Reports\EntraAppReport.html" #> [CmdletBinding()] param( [Parameter()] [ValidateNotNullOrEmpty()] [string]$outpath ) # Display script start message Write-Host "Starting Entra Application Permissions Report..." -ForegroundColor Cyan # Check Microsoft Graph connection Write-Host "Checking Microsoft Graph connection status..." -ForegroundColor Yellow $state = Get-MgContext # Define required permissions properly as an array of strings $requiredPerms = @("AuditLog.Read.All","Organization.Read.All","Application.Read.All","Directory.Read.All") # Check if we're connected and have all required permissions $hasAllPerms = $false if ($state) { $missingPerms = @() foreach ($perm in $requiredPerms) { if ($state.Scopes -notcontains $perm) { $missingPerms += $perm } } if ($missingPerms.Count -eq 0) { $hasAllPerms = $true Write-output "Connected to Microsoft Graph with all required permissions" } else { Write-output "Missing required permissions: $($missingPerms -join ', ')" Write-output "Reconnecting with all required permissions..." } } else { Write-output "Not connected to Microsoft Graph. Connecting now..." } # Connect if we need to if (-not $hasAllPerms) { try { Write-Host "Connecting to Microsoft Graph..." -ForegroundColor Yellow Connect-MgGraph -Scopes $requiredPerms -ErrorAction Stop -NoWelcome Write-output "Successfully connected to Microsoft Graph" } catch { Write-Error "Failed to connect to Microsoft Graph: $_" exit } } # Initialize progress counter $progressSteps = 8 $currentStep = 0 #Get organisation information $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Getting organization information" -PercentComplete (($currentStep / $progressSteps) * 100) $organisationInfo = (Invoke-MgGraphRequest -Uri "v1.0/organization" -OutputType PSObject | Select -Expand value) #Get the Graph Service Principal ID $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Getting Graph service principal" -PercentComplete (($currentStep / $progressSteps) * 100) $graphSp = Invoke-MgGraphRequest -Uri "v1.0/servicePrincipals(appId='{00000003-0000-0000-c000-000000000000}')?`$select=id,appRoles" -OutputType PSObject #Get all OAuth2 delegated Graph permissions for all apps $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Getting OAuth2 delegated permissions" -PercentComplete (($currentStep / $progressSteps) * 100) Write-Host "Retrieving OAuth2 delegated permissions..." -ForegroundColor Yellow $uri = "v1.0/oauth2PermissionGrants?`$filter=ConsentType eq 'AllPrincipals' and resourceId eq '$($graphSp.Id)'&`$top=999" $Result = Invoke-MgGraphRequest -Uri $Uri -OutputType PSObject $coreDelegatedPermissions = $Result.value $NextLink = $Result."@odata.nextLink" while ($NextLink -ne $null) { $Result = Invoke-MgGraphRequest -Method GET -Uri $NextLink -OutputType PSObject $coredelegatedPermissions += $Result.value $NextLink = $Result."@odata.nextLink" } $delegatedPermissions = $coreDelegatedPermissions | Select-Object @{Name='ServicePrincipalId';Expression={$_.ClientId}}, @{Name='ScopeType';Expression={"Delegated"}}, @{Name='Scope'; Expression={$_.Scope -split ' ' -ne '' }} #Get all possible available app roles $graphAppRoles = $graphSp | Select-Object -ExpandProperty AppRoles | Select-Object Id, Value | Group-Object -Property Id -AsHashTable #Get all app role assignments for all apps $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Getting application role assignments" -PercentComplete (($currentStep / $progressSteps) * 100) Write-Host "Retrieving application role assignments..." -ForegroundColor Yellow $uri = "/v1.0/servicePrincipals?`$expand=appRoleAssignments&`$select=id,appId,displayName,appOwnerOrganizationId,appId&`$top=999" $Result = Invoke-MgGraphRequest -Uri $Uri -OutputType PSObject $coreRoleAssignments = $Result.value $NextLink = $Result."@odata.nextLink" while ($NextLink -ne $null) { $Result = Invoke-MgGraphRequest -Method GET -Uri $NextLink -OutputType PSObject $coreRoleAssignments += $Result.value $NextLink = $Result."@odata.nextLink" } $spRoleAssignments = $coreRoleAssignments | Where-Object { ($_.AppRoleAssignments | Where-Object { $_.ResourceId -eq $graphSp.Id }).Count -gt 0 } | Select-Object Id, appOwnerOrganizationId, AppId, DisplayName, @{Name="AppRoleId"; Expression={ ($_.AppRoleAssignments).AppRoleId }} #Expand permissions for all app role assignments $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Processing application permissions" -PercentComplete (($currentStep / $progressSteps) * 100) Write-Host "Processing application permissions..." -ForegroundColor Yellow $appPermissions = $spRoleAssignments | ForEach-Object { [PSCustomObject]@{DisplayName = $_.DisplayName; ServicePrincipalId = $_.Id; AppId = $_.appId; appOwnerOrganizationId = $_.appOwnerOrganizationId; ScopeType="Application"; Scope = @($graphAppRoles[$_.AppRoleId].Value)}} #Update delegated permissions with appOwnerOrganizationId and DisplayName Write-Host "Updating delegated permission details..." -ForegroundColor Yellow $delegatedPermissions | ForEach-Object{ $item = $_ $_ | Add-Member -MemberType NoteProperty -Name "appOwnerOrganizationId" -Value ($coreRoleAssignments | Where-Object {$_.Id -eq $item.ServicePrincipalId}).appOwnerOrganizationId $_ | Add-Member -MemberType NoteProperty -Name "DisplayName" -Value ($coreRoleAssignments | Where-Object {$_.Id -eq $item.ServicePrincipalId}).DisplayName $_ | Add-Member -MemberType NoteProperty -Name "appId" -Value ($coreRoleAssignments | Where-Object {$_.Id -eq $item.ServicePrincipalId}).appId } #Combine all permissions $AllfilteredPermissions = ($delegatedPermissions + $appPermissions) | Select-Object DisplayName, ServicePrincipalId, appId, appOwnerOrganizationId, ScopeType, Scope | Sort-Object DisplayName #Get ServicePrincipal Sign-in activity report $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Getting service principal sign-in activity" -PercentComplete (($currentStep / $progressSteps) * 100) Write-Host "Retrieving service principal sign-in activity..." -ForegroundColor Yellow $SignInActivityReport = Invoke-MgGraphRequest -Uri "/beta/reports/servicePrincipalSignInActivities" -OutputType PSObject | Select -Expand Value #Function to get last sign-in activity function GetLastActivityDate { param ( [string]$appId ) $lastsignin = $SignInActivityReport | Where-Object { $_.appId -eq $appId} $obj = [PSCustomObject]@{DelegatedLastSignIn = $lastsignin.delegatedClientSignInActivity.lastSignInDateTime; ApplicationLastSignIn = $lastsignin.applicationAuthenticationClientSignInActivity.lastSignInDateTime} return $obj } # Get Service Principal Audit Logs Function Get-MgSpSignIns { param( $filter ) process { $response = Invoke-MgGraphRequest -uri "https://graph.microsoft.com/beta/auditLogs/signIns?&source=sp&`$filter=$filter" -OutputType PSObject | Select -Expand Value return $response } } $ServicePrincipalSignIns = Get-MgSpSignIns -filter "appOwnerTenantId ne '$($organisationInfo.id)'" | Select createdDateTime, appDisplayName, appId, clientCredentialType, appOwnerTenantId #Add last sign-in activity to the permissions report Write-Host "Adding sign-in activity data to applications..." -ForegroundColor Yellow $AllfilteredPermissions | ForEach-Object { $app = $_ $SignInInfo = GetLastActivityDate -appId $_.appId $LastSigninType = ($ServicePrincipalSignIns | Where-Object { $_.appId -eq $app.appId } | Sort-Object createdDateTime -Descending | Select-Object -First 1).clientCredentialType $_ | Add-Member -MemberType NoteProperty -Name "DelegatedLastSignIn" -Value $SignInInfo.DelegatedLastSignIn $_ | Add-Member -MemberType NoteProperty -Name "ApplicationLastSignIn" -Value $SignInInfo.ApplicationLastSignIn $_ | Add-Member -MemberType NoteProperty -Name "LastSignInType" -Value $LastSigninType } #Get password and key credentials for first-party applications $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Getting application credentials" -PercentComplete (($currentStep / $progressSteps) * 100) Write-Host "Retrieving application credentials..." -ForegroundColor Yellow $credentials = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/applications/?`$select=displayName,id,appId,info,createdDateTime,keyCredentials,passwordCredentials,deletedDateTime&`$count=true" -OutputType PSObject | Select -Expand Value $credentials | ForEach-Object { $ownerdata = Invoke-MgGraphRequest -Uri "https://graph.microsoft.com/v1.0/applications/$($_.id)/owners" -OutputType PSObject | Select -Expand Value $owner = $ownerdata.userPrincipalName $_ | Add-Member -MemberType NoteProperty -Name "Owners" -Value $owner } $credentials[0].id $AllfilteredPermissions | ForEach-Object { $app = $_ $appcredentials = $credentials | Where-Object { $_.appId -eq $app.appId } $credentialsToAdd = @() $KeyCert = $($appcredentials.keyCredentials).count $Password = $($appcredentials.passwordCredentials).count if ($($appcredentials.keyCredentials).count -gt 0) { $credentialsToAdd += "Client Certificate" } if ($($appcredentials.passwordCredentials).count -gt 0) { $credentialsToAdd += "Client Secret" } $_ | Add-Member -MemberType NoteProperty -Name "App Credentials" -Value $credentialsToAdd $_ | Add-Member -MemberType NoteProperty -Name "Owners" -Value $appcredentials.Owners } # Create additional stats $currentStep++ Write-Progress -Activity "Gathering Entra Application Data" -Status "Creating security indicators" -PercentComplete (($currentStep / $progressSteps) * 100) Write-Host "Generating security risk indicators..." -ForegroundColor Yellow # Third-party apps with insecure sign-in methods $WeakSignInMethods = @("clientSecret","clientAssertion","certificate") $WeakAppCredentials = @("Client Secret","Client Certificate") $InsecureThirdPartyApps = $AllfilteredPermissions | Where { ($_.appOwnerOrganizationId -ne $organisationInfo.id) -and ($_.LastSignInType -in $WeakSignInMethods) } | Select DisplayName -Unique # first-party apps with insecure sign-in methods $InsecureFirstPartyApps = $AllfilteredPermissions | Where { ($_.appOwnerOrganizationId -eq $organisationInfo.id) -and ($_.'App Credentials' -in $WeakAppCredentials) } | Select DisplayName -Unique # Third-party apps with application permissions $ThirdPartyAppsWithAppPermissions = $AllfilteredPermissions | Where { ($_.appOwnerOrganizationId -ne $organisationInfo.id) -and ($_.ScopeType -eq "Application") } | Select DisplayName -Unique # Applications with no sign-in activity $NonActiveApps = $AllfilteredPermissions | Where { ($null -eq $_.DelegatedLastSignIn) -and ($null -eq $_.ApplicationLastSignIn) } | Select DisplayName -Unique # First-party apps with no owners $FirstPartyAppsWithNoOwners = $AllfilteredPermissions | Where { ($_.appOwnerOrganizationId -eq $organisationInfo.id) -and ([string]::IsNullOrEmpty($_.Owners) -or $_.Owners.Count -eq 0) } | Select DisplayName -Unique # Generate HTML Report for Entra Applications function Export-EntraAppHTMLReport { param ( [Parameter(Mandatory = $true)] [Array]$ReportData, [Parameter(Mandatory = $false)] [string]$ReportTitle = "Entra Application Permissions Report", [Parameter(Mandatory = $false)] [string]$OutputPath ) # Define CSS for the report $css = @" <style> :root { --primary-color: #2563EB; --secondary-color: #1E40AF; --accent-color: #3B82F6; --background-color: #f8f9fa; --table-header-bg: #f2f2f2; --table-border: #ddd; --table-hover: #f1f1f1; --warning-color: #e74c3c; } body { font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; color: #333; margin: 0; padding: 0; background-color: var(--background-color); } .container { width: 95%; margin: 20px auto; background-color: white; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); border-radius: 8px; overflow: hidden; } .header { background: linear-gradient(135deg, var(--primary-color), var(--secondary-color)); position: relative; padding: 25px 20px; color: white; overflow: hidden; } .header::before { content: ""; position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: radial-gradient(circle at 15% 50%, rgba(59, 130, 246, 0.3) 0%, transparent 50%), radial-gradient(circle at 85% 30%, rgba(37, 99, 235, 0.2) 0%, transparent 50%); } .header h1 { margin: 0; font-size: 28px; font-weight: 600; position: relative; text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1); } .header-subtitle { font-size: 14px; font-weight: 400; margin-top: 0px; margin-bottom: 10px; opacity: 0.9; } .header-content { display: flex; justify-content: space-between; position: relative; } .header-left-content { flex: 1; } .header-details { display: flex; justify-content: space-between; margin-top: 15px; font-size: 14px; position: relative; opacity: 0.9; } .author-info { margin-top: 12px; border-top: 1px solid rgba(255, 255, 255, 0.3); padding-top: 10px; display: flex; align-items: center; font-size: 13px; } .author-label { opacity: 0.8; margin-right: 6px; } .author-links { display: flex; align-items: center; } .author-link { color: white !important; /* Ensure text color is always white */ text-decoration: none !important; /* Remove underline */ display: inline-flex; align-items: center; border: 1px solid rgba(255, 255, 255, 0.5); padding: 4px 10px; border-radius: 4px; margin-right: 10px; transition: all 0.3s ease; background-color: rgba(255, 255, 255, 0.1); cursor: pointer; z-index: 10; /* Ensure links are above other elements */ position: relative; /* Establish stacking context */ } .author-link:hover { background-color: rgba(255, 255, 255, 0.3); border-color: rgba(255, 255, 255, 0.9); transform: translateY(-2px); box-shadow: 0 3px 5px rgba(0, 0, 0, 0.2); } .author-link:active { transform: translateY(0); box-shadow: 0 1px 2px rgba(0, 0, 0, 0.2); } .author-link svg { margin-right: 5px; flex-shrink: 0; } .report-info { text-align: right; font-size: 14px; display: flex; flex-direction: column; justify-content: flex-end; padding-bottom: 10px; } .report-date { font-weight: 500; margin-top: 5px; } .stats-container { display: flex; justify-content: space-between; margin: 20px; flex-wrap: wrap; gap: 15px; } .stat-box { background-color: white; border-radius: 10px; padding: 18px; box-shadow: 0 3px 10px rgba(0, 0, 0, 0.08); flex: 1; min-width: 200px; transition: transform 0.2s ease, box-shadow 0.2s ease; border: none; position: relative; overflow: hidden; } .stat-box::after { content: ''; position: absolute; bottom: 0; left: 0; right: 0; height: 3px; background: linear-gradient(90deg, rgba(37, 99, 235, 0.8), rgba(59, 130, 246, 0.6), rgba(96, 165, 250, 0.4)); border-bottom-left-radius: 10px; border-bottom-right-radius: 10px; } .stat-box:hover { transform: translateY(-3px); box-shadow: 0 5px 15px rgba(0, 0, 0, 0.1); } .stat-box h3 { margin-top: 0; color: #4b5563; font-size: 14px; font-weight: 500; letter-spacing: 0.3px; margin-bottom: 15px; } .stat-box p { margin-bottom: 0; font-size: 26px; font-weight: 600; color: #1f2937; background: linear-gradient(90deg, #2563EB, #4f46e5); -webkit-background-clip: text; -webkit-text-fill-color: transparent; background-clip: text; text-fill-color: transparent; } .controls { padding: 15px 20px; background-color: #fff; border-bottom: 1px solid var(--table-border); display: flex; flex-wrap: wrap; gap: 10px; align-items: center; } .search-box { flex-grow: 1; position: relative; min-width: 250px; } .search-box input { width: 100%; padding: 8px 12px 8px 35px; border: 1px solid var(--table-border); border-radius: 4px; font-size: 14px; box-sizing: border-box; } .search-box::before { content: "🔍"; position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #999; } select, button { padding: 8px 12px; border: 1px solid var(--table-border); border-radius: 4px; background-color: white; font-size: 14px; } button { cursor: pointer; background-color: var(--primary-color); color: white; border: none; transition: background-color 0.2s; } button:hover { background-color: var(--secondary-color); } .table-container { padding: 0 20px 20px; overflow-x: auto; } table { width: 100%; border-collapse: collapse; font-size: 14px; } th { background-color: var(--table-header-bg); color: #333; font-weight: 600; padding: 12px 15px; text-align: left; position: sticky; top: 0; cursor: pointer; user-select: none; border-bottom: 2px solid var(--table-border); } th:hover { background-color: #e6e6e6; } td { padding: 10px 15px; border-bottom: 1px solid var(--table-border); vertical-align: top; } tr:hover { background-color: var(--table-hover); } .permission-cell { max-width: 300px; } .permission-list { margin: 0; padding-left: 20px; word-break: break-word; } .date-cell { white-space: nowrap; } .no-activity { color: #999; font-style: italic; } .third-party { background-color: rgba(231, 76, 60, 0.1); } .third-party td:first-child::before { margin-right: 5px; } .tooltip { position: relative; display: inline-block; cursor: help; } .tooltip .tooltiptext { visibility: hidden; width: 200px; background-color: #333; color: #fff; text-align: center; border-radius: 6px; padding: 5px; position: absolute; z-index: 1; bottom: 125%; left: 50%; transform: translateX(-50%); opacity: 0; transition: opacity 0.3s; } .tooltip:hover .tooltiptext { visibility: visible; opacity: 1; } .footer { margin: 20px; text-align: center; font-size: 12px; color: #666; border-top: 1px solid var(--table-border); padding-top: 20px; } @media print { body { background-color: white; } .container { width: 100%; box-shadow: none; } .controls, button { display: none; } .tooltip .tooltiptext { display: none; } } @media (max-width: 768px) { .stats-container { flex-direction: column; } .stat-box { margin-bottom: 10px; } .header-details { flex-direction: column; } } .sort-icon::after { content: "↕️"; font-size: 12px; margin-left: 5px; } .sort-asc::after { content: "↑"; } .sort-desc::after { content: "↓"; } /* Add new styles for red flags section */ .red-flag-container { background-color: #FEECED; border-left: 4px solid #CD0000; margin: 20px; padding: 15px 20px; border-radius: 5px; box-shadow: 0 1px 5px rgba(0, 0, 0, 0.1); } .red-flag-title { color: #CD0000; font-weight: 600; margin-top: 0; margin-bottom: 15px; display: flex; align-items: center; } .red-flag-title svg { margin-right: 10px; flex-shrink: 0; } .red-flag-item { margin-bottom: 15px; padding-bottom: 15px; border-bottom: 1px solid rgba(205, 0, 0, 0.2); } .red-flag-item:last-child { margin-bottom: 0; padding-bottom: 0; border-bottom: none; } .red-flag-heading { color: #CD0000; font-weight: 600; margin-top: 0; margin-bottom: 5px; font-size: 15px; display: flex; justify-content: space-between; align-items: center; } .red-flag-heading-text { display: flex; align-items: center; } .minimize-btn { background: none; border: none; color: #777; cursor: pointer; font-size: 18px; padding: 0 5px; line-height: 1; transition: all 0.2s; margin-left: 10px; } .minimize-btn:hover { color: #CD0000; background: none; } .red-flag-content { transition: max-height 0.3s ease-out, opacity 0.3s ease-out; max-height: 500px; opacity: 1; overflow: hidden; } .red-flag-content.collapsed { max-height: 0; opacity: 0; margin-top: 0; margin-bottom: 0; } .red-flag-desc { margin-top: 0; margin-bottom: 10px; } .red-flag-count { display: inline-block; background-color: #CD0000; color: white; font-weight: bold; padding: 3px 8px; border-radius: 12px; font-size: 12px; margin-left: 8px; } .red-flag-apps { background-color: #FFDAD9; padding: 10px; border-radius: 4px; max-height: 120px; overflow-y: auto; font-size: 13px; } .red-flag-apps ul { margin: 0; padding-left: 20px; } .red-flag-apps li { margin-bottom: 3px; } .red-flag-link { color: #CD0000; cursor: pointer; text-decoration: underline; font-size: 13px; } </style> "@ # Format dates for display function Format-DateForDisplay { param($date) if ($null -eq $date -or $date -eq "") { return "<span class='no-activity'>No activity</span>" } else { try { $dt = [datetime]$date return $dt.ToString("yyyy-MM-dd HH:mm") } catch { return "<span class='no-activity'>Invalid date</span>" } } } # Calculate statistics for the report $totalApps = ($ReportData | Select-Object -Unique AppId).Count $delegatedApps = ($ReportData | Where-Object {$_.ScopeType -eq "Delegated"} | Select-Object -Unique AppId).Count $applicationApps = ($ReportData | Where-Object {$_.ScopeType -eq "Application"} | Select-Object -Unique AppId).Count $thirdPartyApps = ($ReportData | Where-Object {$_.appOwnerOrganizationId -ne $organisationInfo.id -and $null -ne $_.appOwnerOrganizationId} | Select-Object -Unique AppId).Count $inactiveApps = ($ReportData | Where-Object {$null -eq $_.DelegatedLastSignIn -and $null -eq $_.ApplicationLastSignIn} | Select-Object -Unique AppId).Count # Create rows for the report $tableRows = "" foreach ($item in $ReportData) { $scopeList = "" if ($null -ne $item.Scope -and $item.Scope.Count -gt 0) { $scopeList = "<ul class='permission-list'>" foreach ($scope in $item.Scope) { $scopeList += "<li>$scope</li>" } $scopeList += "</ul>" } else { $scopeList = "<span class='no-activity'>None</span>" } # Format App Credentials as bullet points similar to permissions $credentialsList = "" if ($null -ne $item.'App Credentials' -and $item.'App Credentials'.Count -gt 0) { $credentialsList = "<ul class='permission-list'>" foreach ($credential in $item.'App Credentials') { $credentialsList += "<li>$credential</li>" } $credentialsList += "</ul>" } else { $credentialsList = "<span class='no-activity'>None</span>" } # Format Owners as bullet points similar to permissions $ownersList = "" if ($null -ne $item.Owners -and $item.Owners.Count -gt 0) { $ownersList = "<ul class='permission-list'>" foreach ($owner in $item.Owners) { $ownersList += "<li>$owner</li>" } $ownersList += "</ul>" } else { $ownersList = "<span class='no-activity'>None</span>" } $delegatedLastSignIn = Format-DateForDisplay $item.DelegatedLastSignIn $applicationLastSignIn = Format-DateForDisplay $item.ApplicationLastSignIn $lastSignInType = if ($item.LastSignInType) { $item.LastSignInType } else { "<span class='no-activity'>Unknown</span>" } # Determine app source and apply styling based on criteria $appSource = "First Party" $rowClass = "" $warningIcon = "" # Check if it's a third-party application $isThirdParty = (($null -eq $item.appOwnerOrganizationId) -or ($item.appOwnerOrganizationId -ne $organisationInfo.id)) if ($isThirdParty) { $appSource = "Third Party" $rowClass = "class='third-party'" # Only add warning icon for third-party apps with application permissions if ($item.ScopeType -eq "Application") { $warningIcon = "⚠️ " } } $tableRows += @" <tr $rowClass> <td>$warningIcon$($item.DisplayName)</td> <td>$($item.appId)</td> <td>$appSource</td> <td>$($item.ScopeType)</td> <td class="permission-cell">$scopeList</td> <td class="date-cell">$delegatedLastSignIn</td> <td class="date-cell">$applicationLastSignIn</td> <td>$lastSignInType</td> <td class="credential-cell">$credentialsList</td> <td class="owner-cell">$ownersList</td> </tr> "@ } # Create the HTML $html = @" <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>$ReportTitle</title> $css </head> <body> <div class="container"> <div class="header"> <div class="header-content"> <div class="header-left-content"> <h1>$ReportTitle</h1> <div class="header-subtitle">Overview of application permissions in your tenant</div> <div class="author-info"> <span class="author-label">Created by:</span> <div class="author-links"> <a href="https://www.linkedin.com/in/danielbradley2/" class="author-link" onclick="window.open('https://www.linkedin.com/in/danielbradley2/', '_blank'); return false;"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="white"> <path d="M19 0h-14c-2.761 0-5 2.239-5 5v14c0 2.761 2.239 5 5 5h14c2.762 0 5-2.239 5-5v-14c0-2.761-2.238-5-5-5zm-11 19h-3v-11h3v11zm-1.5-12.268c-.966 0-1.75-.79-1.75-1.764s.784-1.764 1.75-1.764 1.75.79 1.75 1.764-.783 1.764-1.75 1.764zm13.5 12.268h-3v-5.604c0-3.368-4-3.113-4 0v5.604h-3v-11h3v1.765c1.396-2.586 7-2.777 7 2.476v6.759z"/> </svg> Daniel Bradley </a> <a href="https://ourcloudnetwork.com" class="author-link" onclick="window.open('https://ourcloudnetwork.com', '_blank'); return false;"> <svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="white"> <path d="M21 13v10h-21v-19h12v2h-10v15h17v-8h2zm3-12h-10.988l4.035l4-6.977 7.07 2.828 2.828 6.977-7.07 4.125 4.172v-11z"/> </svg> ourcloudnetwork.com </a> </div> </div> </div> <div class="report-info"> <div class="report-date">Generated: $(Get-Date -Format "MMMM d, yyyy")</div> <div class="tenant">Tenant ID: $($organisationInfo.id)</div> </div> </div> </div> <div class="stats-container"> <div class="stat-box"> <h3>Total Applications</h3> <p>$totalApps</p> </div> <div class="stat-box"> <h3>Apps with Delegated Permissions</h3> <p>$delegatedApps</p> </div> <div class="stat-box"> <h3>Apps with Application Permissions</h3> <p>$applicationApps</p> </div> <div class="stat-box"> <h3>Third-Party Apps</h3> <p>$thirdPartyApps</p> </div> <div class="stat-box"> <h3>Apps with No Sign-In Activity</h3> <p>$inactiveApps</p> </div> </div> "@ # Add Red Flag section if needed if ($InsecureThirdPartyApps.Count -gt 0 -or $InsecureFirstPartyApps.Count -gt 0 -or $ThirdPartyAppsWithAppPermissions.Count -gt 0 -or $NonActiveApps.Count -gt 0 -or $FirstPartyAppsWithNoOwners.Count -gt 0) { $html += @" <div class="red-flag-container"> <h3 class="red-flag-title"> <svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="#CD0000" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path> <line x1="12" y1="9" x2="12" y2="13"></line> <line x1="12" y1="17" x2="12.01" y2="17"></line> </svg> Security Risk Indicators </h3> "@ if ($InsecureThirdPartyApps.Count -gt 0) { $html += @" <div class="red-flag-item"> <h4 class="red-flag-heading"> <span class="red-flag-heading-text">Third-Party Applications Using Insecure Authentication <span class="red-flag-count">$($InsecureThirdPartyApps.Count)</span></span> <button class="minimize-btn" onclick="toggleRedFlag(this)" title="Minimize">−</button> </h4> <div class="red-flag-content"> <p class="red-flag-desc">These third-party applications are using insecure authentication methods (client secrets, certificates or client assertion) which may pose security risks. Work with these vendors to assess the risk.</p> <div style="margin-top: 8px;"> <span class="red-flag-link" onclick="filterByInsecureThirdPartyApps()">Filter to show these apps</span> </div> </div> </div> "@ } if ($InsecureFirstPartyApps.Count -gt 0) { $html += @" <div class="red-flag-item"> <h4 class="red-flag-heading"> <span class="red-flag-heading-text">First-Party Applications Using Insecure Authentication <span class="red-flag-count">$($InsecureFirstPartyApps.Count)</span></span> <button class="minimize-btn" onclick="toggleRedFlag(this)" title="Minimize">−</button> </h4> <div class="red-flag-content"> <p class="red-flag-desc">These first-party applications are using insecure authentication methods (client secrets or certificates) which may pose security risks. Consider using a Managed Identity.</p> <div style="margin-top: 8px;"> <span class="red-flag-link" onclick="filterByInsecureFirstPartyApps()">Filter to show these apps</span> </div> </div> </div> "@ } if ($ThirdPartyAppsWithAppPermissions.Count -gt 0) { $html += @" <div class="red-flag-item"> <h4 class="red-flag-heading"> <span class="red-flag-heading-text">Third-Party Applications with Application Permissions <span class="red-flag-count">$($ThirdPartyAppsWithAppPermissions.Count)</span></span> <button class="minimize-btn" onclick="toggleRedFlag(this)" title="Minimize">−</button> </h4> <div class="red-flag-content"> <p class="red-flag-desc">These third-party applications have application-level permissions which grant access without user context. Review these regularly to ensure they follow the principle of least privilege.</p> <div style="margin-top: 8px;"> <span class="red-flag-link" onclick="filterByThirdPartyWithAppPermissions()">Filter to show these apps</span> </div> </div> </div> "@ } if ($NonActiveApps.Count -gt 0) { $html += @" <div class="red-flag-item"> <h4 class="red-flag-heading"> <span class="red-flag-heading-text">Applications with No Recent Activity <span class="red-flag-count">$($NonActiveApps.Count)</span></span> <button class="minimize-btn" onclick="toggleRedFlag(this)" title="Minimize">−</button> </h4> <div class="red-flag-content"> <p class="red-flag-desc">These applications show no sign-in activity. Consider reviewing and removing unused applications to reduce potential attack surface and improve security.</p> <div style="margin-top: 8px;"> <span class="red-flag-link" onclick="filterByInactiveApps()">Filter to show these apps</span> </div> </div> </div> "@ } if ($FirstPartyAppsWithNoOwners.Count -gt 0) { $html += @" <div class="red-flag-item"> <h4 class="red-flag-heading"> <span class="red-flag-heading-text">First-Party Applications with No Owners <span class="red-flag-count">$($FirstPartyAppsWithNoOwners.Count)</span></span> <button class="minimize-btn" onclick="toggleRedFlag(this)" title="Minimize">−</button> </h4> <div class="red-flag-content"> <p class="red-flag-desc">These first-party applications have no assigned owners. Applications without owners can become orphaned when team members leave and may pose governance risks.</p> <div style="margin-top: 8px;"> <span class="red-flag-link" onclick="filterByFirstPartyAppsWithNoOwners()">Filter to show these apps</span> </div> </div> </div> "@ } $html += " </div>`n" } $html += @" <div class="controls"> <div class="search-box"> <input type="text" id="searchInput" placeholder="Search applications..."> </div> <div class="search-box"> <input type="text" id="permissionSearchInput" placeholder="Search permissions..."> </div> <select id="sourceFilter"> <option value="all">All Sources</option> <option value="First Party">First Party Only</option> <option value="Third Party">Third Party Only</option> </select> <select id="scopeTypeFilter"> <option value="all">All Permission Types</option> <option value="Delegated">Delegated</option> <option value="Application">Application</option> </select> <select id="activityFilter"> <option value="all">All Activity</option> <option value="active">Has Activity</option> <option value="inactive">No Activity</option> </select> <select id="credentialFilter"> <option value="all">All Credentials</option> <option value="any">Any Credentials</option> <option value="none">No Credentials</option> <option value="cert">Client Certificate</option> <option value="secret">Client Secret</option> <option value="both">Certificate + Secret</option> </select> <select id="ownerFilter"> <option value="all">All Owners</option> <option value="with">With Owners</option> <option value="without">No Owners</option> </select> <button onclick="exportCSV()">Export CSV</button> <button onclick="window.print()">Print Report</button> </div> <div class="table-container"> <table id="appTable"> <thead> <tr> <th class="sort-icon" onclick="sortTable(0)">Application Name</th> <th class="sort-icon" onclick="sortTable(1)">App ID</th> <th class="sort-icon" onclick="sortTable(2)">Source</th> <th class="sort-icon" onclick="sortTable(3)">Permission Type</th> <th>Permissions</th> <th class="sort-icon" onclick="sortTable(5)">Last Delegated Sign-in</th> <th class="sort-icon" onclick="sortTable(6)">Last Application Sign-in</th> <th class="sort-icon" onclick="sortTable(7)">Last Sign-in Method</th> <th>App Credentials</th> <th>Owners</th> </tr> </thead> <tbody> $tableRows </tbody> </table> </div> <div class="footer"> <p>Report generated using Entra Application Permissions Report Tool | © $(Get-Date -Format "yyyy") ourcloudnetwork.com</p> </div> </div> <script> // Search & Filter Functionality document.getElementById('searchInput').addEventListener('keyup', filterTable); document.getElementById('permissionSearchInput').addEventListener('keyup', filterTable); document.getElementById('sourceFilter').addEventListener('change', filterTable); document.getElementById('scopeTypeFilter').addEventListener('change', filterTable); document.getElementById('activityFilter').addEventListener('change', filterTable); document.getElementById('credentialFilter').addEventListener('change', filterTable); document.getElementById('ownerFilter').addEventListener('change', filterTable); function filterTable() { const searchTerm = document.getElementById('searchInput').value.toLowerCase(); const permissionSearchTerm = document.getElementById('permissionSearchInput').value.toLowerCase(); const sourceFilter = document.getElementById('sourceFilter').value; const scopeFilter = document.getElementById('scopeTypeFilter').value; const activityFilter = document.getElementById('activityFilter').value; const credentialFilter = document.getElementById('credentialFilter').value; const ownerFilter = document.getElementById('ownerFilter').value; const rows = document.querySelectorAll('#appTable tbody tr'); rows.forEach(row => { const appName = row.cells[0].textContent.toLowerCase(); const appId = row.cells[1].textContent.toLowerCase(); const source = row.cells[2].textContent; const scopeType = row.cells[3].textContent; const permissions = row.cells[4].textContent.toLowerCase(); const delegatedActivity = row.cells[5].textContent; const applicationActivity = row.cells[6].textContent; const signInMethod = row.cells[7] ? row.cells[7].textContent : ''; const credentials = row.cells[8] ? row.cells[8].textContent.toLowerCase() : ''; const owners = row.cells[9] ? row.cells[9].textContent.toLowerCase() : ''; const matchesSearch = appName.includes(searchTerm) || appId.includes(searchTerm) || credentials.includes(searchTerm) || signInMethod.toLowerCase().includes(searchTerm); const matchesPermissionSearch = permissionSearchTerm === '' || permissions.includes(permissionSearchTerm); const matchesSource = sourceFilter === 'all' || source === sourceFilter; const matchesScopeFilter = scopeFilter === 'all' || scopeType === scopeFilter; let matchesActivity = true; if (activityFilter === 'active') { matchesActivity = !(delegatedActivity.includes('No activity') && applicationActivity.includes('No activity')); } else if (activityFilter === 'inactive') { matchesActivity = delegatedActivity.includes('No activity') && applicationActivity.includes('No activity'); } // Process credential filter let matchesCredential = true; const hasCert = credentials.includes('client certificate'); const hasSecret = credentials.includes('client secret'); const hasAnyCredential = hasCert || hasSecret; switch (credentialFilter) { case 'any': matchesCredential = hasAnyCredential; break; case 'none': matchesCredential = credentials.includes('none'); break; case 'cert': matchesCredential = hasCert; break; case 'secret': matchesCredential = hasSecret; break; case 'both': matchesCredential = hasCert && hasSecret; break; default: // 'all' matchesCredential = true; } // Process owner filter let matchesOwner = true; const hasOwners = !owners.includes('none'); switch (ownerFilter) { case 'with': matchesOwner = hasOwners; break; case 'without': matchesOwner = !hasOwners; break; default: // 'all' matchesOwner = true; } row.style.display = (matchesSearch && matchesPermissionSearch && matchesSource && matchesScopeFilter && matchesActivity && matchesCredential && matchesOwner) ? '' : 'none'; }); } // Sorting Functionality - Updated with new column indices let currentSortCol = -1; let currentSortDir = 'asc'; function sortTable(columnIndex) { const table = document.getElementById('appTable'); const headers = table.querySelectorAll('th'); // Reset all headers headers.forEach(header => { header.classList.remove('sort-asc', 'sort-desc'); if (header.classList.contains('sort-icon')) { header.classList.add('sort-icon'); } }); // Set sort direction if (currentSortCol === columnIndex) { currentSortDir = currentSortDir === 'asc' ? 'desc' : 'asc'; } else { currentSortDir = 'asc'; } // Update header class headers[columnIndex].classList.add(currentSortDir === 'asc' ? 'sort-asc' : 'sort-desc'); currentSortCol = columnIndex; const rows = Array.from(table.querySelectorAll('tbody tr')); const sortedRows = rows.sort((a, b) => { let aVal = a.cells[columnIndex].textContent.trim(); let bVal = b.cells[columnIndex].textContent.trim(); // Special handling for date columns if (columnIndex === 6 || columnIndex === 7) { aVal = aVal.includes('No activity') ? '1900-01-01' : aVal; bVal = bVal.includes('No activity') ? '1900-01-01' : bVal; } const comparison = aVal.localeCompare(bVal, undefined, {numeric: true, sensitivity: 'base'}); return currentSortDir === 'asc' ? comparison : -comparison; }); // Remove existing rows rows.forEach(row => row.parentNode.removeChild(row)); // Append sorted rows const tbody = table.querySelector('tbody'); sortedRows.forEach(row => tbody.appendChild(row)); } // CSV Export Functionality function exportCSV() { const table = document.getElementById('appTable'); const rows = Array.from(table.querySelectorAll('tr')); // Create CSV content with BOM for Excel UTF-8 support let csvContent = []; // Process each row rows.forEach((row, i) => { // Skip hidden rows if (row.style.display === 'none') return; const cells = Array.from(row.cells); const rowData = []; // Process each cell cells.forEach(cell => { let content = ''; // For permissions cell, credentials cell, or owners cell (contains a list) if (cell.classList.contains('permission-cell') || cell.classList.contains('credential-cell') || cell.classList.contains('owner-cell')) { // Get all list items and join with semicolons const items = Array.from(cell.querySelectorAll('li')).map(li => li.textContent.trim()); content = items.join('; '); // If no items were found, check for "None" text if (items.length === 0 && cell.textContent.includes('None')) { content = 'None'; } } else { // For regular cells, just get the text content content = cell.textContent.trim(); // Clean up warning icons and other special characters content = content.replace('⚠️', '').trim(); // For checkmark/x-mark cells, convert to Yes/No if (content === '✓') content = 'Yes'; if (content === '✗') content = 'No'; } // Escape quotes with double quotes (CSV standard) content = content.replace(/"/g, '""'); rowData.push('"' + content + '"'); }); // Add row to CSV content csvContent.push(rowData.join(',')); }); // Create a BOM for UTF-8 const BOM = '\uFEFF'; // Join all rows with proper newlines and add BOM const csvString = BOM + csvContent.join('\r\n'); // Create blob and download const blob = new Blob([csvString], { type: 'text/csv;charset=utf-8' }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.setAttribute("href", url); link.setAttribute("download", "EntraAppReport_$(Get-Date -Format 'yyyy-MM-dd').csv"); document.body.appendChild(link); link.click(); document.body.removeChild(link); URL.revokeObjectURL(url); } // Red Flag filtering functions function filterByInsecureThirdPartyApps() { // Reset UI filters document.getElementById('searchInput').value = ''; document.getElementById('permissionSearchInput').value = ''; document.getElementById('sourceFilter').value = 'Third Party'; document.getElementById('scopeTypeFilter').value = 'all'; document.getElementById('activityFilter').value = 'all'; document.getElementById('credentialFilter').value = 'any'; document.getElementById('ownerFilter').value = 'all'; // Apply filtering const rows = document.querySelectorAll('#appTable tbody tr'); rows.forEach(row => { const source = row.cells[2].textContent; const signInMethod = row.cells[7].textContent; const credentials = row.cells[8].textContent; // Show only third party apps with client certificate or client secret authentication const isThirdParty = source === 'Third Party'; const hasInsecureAuth = signInMethod.includes('clientSecret') || signInMethod.includes('certificate') || signInMethod.includes('clientAssertion') || credentials.includes('Client Certificate') || credentials.includes('Client Secret'); row.style.display = (isThirdParty && hasInsecureAuth) ? '' : 'none'; }); } function filterByInsecureFirstPartyApps() { // Reset UI filters document.getElementById('searchInput').value = ''; document.getElementById('permissionSearchInput').value = ''; document.getElementById('sourceFilter').value = 'First Party'; document.getElementById('scopeTypeFilter').value = 'all'; document.getElementById('activityFilter').value = 'all'; document.getElementById('credentialFilter').value = 'any'; document.getElementById('ownerFilter').value = 'all'; // Apply filtering const rows = document.querySelectorAll('#appTable tbody tr'); rows.forEach(row => { const source = row.cells[2].textContent; const credentials = row.cells[8].textContent; // Show only first party apps with client certificate or client secret credentials const isFirstParty = source === 'First Party'; const hasInsecureAuth = credentials.includes('Client Certificate') || credentials.includes('Client Secret'); row.style.display = (isFirstParty && hasInsecureAuth) ? '' : 'none'; }); } function filterByThirdPartyWithAppPermissions() { // Reset UI filters document.getElementById('searchInput').value = ''; document.getElementById('permissionSearchInput').value = ''; document.getElementById('sourceFilter').value = 'Third Party'; document.getElementById('scopeTypeFilter').value = 'Application'; document.getElementById('activityFilter').value = 'all'; document.getElementById('credentialFilter').value = 'all'; document.getElementById('ownerFilter').value = 'all'; // The UI filters should handle this case automatically filterTable(); } function filterByInactiveApps() { // Reset UI filters document.getElementById('searchInput').value = ''; document.getElementById('permissionSearchInput').value = ''; document.getElementById('sourceFilter').value = 'all'; document.getElementById('scopeTypeFilter').value = 'all'; document.getElementById('activityFilter').value = 'inactive'; document.getElementById('credentialFilter').value = 'all'; document.getElementById('ownerFilter').value = 'all'; // The UI filters should handle this case automatically filterTable(); } function filterByFirstPartyAppsWithNoOwners() { // Reset UI filters document.getElementById('searchInput').value = ''; document.getElementById('permissionSearchInput').value = ''; document.getElementById('sourceFilter').value = 'First Party'; document.getElementById('scopeTypeFilter').value = 'all'; document.getElementById('activityFilter').value = 'all'; document.getElementById('credentialFilter').value = 'all'; document.getElementById('ownerFilter').value = 'without'; // The UI filters should handle this case automatically filterTable(); } // Initialize sorting on application name column window.onload = function() { sortTable(0); }; // Function to toggle red flag sections function toggleRedFlag(button) { const content = button.closest('.red-flag-item').querySelector('.red-flag-content'); content.classList.toggle('collapsed'); if (content.classList.contains('collapsed')) { button.textContent = '+'; button.title = 'Expand'; } else { button.textContent = '−'; button.title = 'Minimize'; } } </script> </body> </html> "@ # Save the HTML file try { # If no output path is specified, use script root if ([string]::IsNullOrEmpty($OutputPath)) { $OutputPath = Join-Path $PSScriptRoot "EntraAppReport_$(Get-Date -Format 'yyyy-MM-dd_HH-mm').html" } # Ensure directory exists $directory = Split-Path -Path $OutputPath -Parent if (!(Test-Path $directory)) { New-Item -ItemType Directory -Path $directory -Force | Out-Null } $html | Out-File -FilePath $OutputPath -Encoding UTF8 -Force Write-Output "HTML Report saved to: $OutputPath" # Open the report in the default browser Start-Process $OutputPath } catch { Write-Error "Failed to save the HTML report: $_" return $null } } # Generate and open the HTML report after collecting the data # Determine output path Write-Progress -Activity "Generating Report" -Status "Creating HTML report" -PercentComplete 100 Write-Host "Generating HTML report..." -ForegroundColor Green $reportOutputPath = $null if ($outpath) { # Check if the provided path is a directory or a file if (Test-Path $outpath -PathType Container) { # It's a directory, append filename $reportOutputPath = Join-Path $outpath "EntraAppReport_$(Get-Date -Format 'yyyy-MM-dd_HH-mm').html" } else { # It's a file path or doesn't exist, use as is $reportOutputPath = $outpath } } Export-EntraAppHTMLReport -ReportData $AllfilteredPermissions -ReportTitle "Entra Application Permissions Report" -OutputPath $reportOutputPath # Complete Write-Progress -Activity "Generating Report" -Completed Write-Host "Report generation complete!" -ForegroundColor Green |