# .TITLE Duplicate Applications Report .SYNOPSIS Identify and report duplicate applications across all managed devices in Intune. .DESCRIPTION This script connects to Microsoft Graph, retrieves all applications uploaded to Intune, and identifies potential duplicate applications. Duplicates are identified by applications with the same name but different publishers or name variations. The script generates detailed reports showing duplicate applications and their variations to help with application cleanup and standardization efforts. .TAGS Apps,Reporting .MINROLE Intune Administrator .PERMISSIONS DeviceManagementApps.Read.All .AUTHOR Ugur Koc .VERSION 1.4 .CHANGELOG 1.4 - Added Azure Automation contract validation, portal-safe boolean parameters, beta Graph endpoints, and terminating paging errors 1.3 - Azure Automation now records script progress, outcomes, and summaries in job history 1.2 - Preserve single-element arrays in the paging helper (Count was returning hashtable key count), request only needed app fields via $select 1.1 - Local runs now use MgGraphCommunity for WAM-free interactive sign-in (auto-installed if missing) 1.0 - Initial release .LASTUPDATE 2026-07-30 .EXAMPLE .\get-duplicate-applications.ps1 Generates duplicate applications report for all Intune applications .EXAMPLE .\get-duplicate-applications.ps1 -OutputPath "C:\Reports" Generates duplicate applications report and saves to specified directory .EXAMPLE .\get-duplicate-applications.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 - Reports are saved in both CSV and HTML formats - Analyzes applications uploaded to Intune, not device-installed applications - Duplicate detection criteria: * Same application name with different publishers * Applications with name variations (case differences, extra spaces, etc.) * Same application name with different app types - 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, HelpMessage = "Output directory for reports")] [string]$OutputPath = ".", [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." } # ============================================================================ # 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 = @( "DeviceManagementApps.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 from Graph API function Get-MgGraphAllPage { param( [Parameter(Mandatory = $true)] [string]$Uri, [int]$DelayMs = 100 ) $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 } $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 } # Function to normalize application names for comparison function Get-NormalizedAppName { param([string]$AppName) # Remove common suffixes and prefixes $normalized = $AppName -replace '\s*(x64|x86|32-bit|64-bit|\(.*?\))\s*', '' $normalized = $normalized -replace '\s+', ' ' $normalized = $normalized.Trim() return $normalized.ToLower() } # ============================================================================ # MAIN SCRIPT LOGIC # ============================================================================ try { Write-Output "Starting duplicate applications detection..." # Get all Intune applications Write-Output "Retrieving Intune applications..." # $select trims the payload to the fields the report reads; @odata.type is always returned on typed collections $appsUri = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$select=id,displayName,publisher,description,createdDateTime,lastModifiedDateTime" $intuneApps = Get-MgGraphAllPage -Uri $appsUri Write-Output "`n✓ Found $($intuneApps.Count) Intune applications" # Create application inventory array $applicationInventory = @() $totalApplications = 0 foreach ($app in $intuneApps) { # Skip if no display name if ([string]::IsNullOrWhiteSpace($app.displayName)) { continue } # Create application inventory entry $appEntry = [PSCustomObject]@{ ApplicationName = $app.displayName ApplicationId = $app.id Publisher = if ($app.publisher) { $app.publisher } else { "Unknown" } AppType = $app.'@odata.type' Description = $app.description CreatedDateTime = $app.createdDateTime LastModifiedDateTime = $app.lastModifiedDateTime NormalizedName = Get-NormalizedAppName -AppName $app.displayName } $applicationInventory += $appEntry $totalApplications++ } # ============================================================================ # DUPLICATE DETECTION LOGIC # ============================================================================ Write-Output "Analyzing applications for duplicates..." # Group applications by normalized name to find potential duplicates $appGroups = $applicationInventory | Group-Object NormalizedName # Filter groups that have multiple variations or meet minimum device count $duplicateGroups = @() foreach ($group in $appGroups) { # Skip if only one app in group (not a duplicate) if ($group.Count -lt 2) { continue } # Check for different publishers and names $publishers = $group.Group | Group-Object Publisher | Where-Object { $_.Name -ne "Unknown" } $originalNames = $group.Group | Group-Object ApplicationName $appTypes = $group.Group | Group-Object AppType $isDuplicate = $false $duplicateType = @() # Multiple publishers for same app name if ($publishers.Count -gt 1) { $isDuplicate = $true $duplicateType += "Multiple Publishers" } # Multiple original names (case differences, extra spaces, etc.) if ($originalNames.Count -gt 1) { $isDuplicate = $true $duplicateType += "Name Variations" } # Multiple app types for same name if ($appTypes.Count -gt 1) { $isDuplicate = $true $duplicateType += "Multiple App Types" } if ($isDuplicate) { $duplicateInfo = [PSCustomObject]@{ NormalizedName = $group.Name OriginalNames = ($originalNames | ForEach-Object { $_.Name }) -join "; " Publishers = ($publishers | ForEach-Object { $_.Name }) -join "; " AppTypes = ($appTypes | ForEach-Object { $_.Name }) -join "; " AppCount = $group.Count DuplicateType = $duplicateType -join ", " Applications = $group.Group } $duplicateGroups += $duplicateInfo } } # Sort duplicates by app count (descending) $duplicateGroups = $duplicateGroups | Sort-Object AppCount -Descending Write-Output "✓ Found $($duplicateGroups.Count) duplicate application groups:" # Display duplicate app names in console if ($duplicateGroups.Count -gt 0) { foreach ($duplicate in $duplicateGroups) { Write-Output " • $($duplicate.OriginalNames) ($($duplicate.DuplicateType))" } } # ============================================================================ # GENERATE REPORTS # ============================================================================ # Generate timestamp for file names $timestamp = Get-Date -Format "yyyy-MM-dd_HH-mm-ss" $csvPath = Join-Path $OutputPath "Intune_Duplicate_Applications_Report_$timestamp.csv" $htmlPath = Join-Path $OutputPath "Intune_Duplicate_Applications_Report_$timestamp.html" # Prepare detailed CSV data $csvData = @() foreach ($duplicate in $duplicateGroups) { foreach ($app in $duplicate.Applications) { $csvData += [PSCustomObject]@{ DuplicateGroup = $duplicate.NormalizedName DuplicateType = $duplicate.DuplicateType ApplicationName = $app.ApplicationName ApplicationId = $app.ApplicationId Publisher = $app.Publisher AppType = $app.AppType Description = $app.Description CreatedDateTime = $app.CreatedDateTime LastModifiedDateTime = $app.LastModifiedDateTime GroupAppCount = $duplicate.AppCount } } } # Export to CSV try { $csvData | 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")
Your environment appears to be clean of duplicate applications based on the current criteria.
Publishers: $($duplicate.Publishers)
App Types: $($duplicate.AppTypes)