# .TITLE Application Inventory Report .SYNOPSIS Generate a comprehensive application inventory report for all managed devices in Intune. .DESCRIPTION This script connects to Microsoft Graph, retrieves all managed devices and their installed applications, and generates detailed reports in both CSV and HTML formats. The report includes application details, installation status, version information, and summary statistics across the entire device fleet. .TAGS Apps,Reporting .MINROLE Intune Administrator .PERMISSIONS DeviceManagementManagedDevices.Read.All .AUTHOR Ugur Koc .VERSION 1.5 .CHANGELOG 1.5 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors 1.4 - Azure Automation now records script progress, outcomes, and summaries in job history 1.3 - Preserve single-element arrays in the paging helper (Count was returning hashtable key count), genuinely retry a device after a 429 with max 3 attempts (continue was skipping it), request only needed device fields via $select 1.2 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing); report auto-open failures no longer abort the script 1.0 - Initial release 1.1 - Enrich publisher/platform/size from aggregate detectedApps endpoint (per-device expand returns null/unknown), enforce MaxDevices as a hard cap, drop unused DeviceManagementApps.Read.All permission .LASTUPDATE 2026-07-30 .EXAMPLE .\get-application-inventory-report.ps1 Generates application inventory reports for all managed devices .EXAMPLE .\get-application-inventory-report.ps1 -OutputPath "C:\Reports" -IncludeSystemApps "true" Generates reports including system applications and saves them to the specified directory .EXAMPLE .\get-application-inventory-report.ps1 -FilterByPublisher "Microsoft Corporation" -OpenReport "true" Generates reports filtered by Microsoft applications and opens the HTML report .EXAMPLE .\get-application-inventory-report.ps1 -ForceModuleInstall "true" Forces module installation without prompting and generates the report .NOTES - Requires Microsoft.Graph.Authentication module: Install-Module Microsoft.Graph.Authentication - Requires appropriate permissions in Entra ID - Large tenants may take considerable time to complete due to API rate limits - Reports are saved in both CSV and HTML formats - System applications are excluded by default to focus on business applications - Uses beta endpoint for enhanced application data - Disclaimer: This script is provided AS IS without warranty of any kind. Use it at your own risk. - Local interactive sign-in uses the MgGraphCommunity module to avoid the Graph SDK's mandatory WAM broker on Windows #> [CmdletBinding()] param( [Parameter(Mandatory = $false)] [string]$OutputPath = ".", [Parameter(Mandatory = $false)] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$IncludeSystemApps, [Parameter(Mandatory = $false)] [string]$FilterByPublisher = "", [Parameter(Mandatory = $false)] [string]$FilterByAppName = "", [Parameter(Mandatory = $false)] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$OpenReport, [Parameter(Mandatory = $false)] [int]$MaxDevices = 0, [Parameter(Mandatory = $false, HelpMessage = "Force module installation without prompting")] [ValidateSet("true", "false", "1", "0", '$true', '$false')] [string]$ForceModuleInstall ) # Normalize the local module-install override for Azure Automation parameter binding. $forceModuleInstallRaw = [string]$ForceModuleInstall Remove-Variable -Name ForceModuleInstall if ([string]::IsNullOrWhiteSpace($forceModuleInstallRaw)) { $ForceModuleInstall = $false } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("true", "1", '$true')) { $ForceModuleInstall = $true } elseif ($forceModuleInstallRaw.Trim().ToLowerInvariant() -in @("false", "0", '$false')) { $ForceModuleInstall = $false } else { throw "Parameter 'ForceModuleInstall' accepts only true, false, 1, 0, $true, or $false." } # Azure Automation supplies portal parameter values as strings. Normalize the # public boolean parameters once so local and runbook execution use real booleans. foreach ($runbookBooleanParameter in @('IncludeSystemApps', 'OpenReport')) { $runbookBooleanRaw = [string](Get-Variable -Name $runbookBooleanParameter -ValueOnly) Remove-Variable -Name $runbookBooleanParameter if ([string]::IsNullOrWhiteSpace($runbookBooleanRaw)) { Set-Variable -Name $runbookBooleanParameter -Value $false continue } switch ($runbookBooleanRaw.Trim().ToLowerInvariant()) { { $_ -in @("true", "1", '$true') } { Set-Variable -Name $runbookBooleanParameter -Value $true } { $_ -in @("false", "0", '$false') } { Set-Variable -Name $runbookBooleanParameter -Value $false } default { throw "Parameter '$runbookBooleanParameter' accepts only true, false, 1, 0, $true, or $false." } } } # ============================================================================ # ENVIRONMENT DETECTION AND SETUP # ============================================================================ function Initialize-RequiredModule { <# .SYNOPSIS Ensures required modules are available and loaded #> param( [string[]]$ModuleNames, [bool]$IsAutomationEnvironment, [bool]$ForceInstall = $false ) foreach ($ModuleName in $ModuleNames) { Write-Verbose "Checking module: $ModuleName" # Check if module is available $module = Get-Module -ListAvailable -Name $ModuleName | Select-Object -First 1 if (-not $module) { if ($IsAutomationEnvironment) { $errorMessage = @" Module '$ModuleName' is not available in this Azure Automation Account. To resolve this issue: 1. Go to Azure Portal 2. Navigate to your Automation Account 3. Go to 'Modules' > 'Browse Gallery' 4. Search for '$ModuleName' 5. Click 'Import' and wait for installation to complete Alternative: Use PowerShell to import the module: Import-Module Az.Automation Import-AzAutomationModule -AutomationAccountName "YourAccount" -ResourceGroupName "YourRG" -Name "$ModuleName" "@ throw $errorMessage } else { # Local environment - attempt to install Write-Information "Module '$ModuleName' not found. Attempting to install..." -InformationAction Continue if (-not $ForceInstall) { $response = Read-Host "Install module '$ModuleName'? (Y/N)" if ($response -notmatch '^[Yy]') { throw "Module '$ModuleName' is required but installation was declined." } } try { # Check if running as administrator for AllUsers scope $isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] "Administrator") $scope = if ($isAdmin) { "AllUsers" } else { "CurrentUser" } Write-Information "Installing '$ModuleName' in scope '$scope'..." -InformationAction Continue Install-Module -Name $ModuleName -Scope $scope -Force -AllowClobber -Repository PSGallery Write-Information "✓ Successfully installed '$ModuleName'" -InformationAction Continue } catch { throw "Failed to install module '$ModuleName': $($_.Exception.Message)" } } } # Import the module try { Write-Verbose "Importing module: $ModuleName" Import-Module -Name $ModuleName -Force -ErrorAction Stop Write-Verbose "✓ Successfully imported '$ModuleName'" } catch { throw "Failed to import module '$ModuleName': $($_.Exception.Message)" } } } # Detect execution environment if ($PSPrivateMetadata.JobId.Guid) { Write-Output "Running inside Azure Automation Runbook" $IsAzureAutomation = $true } else { Write-Output "Running locally in IDE or terminal" $IsAzureAutomation = $false } # Initialize required modules $RequiredModules = @( "Microsoft.Graph.Authentication" ) # MgGraphCommunity gives WAM-free interactive sign-in for local runs if (-not $IsAzureAutomation) { $RequiredModules += "MgGraphCommunity" } try { Initialize-RequiredModule -ModuleNames $RequiredModules -IsAutomationEnvironment $IsAzureAutomation -ForceInstall $ForceModuleInstall Write-Verbose "✓ All required modules are available" } catch { Write-Error "Module initialization failed: $_" exit 1 } # ============================================================================ # AUTHENTICATION # ============================================================================ try { if ($IsAzureAutomation) { # Azure Automation - Use Managed Identity Write-Output "Connecting to Microsoft Graph using Managed Identity..." Connect-MgGraph -Identity -NoWelcome -ErrorAction Stop Write-Output "✓ Successfully connected to Microsoft Graph using Managed Identity" } else { # Local execution - WAM-free interactive sign-in via MgGraphCommunity Write-Output "Connecting to Microsoft Graph with interactive authentication..." $Scopes = @( "DeviceManagementManagedDevices.Read.All" ) Connect-MgGraphCommunity -Scopes $Scopes -NoWelcome -ErrorAction Stop Write-Output "✓ Successfully connected to Microsoft Graph" } } catch { Write-Error "Failed to connect to Microsoft Graph: $($_.Exception.Message)" exit 1 } # ============================================================================ # HELPER FUNCTIONS # ============================================================================ # Function to get all pages of results with rate limiting function Get-MgGraphAllPage { param( [string]$Uri, [int]$DelayMs = 100, [int]$MaxResults = 0 ) $allResults = @() $nextLink = $Uri $requestCount = 0 do { try { # Add delay to respect rate limits if ($requestCount -gt 0) { Start-Sleep -Milliseconds $DelayMs } $response = Invoke-MgGraphRequest -Uri $nextLink -Method GET $requestCount++ if ($null -ne $response.value) { $allResults += $response.value } else { $allResults += $response } # Stop paging once the requested maximum is reached ($top only sets # page size, so without this the pager would follow every nextLink) if ($MaxResults -gt 0 -and $allResults.Count -ge $MaxResults) { $allResults = $allResults | Select-Object -First $MaxResults break } $nextLink = $response.'@odata.nextLink' # Show progress for large datasets if ($requestCount % 10 -eq 0) { Write-Information "." -InformationAction Continue } } catch { if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") { Write-Information "`nRate limit hit, waiting 60 seconds..." -InformationAction Continue Start-Sleep -Seconds 60 continue } throw "Error fetching data from $nextLink : $($_.Exception.Message)" } } while ($nextLink) # The comma preserves single-element arrays (Invoke-MgGraphRequest hashtable rows otherwise unroll and .Count returns key count) return , $allResults } # System applications to exclude by default $SystemApps = @( "Microsoft.Windows.*", "Microsoft.VCLibs.*", "Microsoft.NET.*", "Microsoft.UI.*", "Microsoft.Services.*", "Microsoft.Advertising.*", "Microsoft.MicrosoftEdge.*", "Windows.*", "InputApp", "Microsoft.AAD.*", "Microsoft.AccountsControl", "Microsoft.AsyncTextService", "Microsoft.BioEnrollment", "Microsoft.CredDialogHost", "Microsoft.ECApp", "Microsoft.LockApp", "Microsoft.MicrosoftEdgeDevToolsClient", "Microsoft.Win32WebViewHost", "Microsoft.Windows.Apprep.ChxApp", "Microsoft.Windows.AssignedAccessLockApp", "Microsoft.Windows.CapturePicker", "Microsoft.Windows.CloudExperienceHost", "Microsoft.Windows.ContentDeliveryManager", "Microsoft.Windows.Cortana", "Microsoft.Windows.NarratorQuickStart", "Microsoft.Windows.ParentalControls", "Microsoft.Windows.PeopleExperienceHost", "Microsoft.Windows.PinningConfirmationDialog", "Microsoft.Windows.SecHealthUI", "Microsoft.Windows.SecureAssessmentBrowser", "Microsoft.Windows.ShellExperienceHost", "Microsoft.Windows.XGpuEjectDialog", "Microsoft.XboxGameCallableUI" ) # ============================================================================ # MAIN SCRIPT LOGIC # ============================================================================ try { Write-Output "Starting application inventory report generation..." # Get all managed devices Write-Output "Retrieving managed devices..." # $select trims the payload to the fields the report reads $devicesUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$select=id,deviceName,operatingSystem,osVersion,userPrincipalName,userDisplayName,model,manufacturer,lastSyncDateTime,managementState,managedDeviceOwnerType,complianceState" if ($MaxDevices -gt 0) { # Graph caps page size at 1000; MaxResults enforces the actual device limit $pageSize = [math]::Min($MaxDevices, 1000) $devicesUri += "&`$top=$pageSize" } $devices = Get-MgGraphAllPage -Uri $devicesUri -MaxResults $MaxDevices Write-Output "`n✓ Found $($devices.Count) managed devices" # Build a metadata lookup from the aggregate detectedApps endpoint. The # per-device $expand=detectedApps response omits publisher (null), platform # ("unknown") and size (0), so we enrich each app by joining on its id. Write-Output "Building detected application metadata lookup..." $detectedAppLookup = @{} $allDetectedApps = Get-MgGraphAllPage -Uri "https://graph.microsoft.com/beta/deviceManagement/detectedApps" foreach ($detectedApp in $allDetectedApps) { if ($detectedApp.id -and -not $detectedAppLookup.ContainsKey($detectedApp.id)) { $detectedAppLookup[$detectedApp.id] = $detectedApp } } Write-Output "✓ Loaded metadata for $($detectedAppLookup.Count) detected applications" # Create application inventory array $applicationInventory = @() $processedDevices = 0 $totalApplications = 0 Write-Output "Processing device applications..." # Index-based loop so a throttled device can be retried without being skipped $deviceIndex = 0 $throttleAttempts = 0 while ($deviceIndex -lt $devices.Count) { $device = $devices[$deviceIndex] $processedDevices = $deviceIndex + 1 Write-Progress -Activity "Processing Device Applications" -Status "Processing device $processedDevices of $($devices.Count): $($device.deviceName)" -PercentComplete (($processedDevices / $devices.Count) * 100) try { # Use the beta endpoint with expand to get detected apps for each device $deviceAppsUri = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($device.id)?`$expand=detectedApps" Write-Verbose "Processing device: $($device.deviceName) (ID: $($device.id))" -Verbose Write-Verbose "API URL: $deviceAppsUri" -Verbose $deviceWithApps = Invoke-MgGraphRequest -Uri $deviceAppsUri -Method GET if ($deviceWithApps.detectedApps) { foreach ($app in $deviceWithApps.detectedApps) { # Skip system apps if not included if (-not $IncludeSystemApps) { $isSystemApp = $false foreach ($systemApp in $SystemApps) { if ($app.displayName -like $systemApp) { $isSystemApp = $true break } } if ($isSystemApp) { continue } } # Enrich from the aggregate metadata lookup; the per-device # expand omits publisher/platform/size, so prefer the lookup # value and fall back to whatever the expand returned. $appMeta = $detectedAppLookup[$app.id] $resolvedPublisher = if ($appMeta -and $appMeta.publisher) { $appMeta.publisher } elseif ($app.publisher) { $app.publisher } else { $null } $resolvedPlatform = if ($appMeta -and $appMeta.platform -and $appMeta.platform -ne "unknown") { $appMeta.platform } elseif ($app.platform -and $app.platform -ne "unknown") { $app.platform } else { "Unknown" } $resolvedSize = if ($appMeta -and $appMeta.sizeInByte -gt 0) { $appMeta.sizeInByte } else { $app.sizeInByte } # Apply filters if specified if ($FilterByPublisher -and $resolvedPublisher -notlike "*$FilterByPublisher*") { continue } if ($FilterByAppName -and $app.displayName -notlike "*$FilterByAppName*") { continue } # Calculate days since last seen $daysSinceLastSeen = if ($device.lastSyncDateTime) { [math]::Round(((Get-Date) - [DateTime]$device.lastSyncDateTime).TotalDays, 1) } else { "Never" } # Create application inventory entry $appEntry = [PSCustomObject]@{ DeviceName = $device.deviceName DeviceId = $device.id UserPrincipalName = $device.userPrincipalName UserDisplayName = $device.userDisplayName OperatingSystem = $device.operatingSystem OSVersion = $device.osVersion ApplicationName = $app.displayName ApplicationVersion = $app.version Publisher = if ($resolvedPublisher) { $resolvedPublisher } else { "Unknown" } ApplicationSize = if ($resolvedSize -and $resolvedSize -gt 0) { [math]::Round($resolvedSize / 1MB, 2) } else { "Unknown" } ApplicationSizeUnit = if ($resolvedSize -and $resolvedSize -gt 0) { "MB" } else { "N/A" } Platform = $resolvedPlatform LastSyncDateTime = $device.lastSyncDateTime DaysSinceLastSync = $daysSinceLastSeen DeviceModel = $device.model DeviceManufacturer = $device.manufacturer EnrollmentType = $device.managementState OwnerType = $device.managedDeviceOwnerType ComplianceState = $device.complianceState ApplicationId = $app.id } $applicationInventory += $appEntry $totalApplications++ } } # Add a small delay to respect rate limits Start-Sleep -Milliseconds 100 } catch { if ($_.Exception.Message -like "*429*" -or $_.Exception.Message -like "*throttled*") { $throttleAttempts++ if ($throttleAttempts -lt 3) { Write-Output "`nRate limit hit, waiting 60 seconds..." Start-Sleep -Seconds 60 # Retry the same device without advancing the index continue } Write-Warning "Rate limit persisted after 3 attempts for device $($device.deviceName), skipping" } else { Write-Warning "Error processing applications for device $($device.deviceName): $($_.Exception.Message)" } } $throttleAttempts = 0 $deviceIndex++ } Write-Progress -Activity "Processing Device Applications" -Completed # Generate summary statistics $uniqueApplications = $applicationInventory | Group-Object ApplicationName | Measure-Object | Select-Object -ExpandProperty Count $uniquePublishers = $applicationInventory | Group-Object Publisher | Measure-Object | Select-Object -ExpandProperty Count $uniqueDevices = $applicationInventory | Group-Object DeviceName | Measure-Object | Select-Object -ExpandProperty Count # Get top applications by device count $topApplications = $applicationInventory | Group-Object ApplicationName | ForEach-Object { [PSCustomObject]@{ ApplicationName = $_.Name DeviceCount = $_.Count UniqueVersions = ($_.Group | Group-Object ApplicationVersion | Measure-Object).Count Publishers = ($_.Group | Group-Object Publisher | Select-Object -First 1).Name } } | Sort-Object DeviceCount -Descending | Select-Object -First 10 # Get top publishers by application count $topPublishers = $applicationInventory | Group-Object Publisher | ForEach-Object { [PSCustomObject]@{ Publisher = $_.Name ApplicationCount = ($_.Group | Group-Object ApplicationName | Measure-Object).Count DeviceCount = $_.Count } } | Sort-Object ApplicationCount -Descending | Select-Object -First 10 # Generate timestamp for file names $timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" $csvPath = Join-Path $OutputPath "Intune_Application_Inventory_Report_$timestamp.csv" $htmlPath = Join-Path $OutputPath "Intune_Application_Inventory_Report_$timestamp.html" # Export to CSV try { $applicationInventory | Export-Csv -Path $csvPath -NoTypeInformation -Encoding UTF8 Write-Output "✓ CSV report saved: $csvPath" } catch { Write-Error "Failed to save CSV report: $($_.Exception.Message)" } # Generate HTML report try { $htmlContent = @"
Generated on: $(Get-Date -Format "dddd, MMMM dd, yyyy 'at' HH:mm:ss")
| Device Name | User | Application Name | Version | Publisher | Platform | Size (MB) | OS | Last Sync |
|---|---|---|---|---|---|---|---|---|
| $($app.DeviceName) | $($app.UserDisplayName) | $($app.ApplicationName) | $($app.ApplicationVersion) | $($app.Publisher) | $($app.Platform) | $sizeDisplay | $($app.OperatingSystem) $($app.OSVersion) | $($app.LastSyncDateTime) |