# .SYNOPSIS Intune Device Details HTML report (Graph API) .DESCRIPTION Console-based tool for searching Intune managed devices and generating modern HTML reports with complete device intelligence: apps, policies, scripts, assignments, group memberships, and conflict detection. Works in both Windows PowerShell 5.1 and PowerShell 7.x. .PARAMETER Id Intune device ID (GUID) to generate report for. Accepts pipeline input from Get-IntuneManagedDevice or other Graph API cmdlets. Alias: IntuneDeviceId .PARAMETER SearchText Optional search text to pre-filter the device list in interactive mode. .PARAMETER ReloadCache Forces reload of all cached data (apps, configuration profiles, scripts, assignments) from Microsoft Graph API, ignoring cache timestamps. .PARAMETER SkipAssignments Skips downloading assignment information. Creates minimal reports faster but with incomplete data. .PARAMETER DoNotOpenReportAutomatically Prevents the generated HTML report from opening automatically in the default browser. .PARAMETER ExtendedReport Generates extended report including detailed policy settings, script contents, conflict detection, and full JSON data. Recommended for troubleshooting and documentation. .PARAMETER OutputFolder Custom folder path for saving generated HTML reports. Defaults to 'reports' subfolder. .EXAMPLE .\IntuneDeviceDetailsGUI.ps1 Launches interactive mode with device search and report type selection. .EXAMPLE .\IntuneDeviceDetailsGUI.ps1 -SearchText "DESKTOP" Pre-filters device list to show only devices matching "DESKTOP". .EXAMPLE .\IntuneDeviceDetailsGUI.ps1 -Id 2e6e1d5f-b18a-44c6-989e-9bbb1efafbff -ExtendedReport Generates extended report directly for the specified device ID. .NOTES Version: 4.21 Author: Petri Paavola Requires: Microsoft.Graph.Authentication PowerShell module Cache files: cache\{TenantId}\ folder Reports: reports\ folder (or custom OutputFolder) .LINK https://github.com/petripaavola/IntuneDeviceDetailsGUI #> [CmdletBinding(DefaultParameterSetName = 'interactive')] param( [Parameter(Mandatory = $false, ParameterSetName = 'id', ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] [ValidateScript({ try { [System.Guid]::Parse($_) | Out-Null $true } catch { $false } })] [Alias('IntuneDeviceId')] [string]$Id, [Parameter(ParameterSetName = 'interactive')] [string]$SearchText, [switch]$ReloadCache, [switch]$SkipAssignments, [switch]$DoNotOpenReportAutomatically, [switch]$ExtendedReport, [string]$OutputFolder ) $Version = '4.21' $TimeOutBetweenGraphAPIRequests = 350 $GraphAPITop = 100 $script:ReloadCacheEveryNDays = 1 $script:ReportOutputFolder = if ($OutputFolder) { $OutputFolder } else { Join-Path -Path $PSScriptRoot -ChildPath 'reports' } $script:ReportOutputFolder = [System.IO.Path]::GetFullPath($script:ReportOutputFolder) $script:QuickSearchFilters = @() $script:AllIntuneFilters = @() $script:AppsWithAssignments = $null $Script:IntuneConfigurationProfilesWithAssignments = @() $script:GUIDHashtable = @{} function Validate-GUID { param([string]$GUID) if (-not $GUID) { return $false } $pattern = '^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$' return [bool]($GUID -match $pattern) } function Add-GUIDToHashtable { param( [Parameter(Mandatory)][PSObject]$Object ) # Extract ID from common property names $id = $null if ($Object.id) { $id = $Object.id } elseif ($Object.Id) { $id = $Object.Id } if (-not $id -or -not (Validate-GUID $id)) { return } if (-not $script:GUIDHashtable.ContainsKey($id)) { $value = @{ Object = $Object } # Extract common name properties for quick access if ($Object.displayName) { $value.displayName = $Object.displayName } if ($Object.name) { $value.name = $Object.name } $script:GUIDHashtable[$id] = $value } } function Get-NameFromGUID { param( [Parameter(Mandatory)][string]$Id, [ValidateSet('displayName', 'name', 'any')] [string]$PreferredProperty = 'any' ) if (-not $script:GUIDHashtable.ContainsKey($Id)) { return $null } $entry = $script:GUIDHashtable[$Id] if ($PreferredProperty -eq 'displayName' -and $entry.displayName) { return $entry.displayName } elseif ($PreferredProperty -eq 'name' -and $entry.name) { return $entry.name } else { # Return whichever is available if ($entry.displayName) { return $entry.displayName } if ($entry.name) { return $entry.name } } return $null } function Get-ObjectFromGUID { param( [Parameter(Mandatory)][string]$Id ) if (-not $script:GUIDHashtable.ContainsKey($Id)) { return $null } return $script:GUIDHashtable[$Id].Object } function ConvertTo-LocalDateTimeString { param([Parameter(Mandatory = $false)][AllowNull()][object]$DateTimeValue) if (-not $DateTimeValue) { return 'n/a' } try { $parsed = [datetimeoffset]::Parse($DateTimeValue.ToString()) } catch { return $DateTimeValue } return $parsed.LocalDateTime.ToString('yyyy-MM-dd HH:mm') } function Fix-UrlSpecialCharacters { param([Parameter(Mandatory)][string]$Url) $replacements = @( @(' ', '%20'), @('"', '%22'), @("'", '%27'), @('\\', '%5C'), @('@', '%40'), @('ä', '%C3%A4'), @('Ä', '%C3%84'), @('ö', '%C3%B6'), @('Ö', '%C3%96'), @('å', '%C3%A5'), @('Å', '%C3%85') ) foreach ($pair in $replacements) { $Url = $Url.Replace($pair[0], $pair[1]) } return $Url } function Get-CacheFolder { $tenantSegment = if ($script:TenantId) { $script:TenantId } else { 'default' } $cacheFolder = Join-Path -Path $PSScriptRoot -ChildPath "cache\$tenantSegment" Ensure-Directory -Path $cacheFolder | Out-Null return $cacheFolder } function Download-IntuneFilters { $url = 'https://graph.microsoft.com/beta/deviceManagement/assignmentFilters?`$select=*' $filters = Invoke-MGGraphGetRequestWithMSGraphAllPages $url # Add filter names to hashtable for easier access foreach ($filter in $filters) { Add-GUIDToHashtable -Object $filter } return [array]$filters } function Invoke-MGGraphPostRequest { param( [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$Body ) Start-Sleep -Milliseconds $TimeOutBetweenGraphAPIRequests $temporaryPath = Join-Path -Path $PSScriptRoot -ChildPath ("MgGraphRequest_{0}.json" -f (Get-Random)) try { Invoke-MgGraphRequest -Uri $Uri -Method POST -Body $Body -OutputFilePath $temporaryPath -ContentType 'application/json' | Out-Null if (-not (Test-Path $temporaryPath)) { return $null } return Get-Content $temporaryPath -Raw | ConvertFrom-Json } finally { if (Test-Path $temporaryPath) { Remove-Item -Path $temporaryPath -ErrorAction SilentlyContinue } } } function Objectify_JSON_Schema_and_Data_To_PowershellObjects { param([Parameter(Mandatory)][psobject]$ReportData) # Objectify Intune configuration policies report json results to individual PowerShell objects if (-not $ReportData.Schema -or (-not $ReportData.Values)) { return @() } $rows = @() foreach ($row in $ReportData.Values) { $entry = [ordered]@{} for ($i = 0; $i -lt $ReportData.Schema.Count; $i++) { #$name = $ReportData.Schema[$i].Name $name = $ReportData.Schema[$i].Column $entry[$name] = $row[$i] } $rows += [pscustomobject]$entry } return $rows } function Download-IntunePostTypeReport { param( [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$GraphAPIPostBody ) $ConfigurationPoliciesReportForDevice = @() do { $GraphAPIPostBodyJSON = $GraphAPIPostBody | ConvertFrom-Json $top = $GraphAPIPostBodyJSON.top $skip = $GraphAPIPostBodyJSON.skip $response = Invoke-MGGraphPostRequest -Uri $Uri -Body $GraphAPIPostBody if ($response) { # Success if ($response.Schema -and $response.Values) { # Objectify report results $MgGraphRequestObjectified = Objectify_JSON_Schema_and_Data_To_PowershellObjects -ReportData $response # Save results to variable $ConfigurationPoliciesReportForDevice += $MgGraphRequestObjectified # Get Count of results $count = $MgGraphRequestObjectified.Count if($count -ge $top) { # Increase report skip-value with amount of results we got earlier (should be same as top) # to get next batch of results $skip += $count # Increase count in json and convert to text #$GraphAPIPostRequestJSON.top = $top $GraphAPIPostBodyJSON.skip = $skip # Convert json to text $GraphAPIPostBody = $GraphAPIPostBodyJSON | ConvertTo-Json -Depth 3 } else { # Got all results Write-Verbose "Found $($ConfigurationPoliciesReportForDevice.Count) assignment objects" } } } else { Write-Verbose "Empty response from Graph API POST request to get report from $Uri" return $ConfigurationPoliciesReportForDevice } } while ($count -ge $top) return $ConfigurationPoliciesReportForDevice } function Download-IntuneConfigurationProfiles2 { param( [Parameter(Mandatory)][string]$GraphAPIUrl, [Parameter(Mandatory)][string]$jsonCacheFileName, [bool]$ReloadCacheData = $false ) $cacheFolder = Get-CacheFolder $jsonCacheFilePath = Join-Path $cacheFolder $jsonCacheFileName if ((Test-Path $jsonCacheFilePath) -and (-not $ReloadCacheData)) { $fileDetails = Get-Item $jsonCacheFilePath $cacheAgeDays = (New-TimeSpan $fileDetails.LastWriteTimeUtc (Get-Date)).Days if ($cacheAgeDays -lt $script:ReloadCacheEveryNDays) { # Check if any configurations were modified after cache file timestamp # ORIGINAL # -UFormat type #$cacheFileLastWriteTimeUtc = Get-Date $fileDetails.LastWriteTimeUtc -UFormat '%Y-%m-%dT%H:%M:%S.000Z' # Real ISO 8601 format # You need to escape the colon characters in the format string to make sure every culture uses the correct format $cacheFileLastWriteTimeUtc = $fileDetails.LastWriteTimeUtc.ToString("yyyy-MM-ddTHH\:mm\:ss.fffffffZ") # Replace $select=* with a narrow select $GraphAPIUrlCheckUpdatesFix = $GraphAPIUrl -replace '\$select=\*', '$select=id,lastModifiedDateTime' # URL for checking for changes using lastModifiedDateTime filter $checkUrl = "$($GraphAPIUrlCheckUpdatesFix)&`$filter=lastModifiedDateTime%20gt%20$($cacheFileLastWriteTimeUtc)&`$orderby=lastModifiedDateTime%20desc&`$top=100" Write-Verbose "Checking for changes in $jsonCacheFileName since $cacheFileLastWriteTimeUtc" try { $changedConfigs = Invoke-MgGraphGetRequestWithMSGraphAllPages $checkUrl if (-not $changedConfigs) { # No changes found, use cache Write-Verbose "No changes detected, using cached $jsonCacheFileName" return Get-Content $jsonCacheFilePath -Raw | ConvertFrom-Json } Write-Host "Changes detected in $jsonCacheFileName, reloading from Graph API" } catch { # If delta query fails, fall back to full reload Write-Host "Delta query failed for $jsonCacheFileName (error: $_), performing full reload" -ForegroundColor Yellow } } } $data = Invoke-MgGraphGetRequestWithMSGraphAllPages $GraphAPIUrl if ($data) { $data | ConvertTo-Json -Depth 6 | Out-File $jsonCacheFilePath -Force return Get-Content $jsonCacheFilePath -Raw | ConvertFrom-Json } return @() } function Invoke-MGGraphGetRequestWithMSGraphAllPages { param([Parameter(Mandatory)][string]$url) $allGraphAPIData = @() do { Start-Sleep -Milliseconds $TimeOutBetweenGraphAPIRequests # Retry logic for transient Graph API failures $maxRetries = 5 $retryCount = 0 $response = $null while ($retryCount -lt $maxRetries) { try { $response = Invoke-MgGraphRequest -Uri $url -Method Get -OutputType PSObject -ContentType 'application/json' break # Success - exit retry loop } catch { # Check if this is a retryable error (transient failures) $statusCode = $null # Try to get status code directly from Response object (most reliable) if ($_.Exception.Response -and $_.Exception.Response.StatusCode) { $statusCode = [int]$_.Exception.Response.StatusCode Write-Verbose "Graph API error status code: $statusCode" } # Determine if we should retry based on status code $shouldRetry = $false switch ($statusCode) { 429 { $shouldRetry = $true } # Too Many Requests (throttling) 500 { $shouldRetry = $true } # Internal Server Error 502 { $shouldRetry = $true } # Bad Gateway 503 { $shouldRetry = $true } # Service Unavailable 504 { $shouldRetry = $true } # Gateway Timeout default { # For other errors (404, 401, 403, etc.) or unknown errors, don't retry $shouldRetry = $false } } if ($shouldRetry -and $retryCount -lt $maxRetries) { $retryCount++ Write-Verbose "Graph API call failed with status $statusCode (attempt $retryCount/$maxRetries). Retrying in 1 second..." Start-Sleep -Seconds 1 } else { # Non-retryable error or max retries reached if ($statusCode -eq 404) { Write-Verbose "Resource not found (404): $url" } elseif ($statusCode -eq 400) { Write-Verbose "Bad request (400): $url - Check query parameters" } elseif ($statusCode -in @(401, 403)) { Write-Warning "Graph API permission error ($statusCode): $url" } elseif ($shouldRetry) { Write-Warning "Graph API call failed after $maxRetries attempts (status: $statusCode): $_" } else { Write-Verbose "Graph API call failed (non-retryable error, status: $statusCode): $_" } return $null } } } if (-not $response) { return $null } if (Get-Member -InputObject $response -Name 'Value' -MemberType Properties) { $allGraphAPIData += $response.Value if (($response.'@odata.nextLink' -like 'https://*') -and (-not ($url.Contains('$top=')))) { $url = $response.'@odata.nextLink' continue } $url = $null } else { return $response } } while ($url) return $allGraphAPIData } function Add-AzureADGroupGroupTypeExtraProperties { param([array]$Groups) foreach ($group in $Groups) { if ($group.'@odata.type' -eq '#microsoft.graph.directoryRole') { $group | Add-Member -NotePropertyName 'YodamiittiCustomGroupType' -NotePropertyValue 'DirectoryRole' -Force $group | Add-Member -NotePropertyName 'YodamiittiCustomMembershipType' -NotePropertyValue 'Role' -Force } else { $membershipRule = $group.membershipRule if ([string]::IsNullOrEmpty($membershipRule)) { $group | Add-Member -NotePropertyName 'YodamiittiCustomGroupType' -NotePropertyValue 'Security' -Force $group | Add-Member -NotePropertyName 'YodamiittiCustomMembershipType' -NotePropertyValue 'Assigned' -Force } else { $group | Add-Member -NotePropertyName 'YodamiittiCustomGroupType' -NotePropertyValue 'Security' -Force $group | Add-Member -NotePropertyName 'YodamiittiCustomMembershipType' -NotePropertyValue 'Dynamic' -Force } } } return $Groups } function Add-AzureADGroupDevicesAndUserMemberCountExtraProperties { param([array]$Groups) if (-not $Groups -or $Groups.Count -eq 0) { return $Groups } Write-Verbose "Getting Entra ID groups member count for $($Groups.Count) groups" # Process groups in batches of 20 (Graph API batch limit) for ($i = 0; $i -lt $Groups.count; $i += 20) { # Create requests hashtables $requests_devices_count = @{ requests = @() } $requests_users_count = @{ requests = @() } # Create max 20 requests in for-loop for ($a = $i; (($a -lt $i + 20) -and ($a -lt $Groups.count)); $a += 1) { if ($Groups[$a].'@odata.type' -eq '#microsoft.graph.directoryRole') { # Azure DirectoryRole is not Entra ID Group $GraphAPIBatchEntry_DevicesCount = @{ id = ($a + 1).ToString() method = "GET" url = "/directoryRoles/$($Groups[$a].id)" } $GraphAPIBatchEntry_UsersCount = @{ id = ($a + 1).ToString() method = "GET" url = "/directoryRoles/$($Groups[$a].id)" } } else { # Entra ID Group - get transitive member counts $GraphAPIBatchEntry_DevicesCount = @{ id = ($a + 1).ToString() method = "GET" url = "/groups/$($Groups[$a].id)/transitivemembers/microsoft.graph.device/`$count?ConsistencyLevel=eventual" } $GraphAPIBatchEntry_UsersCount = @{ id = ($a + 1).ToString() method = "GET" url = "/groups/$($Groups[$a].id)/transitivemembers/microsoft.graph.user/`$count?ConsistencyLevel=eventual" } } $requests_devices_count.requests += $GraphAPIBatchEntry_DevicesCount $requests_users_count.requests += $GraphAPIBatchEntry_UsersCount } # Get device counts via batch API $requests_devices_count_JSON = $requests_devices_count | ConvertTo-Json -Depth 10 $uri = 'https://graph.microsoft.com/beta/$batch' $AzureADGroups_Devices_MemberCount_Batch_Result = Invoke-MGGraphPostRequest -Uri $uri -Body $requests_devices_count_JSON.ToString() if ($AzureADGroups_Devices_MemberCount_Batch_Result) { # Process results for devices count batch requests foreach ($response in $AzureADGroups_Devices_MemberCount_Batch_Result.responses) { $GroupArrayIndex = $response.id - 1 if ($response.status -eq 200) { if ($Groups[$GroupArrayIndex].'@odata.type' -eq '#microsoft.graph.directoryRole') { $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupMembersCountDevices -Value 'N/A' -Force } else { $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupMembersCountDevices -Value $response.body -Force } } else { Write-Warning "Error getting devices count for group $($Groups[$GroupArrayIndex].displayName)" $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupMembersCountDevices -Value 'N/A' -Force } } } # Get user counts via batch API $requests_users_count_JSON = $requests_users_count | ConvertTo-Json -Depth 10 $AzureADGroups_Users_MemberCount_Batch_Result = Invoke-MGGraphPostRequest -Uri $uri -Body $requests_users_count_JSON.ToString() if ($AzureADGroups_Users_MemberCount_Batch_Result) { # Process results for users count batch requests foreach ($response in $AzureADGroups_Users_MemberCount_Batch_Result.responses) { $GroupArrayIndex = $response.id - 1 if ($response.status -eq 200) { if ($Groups[$GroupArrayIndex].'@odata.type' -eq '#microsoft.graph.directoryRole') { # Replace whole object with directoryRole details $Groups[$GroupArrayIndex] = $response.body $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupMembersCountUsers -Value 'N/A' -Force $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupMembersCountDevices -Value 'N/A' -Force $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupType -Value 'DirectoryRole' -Force } else { $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupMembersCountUsers -Value $response.body -Force } } else { Write-Warning "Error getting users count for group $($Groups[$GroupArrayIndex].displayName)" $Groups[$GroupArrayIndex] | Add-Member -MemberType NoteProperty -Name YodamiittiCustomGroupMembersCountUsers -Value 'N/A' -Force } } } } return $Groups } function Get-ApplicationsWithAssignments { param([bool]$ReloadCacheData = $false) $cacheFolder = Get-CacheFolder $cachePath = Join-Path $cacheFolder 'AllApplicationsWithAssignments.json' if ((Test-Path $cachePath) -and (-not $ReloadCacheData)) { $fileDetails = Get-Item $cachePath $ageDays = (New-TimeSpan $fileDetails.LastWriteTimeUtc (Get-Date)).Days if ($ageDays -lt $script:ReloadCacheEveryNDays) { # Check if any apps were modified after cache file timestamp $cacheFileLastWriteTimeUtc = Get-Date $fileDetails.LastWriteTimeUtc -UFormat '%Y-%m-%dT%H:%M:%S.000Z' Write-Host "Checking if apps were modified after $cacheFileLastWriteTimeUtc..." -ForegroundColor Cyan $checkUrl = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$filter=lastModifiedDateTime%20gt%20$cacheFileLastWriteTimeUtc&`$top=1" $changedApps = Invoke-MGGraphGetRequestWithMSGraphAllPages $checkUrl if (-not $changedApps) { # No changes found, use cache Write-Host "No app changes detected. Using cached data." -ForegroundColor Green $cachedApps = Get-Content $cachePath -Raw | ConvertFrom-Json foreach ($app in $cachedApps) { Add-GUIDToHashtable -Object $app } return $cachedApps } else { Write-Host "App changes detected. Reloading all apps..." -ForegroundColor Yellow } } } Write-Host "Downloading all apps with assignments..." -ForegroundColor Cyan $url = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?$expand=assignments&_=1577625591870' $apps = $null $apps = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($apps) { # Add App GUIDs to hashtable for easier access foreach ($app in $apps) { Add-GUIDToHashtable -Object $app } $apps | ConvertTo-Json -Depth 5 | Out-File $cachePath -Force return Get-Content $cachePath -Raw | ConvertFrom-Json } return @() } function Get-RemediationScriptsWithAssignments { param([bool]$ReloadCacheData = $false) # Note: Graph API does not support lastModifiedDateTime filtering for remediation scripts # Always download fresh data, but save to cache file for reference $cacheFolder = Get-CacheFolder $cachePath = Join-Path $cacheFolder 'RemediationScriptsAssignments.json' $url = 'https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?$expand=assignments&select=id,displayName,description,createdDateTime,lastModifiedDateTime,runAsAccount,deviceHealthScriptType,assignments' $scripts = $null $scripts = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scripts) { # Add Script GUIDs to hashtable for easier access foreach ($script in $scripts) { Add-GUIDToHashtable -Object $script } # Save to cache file for reference/debugging $scripts | ConvertTo-Json -Depth 5 | Out-File $cachePath -Force return $scripts } return @() } function Get-PlatformScriptsWithAssignments { param([bool]$ReloadCacheData = $false) $allScripts = @() # Windows platform scripts # Note: Graph API does not support lastModifiedDateTime filtering for platform scripts # Always download fresh data, but save to cache file for reference $cacheFolder = Get-CacheFolder $cachePath = Join-Path $cacheFolder 'WindowsPlatformScripts.json' $url = 'https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?$expand=assignments' $scripts = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scripts) { foreach ($script in $scripts) { $script | Add-Member -MemberType NoteProperty -Name 'ScriptPlatform' -Value 'Windows' -Force Add-GUIDToHashtable -Object $script } # Save to cache file for reference/debugging $scripts | ConvertTo-Json -Depth 5 | Out-File $cachePath -Force $allScripts += $scripts } # macOS shell scripts $cachePath = Join-Path $cacheFolder 'macOSShellScripts.json' $url = 'https://graph.microsoft.com/beta/deviceManagement/deviceShellScripts?$expand=assignments' $scripts = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scripts) { foreach ($script in $scripts) { $script | Add-Member -MemberType NoteProperty -Name 'ScriptPlatform' -Value 'macOS' -Force Add-GUIDToHashtable -Object $script } # Save to cache file for reference/debugging $scripts | ConvertTo-Json -Depth 5 | Out-File $cachePath -Force $allScripts += $scripts } # Linux bash scripts $cachePath = Join-Path $cacheFolder 'LinuxBashScripts.json' $url = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?`$expand=assignments&`$select=id,name,description,platforms,lastModifiedDateTime,technologies,settingCount,roleScopeTagIds,isAssigned,templateReference&`$filter=templateReference/TemplateFamily eq 'deviceConfigurationScripts'" $scripts = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scripts) { foreach ($script in $scripts) { $script | Add-Member -MemberType NoteProperty -Name 'ScriptPlatform' -Value 'Linux' -Force Add-GUIDToHashtable -Object $script } # Save to cache file for reference/debugging $scripts | ConvertTo-Json -Depth 5 | Out-File $cachePath -Force $allScripts += $scripts } return $allScripts } function Get-PowerShellScriptContent { param ( [Parameter(Mandatory = $true)] [string]$PowershellScriptPolicyId ) try { Write-Verbose "Downloading script content for script ID: $PowershellScriptPolicyId" $url = "https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts/$PowershellScriptPolicyId" $scriptData = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scriptData -and $scriptData.scriptContent) { # Decode base64 content $bytes = [System.Convert]::FromBase64String($scriptData.scriptContent) $scriptContentClearText = [System.Text.Encoding]::UTF8.GetString($bytes) return $scriptContentClearText } return $null } catch { Write-Warning "Failed to download script content for ID $PowershellScriptPolicyId : $_" return $null } } function Get-MacOSShellScriptContent { param ( [Parameter(Mandatory = $true)] [string]$ShellScriptPolicyId ) try { Write-Verbose "Downloading macOS shell script content for script ID: $ShellScriptPolicyId" $url = "https://graph.microsoft.com/beta/deviceManagement/deviceShellScripts/$ShellScriptPolicyId" $scriptData = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scriptData -and $scriptData.scriptContent) { # Decode base64 content $bytes = [System.Convert]::FromBase64String($scriptData.scriptContent) $scriptContentClearText = [System.Text.Encoding]::UTF8.GetString($bytes) return $scriptContentClearText } return $null } catch { Write-Warning "Failed to download macOS shell script content for ID $ShellScriptPolicyId : $_" return $null } } function Get-RemediationDetectionScriptContent { param ( [Parameter(Mandatory = $true)] [string]$ScriptPolicyId ) try { Write-Verbose "Downloading remediation detection script content for script ID: $ScriptPolicyId" $url = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$ScriptPolicyId" $scriptData = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scriptData -and $scriptData.detectionScriptContent) { # Decode base64 content $bytes = [System.Convert]::FromBase64String($scriptData.detectionScriptContent) $scriptContentClearText = [System.Text.Encoding]::UTF8.GetString($bytes) return $scriptContentClearText } return $null } catch { Write-Warning "Failed to download remediation detection script content for ID $ScriptPolicyId : $_" return $null } } function Get-RemediationRemediateScriptContent { param ( [Parameter(Mandatory = $true)] [string]$ScriptPolicyId ) try { Write-Verbose "Downloading remediation remediate script content for script ID: $ScriptPolicyId" $url = "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts/$ScriptPolicyId" $scriptData = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($scriptData -and $scriptData.remediationScriptContent) { # Decode base64 content $bytes = [System.Convert]::FromBase64String($scriptData.remediationScriptContent) $scriptContentClearText = [System.Text.Encoding]::UTF8.GetString($bytes) return $scriptContentClearText } return $null } catch { Write-Warning "Failed to download remediation remediate script content for ID $ScriptPolicyId : $_" return $null } } function Get-AppleEnrollmentProfileDetails { param ( [Parameter(Mandatory = $true)] [string]$EnrollmentProfileName ) try { Write-Verbose "Searching for Apple enrollment profile: $EnrollmentProfileName" # First, get all DEP onboarding settings with default profiles expanded $url = 'https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings?$expand=defaultiosenrollmentprofile,defaultmacosenrollmentprofile' $depSettings = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if (-not $depSettings) { Write-Verbose "No DEP onboarding settings found" return $null } # Phase 1: Check default enrollment profiles first foreach ($depToken in $depSettings) { # Check default iOS enrollment profile if ($depToken.defaultIosEnrollmentProfile -and $depToken.defaultIosEnrollmentProfile.displayName -eq $EnrollmentProfileName) { Write-Verbose "Found matching profile in default iOS enrollment profile of DEP token: $($depToken.tokenName)" return $depToken.defaultIosEnrollmentProfile } # Check default macOS enrollment profile if ($depToken.defaultMacOsEnrollmentProfile -and $depToken.defaultMacOsEnrollmentProfile.displayName -eq $EnrollmentProfileName) { Write-Verbose "Found matching profile in default macOS enrollment profile of DEP token: $($depToken.tokenName)" return $depToken.defaultMacOsEnrollmentProfile } } # Phase 2: If not found in defaults, search through all enrollment profiles for each DEP token Write-Verbose "Profile not found in default profiles, searching all enrollment profiles..." foreach ($depToken in $depSettings) { Write-Verbose "Searching enrollment profiles for DEP token: $($depToken.tokenName) (ID: $($depToken.id))" $profilesUrl = "https://graph.microsoft.com/beta/deviceManagement/depOnboardingSettings/$($depToken.id)/enrollmentProfiles" $enrollmentProfiles = Invoke-MGGraphGetRequestWithMSGraphAllPages $profilesUrl if ($enrollmentProfiles) { foreach ($profile in $enrollmentProfiles) { if ($profile.displayName -eq $EnrollmentProfileName) { Write-Verbose "Found matching profile: $($profile.displayName) in DEP token: $($depToken.tokenName)" return $profile } } } } Write-Verbose "Enrollment profile '$EnrollmentProfileName' not found in any DEP token" return $null } catch { Write-Warning "Failed to fetch Apple enrollment profile details for '$EnrollmentProfileName': $_" return $null } } function Get-SettingsCatalogPolicyDetails { param ( [Parameter(Mandatory = $true)] [string]$PolicyId ) try { Write-Verbose "Downloading Settings Catalog policy details for policy ID: $PolicyId" $url = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$PolicyId')/settings?`$expand=settingDefinitions" $settingsData = Invoke-MGGraphGetRequestWithMSGraphAllPages $url return $settingsData } catch { Write-Warning "Failed to download Settings Catalog policy details for ID $PolicyId : $_" return $null } } function Analyze-SettingsCatalogConflicts { param ( [Parameter(Mandatory = $true)] [array]$SettingsCatalogPolicies ) if (-not $SettingsCatalogPolicies -or $SettingsCatalogPolicies.Count -lt 2) { # Need at least 2 policies to have conflicts return $null } Write-Verbose "Analyzing Settings Catalog conflicts across $($SettingsCatalogPolicies.Count) assigned policies..." $allSettings = @() # Extract all settings from all policies into flat structure foreach ($policy in $SettingsCatalogPolicies) { $policyId = $policy.id # Settings Catalog policies use 'name' property, not 'displayName' $policyName = if ($policy.name) { $policy.name } elseif ($policy.displayName) { $policy.displayName } else { $policyId } Write-Verbose "Processing policy: ID=$policyId, Name='$policyName'" # Get the downloaded settings details if (-not $script:GUIDHashtable.ContainsKey($policyId)) { Write-Verbose "Policy $policyId not found in GUIDHashtable" continue } $policyData = $script:GUIDHashtable[$policyId] if (-not $policyData.settingsRawData) { Write-Verbose "Policy $policyId has no settingsRawData" continue } Write-Verbose "Policy $policyId has $($policyData.settingsRawData.Count) settings" # Process each setting in the policy foreach ($settingItem in $policyData.settingsRawData) { $settingInstance = $settingItem.settingInstance $settingDefinitions = $settingItem.settingDefinitions # Extract setting info $extracted = Extract-SettingInfo -SettingInstance $settingInstance -SettingDefinitions $settingDefinitions -PolicyId $policyId -PolicyName $policyName if ($extracted) { $allSettings += $extracted } } } if ($allSettings.Count -eq 0) { return $null } Write-Verbose "Extracted $($allSettings.Count) settings from policies. Analyzing for conflicts..." # Group by both SettingDefinitionId AND SettingName to ensure we're comparing the exact same setting # This prevents false positives when different settings share the same parent category $grouped = $allSettings | Group-Object -Property SettingDefinitionId,SettingName | Where-Object { $_.Count -gt 1 } $conflicts = @() $warnings = @() # Known additive settings that should always be treated as warnings, not conflicts # These settings merge values across policies rather than conflicting $additiveSettingNames = @( 'Excluded Extensions', 'Excluded Paths', 'Excluded Processes', 'Excluded File Extensions', 'Excluded File Paths', 'Excluded Process Names' ) foreach ($group in $grouped) { $settingDef = $group.Name $instances = $group.Group # Sanity check: Filter out instances from the same policy (rare edge case) # Group by PolicyId to ensure we only report conflicts/warnings between different policies $uniquePolicyIds = $instances | Select-Object -ExpandProperty PolicyId -Unique if ($uniquePolicyIds.Count -lt 2) { Write-Verbose "Skipping setting '$($instances[0].SettingName)' - all instances are from the same policy (PolicyId: $($uniquePolicyIds[0]))" continue } # Check if this is a known additive setting $isAdditiveSetting = $false foreach ($instance in $instances) { if ($additiveSettingNames -contains $instance.SettingName) { $isAdditiveSetting = $true break } } # Get unique values $uniqueValues = $instances | Select-Object -ExpandProperty Value -Unique if ($isAdditiveSetting) { # Additive settings - always treat as warning even with different values $warnings += [PSCustomObject]@{ SettingDefinitionId = $settingDef SettingName = $instances[0].SettingName Value = "Multiple values (additive)" Instances = $instances IsAdditive = $true } } elseif ($uniqueValues.Count -gt 1) { # CONFLICT - Different values for same setting $conflicts += [PSCustomObject]@{ SettingDefinitionId = $settingDef SettingName = $instances[0].SettingName Instances = $instances } } else { # WARNING - Same setting configured in multiple policies with same value $warnings += [PSCustomObject]@{ SettingDefinitionId = $settingDef SettingName = $instances[0].SettingName Value = $instances[0].Value Instances = $instances IsAdditive = $false } } } Write-Verbose "Found $($conflicts.Count) conflicts and $($warnings.Count) warnings" return [PSCustomObject]@{ Conflicts = $conflicts Warnings = $warnings HasIssues = ($conflicts.Count -gt 0 -or $warnings.Count -gt 0) } } function Extract-SettingInfo { param ( $SettingInstance, $SettingDefinitions, [string]$PolicyId, [string]$PolicyName, [string]$ParentPath = "", [switch]$IsInCollection ) if (-not $SettingInstance) { return $null } $results = @() $settingDefId = $SettingInstance.settingDefinitionId $settingDef = $SettingDefinitions | Where-Object { $_.id -eq $settingDefId } if (-not $settingDef) { return $null } $displayName = $settingDef.displayName $currentPath = if ($ParentPath) { "$ParentPath > $displayName" } else { $displayName } # Extract value based on setting type switch ($SettingInstance.'@odata.type') { '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' { $choiceValue = $SettingInstance.choiceSettingValue.value $matchingOption = $settingDef.options | Where-Object { $_.itemId -eq $choiceValue } $valueDisplay = if ($matchingOption) { $matchingOption.displayName } else { $choiceValue } # Only add if not in a collection (to avoid false positives from collection items) if (-not $IsInCollection) { $results += [PSCustomObject]@{ SettingDefinitionId = $settingDefId SettingName = $currentPath Value = $valueDisplay PolicyId = $PolicyId PolicyName = $PolicyName } } # Process children if ($SettingInstance.choiceSettingValue.children) { foreach ($child in $SettingInstance.choiceSettingValue.children) { $childResults = Extract-SettingInfo -SettingInstance $child -SettingDefinitions $SettingDefinitions -PolicyId $PolicyId -PolicyName $PolicyName -ParentPath $currentPath -IsInCollection:$IsInCollection if ($childResults) { $results += $childResults } } } } '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' { $simpleValue = $SettingInstance.simpleSettingValue.value # Only add if not in a collection if (-not $IsInCollection) { $results += [PSCustomObject]@{ SettingDefinitionId = $settingDefId SettingName = $currentPath Value = $simpleValue PolicyId = $PolicyId PolicyName = $PolicyName } } } '#microsoft.graph.deviceManagementConfigurationGroupSettingInstance' { # Group settings - process children if ($SettingInstance.groupSettingValue.children) { foreach ($child in $SettingInstance.groupSettingValue.children) { $childResults = Extract-SettingInfo -SettingInstance $child -SettingDefinitions $SettingDefinitions -PolicyId $PolicyId -PolicyName $PolicyName -ParentPath $currentPath -IsInCollection:$IsInCollection if ($childResults) { $results += $childResults } } } } '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance' { # Collection items are independent - don't compare across policies # Skip extracting values from collection items to avoid false positives Write-Verbose "Skipping collection setting: $currentPath (collections are additive, not conflicting)" } '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance' { # Multiple choice values - only compare if not already in a collection if (-not $IsInCollection -and $SettingInstance.choiceSettingCollectionValue) { $values = @() foreach ($choiceValue in $SettingInstance.choiceSettingCollectionValue) { $matchingOption = $settingDef.options | Where-Object { $_.itemId -eq $choiceValue.value } $values += if ($matchingOption) { $matchingOption.displayName } else { $choiceValue.value } } $results += [PSCustomObject]@{ SettingDefinitionId = $settingDefId SettingName = $currentPath Value = ($values -join ', ') PolicyId = $PolicyId PolicyName = $PolicyName } } } '#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance' { # Simple value collection - only compare if not already in a collection if (-not $IsInCollection -and $SettingInstance.simpleSettingCollectionValue) { $values = $SettingInstance.simpleSettingCollectionValue | ForEach-Object { $_.value } $results += [PSCustomObject]@{ SettingDefinitionId = $settingDefId SettingName = $currentPath Value = ($values -join ', ') PolicyId = $PolicyId PolicyName = $PolicyName } } } } return $results } function Get-OmaSettingPlainTextValue { param ( [Parameter(Mandatory = $true)] [string]$PolicyId, [Parameter(Mandatory = $true)] [string]$SecretReferenceValueId ) try { Write-Verbose "Fetching encrypted OMA setting value for policy ID: $PolicyId, secret: $SecretReferenceValueId" $url = "https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations('$PolicyId')/getOmaSettingPlainTextValue(secretReferenceValueId='$SecretReferenceValueId')" $response = Invoke-MgGraphRequest -Method GET -Uri $url return $response.value } catch { Write-Warning "Failed to fetch encrypted OMA setting value for policy ID $PolicyId : $_" return $null } } function ConvertTo-ReadableSettingsCatalog { param ( [Parameter(Mandatory = $true)] $SettingsData ) if (-not $SettingsData -or $SettingsData.Count -eq 0) { return $null } $readableSettings = @() foreach ($settingItem in $SettingsData) { $settingInstance = $settingItem.settingInstance $settingDefinitions = $settingItem.settingDefinitions # Parse the setting recursively $parsedSetting = Parse-SettingInstance -SettingInstance $settingInstance -SettingDefinitions $settingDefinitions -IndentLevel 0 if ($parsedSetting) { $readableSettings += $parsedSetting } } return ($readableSettings -join "`n`n") } function Parse-SettingInstance { param ( $SettingInstance, $SettingDefinitions, [int]$IndentLevel = 0 ) if (-not $SettingInstance) { return $null } $indent = ' ' * $IndentLevel $output = @() # Find the setting definition for this instance $settingDefId = $SettingInstance.settingDefinitionId $settingDef = $SettingDefinitions | Where-Object { $_.id -eq $settingDefId } if ($settingDef) { $displayName = $settingDef.displayName # Determine the configured value based on setting type switch ($SettingInstance.'@odata.type') { '#microsoft.graph.deviceManagementConfigurationChoiceSettingInstance' { # This is a choice setting (like a dropdown or toggle) $choiceValue = $SettingInstance.choiceSettingValue.value # Find the matching option in the definition $matchingOption = $settingDef.options | Where-Object { $_.itemId -eq $choiceValue } if ($matchingOption) { $configuredValue = $matchingOption.displayName } else { $configuredValue = $choiceValue } $output += "$indent$displayName : $configuredValue" # Process child settings if they exist if ($SettingInstance.choiceSettingValue.children -and $SettingInstance.choiceSettingValue.children.Count -gt 0) { foreach ($child in $SettingInstance.choiceSettingValue.children) { $childOutput = Parse-SettingInstance -SettingInstance $child -SettingDefinitions $SettingDefinitions -IndentLevel ($IndentLevel + 1) if ($childOutput) { $output += $childOutput } } } } '#microsoft.graph.deviceManagementConfigurationSimpleSettingInstance' { # Simple setting (text, number, etc.) $simpleValue = $SettingInstance.simpleSettingValue.value $output += "$indent$displayName : $simpleValue" } '#microsoft.graph.deviceManagementConfigurationGroupSettingInstance' { # Group setting - has multiple child settings $output += "$indent$displayName" if ($SettingInstance.groupSettingValue.children -and $SettingInstance.groupSettingValue.children.Count -gt 0) { foreach ($child in $SettingInstance.groupSettingValue.children) { $childOutput = Parse-SettingInstance -SettingInstance $child -SettingDefinitions $SettingDefinitions -IndentLevel ($IndentLevel + 1) if ($childOutput) { $output += $childOutput } } } } '#microsoft.graph.deviceManagementConfigurationGroupSettingCollectionInstance' { # Group setting collection - has an array of group setting values # Each group setting value has children # Example: Firewall rules, Windows Hello for Business policies if ($SettingInstance.groupSettingCollectionValue -and $SettingInstance.groupSettingCollectionValue.Count -gt 0) { $groupIndex = 0 foreach ($groupValue in $SettingInstance.groupSettingCollectionValue) { if ($groupValue.children -and $groupValue.children.Count -gt 0) { foreach ($child in $groupValue.children) { $childOutput = Parse-SettingInstance -SettingInstance $child -SettingDefinitions $SettingDefinitions -IndentLevel $IndentLevel if ($childOutput) { $output += $childOutput } } # Add blank line between collection items (e.g., between firewall rules) if ($groupIndex -lt ($SettingInstance.groupSettingCollectionValue.Count - 1)) { $output += "" } } $groupIndex++ } } } '#microsoft.graph.deviceManagementConfigurationChoiceSettingCollectionInstance' { # Choice setting collection - has an array of choice values # Example: Interface Types, Network Types (Profiles) in firewall rules if ($SettingInstance.choiceSettingCollectionValue -and $SettingInstance.choiceSettingCollectionValue.Count -gt 0) { $values = @() foreach ($choiceValue in $SettingInstance.choiceSettingCollectionValue) { # Find the matching option in the definition $matchingOption = $settingDef.options | Where-Object { $_.itemId -eq $choiceValue.value } if ($matchingOption) { $values += $matchingOption.displayName } else { $values += $choiceValue.value } } $output += "$indent$displayName : $($values -join ', ')" } } '#microsoft.graph.deviceManagementConfigurationSimpleSettingCollectionInstance' { # Simple setting collection - has an array of simple values # Example: Reusable groups (Remote Address Dynamic Keywords) in firewall rules if ($SettingInstance.simpleSettingCollectionValue -and $SettingInstance.simpleSettingCollectionValue.Count -gt 0) { $values = @() foreach ($simpleValue in $SettingInstance.simpleSettingCollectionValue) { # Handle reference settings (GUID references) if ($simpleValue.'@odata.type' -eq '#microsoft.graph.deviceManagementConfigurationReferenceSettingValue') { # This is a reference to another setting (like a reusable group) # For now, display the GUID - could potentially resolve to name later $values += $simpleValue.value } else { # Regular simple value $values += $simpleValue.value } } $output += "$indent$displayName : $($values -join ', ')" } } Default { # Unknown type - try to extract basic info $output += "$indent$displayName : (unsupported setting type: $($SettingInstance.'@odata.type'))" } } } return ($output -join "`n") } function ConvertTo-ReadableOmaSettings { param($OmaSettings) if (-not $OmaSettings -or $OmaSettings.Count -eq 0) { return $null } $output = @() foreach ($setting in $OmaSettings) { $lines = @() # Display Name if ($setting.displayName) { $lines += "Name : $($setting.displayName)" } # Description if ($setting.description) { $lines += "Description : $($setting.description)" } # OMA-URI if ($setting.omaUri) { $lines += "OMA-URI : $($setting.omaUri)" } # Data type based on @odata.type $dataType = switch ($setting.'@odata.type') { '#microsoft.graph.omaSettingBoolean' { 'Boolean' } '#microsoft.graph.omaSettingString' { 'String' } '#microsoft.graph.omaSettingInteger' { 'Integer' } '#microsoft.graph.omaSettingStringXml' { 'String (XML file)' } '#microsoft.graph.omaSettingBase64' { 'Base64' } Default { $setting.'@odata.type' -replace '#microsoft\.graph\.', '' } } $lines += "Data type : $dataType" # Value - handle different types if ($null -ne $setting.value) { $valueDisplay = switch ($setting.'@odata.type') { '#microsoft.graph.omaSettingBoolean' { $setting.value.ToString() } '#microsoft.graph.omaSettingStringXml' { # For XML, show first 100 chars or indicate it's XML content if ($setting.fileName) { "$($setting.fileName) (XML content)" } else { $xmlPreview = $setting.value.ToString() if ($xmlPreview.Length -gt 100) { "$($xmlPreview.Substring(0, 100))..." } else { $xmlPreview } } } '#microsoft.graph.omaSettingBase64' { # For Base64, show file name if available if ($setting.fileName) { "$($setting.fileName) (Base64 encoded)" } else { "Base64 encoded data" } } Default { $setting.value.ToString() } } $lines += "Value : $valueDisplay" } $output += ($lines -join "`n") } return ($output -join "`n`n") } function ConvertTo-ReadableWin32LobApp { param( $AppData, [switch]$ExtendedReport ) if (-not $AppData) { return $null } $odataType = $AppData.'@odata.type' if ($odataType -ne '#microsoft.graph.win32LobApp' -and $odataType -ne '#microsoft.graph.win32CatalogApp') { return $null } $output = @() # App Information if ($AppData.description) { $output += "Description : $($AppData.description)" } if ($AppData.publisher) { $output += "Publisher : $($AppData.publisher)" } if ($AppData.displayVersion) { $output += "App Version : $($AppData.displayVersion)" } # Program section $output += "`n--- Program ---" # Check for new install/uninstall script feature (January 2026) # Note: The batch apps query doesn't return activeInstallScript/activeUninstallScript properties # We use a dirty workaround: if we see placeholder command lines, fetch individual app to check for scripts $hasInstallScript = $AppData.activeInstallScript -and $AppData.activeInstallScript.targetId $hasUninstallScript = $AppData.activeUninstallScript -and $AppData.activeUninstallScript.targetId # Dirty workaround: Check for Microsoft's internal placeholder command lines that indicate scripts are used $suspectInstallPlaceholder = $AppData.installCommandLine -eq 'foobar.cmd' $suspectUninstallPlaceholder = $AppData.uninstallCommandLine -eq 'uninstall-foobar.cmd' # If both script properties are null AND we see the placeholder commands, fetch individual app if (($null -eq $AppData.activeInstallScript) -and ($null -eq $AppData.activeUninstallScript) -and ($suspectInstallPlaceholder -or $suspectUninstallPlaceholder) -and $AppData.id) { Write-Verbose "Win32App '$($AppData.displayName)': Detected placeholder commands, fetching individual app to check for scripts..." try { $individualAppUrl = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($AppData.id)?`$expand=assignments" $individualApp = Invoke-MGGraphGetRequestWithMSGraphAllPages $individualAppUrl if ($individualApp) { $hasInstallScript = $individualApp.activeInstallScript -and $individualApp.activeInstallScript.targetId $hasUninstallScript = $individualApp.activeUninstallScript -and $individualApp.activeUninstallScript.targetId Write-Verbose " After individual fetch: hasInstallScript=$hasInstallScript, hasUninstallScript=$hasUninstallScript" # Update AppData with the fetched script info for later use if ($individualApp.activeInstallScript) { $AppData | Add-Member -NotePropertyName 'activeInstallScript' -NotePropertyValue $individualApp.activeInstallScript -Force } if ($individualApp.activeUninstallScript) { $AppData | Add-Member -NotePropertyName 'activeUninstallScript' -NotePropertyValue $individualApp.activeUninstallScript -Force } } } catch { Write-Verbose " Failed to fetch individual app for script detection: $_" } } Write-Verbose "Win32App '$($AppData.displayName)': hasInstallScript=$hasInstallScript, hasUninstallScript=$hasUninstallScript" if ($hasInstallScript -or $hasUninstallScript) { # New script-based installation if ($hasInstallScript) { if ($ExtendedReport) { # Fetch script content try { $scriptId = $AppData.activeInstallScript.targetId $contentVersion = if ($AppData.committedContentVersion) { $AppData.committedContentVersion } else { '1' } $scriptUrl = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($AppData.id)/microsoft.graph.win32LobApp/contentVersions/$contentVersion/scripts/$scriptId`?`$select=id,displayName,content,state,microsoft.graph.win32LobAppInstallPowerShellScript/enforceSignatureCheck,microsoft.graph.win32LobAppInstallPowerShellScript/runAs32Bit" $scriptData = Invoke-MGGraphGetRequestWithMSGraphAllPages $scriptUrl if ($scriptData) { $output += "Install script : $($scriptData.displayName)" if ($scriptData.content) { $decodedScript = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($scriptData.content)) $output += " Enforce signature check : $(if ($scriptData.enforceSignatureCheck) { 'Yes' } else { 'No' })" $output += " Run as 32-bit : $(if ($scriptData.runAs32Bit) { 'Yes' } else { 'No' })" $output += "`n Install Script Content :" $output += " $decodedScript" } } } catch { $output += "Install script : Configured (ID: $($AppData.activeInstallScript.targetId))" Write-Verbose "Failed to fetch install script content: $_" } } else { $output += "Install script : Configured (use ExtendedReport to view content)" Write-Verbose " Added to output: Install script : Configured (use ExtendedReport to view content)" } } else { # No install script, show traditional command if present if ($AppData.installCommandLine) { $output += "Install command : $($AppData.installCommandLine)" Write-Verbose " Added to output: Install command : $($AppData.installCommandLine)" } } if ($hasUninstallScript) { if ($ExtendedReport) { # Fetch script content try { $scriptId = $AppData.activeUninstallScript.targetId $contentVersion = if ($AppData.committedContentVersion) { $AppData.committedContentVersion } else { '1' } $scriptUrl = "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps/$($AppData.id)/microsoft.graph.win32LobApp/contentVersions/$contentVersion/scripts/$scriptId`?`$select=id,displayName,content,state,microsoft.graph.win32LobAppUninstallPowerShellScript/enforceSignatureCheck,microsoft.graph.win32LobAppUninstallPowerShellScript/runAs32Bit" $scriptData = Invoke-MGGraphGetRequestWithMSGraphAllPages $scriptUrl if ($scriptData) { $output += "Uninstall script : $($scriptData.displayName)" if ($scriptData.content) { $decodedScript = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($scriptData.content)) $output += " Enforce signature check : $(if ($scriptData.enforceSignatureCheck) { 'Yes' } else { 'No' })" $output += " Run as 32-bit : $(if ($scriptData.runAs32Bit) { 'Yes' } else { 'No' })" $output += "`n Uninstall Script Content :" $output += " $decodedScript" } } } catch { $output += "Uninstall script : Configured (ID: $($AppData.activeUninstallScript.targetId))" Write-Verbose "Failed to fetch uninstall script content: $_" } } else { $output += "Uninstall script : Configured (use ExtendedReport to view content)" } } else { # No uninstall script, show traditional command if present if ($AppData.uninstallCommandLine) { $output += "Uninstall command : $($AppData.uninstallCommandLine)" } } } else { # No scripts - use traditional command-line installation if ($AppData.installCommandLine) { $output += "Install command : $($AppData.installCommandLine)" } if ($AppData.uninstallCommandLine) { $output += "Uninstall command : $($AppData.uninstallCommandLine)" } } if ($AppData.installExperience) { $runAs = switch ($AppData.installExperience.runAsAccount) { 'system' { 'System' } 'user' { 'User' } Default { $AppData.installExperience.runAsAccount } } $output += "Install behavior : $runAs" $restartBehavior = switch ($AppData.installExperience.deviceRestartBehavior) { 'suppress' { 'No specific action' } 'allow' { 'App install may force a device restart' } 'basedOnReturnCode' { 'Determine behavior based on return codes' } 'force' { 'Intune will force mandatory device restart' } Default { $AppData.installExperience.deviceRestartBehavior } } $output += "Device restart behavior : $restartBehavior" if ($AppData.installExperience.maxRunTimeInMinutes) { $output += "Installation time required (mins) : $($AppData.installExperience.maxRunTimeInMinutes)" } } # Return codes if ($AppData.returnCodes -and $AppData.returnCodes.Count -gt 0) { $output += "`nReturn codes :" foreach ($returnCode in $AppData.returnCodes) { $typeDisplay = switch ($returnCode.type) { 'success' { 'Success' } 'softReboot' { 'Soft reboot' } 'hardReboot' { 'Hard reboot' } 'retry' { 'Retry' } 'failed' { 'Failed' } Default { $returnCode.type } } $output += " $($returnCode.returnCode) - $typeDisplay" } } # Requirements $output += "`n--- Requirements ---" # Operating system architecture if ($AppData.applicableArchitectures) { $archDisplay = $AppData.applicableArchitectures -replace 'x64', 'x64' -replace 'x86', 'x86' -replace 'arm64', 'arm64' $output += "Check operating system architecture : $archDisplay" } # Minimum OS if ($AppData.minimumSupportedWindowsRelease) { $osVersion = switch ($AppData.minimumSupportedWindowsRelease) { '1607' { 'Windows 10 1607' } '1703' { 'Windows 10 1703' } '1709' { 'Windows 10 1709' } '1803' { 'Windows 10 1803' } '1809' { 'Windows 10 1809' } '1903' { 'Windows 10 1903' } '1909' { 'Windows 10 1909' } '2004' { 'Windows 10 2004' } '2H20' { 'Windows 10 20H2' } '21H1' { 'Windows 10 21H1' } Default { "Windows 10 $($AppData.minimumSupportedWindowsRelease)" } } $output += "Minimum operating system : $osVersion" } # Additional requirements $hasAdditionalReqs = $false if ($AppData.minimumFreeDiskSpaceInMB) { $output += "Disk space required (MB) : $($AppData.minimumFreeDiskSpaceInMB)" $hasAdditionalReqs = $true } if ($AppData.minimumMemoryInMB) { $output += "Physical memory required (MB) : $($AppData.minimumMemoryInMB)" $hasAdditionalReqs = $true } if ($AppData.minimumNumberOfProcessors) { $output += "Minimum number of logical processors required : $($AppData.minimumNumberOfProcessors)" $hasAdditionalReqs = $true } if ($AppData.minimumCpuSpeedInMHz) { $output += "Minimum CPU speed required (MHz) : $($AppData.minimumCpuSpeedInMHz)" $hasAdditionalReqs = $true } # Custom requirement rules # Win32LobApp uses requirementRules, Win32CatalogApp uses rules array with ruleType='requirement' $requirementRulesToProcess = @() if ($AppData.requirementRules -and $AppData.requirementRules.Count -gt 0) { $requirementRulesToProcess = $AppData.requirementRules } elseif ($AppData.rules -and $AppData.rules.Count -gt 0) { $requirementRulesToProcess = $AppData.rules | Where-Object { $_.ruleType -eq 'requirement' } } if ($requirementRulesToProcess -and $requirementRulesToProcess.Count -gt 0) { $hasAdditionalReqs = $true foreach ($rule in $requirementRulesToProcess) { switch ($rule.'@odata.type') { '#microsoft.graph.win32LobAppFileSystemRequirement' { $output += "`nFile or folder requirement :" $output += " Path : $($rule.path)" if ($rule.fileOrFolderName) { $output += " File or folder name : $($rule.fileOrFolderName)" } $operatorText = switch ($rule.operator) { 'notConfigured' { 'Not configured' } 'exists' { 'Exists' } 'modifiedDate' { 'Modified date' } 'createdDate' { 'Created date' } 'version' { 'Version' } 'sizeInMB' { 'Size in MB' } Default { $rule.operator } } $output += " Detection type : $operatorText" if ($rule.comparisonValue) { $output += " Value : $($rule.comparisonValue)" } } '#microsoft.graph.win32LobAppRegistryRequirement' { $output += "`nRegistry requirement :" $keyPath = switch ($rule.keyPath) { { $_ -match '^HKEY_LOCAL_MACHINE' } { $_ -replace 'HKEY_LOCAL_MACHINE', 'HKLM' } { $_ -match '^HKEY_CURRENT_USER' } { $_ -replace 'HKEY_CURRENT_USER', 'HKCU' } Default { $rule.keyPath } } $output += " Key path : $keyPath" if ($rule.valueName) { $output += " Value name : $($rule.valueName)" } $operatorText = switch ($rule.operator) { 'notConfigured' { 'Not configured' } 'exists' { 'Key exists' } 'doesNotExist' { 'Key does not exist' } 'string' { 'String comparison' } 'integer' { 'Integer comparison' } 'version' { 'Version comparison' } Default { $rule.operator } } $output += " Detection type : $operatorText" if ($rule.comparisonValue) { $output += " Value : $($rule.comparisonValue)" } } '#microsoft.graph.win32LobAppPowerShellScriptRequirement' { $output += "`nPowerShell script requirement :" $output += " Display name : $($rule.displayName)" $output += " Enforce signature check : $(if ($rule.enforceSignatureCheck) { 'Yes' } else { 'No' })" $output += " Run as 32-bit on 64-bit : $(if ($rule.runAs32Bit) { 'Yes' } else { 'No' })" if ($rule.scriptContent) { try { # Decode Base64 script content $decodedScript = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($rule.scriptContent)) $output += "`n Script content :" $output += " $decodedScript" } catch { # If decoding fails, show as-is $output += "`n Script content (Base64) :" $output += " $($rule.scriptContent)" } } } } } } if (-not $hasAdditionalReqs) { $output += "No Additional requirement rules" } # Detection rules $output += "`n--- Detection rules ---" # Win32LobApp uses detectionRules, Win32CatalogApp uses rules array with ruleType='detection' $detectionRulesToProcess = @() if ($AppData.detectionRules -and $AppData.detectionRules.Count -gt 0) { $detectionRulesToProcess = $AppData.detectionRules } elseif ($AppData.rules -and $AppData.rules.Count -gt 0) { $detectionRulesToProcess = $AppData.rules | Where-Object { $_.ruleType -eq 'detection' } } if ($detectionRulesToProcess -and $detectionRulesToProcess.Count -gt 0) { $ruleCount = 0 foreach ($rule in $detectionRulesToProcess) { $ruleCount++ if ($detectionRulesToProcess.Count -gt 1) { $output += "`nRule $ruleCount :" } switch ($rule.'@odata.type') { { $_ -in @('#microsoft.graph.win32LobAppFileSystemDetection', '#microsoft.graph.win32LobAppFileSystemRule') } { $output += "File or folder detection :" $output += " Path : $($rule.path)" if ($rule.fileOrFolderName) { $output += " File or folder name : $($rule.fileOrFolderName)" } # Use operationType for Win32CatalogApp, detectionType for Win32LobApp $typeValue = if ($rule.operationType) { $rule.operationType } else { $rule.detectionType } $detectionType = switch ($typeValue) { 'notConfigured' { 'Not configured' } 'exists' { 'File or folder exists' } 'modifiedDate' { 'Modified date' } 'createdDate' { 'Created date' } 'version' { 'Version' } 'sizeInMB' { 'Size (MB)' } 'sizeInBytes' { 'Size (bytes)' } Default { $typeValue } } $output += " Detection type : $detectionType" # Handle both Win32CatalogApp (operator/comparisonValue) and Win32LobApp (operator/detectionValue) $operatorValue = $rule.operator $compValue = if ($rule.comparisonValue) { $rule.comparisonValue } else { $rule.detectionValue } if ($operatorValue -and $compValue) { $operatorText = switch ($operatorValue) { 'equal' { 'Equal to' } 'notEqual' { 'Not equal to' } 'greaterThan' { 'Greater than' } 'greaterThanOrEqual' { 'Greater than or equal to' } 'lessThan' { 'Less than' } 'lessThanOrEqual' { 'Less than or equal to' } Default { $operatorValue } } $output += " Operator : $operatorText" $output += " Value : $compValue" } if ($rule.check32BitOn64System) { $output += " Associated with a 32-bit app on 64-bit : Yes" } } { $_ -in @('#microsoft.graph.win32LobAppRegistryDetection', '#microsoft.graph.win32LobAppRegistryRule') } { $output += "Registry detection :" $keyPath = switch ($rule.keyPath) { { $_ -match '^HKEY_LOCAL_MACHINE' } { $_ -replace 'HKEY_LOCAL_MACHINE', 'HKLM' } { $_ -match '^HKEY_CURRENT_USER' } { $_ -replace 'HKEY_CURRENT_USER', 'HKCU' } Default { $rule.keyPath } } $output += " Key path : $keyPath" if ($rule.valueName) { $output += " Value name : $($rule.valueName)" } # Use operationType for Win32CatalogApp, detectionType for Win32LobApp $typeValue = if ($rule.operationType) { $rule.operationType } else { $rule.detectionType } $detectionType = switch ($typeValue) { 'notConfigured' { 'Not configured' } 'exists' { 'Key or value exists' } 'doesNotExist' { 'Key or value does not exist' } 'string' { 'String comparison' } 'integer' { 'Integer comparison' } 'version' { 'Version comparison' } Default { $typeValue } } $output += " Detection type : $detectionType" # Handle both Win32CatalogApp (operator/comparisonValue) and Win32LobApp (operator/detectionValue) $operatorValue = $rule.operator $compValue = if ($rule.comparisonValue) { $rule.comparisonValue } else { $rule.detectionValue } if ($operatorValue -and $compValue) { $operatorText = switch ($operatorValue) { 'equal' { 'Equal to' } 'notEqual' { 'Not equal to' } 'greaterThan' { 'Greater than' } 'greaterThanOrEqual' { 'Greater than or equal to' } 'lessThan' { 'Less than' } 'lessThanOrEqual' { 'Less than or equal to' } Default { $operatorValue } } $output += " Operator : $operatorText" $output += " Value : $compValue" } if ($rule.check32BitOn64System) { $output += " Associated with a 32-bit app on 64-bit : Yes" } } '#microsoft.graph.win32LobAppProductCodeDetection' { $output += "MSI product code detection :" $output += " Product code : $($rule.productCode)" if ($rule.productVersion) { $versionOperator = switch ($rule.productVersionOperator) { 'notConfigured' { 'Not configured' } 'equal' { 'Equal' } 'notEqual' { 'Not equal' } 'greaterThan' { 'Greater than' } 'greaterThanOrEqual' { 'Greater than or equal' } 'lessThan' { 'Less than' } 'lessThanOrEqual' { 'Less than or equal' } Default { $rule.productVersionOperator } } $output += " Product version operator : $versionOperator" $output += " Product version : $($rule.productVersion)" } } '#microsoft.graph.win32LobAppPowerShellScriptDetection' { $output += "PowerShell script detection :" $output += " Enforce signature check : $(if ($rule.enforceSignatureCheck) { 'Yes' } else { 'No' })" $output += " Run as 32-bit on 64-bit : $(if ($rule.runAs32Bit) { 'Yes' } else { 'No' })" if ($rule.scriptContent) { try { # Decode Base64 script content $decodedScript = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($rule.scriptContent)) $output += "`n Script content :" $output += " $decodedScript" } catch { # If decoding fails, show as-is $output += "`n Script content (Base64) :" $output += " $($rule.scriptContent)" } } } } } } # MSI Information (if available) if ($AppData.msiInformation) { $output += "`n--- MSI Information ---" if ($AppData.msiInformation.productName) { $output += "Product name : $($AppData.msiInformation.productName)" } if ($AppData.msiInformation.publisher) { $output += "Publisher : $($AppData.msiInformation.publisher)" } if ($AppData.msiInformation.productVersion) { $output += "Product version : $($AppData.msiInformation.productVersion)" } if ($AppData.msiInformation.productCode) { $output += "Product code : $($AppData.msiInformation.productCode)" } if ($AppData.msiInformation.upgradeCode) { $output += "Upgrade code : $($AppData.msiInformation.upgradeCode)" } $packageType = switch ($AppData.msiInformation.packageType) { 'perMachine' { 'Per-machine' } 'perUser' { 'Per-user' } 'dualPurpose' { 'Dual purpose' } Default { $AppData.msiInformation.packageType } } $output += "Package type : $packageType" $output += "Requires reboot : $(if ($AppData.msiInformation.requiresReboot) { 'Yes' } else { 'No' })" } return ($output -join "`n") } function ConvertTo-ReadableMacOSDmgApp { param($AppData) if (-not $AppData) { return $null } $odataType = $AppData.'@odata.type' if ($odataType -ne '#microsoft.graph.macOSDmgApp') { return $null } $output = @() # App Information if ($AppData.description) { $output += "Description : $($AppData.description)" } if ($AppData.publisher) { $output += "Publisher : $($AppData.publisher)" } if ($AppData.primaryBundleVersion) { $output += "Bundle Version : $($AppData.primaryBundleVersion)" } if ($AppData.primaryBundleId) { $output += "Bundle ID : $($AppData.primaryBundleId)" } # File Information $output += "`n--- File Information ---" if ($AppData.fileName) { $output += "File name : $($AppData.fileName)" } if ($AppData.size) { $sizeGB = [math]::Round($AppData.size / 1GB, 2) $sizeMB = [math]::Round($AppData.size / 1MB, 2) if ($sizeGB -ge 1) { $output += "File size : $sizeGB GB" } else { $output += "File size : $sizeMB MB" } } # Detection Settings $output += "`n--- Detection ---" if ($AppData.ignoreVersionDetection -ne $null) { $output += "Ignore app version : $(if ($AppData.ignoreVersionDetection) { 'Yes' } else { 'No' })" } if ($AppData.includedApps -and $AppData.includedApps.Count -gt 0) { $output += "`nIncluded Apps:" foreach ($includedApp in $AppData.includedApps) { $output += " Bundle ID : $($includedApp.bundleId)" if ($includedApp.bundleVersion) { $output += " Version : $($includedApp.bundleVersion)" } } } # Minimum OS Requirements if ($AppData.minimumSupportedOperatingSystem) { $output += "`n--- Minimum macOS Version ---" $minOS = $AppData.minimumSupportedOperatingSystem $osVersions = @( @{Name='macOS 10.7 (Lion)'; Key='v10_7'}, @{Name='macOS 10.8 (Mountain Lion)'; Key='v10_8'}, @{Name='macOS 10.9 (Mavericks)'; Key='v10_9'}, @{Name='macOS 10.10 (Yosemite)'; Key='v10_10'}, @{Name='macOS 10.11 (El Capitan)'; Key='v10_11'}, @{Name='macOS 10.12 (Sierra)'; Key='v10_12'}, @{Name='macOS 10.13 (High Sierra)'; Key='v10_13'}, @{Name='macOS 10.14 (Mojave)'; Key='v10_14'}, @{Name='macOS 10.15 (Catalina)'; Key='v10_15'}, @{Name='macOS 11.0 (Big Sur)'; Key='v11_0'}, @{Name='macOS 12.0 (Monterey)'; Key='v12_0'}, @{Name='macOS 13.0 (Ventura)'; Key='v13_0'}, @{Name='macOS 14.0 (Sonoma)'; Key='v14_0'}, @{Name='macOS 15.0 (Sequoia)'; Key='v15_0'} ) $requiredOS = $null foreach ($ver in $osVersions) { if ($minOS.($ver.Key) -eq $true) { $requiredOS = $ver.Name break } } if ($requiredOS) { $output += "Minimum OS : $requiredOS" } } return ($output -join "`n") } function ConvertTo-ReadableMacOSPkgApp { param($AppData) if (-not $AppData) { return $null } $odataType = $AppData.'@odata.type' if ($odataType -ne '#microsoft.graph.macOSPkgApp') { return $null } $output = @() # App Information if ($AppData.description) { $output += "Description : $($AppData.description)" } if ($AppData.publisher) { $output += "Publisher : $($AppData.publisher)" } if ($AppData.primaryBundleVersion) { $output += "Bundle Version : $($AppData.primaryBundleVersion)" } if ($AppData.primaryBundleId) { $output += "Bundle ID : $($AppData.primaryBundleId)" } # File Information $output += "`n--- File Information ---" if ($AppData.fileName) { $output += "File name : $($AppData.fileName)" } if ($AppData.size) { $sizeGB = [math]::Round($AppData.size / 1GB, 2) $sizeMB = [math]::Round($AppData.size / 1MB, 2) if ($sizeGB -ge 1) { $output += "File size : $sizeGB GB" } else { $output += "File size : $sizeMB MB" } } # Scripts if ($AppData.preInstallScript -or $AppData.postInstallScript) { $output += "`n--- Install Scripts ---" if ($AppData.preInstallScript) { $output += "Pre-install script : Configured" if ($AppData.preInstallScript.scriptContent) { $output += " Script content available" } } if ($AppData.postInstallScript) { $output += "Post-install script : Configured" if ($AppData.postInstallScript.scriptContent) { $output += " Script content available" } } } # Detection Settings $output += "`n--- Detection ---" if ($AppData.ignoreVersionDetection -ne $null) { $output += "Ignore app version : $(if ($AppData.ignoreVersionDetection) { 'Yes' } else { 'No' })" } if ($AppData.includedApps -and $AppData.includedApps.Count -gt 0) { $output += "`nIncluded Apps:" foreach ($includedApp in $AppData.includedApps) { $output += " Bundle ID : $($includedApp.bundleId)" if ($includedApp.bundleVersion) { $output += " Version : $($includedApp.bundleVersion)" } } } # Minimum OS Requirements if ($AppData.minimumSupportedOperatingSystem) { $output += "`n--- Minimum macOS Version ---" $minOS = $AppData.minimumSupportedOperatingSystem $osVersions = @( @{Name='macOS 10.7 (Lion)'; Key='v10_7'}, @{Name='macOS 10.8 (Mountain Lion)'; Key='v10_8'}, @{Name='macOS 10.9 (Mavericks)'; Key='v10_9'}, @{Name='macOS 10.10 (Yosemite)'; Key='v10_10'}, @{Name='macOS 10.11 (El Capitan)'; Key='v10_11'}, @{Name='macOS 10.12 (Sierra)'; Key='v10_12'}, @{Name='macOS 10.13 (High Sierra)'; Key='v10_13'}, @{Name='macOS 10.14 (Mojave)'; Key='v10_14'}, @{Name='macOS 10.15 (Catalina)'; Key='v10_15'}, @{Name='macOS 11.0 (Big Sur)'; Key='v11_0'}, @{Name='macOS 12.0 (Monterey)'; Key='v12_0'}, @{Name='macOS 13.0 (Ventura)'; Key='v13_0'}, @{Name='macOS 14.0 (Sonoma)'; Key='v14_0'}, @{Name='macOS 15.0 (Sequoia)'; Key='v15_0'} ) $requiredOS = $null foreach ($ver in $osVersions) { if ($minOS.($ver.Key) -eq $true) { $requiredOS = $ver.Name break } } if ($requiredOS) { $output += "Minimum OS : $requiredOS" } } return ($output -join "`n") } function ConvertTo-ReadableIosVppApp { param($AppData) if (-not $AppData) { return $null } $odataType = $AppData.'@odata.type' if ($odataType -ne '#microsoft.graph.iosVppApp') { return $null } $output = @() # App Information if ($AppData.description) { $output += "Description : $($AppData.description)" } if ($AppData.publisher) { $output += "Publisher : $($AppData.publisher)" } if ($AppData.bundleId) { $output += "Bundle ID : $($AppData.bundleId)" } if ($AppData.informationUrl) { $output += "App Store URL : $($AppData.informationUrl)" } # VPP Information $output += "`n--- VPP (Volume Purchase Program) ---" if ($AppData.vppTokenOrganizationName) { $output += "Organization : $($AppData.vppTokenOrganizationName)" } if ($AppData.vppTokenDisplayName) { $output += "VPP Token : $($AppData.vppTokenDisplayName)" } if ($AppData.vppTokenAppleId) { $output += "Apple ID : $($AppData.vppTokenAppleId)" } if ($AppData.vppTokenAccountType) { $accountType = switch ($AppData.vppTokenAccountType) { 'business' { 'Business' } 'education' { 'Education' } Default { $AppData.vppTokenAccountType } } $output += "Account Type : $accountType" } # License Information $output += "`n--- License Information ---" if ($AppData.totalLicenseCount -ne $null) { $output += "Total Licenses : $($AppData.totalLicenseCount)" } if ($AppData.usedLicenseCount -ne $null) { $available = $AppData.totalLicenseCount - $AppData.usedLicenseCount $output += "Used Licenses : $($AppData.usedLicenseCount)" $output += "Available Licenses : $available" } # Licensing Type if ($AppData.licensingType) { $output += "`nLicensing Support:" if ($AppData.licensingType.supportsUserLicensing -or $AppData.licensingType.supportUserLicensing) { $output += " User Licensing : Yes" } if ($AppData.licensingType.supportsDeviceLicensing -or $AppData.licensingType.supportDeviceLicensing) { $output += " Device Licensing : Yes" } } # Applicable Device Type if ($AppData.applicableDeviceType) { $output += "`n--- Applicable Devices ---" $deviceTypes = @() if ($AppData.applicableDeviceType.iPad) { $deviceTypes += "iPad" } if ($AppData.applicableDeviceType.iPhoneAndIPod) { $deviceTypes += "iPhone and iPod" } if ($deviceTypes.Count -gt 0) { $output += "Device Types : $($deviceTypes -join ', ')" } } return ($output -join "`n") } function ConvertTo-ReadableMacOsVppApp { param($AppData) if (-not $AppData) { return $null } $odataType = $AppData.'@odata.type' if ($odataType -ne '#microsoft.graph.macOsVppApp') { return $null } $output = @() # App Information if ($AppData.description) { $output += "Description : $($AppData.description)" } if ($AppData.publisher) { $output += "Publisher : $($AppData.publisher)" } if ($AppData.bundleId) { $output += "Bundle ID : $($AppData.bundleId)" } if ($AppData.informationUrl) { $output += "App Store URL : $($AppData.informationUrl)" } # VPP Information $output += "`n--- VPP (Volume Purchase Program) ---" if ($AppData.vppTokenOrganizationName) { $output += "Organization : $($AppData.vppTokenOrganizationName)" } if ($AppData.vppTokenDisplayName) { $output += "VPP Token : $($AppData.vppTokenDisplayName)" } if ($AppData.vppTokenAppleId) { $output += "Apple ID : $($AppData.vppTokenAppleId)" } if ($AppData.vppTokenAccountType) { $accountType = switch ($AppData.vppTokenAccountType) { 'business' { 'Business' } 'education' { 'Education' } Default { $AppData.vppTokenAccountType } } $output += "Account Type : $accountType" } # License Information $output += "`n--- License Information ---" if ($AppData.totalLicenseCount -ne $null) { $output += "Total Licenses : $($AppData.totalLicenseCount)" } if ($AppData.usedLicenseCount -ne $null) { $available = $AppData.totalLicenseCount - $AppData.usedLicenseCount $output += "Used Licenses : $($AppData.usedLicenseCount)" $output += "Available Licenses : $available" } # Licensing Type if ($AppData.licensingType) { $output += "`nLicensing Support:" if ($AppData.licensingType.supportsUserLicensing -or $AppData.licensingType.supportUserLicensing) { $output += " User Licensing : Yes" } if ($AppData.licensingType.supportsDeviceLicensing -or $AppData.licensingType.supportDeviceLicensing) { $output += " Device Licensing : Yes" } } return ($output -join "`n") } function ConvertTo-ReadableWebApp { param($AppData) if (-not $AppData) { return $null } $odataType = $AppData.'@odata.type' if ($odataType -ne '#microsoft.graph.webApp') { return $null } $output = @() # App URL - most important if ($AppData.appUrl) { $output += "App URL : $($AppData.appUrl)" } # App Information if ($AppData.description) { $output += "Description : $($AppData.description)" } if ($AppData.publisher) { $output += "Publisher : $($AppData.publisher)" } # Browser settings if ($AppData.useManagedBrowser -ne $null) { $browserSetting = if ($AppData.useManagedBrowser) { 'Yes (Managed Browser required)' } else { 'No (Any browser)' } $output += "Use Managed Browser : $browserSetting" } # Additional URLs if ($AppData.informationUrl) { $output += "Information URL : $($AppData.informationUrl)" } if ($AppData.privacyInformationUrl) { $output += "Privacy URL : $($AppData.privacyInformationUrl)" } return ($output -join "`n") } function ConvertTo-ReadableWinGetApp { param($AppData) if (-not $AppData) { return $null } $odataType = $AppData.'@odata.type' if ($odataType -ne '#microsoft.graph.winGetApp') { return $null } $output = @() # App Information if ($AppData.description) { $output += "Description : $($AppData.description)" } if ($AppData.publisher) { $output += "`nPublisher : $($AppData.publisher)" } # Package Identifier - most important for WinGet if ($AppData.packageIdentifier) { $output += "Package Identifier : $($AppData.packageIdentifier)" } # Install Experience if ($AppData.installExperience) { $output += "`n--- Install Experience ---" if ($AppData.installExperience.runAsAccount) { $runAs = switch ($AppData.installExperience.runAsAccount) { 'system' { 'System' } 'user' { 'User' } Default { $AppData.installExperience.runAsAccount } } $output += "Run as account : $runAs" } } # Publishing Information if ($AppData.publishingState) { $output += "`nPublishing State : $($AppData.publishingState)" } # Additional URLs if ($AppData.informationUrl) { $output += "Information URL : $($AppData.informationUrl)" } if ($AppData.privacyInformationUrl) { $output += "Privacy URL : $($AppData.privacyInformationUrl)" } # App Dependencies if ($AppData.dependentAppCount -and $AppData.dependentAppCount -gt 0) { $output += "`nDependent Apps : $($AppData.dependentAppCount)" } if ($AppData.supersedingAppCount -and $AppData.supersedingAppCount -gt 0) { $output += "Superseding Apps : $($AppData.supersedingAppCount)" } if ($AppData.supersededAppCount -and $AppData.supersededAppCount -gt 0) { $output += "Superseded Apps : $($AppData.supersededAppCount)" } return ($output -join "`n") } function ConvertTo-ReadableMacOSCustomConfiguration { param($PolicyData) if (-not $PolicyData) { return $null } $odataType = $PolicyData.'@odata.type' if ($odataType -ne '#microsoft.graph.macOSCustomConfiguration') { return $null } $output = @() # Basic Information if ($PolicyData.description) { $output += "Description : $($PolicyData.description)" } if ($PolicyData.payloadName) { $output += "Payload Name : $($PolicyData.payloadName)" } if ($PolicyData.payloadFileName) { $output += "Filename : $($PolicyData.payloadFileName)" } if ($PolicyData.deploymentChannel) { $channel = switch ($PolicyData.deploymentChannel) { 'deviceChannel' { 'Device Channel' } 'userChannel' { 'User Channel' } Default { $PolicyData.deploymentChannel } } $output += "Deployment : $channel" } # Decode and display the payload if ($PolicyData.payload) { $output += "`n--- Configuration Profile (Decoded) ---`n" try { # Decode base64 payload $decodedBytes = [System.Convert]::FromBase64String($PolicyData.payload) $decodedText = [System.Text.Encoding]::UTF8.GetString($decodedBytes) # Add the decoded XML/plist content $output += $decodedText } catch { $output += "Error decoding payload: $_" } } return ($output -join "`n") } function ConvertTo-ReadableMacOSCustomAppConfiguration { param($PolicyData) if (-not $PolicyData) { return $null } $odataType = $PolicyData.'@odata.type' if ($odataType -ne '#microsoft.graph.macOSCustomAppConfiguration') { return $null } $output = @() # Basic Information if ($PolicyData.description) { $output += "Description : $($PolicyData.description)" } if ($PolicyData.bundleId) { $output += "Bundle ID : $($PolicyData.bundleId)" } if ($PolicyData.fileName) { $output += "Filename : $($PolicyData.fileName)" } # Decode and display the configuration XML (plist fragment) if ($PolicyData.configurationXml) { $output += "`n--- Plist Configuration (Decoded) ---`n" try { # Decode base64 configuration XML $decodedBytes = [System.Convert]::FromBase64String($PolicyData.configurationXml) $decodedText = [System.Text.Encoding]::UTF8.GetString($decodedBytes) # Add the decoded plist fragment $output += $decodedText } catch { $output += "Error decoding configuration XML: $_" } } return ($output -join "`n") } function ConvertTo-ReadableAppleEnrollmentProfile { param($ProfileData) if (-not $ProfileData) { return $null } $output = @() # Determine profile type - try multiple methods $profileType = 'Unknown' # Method 1: Check @odata.type if ($ProfileData.'@odata.type' -eq '#microsoft.graph.depIOSEnrollmentProfile') { $profileType = 'iOS' } elseif ($ProfileData.'@odata.type' -eq '#microsoft.graph.depMacOSEnrollmentProfile') { $profileType = 'macOS' } # Method 2: If still unknown, try to detect based on specific properties elseif ($ProfileData.fileVaultDisabled -ne $null -or $ProfileData.iCloudDiagnosticsDisabled -ne $null -or $ProfileData.iCloudStorageDisabled -ne $null -or $ProfileData.registrationDisabled -ne $null -or $ProfileData.skipPrimarySetupAccountCreation -ne $null) { # These properties only exist in macOS profiles $profileType = 'macOS' } # Method 3: Check if it has iOS-specific properties elseif ($ProfileData.enableSharedIPad -ne $null -or $ProfileData.iTunesPairingMode -ne $null) { $profileType = 'iOS' } # Header - Name, Description, Platform if ($ProfileData.displayName) { $output += "Name: $($ProfileData.displayName)" } if ($ProfileData.description) { $output += "Description: $($ProfileData.description)" } $output += "Platform: $profileType" $output += "" # Management Settings $output += "=== Management Settings ===" $output += "" $output += "User Affinity & Authentication Method" if ($ProfileData.requiresUserAuthentication) { $output += " User affinity: Enroll with User Affinity" $authMethod = if ($ProfileData.enableAuthenticationViaCompanyPortal) { "Company Portal" } else { "Setup Assistant with modern authentication" } $output += " Authentication Method: $authMethod" } else { $output += " User affinity: Enroll without User Affinity" } $output += "" $output += "Management Options" if ($null -ne $ProfileData.waitForDeviceConfiguredConfirmation) { $awaitConfig = if ($ProfileData.waitForDeviceConfiguredConfirmation) { "Yes" } else { "No" } $output += " Await final configuration: $awaitConfig" } if ($null -ne $ProfileData.profileRemovalDisabled) { $lockedEnroll = if ($ProfileData.profileRemovalDisabled) { "Yes" } else { "No" } $output += " Locked enrollment: $lockedEnroll" } $output += "" # Setup Assistant $output += "=== Setup Assistant ===" $output += "" if ($ProfileData.supportDepartment -or $ProfileData.supportPhoneNumber) { $output += "Department" if ($ProfileData.supportDepartment) { $output += " $($ProfileData.supportDepartment)" } $output += "Department Phone" if ($ProfileData.supportPhoneNumber) { $output += " $($ProfileData.supportPhoneNumber)" } $output += "" } $output += "Setup Assistant Screens" # Create mapping of API keys to UI labels $screenMapping = @{ 'Location' = 'Location Services' 'Restore' = 'Restore' 'AppleID' = 'Apple ID' 'TOS' = 'Terms and conditions' 'Biometric' = 'Touch ID and Face ID' 'TouchId' = 'Touch ID and Face ID' 'Payment' = 'Apple Pay' 'Siri' = 'Siri' 'Diagnostics' = 'Diagnostics Data' 'DisplayTone' = 'Display Tone' 'Privacy' = 'Privacy' 'ScreenTime' = 'Screen Time' 'Zoom' = 'Zoom' 'Android' = 'Android' 'HomeButtonSensitivity' = 'Home Button Sensitivity' 'iMessageAndFaceTime' = 'iMessage and FaceTime' 'OnBoarding' = 'OnBoarding' 'WatchMigration' = 'Watch Migration' 'Passcode' = 'Passcode' 'Welcome' = 'Welcome' 'RestoreCompleted' = 'Restore Completed' 'UpdateCompleted' = 'Update Completed' 'DeviceToDeviceMigration' = 'Device to Device Migration' 'SIMSetup' = 'SIM Setup' 'Appearance' = 'Appearance' 'FileVault' = 'FileVault' 'iCloudDiagnostics' = 'iCloud Diagnostics' 'iCloudStorage' = 'iCloud Storage' 'Registration' = 'Registration' 'Accessibility' = 'Accessibility' 'UnlockWithWatch' = 'Auto unlock with Apple Watch' 'Lockdown' = 'Lockdown mode' 'EnableLockdownMode' = 'Lockdown mode' 'Wallpaper' = 'Wallpaper' 'SoftwareUpdate' = 'Software Update' 'TermsOfAddress' = 'Terms of Address' 'Intelligence' = 'Intelligence' 'Safety' = 'Safety' 'ActionButton' = 'Action Button' } # Determine which screens are shown or hidden $skippedKeys = if ($ProfileData.enabledSkipKeys) { $ProfileData.enabledSkipKeys } else { @() } # Define the order and screens for each platform type if ($profileType -eq 'macOS') { # macOS specific screens in order $orderedScreens = @( 'Location', 'Restore', 'AppleID', 'TOS', 'Biometric', 'Payment', 'Siri', 'Diagnostics', 'DisplayTone', 'Privacy', 'ScreenTime', 'iCloudDiagnostics', 'iCloudStorage', 'Appearance', 'Registration', 'Accessibility', 'UnlockWithWatch', 'TermsOfAddress', 'Intelligence', 'EnableLockdownMode', 'Wallpaper', 'FileVault' ) } else { # iOS/iPadOS screens in order (based on screenshot) $orderedScreens = @( 'Location', 'Restore', 'AppleID', 'TOS', 'Biometric', 'Payment', 'Siri', 'Diagnostics', 'DisplayTone', 'Privacy', 'ScreenTime', 'Zoom', 'Android', 'HomeButtonSensitivity', 'iMessageAndFaceTime', 'OnBoarding', 'WatchMigration', 'Passcode', 'Welcome', 'RestoreCompleted', 'UpdateCompleted', 'DeviceToDeviceMigration', 'SIMSetup', 'Appearance' ) } # Output each screen with Show/Hide status foreach ($screen in $orderedScreens) { if ($screenMapping.ContainsKey($screen)) { $screenLabel = $screenMapping[$screen] $status = if ($skippedKeys -contains $screen) { "Hide" } else { "Show" } $output += " $screenLabel`: $status" } } # Add additional screens that might be present but not in our ordered list foreach ($screen in $skippedKeys) { if ($screenMapping.ContainsKey($screen) -and $orderedScreens -notcontains $screen) { $screenLabel = $screenMapping[$screen] $output += " $screenLabel`: Hide" } } # Add OS Showcase and App Store for macOS if ($profileType -eq 'macOS') { $output += " OS showcase: Show" $output += " App Store: Show" } $output += "" # Account Settings (macOS only) if ($profileType -eq 'macOS') { $output += "=== Account Settings ===" $output += "" $output += "Local administrator account" $createAdmin = if ($ProfileData.enableRestrictEditing) { "Yes" } else { "No" } $output += " Create a local admin account: $createAdmin" if ($ProfileData.enableRestrictEditing) { if ($ProfileData.adminAccountUserName) { $output += " Admin account username: $($ProfileData.adminAccountUserName)" } if ($ProfileData.adminAccountFullName) { $output += " Admin account full name: $($ProfileData.adminAccountFullName)" } if ($null -ne $ProfileData.hideAdminAccount) { $hideAdmin = if ($ProfileData.hideAdminAccount) { "Yes" } else { "No" } $output += " Hide in Users & Groups: $hideAdmin" } if ($ProfileData.depProfileAdminAccountPasswordRotationSetting) { $output += " Admin account password rotation period (days): $($ProfileData.depProfileAdminAccountPasswordRotationSetting)" } else { $output += " Admin account password rotation period (days): No Admin account password rotation period (days)" } } $output += "" $output += "Local user account" $createLocal = if ($ProfileData.skipPrimarySetupAccountCreation -eq $false) { "Yes" } else { "No" } $output += " Create a local primary account: $createLocal" if ($ProfileData.skipPrimarySetupAccountCreation -eq $false) { $accountType = if ($ProfileData.setPrimarySetupAccountAsRegularUser) { "Standard" } else { "Administrator" } $output += " Account type: $accountType" if ($null -ne $ProfileData.dontAutoPopulatePrimaryAccountInfo) { $prefill = if ($ProfileData.dontAutoPopulatePrimaryAccountInfo) { "No" } else { "Yes" } $output += " Prefill account info: $prefill" } if ($ProfileData.primaryAccountFullName) { $output += " Primary account name: $($ProfileData.primaryAccountFullName)" } if ($ProfileData.primaryAccountUserName) { $output += " Primary account full name: $($ProfileData.primaryAccountUserName)" } if ($null -ne $ProfileData.enableRestrictEditing) { $restrictEdit = if ($ProfileData.enableRestrictEditing) { "Yes" } else { "No" } $output += " Restrict editing: $restrictEdit" } } } return ($output -join "`n") } function Update-QuickFilters { $maxDevicesLabel = "(Max $GraphAPITop devices)" $filters = @() $filters += [pscustomobject]@{ QuickFilterName = 'Search by deviceName, serialNumber, emailAddress, OS or id' QuickFilterGraphAPIFilter = $null } $AddSyncFilter = { param([string]$Label,[datetime]$Since) $timestamp = $Since.ToUniversalTime().ToString('yyyy-MM-ddTHH\:mm\:ss.000Z') return [pscustomobject]@{ QuickFilterName = ("{0,-55} {1,-20}" -f $Label, $maxDevicesLabel) QuickFilterGraphAPIFilter = "(lastSyncDateTime gt $timestamp)&`$top=$GraphAPITop" } } $AddEnrollFilter = { param([string]$Label,[datetime]$Since) $timestamp = $Since.ToUniversalTime().ToString('yyyy-MM-ddTHH\:mm\:ss.000Z') return [pscustomobject]@{ QuickFilterName = ("{0,-55} {1,-20}" -f $Label, $maxDevicesLabel) QuickFilterGraphAPIFilter = "(enrolleddatetime gt $timestamp)&`$top=$GraphAPITop" } } $syncLabels = @( @('Quick filter: Devices Synced in last 15 minutes', (Get-Date).AddMinutes(-15)), @('Quick filter: Devices Synced in last 1 hour', (Get-Date).AddHours(-1)), @('Quick filter: Devices Synced in last 24 hours', (Get-Date).AddHours(-24)), @('Quick filter: Devices Synced today (since midnight)', (Get-Date -Hour 0 -Minute 0 -Second 0)), @('Quick filter: Devices Synced in last 7 days', ((Get-Date).Date.AddDays(-7))), @('Quick filter: Devices Synced in last 30 days', ((Get-Date).Date.AddDays(-30))) ) foreach ($entry in $syncLabels) { $filters += & $AddSyncFilter $entry[0] $entry[1] } $enrollLabels = @( @('Quick filter: Devices Enrolled in last 15 minutes', (Get-Date).AddMinutes(-15)), @('Quick filter: Devices Enrolled in last 1 hour', (Get-Date).AddHours(-1)), @('Quick filter: Devices Enrolled today (since midnight)', (Get-Date -Hour 0 -Minute 0 -Second 0)), @('Quick filter: Devices Enrolled in last 7 days', ((Get-Date).Date.AddDays(-7))), @('Quick filter: Devices Enrolled in last 30 days', ((Get-Date).Date.AddDays(-30))) ) foreach ($entry in $enrollLabels) { $filters += & $AddEnrollFilter $entry[0] $entry[1] } $filters += [pscustomobject]@{ QuickFilterName = ("{0,-32} {1,-22} {2,-20}" -f 'Quick filter: Compliance','Compliant',$maxDevicesLabel) QuickFilterGraphAPIFilter = "(complianceState eq 'compliant')&`$top=$GraphAPITop" } $filters += [pscustomobject]@{ QuickFilterName = ("{0,-32} {1,-22} {2,-20}" -f 'Quick filter: Compliance','Non-compliant',$maxDevicesLabel) QuickFilterGraphAPIFilter = "(complianceState eq 'noncompliant')&`$top=$GraphAPITop" } $filters += [pscustomobject]@{ QuickFilterName = ("{0,-32} {1,-22} {2,-20}" -f 'Quick filter: Compliance','Unknown',$maxDevicesLabel) QuickFilterGraphAPIFilter = "(complianceState eq 'unknown')&`$top=$GraphAPITop" } $filters += [pscustomobject]@{ QuickFilterName = ("{0,-32} {1,-22} {2,-20}" -f 'Quick filter: Ownership','Company devices',$maxDevicesLabel) QuickFilterGraphAPIFilter = "(ownerType eq 'company')&`$top=$GraphAPITop" } $filters += [pscustomobject]@{ QuickFilterName = ("{0,-32} {1,-22} {2,-20}" -f 'Quick filter: Ownership','Personal devices',$maxDevicesLabel) QuickFilterGraphAPIFilter = "(ownerType eq 'personal')&`$top=$GraphAPITop" } return $filters } function Search-ManagedDevices { param( [string]$SearchString = '', [PSCustomObject]$QuickFilter, [switch]$IsQuickFilter ) $results = @() if ($IsQuickFilter -and $QuickFilter) { $filter = $QuickFilter.QuickFilterGraphAPIFilter if (-not $filter) { return @() } $url = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=$filter&`$select=id,deviceName,usersLoggedOn,lastSyncDateTime,operatingSystem,deviceType,enrolledDateTime,Manufacturer,Model,SerialNumber,userPrincipalName" $url = Fix-UrlSpecialCharacters $url $results = Invoke-MGGraphGetRequestWithMSGraphAllPages $url $results = $results | Sort-Object -Property deviceName } else { if (Validate-GUID $SearchString) { $url = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$SearchString" $device = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($device) { $results += $device } } else { # Search by deviceName $query = Fix-UrlSpecialCharacters $SearchString $url = "https://graph.microsoft.com/beta/deviceManagement/managedDevices?`$filter=contains(deviceName,%27$query%27)&`$select=id,deviceName,usersLoggedOn,lastSyncDateTime,operatingSystem,deviceType,enrolledDateTime,Manufacturer,Model,SerialNumber,userPrincipalName&`$Top=$GraphAPITop" $results = Invoke-MGGraphGetRequestWithMSGraphAllPages $url $results = $results | Sort-Object -Property deviceName if (($SearchString -like '*@*.*') -or ($SearchString -like '*%40*.*')) { $userUrl = "https://graph.microsoft.com/beta/users?`$filter=userPrincipalName%20eq%20'$query'&`$select=id,mail,userPrincipalName" $azureUser = Invoke-MGGraphGetRequestWithMSGraphAllPages $userUrl if ($azureUser -and -not ($azureUser -is [array])) { $deviceUrl = "https://graph.microsoft.com/beta/users/$($azureUser.id)/getLoggedOnManagedDevices?`$select=id,deviceName,usersLoggedOn,lastSyncDateTime,operatingSystem,deviceType,enrolledDateTime,Manufacturer,Model,SerialNumber,userPrincipalName" $userDevices = Invoke-MGGraphGetRequestWithMSGraphAllPages $deviceUrl $results += $userDevices } } } } $results = [array]$results foreach ($device in $results) { $lastSyncDays = if ($device.lastSyncDateTime) { (New-TimeSpan $device.lastSyncDateTime).Days } else { 999 } $device | Add-Member -NotePropertyName 'searchStringDeviceProperty' -NotePropertyValue ("{0,-25} {1,-10} {2,4} {3,8}" -f $device.deviceName, 'Last sync', $lastSyncDays, 'days ago') -Force $toolTip = ($device | Select-Object deviceName,userPrincipalName,operatingSystem,Manufacturer,Model,SerialNumber | Format-List | Out-String).Trim() $device | Add-Member -NotePropertyName 'SearchResultToolTip' -NotePropertyValue $toolTip -Force } return $results } function Get-CheckedInUsersInfo { param([PSObject]$SelectedUser) $usersLoggedOnString = '' $latestUser = $null $latestGroups = @() $collection = @() $orderedUsers = $script:IntuneManagedDevice.usersLoggedOn | Sort-Object -Property lastLogOnDateTime -Descending foreach ($loggedOn in $orderedUsers) { if (-not (Validate-GUID $loggedOn.userId)) { continue } if (-not $latestUser) { if ($SelectedUser) { if ($loggedOn.userId -ne $SelectedUser.id) { continue } } if ($script:PrimaryUser -and $loggedOn.userId -eq $script:PrimaryUser.id) { $latestUser = $script:PrimaryUser } else { $userUrl = "https://graph.microsoft.com/beta/users/$($loggedOn.userId)?`$select=*" $latestUser = Invoke-MGGraphGetRequestWithMSGraphAllPages $userUrl } $groupUrl = "https://graph.microsoft.com/beta/users/$($latestUser.id)/memberOf?_=1577625591876" $latestGroups = Invoke-MGGraphGetRequestWithMSGraphAllPages $groupUrl if ($latestGroups) { $latestGroups = Add-AzureADGroupGroupTypeExtraProperties $latestGroups $latestGroups = Add-AzureADGroupDevicesAndUserMemberCountExtraProperties $latestGroups } } $userUrl = "https://graph.microsoft.com/beta/users/$($loggedOn.userId)?`$select=id,displayName,mail,userPrincipalName" $aadUser = Invoke-MGGraphGetRequestWithMSGraphAllPages $userUrl $usersLoggedOnString += "$($aadUser.userPrincipalName)`n" $usersLoggedOnString += "$(ConvertTo-LocalDateTimeString $loggedOn.lastLogOnDateTime)`n`n" $collection += $aadUser } return [pscustomobject]@{ LatestUser = $latestUser LatestGroups = $latestGroups RecentText = $usersLoggedOnString.Trim() LoggedOn = $collection } } function Get-MobileAppAssignments { param( [Parameter(Mandatory)][string]$UserId, [Parameter(Mandatory)][string]$IntuneDeviceId ) $script:AppsAssignmentsObservableCollection = @() $script:UnknownAppAssignments = $false if (-not $script:AppsWithAssignments) { $script:AppsWithAssignments = Get-ApplicationsWithAssignments -ReloadCacheData:$false } if (-not $UserId -or -not $IntuneDeviceId) { return [pscustomobject]@{ Items = @() UnknownAssignments = $false } } $url = "https://graph.microsoft.com/beta/users('$UserId')/mobileAppIntentAndStates('$IntuneDeviceId')" $intentResponse = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if (-not $intentResponse.mobileAppList) { return [pscustomobject]@{ Items = @() UnknownAssignments = $false } } $copyOfMobileAppList = $intentResponse.mobileAppList foreach ($mobileApp in $intentResponse.mobileAppList) { $app = $script:AppsWithAssignments | Where-Object { $_.id -eq $mobileApp.applicationId } if (-not $app) { continue } foreach ($assignment in $app.assignments) { $include = $false $context = '_unknown' $contextToolTip = '' $assignmentGroup = 'unknown' $assignmentGroupId = '' $assignmentGroupMembers = 'N/A' $assignmentGroupTooltip = '' $membershipType = '' $filterDisplayName = '' $filterId = '' $filterMode = '' $filterTooltip = '' if ($assignment.target.'@odata.type' -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { $context = 'User' $contextToolTip = 'Built-in All Users group' $assignmentGroup = 'All Users' $include = $true } elseif ($assignment.target.'@odata.type' -eq '#microsoft.graph.allDevicesAssignmentTarget') { $context = 'Device' $contextToolTip = 'Built-in All Devices group' $assignmentGroup = 'All Devices' $include = $true } elseif ($assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget') { $group = $script:deviceGroupMemberships | Where-Object { $_.id -eq $assignment.target.groupId } if ($group) { $context = 'Device' $contextToolTip = $script:IntuneManagedDevice.deviceName $assignmentGroup = $group.displayName $assignmentGroupId = $group.id $assignmentGroupTooltip = $group.membershipRule $membershipType = $group.YodamiittiCustomMembershipType $assignmentGroupMembers = '' if ($group.YodamiittiCustomGroupMembersCountDevices -gt 0) { $assignmentGroupMembers += "$($group.YodamiittiCustomGroupMembersCountDevices) devices " } if ($group.YodamiittiCustomGroupMembersCountUsers -gt 0) { $assignmentGroupMembers += "$($group.YodamiittiCustomGroupMembersCountUsers) users " } $include = $true } $primaryGroup = $script:PrimaryUserGroupsMemberOf | Where-Object { $_.id -eq $assignment.target.groupId } if ($primaryGroup) { $context = if ($context -eq 'Device') { '_Device/User' } else { 'User' } $contextToolTip = $script:PrimaryUser.userPrincipalName $assignmentGroup = $primaryGroup.displayName $assignmentGroupId = $primaryGroup.id $assignmentGroupTooltip = $primaryGroup.membershipRule $membershipType = $primaryGroup.YodamiittiCustomMembershipType $assignmentGroupMembers = '' if ($primaryGroup.YodamiittiCustomGroupMembersCountDevices -gt 0) { $assignmentGroupMembers += "$($primaryGroup.YodamiittiCustomGroupMembersCountDevices) devices " } if ($primaryGroup.YodamiittiCustomGroupMembersCountUsers -gt 0) { $assignmentGroupMembers += "$($primaryGroup.YodamiittiCustomGroupMembersCountUsers) users " } $include = $true } $latestGroup = $script:LatestCheckedInUserGroupsMemberOf | Where-Object { $_.id -eq $assignment.target.groupId } if ($latestGroup -and $UserId -ne $script:PrimaryUser.id) { $context = if ($context -eq 'Device') { '_Device/User' } else { 'User' } $contextToolTip = $script:LatestCheckedInUser.userPrincipalName $assignmentGroup = $latestGroup.displayName $assignmentGroupId = $latestGroup.id $assignmentGroupTooltip = $latestGroup.membershipRule $membershipType = $latestGroup.YodamiittiCustomMembershipType $assignmentGroupMembers = '' if ($latestGroup.YodamiittiCustomGroupMembersCountDevices -gt 0) { $assignmentGroupMembers += "$($latestGroup.YodamiittiCustomGroupMembersCountDevices) devices " } if ($latestGroup.YodamiittiCustomGroupMembersCountUsers -gt 0) { $assignmentGroupMembers += "$($latestGroup.YodamiittiCustomGroupMembersCountUsers) users " } $include = $true } } if (-not $include) { continue } $filterId = $assignment.target.deviceAndAppManagementAssignmentFilterId if ($filterId) { $filter = $script:AllIntuneFilters | Where-Object { $_.id -eq $filterId } $filterDisplayName = $filter.displayName $filterMode = $assignment.target.deviceAndAppManagementAssignmentFilterType if ($filterMode -eq 'none') { $filterMode = '' } $filterTooltip = $filter.rule } $assignmentIntent = $assignment.intent $includeExclude = switch ($assignment.target.'@odata.type') { '#microsoft.graph.groupAssignmentTarget' { 'Included' } '#microsoft.graph.exclusionGroupAssignmentTarget' { 'Excluded' } Default { '' } } if (($assignmentIntent -eq 'available') -and ($mobileApp.installState -eq 'unknown')) { $mobileApp.installState = 'Available for install' } elseif (($assignmentIntent -eq 'required') -and ($mobileApp.installState -eq 'unknown')) { $mobileApp.installState = 'Waiting for install status' } $displayName = if ($app.licenseType -eq 'offline') { "$($app.displayName) (offline)" } else { $app.displayName } $odataType = $app.'@odata.type'.Replace('#microsoft.graph.', '') $properties = [ordered]@{ context = [string]$context contextToolTip = [string]$contextToolTip odatatype = [string]$odataType displayName = [string]$displayName version = [string]$mobileApp.displayVersion assignmentIntent = [string]$assignmentIntent IncludeExclude = [string]$includeExclude assignmentGroup = [string]$assignmentGroup YodamiittiCustomGroupMembers = [string]$assignmentGroupMembers assignmentGroupId = [string]$assignmentGroupId installState = [string]$mobileApp.installState lastModifiedDateTime = $app.lastModifiedDateTime YodamiittiCustomMembershipType = [string]$membershipType id = $app.id filter = [string]$filterDisplayName filterId = [string]$filterId filterMode = [string]$filterMode filterTooltip = [string]$filterTooltip AssignmentGroupToolTip = [string]$assignmentGroupTooltip displayNameToolTip = [string]$app.description } $script:AppsAssignmentsObservableCollection += [pscustomobject]$properties } if ($script:AppsAssignmentsObservableCollection | Where-Object { $_.id -eq $mobileApp.applicationId }) { $copyOfMobileAppList = $copyOfMobileAppList | Where-Object { $_.applicationId -ne $mobileApp.applicationId } } else { $script:UnknownAppAssignments = $true $assignmentIntent = $mobileApp.mobileAppIntent.Replace('Install','') $properties = [ordered]@{ context = '_unknown' contextToolTip = '' odatatype = ($app.'@odata.type').Replace('#microsoft.graph.','') displayName = [string]$app.displayName version = [string]$mobileApp.displayVersion assignmentIntent = [string]$assignmentIntent IncludeExclude = '' assignmentGroup = 'unknown (possible nested group or removed assignment)' YodamiittiCustomGroupMembers = 'N/A' assignmentGroupId = '' installState = [string]$mobileApp.installState lastModifiedDateTime = $app.lastModifiedDateTime YodamiittiCustomMembershipType = '' id = $app.id filter = '' filterId = '' filterMode = '' filterTooltip = '' AssignmentGroupToolTip = '' displayNameToolTip = '' } $script:AppsAssignmentsObservableCollection += [pscustomobject]$properties } } return [pscustomobject]@{ Items = $script:AppsAssignmentsObservableCollection | Sort-Object -Property context, @{ expression = 'assignmentIntent'; Descending = $true }, IncludeExclude, displayName UnknownAssignments = $script:UnknownAppAssignments } } function Ensure-Directory { param([Parameter(Mandatory)][string]$Path) if (-not (Test-Path -Path $Path)) { New-Item -ItemType Directory -Path $Path -Force | Out-Null } return (Resolve-Path -Path $Path).Path } function Initialize-IntuneSession { if ($script:TenantId) { return } # Check for required Microsoft.Graph.Authentication module Write-Host "" Write-Host "🔍 Checking for required PowerShell modules..." -ForegroundColor Cyan $module = Get-Module -Name Microsoft.Graph.Authentication -ListAvailable if (-not $module) { Write-Host "" Write-Host "❌ Microsoft.Graph.Authentication module is not installed" -ForegroundColor Red Write-Host "" Write-Host "This script requires the Microsoft Graph Authentication -module." -ForegroundColor Yellow Write-Host "Please install it using one of the following commands:" -ForegroundColor Yellow Write-Host "" Write-Host " For current user only:" -ForegroundColor Cyan Write-Host " Install-Module Microsoft.Graph.Authentication -Scope CurrentUser" -ForegroundColor White Write-Host "" Write-Host " For all users (requires admin):" -ForegroundColor Cyan Write-Host " Install-Module Microsoft.Graph.Authentication -Scope AllUsers" -ForegroundColor White Write-Host "" throw "Microsoft.Graph.Authentication module is required but not installed." } Write-Host "" Write-Host "🔗 Connecting to Microsoft Graph..." -ForegroundColor Cyan Import-Module Microsoft.Graph.Authentication -ErrorAction Stop $scopes = @( 'DeviceManagementManagedDevices.Read.All', 'DeviceManagementApps.Read.All', 'DeviceManagementConfiguration.Read.All', 'DeviceManagementServiceConfig.Read.All', 'DeviceManagementScripts.Read.All', 'User.Read.All', 'Group.Read.All', 'GroupMember.Read.All', 'Directory.Read.All' ) $null = Connect-MgGraph -Scopes $scopes $context = Get-MgContext if (-not $context -or -not $context.TenantId) { throw 'Unable to determine tenant information from Microsoft Graph context.' } $script:TenantId = $context.TenantId $script:ConnectedUser = $context.Account # Get tenant display name try { $orgUrl = "https://graph.microsoft.com/v1.0/organization" $org = Invoke-MgGraphRequest -Uri $orgUrl -Method Get -OutputType PSObject $script:TenantDisplayName = if ($org.value -and $org.value.Count -gt 0) { $org.value[0].displayName } else { $script:TenantId } } catch { Write-Warning "Could not retrieve tenant display name: $_" $script:TenantDisplayName = $script:TenantId } $cachePath = Join-Path -Path $PSScriptRoot -ChildPath "cache\$($script:TenantId)" Ensure-Directory -Path $cachePath | Out-Null $script:QuickSearchFilters = Update-QuickFilters $script:ReportOutputFolder = Ensure-Directory -Path $script:ReportOutputFolder Write-Host "" Write-Host "✓ Connected to Microsoft Graph" -ForegroundColor Green Write-Host " Tenant: $($script:TenantDisplayName)" -ForegroundColor Cyan Write-Host " Account: $($script:ConnectedUser)" -ForegroundColor Cyan Write-Host "" } function Write-DeviceSearchTable { param([array]$Devices) $format = '{0,3} | {1,-30} | {2,-35} | {3,-12} | {4,-10}' Write-Host "" Write-Host "📱 Found $($Devices.Count) device(s):" -ForegroundColor Yellow Write-Host "" Write-Host ($format -f '#','Device','User','OS','Last Sync (days)') -ForegroundColor Cyan -BackgroundColor DarkGray -NoNewline Write-Host "" Write-Host ('-' * 108) -ForegroundColor DarkGray for ($i = 0; $i -lt $Devices.Count; $i++) { $device = $Devices[$i] $days = if ($device.lastSyncDateTime) { (New-TimeSpan $device.lastSyncDateTime).Days } else { 'n/a' } $color = if ($days -eq 'n/a' -or $days -gt 7) { 'Red' } elseif ($days -gt 1) { 'Yellow' } else { 'White' } Write-Host ($format -f $i,$device.deviceName,$device.userPrincipalName,$device.operatingSystem,$days) -ForegroundColor $color } Write-Host "" } function Invoke-InteractiveDeviceSelection { param( [string]$InitialSearch ) $search = $InitialSearch while ($true) { if (-not $search) { Write-Host "🔍 Search for device" -ForegroundColor Green $input = Read-Host ' Enter device name/email/serial (? for Quick Search Filters, Q to quit)' if ($input -match '^[qQ]$') { return $null } if ($input -eq '?') { Write-Host "" Write-Host "⚡ Quick Filters:" -ForegroundColor Yellow Write-Host "" for ($idx = 0; $idx -lt $script:QuickSearchFilters.Count; $idx++) { $color = if ($idx -eq 0) { 'DarkGray' } else { 'White' } Write-Host (" [{0,2}] {1}" -f $idx,$script:QuickSearchFilters[$idx].QuickFilterName) -ForegroundColor $color } Write-Host "" $choice = Read-Host ' Select filter index' if ($choice -match '^\d+$' -and [int]$choice -lt $script:QuickSearchFilters.Count) { $filter = $script:QuickSearchFilters[[int]$choice] $results = Search-ManagedDevices -QuickFilter $filter -IsQuickFilter # Force array to handle PS5.1 vs PS7 differences $resultsArray = @($results) if ($resultsArray -and $resultsArray.Count -gt 0) { Write-DeviceSearchTable -Devices $resultsArray if ($resultsArray.Count -eq 1) { Write-Host "✓ Auto-selected the only device found" -ForegroundColor Green Write-Host "" return $resultsArray[0] } $selection = (Read-Host 'Enter result index or press Enter to search again').Trim() [int]$selectedIndex = -1 if ([int]::TryParse($selection, [ref]$selectedIndex) -and $selectedIndex -ge 0 -and $selectedIndex -lt $resultsArray.Count) { return $resultsArray[$selectedIndex] } } else { Write-Warning 'No devices found for the selected quick filter.' } } $search = $null continue } $search = $input } $results = Search-ManagedDevices -SearchString $search # Force array to handle PS5.1 vs PS7 differences $resultsArray = @($results) if (-not $resultsArray -or $resultsArray.Count -eq 0) { Write-Warning 'No devices found. Try another search.' $search = $null continue } Write-DeviceSearchTable -Devices $resultsArray if ($resultsArray.Count -eq 1) { Write-Host "✓ Auto-selected the only device found" -ForegroundColor Green Write-Host "" return $resultsArray[0] } $selected = (Read-Host 'Enter result index or press Enter to refine search').Trim() [int]$selectedIndex = -1 if ([int]::TryParse($selected, [ref]$selectedIndex) -and $selectedIndex -ge 0 -and $selectedIndex -lt $resultsArray.Count) { return $resultsArray[$selectedIndex] } $search = $null } } function Resolve-DeviceId { param( [string]$PipelineId, [string]$SearchText ) if ($PipelineId) { return $PipelineId } $device = Invoke-InteractiveDeviceSelection -InitialSearch $SearchText Write-Verbose "Selected device: $($device | Format-List | Out-String)" if ($device) { return $device.id } return $null } function Get-ManagedDeviceSnapshot { param([Parameter(Mandatory)][string]$IntuneDeviceId) $url = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($IntuneDeviceId)?`$expand=deviceCategory" return Invoke-MGGraphGetRequestWithMSGraphAllPages $url } function Get-AdditionalDeviceHardware { param([Parameter(Mandatory)][string]$IntuneDeviceId) $url = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($IntuneDeviceId)?`$select=id,hardwareinformation,activationLockBypassCode,iccid,udid,roleScopeTagIds,ethernetMacAddress,processorArchitecture" return Invoke-MGGraphGetRequestWithMSGraphAllPages $url } function Get-PrimaryUserContext { param([PSObject]$Device) # Check if userPrincipalName exists (primary user is assigned) if (-not $Device.userPrincipalName -or [string]::IsNullOrWhiteSpace($Device.userPrincipalName)) { return $null } $userUrl = "https://graph.microsoft.com/beta/users?`$filter=userPrincipalName eq '$($Device.userPrincipalName)'&`$select=*" $user = Invoke-MGGraphGetRequestWithMSGraphAllPages $userUrl if (-not $user) { return $null } # If filter returns array, take first result if ($user -is [array]) { $user = $user[0] } $groupUrl = "https://graph.microsoft.com/beta/users/$($user.id)/memberOf?_=1577625591876" $groups = Invoke-MGGraphGetRequestWithMSGraphAllPages $groupUrl if ($groups) { $groups = Add-AzureADGroupGroupTypeExtraProperties $groups $groups = Add-AzureADGroupDevicesAndUserMemberCountExtraProperties $groups } return [pscustomobject]@{ User = $user Groups = $groups } } function Get-LatestLogonContext { param([PSObject]$Device) $usersInfo = Get-CheckedInUsersInfo return $usersInfo } function Get-AzureDeviceContext { param([PSObject]$Device) if (-not $Device.azureADDeviceId) { return $null } $url = "https://graph.microsoft.com/beta/devices?`$filter=deviceId%20eq%20`'$($Device.azureADDeviceId)`'" $aadDevice = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($aadDevice) { $groupUrl = "https://graph.microsoft.com/beta/devices/$($aadDevice.id)/transitiveMemberOf?_=1577625591876" $deviceGroups = Invoke-MGGraphGetRequestWithMSGraphAllPages $groupUrl if ($deviceGroups) { $deviceGroups = Add-AzureADGroupGroupTypeExtraProperties $deviceGroups $deviceGroups = Add-AzureADGroupDevicesAndUserMemberCountExtraProperties $deviceGroups } return [pscustomobject]@{ AzureDevice = $aadDevice Groups = $deviceGroups } } return $null } function Get-AutopilotContext { param([PSObject]$Device) if (-not $Device.autopilotEnrolled -or -not $Device.serialNumber) { return $null } $url = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities?`$filter=contains(serialNumber,%27$($Device.serialNumber)%27)" $autopilotDevice = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if (-not $autopilotDevice) { return $null } # Get Device Autopilot Details $detailUrl = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeviceIdentities/$($autopilotDevice.id)?`$expand=deploymentProfile,intendedDeploymentProfile" $detail = Invoke-MGGraphGetRequestWithMSGraphAllPages $detailUrl # Get Autopilot configuration policy details with assignment information # Use this as example for uri: https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/04bfb9da-0144-4788-9691-a06290516807?$expand=assignments if ($detail.deploymentProfile -and $detail.deploymentProfile.id) { $profileUrl = "https://graph.microsoft.com/beta/deviceManagement/windowsAutopilotDeploymentProfiles/$($detail.deploymentProfile.id)?`$expand=assignments" $autopilotProfile = $null $autopilotProfile = Invoke-MGGraphGetRequestWithMSGraphAllPages $profileUrl # Add new detail property for deployment profile with assignments $detail | Add-Member -NotePropertyName 'DeploymentProfileDetail' -NotePropertyValue $autopilotProfile -Force } return [pscustomobject]@{ Device = $autopilotDevice Detail = $detail } } function Get-AutopilotDevicePreparationContext { param([PSObject]$Device) # Check if device has enrollment profile name if (-not $Device.enrollmentProfileName) { return $null } $searchTerm = [System.Web.HttpUtility]::UrlEncode("`"$($Device.enrollmentProfileName)`"") # Template IDs for Device Preparation policies $templateIds = @( '80d33118-b7b4-40d8-b15f-81be745e053f_1', # Device Preparation 'a6157a7f-aa00-42d9-ac82-7d2479f545db_1' # Device Preparation (alternate) ) $devicePrepPolicy = $null # Search for Device Preparation policy using both template IDs foreach ($templateId in $templateIds) { $url = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?" + "`$select=id,name,description,platforms,lastModifiedDateTime,technologies,settingCount,roleScopeTagIds,isAssigned,templateReference,priorityMetaData" + "&`$top=100" + "&`$filter=(technologies has 'enrollment') and (platforms eq 'windows10') and (TemplateReference/templateId eq '$templateId') and (Templatereference/templateFamily eq 'enrollmentConfiguration')" + "&`$search=$searchTerm" $result = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($result.id) { $devicePrepPolicy = $result break } } # Now we found that Device Preparation policy exists # Next we are actually fetching full details of the policy including assignments # URL example for getting the Device Preparation policy: https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('c2905169-29b3-4580-bbfd-5c0a332d480b')?$expand=settings # We also need to make second GET to retrive assignments: https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('c2905169-29b3-4580-bbfd-5c0a332d480b')/assignments if ($devicePrepPolicy -and $devicePrepPolicy.id) { $detailUrl = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($devicePrepPolicy.id)')?`$expand=settings" $devicePrepPolicyDetail = Invoke-MGGraphGetRequestWithMSGraphAllPages $detailUrl # Replace the original policy object with the detailed one $devicePrepPolicy = $devicePrepPolicyDetail # Get assignments and add to the policy object $assignmentsUrl = "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies('$($devicePrepPolicy.id)')/assignments" $assignments = Invoke-MGGraphGetRequestWithMSGraphAllPages $assignmentsUrl # If we have only 1 assignment then we get the object directly, but we need to wrap it into 'value' array to keep consistent with multiple assignments if ($assignments -and -not ($assignments -is [array])) { $assignments = [pscustomobject]@{ value = @($assignments) } } $devicePrepPolicy | Add-Member -NotePropertyName 'assignments' -NotePropertyValue $assignments.value -Force } if(-not $devicePrepPolicy) { return $null } # Add policy to GUID hashtable $devicePrepPolicy = Resolve-AssignmentGroupNames -Object $devicePrepPolicy return $devicePrepPolicy } function Get-EnrollmentStatusPageContext { param([PSObject]$Device) if (-not $Device.id) { return $null } $uri = 'https://graph.microsoft.com/beta/deviceManagement/reports/getEnrollmentConfigurationPoliciesByDevice' $body = @" { "search": "", "orderBy": [ ], "select": [ "ProfileName", "UserPrincipalName", "PolicyType", "State", "FilterIds", "Priority", "Target", "LastAppliedTime", "PolicyId" ], "filter": "(DeviceId eq \u0027$($Device.id)\u0027)", "skip": 0, "top": 50 } "@ try { $result = Invoke-MGGraphPostRequest -Uri $uri -Body $body if (-not $result) { return $null } else { $rows = Objectify_JSON_Schema_and_Data_To_PowershellObjects -ReportData $result if (-not $rows) { return $null } # DEBUG $rows #Write-Host "DEBUG: Enrollment Status Page / Enrollment Restriction Rows:" -ForegroundColor Yellow #$rows | ConvertTo-Json -Depth 5 | Set-Clipboard #Pause # PolicyType values appear as ints in the report rows (eg. 27 = ESP, 22 = Device type enrollment restriction) # $espRow = $rows | Where-Object { $_.PolicyType -eq 27 -or $_.PolicyType_loc -eq 'Enrollment status page' } | Select-Object -First 1 $restrictionRow = $rows | Where-Object { $_.PolicyType -eq 22 -or $_.PolicyType_loc -eq 'Device type enrollment restriction' } | Select-Object -First 1 $espDetail = $null if ($espRow -and $espRow.PolicyId) { $espUrl = "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations/$($espRow.PolicyId)_Windows10EnrollmentCompletionPageConfiguration?`$expand=assignments" $espDetail = Invoke-MGGraphGetRequestWithMSGraphAllPages $espUrl } $restrictionDetail = $null if ($restrictionRow -and $restrictionRow.PolicyId) { $restrictionUrl = "https://graph.microsoft.com/beta/deviceManagement/deviceEnrollmentConfigurations/$($restrictionRow.PolicyId)_SinglePlatformRestriction?`$expand=assignments" $restrictionDetail = Invoke-MGGraphGetRequestWithMSGraphAllPages $restrictionUrl } # Keep existing report expectation (ESP object), but also include restrictions. return [pscustomobject]@{ Id = [string]($espRow.PolicyId) Name = [string]($espRow.ProfileName) Detail = $espDetail EnrollmentRestriction = if ($restrictionRow) { [pscustomobject]@{ Id = [string]($restrictionRow.PolicyId) Name = [string]($restrictionRow.ProfileName) Detail = $restrictionDetail } } else { $null } RawRows = $rows } } } catch { Write-Verbose "Failed to get Enrollment Status Page / Enrollment Restriction:`n$_" } return $null } function Resolve-AssignmentGroupNames { param( [Parameter(Mandatory)][PSObject]$Object ) if (-not $Object) { return $Object } # Resolve group names in assignments - add displayName inside target object if ($Object.assignments) { foreach ($assignment in $Object.assignments) { if ($assignment.target.groupId) { $resolvedName = Get-NameFromGUID -Id $assignment.target.groupId -PreferredProperty 'displayName' if ($resolvedName) { $assignment.target | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $resolvedName -Force } } } } return $Object } function Resolve-EspAssignmentGroupNames { param([PSObject]$Esp) if (-not $Esp -or -not $Esp.Detail) { return $Esp } # Resolve group names in assignments - add displayName inside target object if ($Esp.Detail.assignments) { foreach ($assignment in $Esp.Detail.assignments) { if ($assignment.target.groupId) { $resolvedName = Get-NameFromGUID -Id $assignment.target.groupId -PreferredProperty 'displayName' if ($resolvedName) { $assignment.target | Add-Member -NotePropertyName 'displayName' -NotePropertyValue $resolvedName -Force } } } } # Replace blocking app GUIDs with display names if ($Esp.Detail.selectedMobileAppIds) { $resolvedAppNames = @() foreach ($appId in $Esp.Detail.selectedMobileAppIds) { $resolvedName = Get-NameFromGUID -Id $appId -PreferredProperty 'displayName' if ($resolvedName) { $resolvedAppNames += $resolvedName } else { $resolvedAppNames += "Unknown app ($appId)" } } $Esp.Detail.selectedMobileAppIds = $resolvedAppNames } return $Esp } function Get-ApplicationAssignmentsContext { param( [string]$UserId, [string]$IntuneDeviceId, [switch]$Skip, [switch]$ReloadCache ) if ($Skip) { return $null } $script:AllIntuneFilters = Download-IntuneFilters if ($ReloadCache) { $script:AppsWithAssignments = Get-ApplicationsWithAssignments -ReloadCacheData:$true } $appAssignments = Get-MobileAppAssignments -UserId $UserId -IntuneDeviceId $IntuneDeviceId return $appAssignments } function Get-ConfigurationPolicyReport { param([string]$IntuneDeviceId) # Initialize Settings Catalog policy IDs tracking array for Extended Report $script:SettingsCatalogPolicyIdsToDownload = @() # Initialize Custom Configuration policies with encrypted OMA settings tracking $script:CustomConfigPoliciesWithSecrets = @{} Write-Host "Downloading Intune configuration profiles with assignments…" -ForegroundColor Cyan # User Powershell splatting to specify function parameters # Limited properties #GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$expand=assignments&$select=id,description,createdDateTime,lastModifiedDateTime,name,assignments' $Params = @{ GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?$expand=assignments&$select=*' jsonCacheFileName = 'configurationPolicies.json' ReloadCacheData = $ReloadCache } $Script:IntuneConfigurationProfilesWithAssignments += Download-IntuneConfigurationProfiles2 @Params # Limited properties #GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations?$expand=assignments&$select=id,description,createdDateTime,lastModifiedDateTime,displayname,assignments' $Params = @{ GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations?$expand=assignments&$select=*' jsonCacheFileName = 'groupPolicyConfigurations.json' ReloadCacheData = $ReloadCache } $Script:IntuneConfigurationProfilesWithAssignments += Download-IntuneConfigurationProfiles2 @Params # Limited properties #$GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$expand=assignments&$select=id,description,createdDateTime,lastModifiedDateTime,displayname,assignments' $Params = @{ GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?$expand=assignments&$select=*' jsonCacheFileName = 'deviceConfigurations.json' ReloadCacheData = $ReloadCache } $Script:IntuneConfigurationProfilesWithAssignments += Download-IntuneConfigurationProfiles2 @Params # Limited properties #raphAPIUrl = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileAppConfigurations?$expand=assignments&$select=id,description,createdDateTime,lastModifiedDateTime,displayname,assignments' $Params = @{ GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceAppManagement/mobileAppConfigurations?$expand=assignments&$select=*' jsonCacheFileName = 'mobileAppConfigurations.json' ReloadCacheData = $ReloadCache } $Script:IntuneConfigurationProfilesWithAssignments += Download-IntuneConfigurationProfiles2 @Params $Params = @{ GraphAPIUrl = 'https://graph.microsoft.com/beta/deviceManagement/intents?$select=*' jsonCacheFileName = 'intents.json' ReloadCacheData = $ReloadCache } $Script:IntuneConfigurationProfilesWithAssignments += Download-IntuneConfigurationProfiles2 @Params # Add configuration profiles GUIDs to global list for later use foreach ($profile in $Script:IntuneConfigurationProfilesWithAssignments) { Add-GUIDToHashtable -Object $profile } Write-Host "Found $($Script:IntuneConfigurationProfilesWithAssignments.Count) configuration profiles" Write-Host $uri = 'https://graph.microsoft.com/beta/deviceManagement/reports/getConfigurationPoliciesReportForDevice' $body = @" { "select": [ "IntuneDeviceId", "PolicyBaseTypeName", "PolicyId", "PolicyStatus", "UPN", "UserId", "PspdpuLastModifiedTimeUtc", "PolicyName", "UnifiedPolicyType" ], "filter": "((PolicyBaseTypeName eq \u0027Microsoft.Management.Services.Api.DeviceConfiguration\u0027) or (PolicyBaseTypeName eq \u0027DeviceManagementConfigurationPolicy\u0027) or (PolicyBaseTypeName eq \u0027DeviceConfigurationAdmxPolicy\u0027) or (PolicyBaseTypeName eq \u0027Microsoft.Management.Services.Api.DeviceManagementIntent\u0027)) and (IntuneDeviceId eq \u0027$($IntuneDeviceId)\u0027)", "skip": 0, "top": 50, "orderBy": [ "PolicyName" ] } "@ # Download (and convert) Device Configuration Policies report Write-Host "Get Intune device Configuration Assignment information" $ConfigurationPoliciesReportForDevice = Download-IntunePostTypeReport -Uri $uri -GraphAPIPostBody $body Write-Host "Found $($ConfigurationPoliciesReportForDevice.Count) Configuration Assignments" $script:ConfigurationsAssignmentsObservableCollection = @() # Sort policies by PolicyId so we will download policies only once in next steps $ConfigurationPoliciesReportForDevice = $ConfigurationPoliciesReportForDevice | Sort-Object -Property PolicyId # DEBUG to clipboard -> Paste to text editor after script has run #$ConfigurationPoliciesReportForDevice | ConvertTo-Json -Depth 6 | Set-Clipboard $lastDeviceConfigurationId = $null $CopyOfConfigurationPoliciesReportForDevice = $ConfigurationPoliciesReportForDevice $odatatype = $null $assignmentGroup = $null foreach($ConfigurationPolicyReportState in $ConfigurationPoliciesReportForDevice) { $assignmentGroup = $null $assignmentGroupId = $null $YodamiittiCustomGroupMembers = 'N/A' $context = $null $DeviceConfiguration = $null $IntuneDeviceConfigurationPolicyAssignments = $null $IncludeConfigurationAssignmentInSummary = $true $properties = $null $odatatype = $ConfigurationPolicyReportState.UnifiedPolicyType_loc $AssignmentGroupToolTip = $null $displayNameToolTip = $null $assignmentFilterId = $null $assignmentFilterDisplayName = $null $FilterToolTip = $null $FilterMode = $null # Cast as string so our column sorting works $YodamiittiCustomMembershipType = [String]'' # Change PolicyStatus numbers to text Switch ($ConfigurationPolicyReportState.PolicyStatus) { 1 { $ConfigurationPolicyReportState.PolicyStatus = 'Not applicable' } 2 { $ConfigurationPolicyReportState.PolicyStatus = 'Succeeded' } # User based result? 3 { $ConfigurationPolicyReportState.PolicyStatus = 'Succeeded' } # Device based result? 4 { $ConfigurationPolicyReportState.PolicyStatus = 'Error' } # Device based result ??? - This is unknown but should be error 5 { $ConfigurationPolicyReportState.PolicyStatus = 'Error' } # User based result? 6 { $ConfigurationPolicyReportState.PolicyStatus = 'Conflict' } Default { } } if($ConfigurationPolicyReportState.PolicyBaseTypeName -eq 'Microsoft.Management.Services.Api.DeviceManagementIntent') { # Endpoint Security templates information does not include assignments # So we get assignment information separately to those templates #https://graph.microsoft.com/beta/deviceManagement/intents/932d590f-b340-4a7c-b199-048fb98f09b2/assignments $url = "https://graph.microsoft.com/beta/deviceManagement/intents/$($ConfigurationPolicyReportState.PolicyId)/assignments" $IntuneDeviceConfigurationPolicyAssignments = Invoke-MgGraphGetRequestWithMSGraphAllPages $url } else { $IntunePolicyObject = $Script:IntuneConfigurationProfilesWithAssignments | Where-Object id -eq $ConfigurationPolicyReportState.PolicyId $IntuneDeviceConfigurationPolicyAssignments = $IntunePolicyObject.assignments $displayNameToolTip = $IntunePolicyObject.description # Use the actual @odata.type from the policy object if available, instead of the localized UnifiedPolicyType_loc if ($IntunePolicyObject.'@odata.type') { $odatatype = $IntunePolicyObject.'@odata.type' } } if($ConfigurationPolicyReportState.PolicyStatus -eq 'Not applicable' ) { $context = '' } else { # Default value started with. # This will change later on the script if we find where assignment came from $context = '_unknown' } $lastModifiedDateTime = $DeviceConfiguration.PspdpuLastModifiedTimeUtc # Remove #microsoft.graph. from @odata.type # Value can be empty string also so we need to test that also if ($odatatype -and -not [string]::IsNullOrWhiteSpace($odatatype)) { $odatatype = $odatatype.Replace('#microsoft.graph.', '') } # Map odata type to friendly display names $odatatypeDisplayName = switch ($odatatype) { 'macOSCustomAppConfiguration' { 'Preference file' } 'macOSCustomConfiguration' { 'Custom' } default { $odatatype } } $odatatype = $odatatypeDisplayName $assignmentGroup = $null foreach ($IntuneDeviceConfigurationPolicyAssignment in $IntuneDeviceConfigurationPolicyAssignments) { $assignmentGroup = $null $YodamiittiCustomGroupMembers = 'N/A' # Only include Configuration which have assignments targeted to this device/user $IncludeConfigurationAssignmentInSummary = $false $context = '_unknown' if ($IntuneDeviceConfigurationPolicyAssignment.target.'@odata.type' -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { # Special case for All Users $assignmentGroup = 'All Users' $context = 'User' $AssignmentGroupToolTip = 'Built-in All Users group' $YodamiittiCustomGroupMembers = '' $IncludeConfigurationAssignmentInSummary = $true } if ($IntuneDeviceConfigurationPolicyAssignment.target.'@odata.type' -eq '#microsoft.graph.allDevicesAssignmentTarget') { # Special case for All Devices $assignmentGroup = 'All Devices' $context = 'Device' $AssignmentGroupToolTip = 'Built-in All Devices group' $YodamiittiCustomGroupMembers = '' $IncludeConfigurationAssignmentInSummary = $true } if(($IntuneDeviceConfigurationPolicyAssignment.target.'@odata.type' -ne '#microsoft.graph.allLicensedUsersAssignmentTarget') -and ($IntuneDeviceConfigurationPolicyAssignment.target.'@odata.type' -ne '#microsoft.graph.allDevicesAssignmentTarget')) { # Group based assignment. We need to get Entra ID Group Name # #microsoft.graph.groupAssignmentTarget # Test if device is member of this group if($Script:deviceGroupMemberships | Where-Object { $_.id -eq $IntuneDeviceConfigurationPolicyAssignment.target.groupId}) { $assignmentGroupObject = $Script:deviceGroupMemberships | Where-Object { $_.id -eq $IntuneDeviceConfigurationPolicyAssignment.target.groupId} $assignmentGroup = $assignmentGroupObject.displayName $assignmentGroupId = $assignmentGroupObject.id # Create Group Members column information $DevicesCount = $assignmentGroupObject.YodamiittiCustomGroupMembersCountDevices $UsersCount = $assignmentGroupObject.YodamiittiCustomGroupMembersCountUsers #$YodamiittiCustomGroupMembers = "$DevicesCount devices, $UsersCount users" $YodamiittiCustomGroupMembers = '' if($DevicesCount -gt 0) { $YodamiittiCustomGroupMembers += "$DevicesCount devices " } if($UsersCount -gt 0) { $YodamiittiCustomGroupMembers += "$UsersCount users " } $AssignmentGroupToolTip = "$($assignmentGroupObject.membershipRule)" $YodamiittiCustomMembershipType = $assignmentGroupObject.YodamiittiCustomMembershipType #Write-Host "device group found: $($assignmentGroup.displayName)" $context = 'Device' $IncludeConfigurationAssignmentInSummary = $true } else { # Group not found on member of devicegroups } # Test if primary user is member of assignment group if($Script:PrimaryUserGroupsMemberOf | Where-Object { $_.id -eq $IntuneDeviceConfigurationPolicyAssignment.target.groupId}) { if($assignmentGroup) { # Device also is member of this group. Now we got mixed User and Device memberships # Maybe not good practise but it is possible # We will actually skip getting possible user Group for this assignment # Future improvement is to add user Group information also $context = '_Device/User' } else { # No assignment group was found earlier $context = 'User' $assignmentGroupObject = $Script:PrimaryUserGroupsMemberOf | Where-Object { $_.id -eq $IntuneDeviceConfigurationPolicyAssignment.target.groupId} $assignmentGroup = $assignmentGroupObject.displayName $assignmentGroupId = $assignmentGroupObject.id # Create Group Members column information $DevicesCount = $assignmentGroupObject.YodamiittiCustomGroupMembersCountDevices $UsersCount = $assignmentGroupObject.YodamiittiCustomGroupMembersCountUsers #$YodamiittiCustomGroupMembers = "$DevicesCount devices, $UsersCount users" $YodamiittiCustomGroupMembers = '' if($DevicesCount -gt 0) { $YodamiittiCustomGroupMembers += "$DevicesCount devices " } if($UsersCount -gt 0) { $YodamiittiCustomGroupMembers += "$UsersCount users " } $AssignmentGroupToolTip = "$($assignmentGroupObject.membershipRule)" $YodamiittiCustomMembershipType = $assignmentGroupObject.YodamiittiCustomMembershipType #Write-Host "User group found: $($assignmentGroup.displayName)" } $IncludeConfigurationAssignmentInSummary = $true } else { # Group not found on member of devicegroups } # Test if Latest LoggedIn User is member of assignment group # Only test this if PrimaryUser and Latest LoggedIn User is different user if($Script:PrimaryUser.id -ne $Script:LatestCheckedinUser.id) { if($Script:LatestCheckedInUserGroupsMemberOf | Where-Object { $_.id -eq $IntuneDeviceConfigurationPolicyAssignment.target.groupId}) { if($assignmentGroup) { # Device or PrimaryUser also is member of this group. # Now we may got mixed User and Device memberships # Maybe not good practise but it is possible if($context -eq 'Device') { $context = '_Device/User' } } else { $context = 'User' $assignmentGroupObject = $Script:LatestCheckedInUserGroupsMemberOf | Where-Object { $_.id -eq $IntuneDeviceConfigurationPolicyAssignment.target.groupId} $assignmentGroup = $assignmentGroupObject.displayName $assignmentGroupId = $assignmentGroupObject.id # Create Group Members column information $DevicesCount = $assignmentGroupObject.YodamiittiCustomGroupMembersCountDevices $UsersCount = $assignmentGroupObject.YodamiittiCustomGroupMembersCountUsers $YodamiittiCustomGroupMembers = "$DevicesCount devices, $UsersCount users" $AssignmentGroupToolTip = "$($assignmentGroupObject.membershipRule)" $YodamiittiCustomMembershipType = $assignmentGroupObject.YodamiittiCustomMembershipType #Write-Host "User group found: $($assignmentGroup.displayName)" $IncludeConfigurationAssignmentInSummary = $true } } else { # Group not found on member of devicegroups } } } if($IncludeConfigurationAssignmentInSummary) { # Track Settings Catalog policy IDs for extended report download if ($ExtendedReport -and $ConfigurationPolicyReportState.PolicyBaseTypeName -eq 'DeviceManagementConfigurationPolicy') { if ($script:SettingsCatalogPolicyIdsToDownload -notcontains $ConfigurationPolicyReportState.PolicyId) { $script:SettingsCatalogPolicyIdsToDownload += $ConfigurationPolicyReportState.PolicyId Write-Verbose "Tracking Settings Catalog policy ID for download: $($ConfigurationPolicyReportState.PolicyId) - $($ConfigurationPolicyReportState.PolicyName)" } } # Track Custom Configuration policies with encrypted OMA settings for extended report if ($ExtendedReport -and $IntunePolicyObject.'@odata.type' -eq '#microsoft.graph.windows10CustomConfiguration' -and $IntunePolicyObject.omaSettings) { foreach ($omaSetting in $IntunePolicyObject.omaSettings) { if ($omaSetting.isEncrypted -eq $true -and $omaSetting.secretReferenceValueId) { if (-not $script:CustomConfigPoliciesWithSecrets.ContainsKey($ConfigurationPolicyReportState.PolicyId)) { $script:CustomConfigPoliciesWithSecrets[$ConfigurationPolicyReportState.PolicyId] = @() } $script:CustomConfigPoliciesWithSecrets[$ConfigurationPolicyReportState.PolicyId] += $omaSetting.secretReferenceValueId Write-Verbose "Tracking encrypted OMA setting for policy: $($ConfigurationPolicyReportState.PolicyName) - Secret ID: $($omaSetting.secretReferenceValueId)" } } } # Set included/excluded attribute $PolicyIncludeExclude = '' if ($IntuneDeviceConfigurationPolicyAssignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget') { $PolicyIncludeExclude = 'Included' } if ($IntuneDeviceConfigurationPolicyAssignment.target.'@odata.type' -eq '#microsoft.graph.exclusionGroupAssignmentTarget') { $PolicyIncludeExclude = 'Excluded' } $state = $ConfigurationPolicyReportState.PolicyStatus $assignmentFilterId = $IntuneDeviceConfigurationPolicyAssignment.target.deviceAndAppManagementAssignmentFilterId #$assignmentFilterDisplayName = $AllIntuneFilters | Where-Object { $_.id -eq $assignmentFilterId } | Select-Object -ExpandProperty displayName $assignmentFilterObject = $AllIntuneFilters | Where-Object { $_.id -eq $assignmentFilterId } $assignmentFilterDisplayName = $assignmentFilterObject.displayName $FilterToolTip = $assignmentFilterObject.rule $FilterMode = $IntuneDeviceConfigurationPolicyAssignment.target.deviceAndAppManagementAssignmentFilterType if($FilterMode -eq 'None') { $FilterMode = $null } # Cast variable types to make sure column click based sorting works # Sorting may break if there are different kind of objects $properties = @{ context = [String]$context odatatype = [String]$odatatype userPrincipalName = [String]$ConfigurationPolicyReportState.UPN displayname = [String]$ConfigurationPolicyReportState.PolicyName assignmentIntent = [String]$assignmentIntent IncludeExclude = [String]$PolicyIncludeExclude assignmentGroup = [String]$assignmentGroup YodamiittiCustomGroupMembers = [String]$YodamiittiCustomGroupMembers assignmentGroupId = [String]$assignmentGroupId state = [String]$state YodamiittiCustomMembershipType = [String]$YodamiittiCustomMembershipType id = $ConfigurationPolicyReportState.PolicyId filter = [String]$assignmentFilterDisplayName filterId = [String]$assignmentFilterId filterMode = [String]$FilterMode filterTooltip = [String]$FilterTooltip AssignmentGroupToolTip = [String]$AssignmentGroupToolTip displayNameToolTip = [String]$displayNameToolTip } # Create new custom object every time inside foreach-loop # If you create custom object outside of foreach then you would edit same custom object on every foreach cycle resulting only 1 app in custom object array $CustomObject = New-Object -TypeName PSObject -Prop $properties # Add custom object to our custom object array. $script:ConfigurationsAssignmentsObservableCollection += $CustomObject } } # Remove DeviceConfiguration from our copy object array if any assignment was found $DeviceConfigurationWithAssignment = $script:ConfigurationsAssignmentsObservableCollection | Where-Object { $_.id -eq $ConfigurationPolicyReportState.PolicyId } if ($DeviceConfigurationWithAssignment) { # Remove DeviceConfiguration from copy array because that Configration had Assignment # We will end up only having Configurations which we did NOT find assignments # We may use this object array with future features $CopyOfConfigurationPoliciesReportForDevice = $CopyOfConfigurationPoliciesReportForDevice | Where-Object { $_.id -ne $ConfigurationPolicyReportState.PolicyId} } else { # We could not determine Assignment source # Either assignments does not exists at all # or assignment is based on nested groups so earlier check did not find Entra ID group where device and/or user is member $context = '_unknown' $PolicyIncludeExclude = '' # Set variable which we return from this function $UnknownAssignmentGroupFound = $true # Check if assignments is $null but Policy was found # Intune may show Configuration profile status for configuration which is not deployed anymore # Check that we did find policy but assignments for that found policy is $null if((-not $IntuneDeviceConfigurationPolicyAssignments) -and ($Script:IntuneConfigurationProfilesWithAssignments | Where-Object id -eq $ConfigurationPolicyReportState.PolicyId)) { Write-Host "Warning: Policy $($ConfigurationPolicyReportState.PolicyName) does not have any assignments!" -ForegroundColor Yellow $assignmentGroup = "Policy does not have any assignments!" } else { # There were assignments in Policy but we could not find which Entra ID group is causing policy to be applied Write-Host "Warning: Could not resolve Entra ID Group assignment for Policy $($ConfigurationPolicyReportState.PolicyName)!" -ForegroundColor Yellow $assignmentGroup = "unknown (possible user targeted group, nested group or removed assignment)" } $YodamiittiCustomGroupMembers = 'N/A' # Cast variable types to make sure column click based sorting works # Sorting may break if there are different kind of objects $properties = @{ context = [String]$context odatatype = [String]$odatatype userPrincipalName = [String]$ConfigurationPolicyReportState.UPN displayname = [String]$ConfigurationPolicyReportState.PolicyName assignmentIntent = [String]$assignmentIntent IncludeExclude = [String]$PolicyIncludeExclude assignmentGroup = [String]$assignmentGroup YodamiittiCustomGroupMembers = [String]$YodamiittiCustomGroupMembers assignmentGroupId = $null state = [String]$ConfigurationPolicyReportState.PolicyStatus YodamiittiCustomMembershipType = [String]'' id = $ConfigurationPolicyReportState.PolicyId filter = [String]'' filterId = $null filterMode = [String]'' filterTooltip = [String]'' AssignmentGroupToolTip = [String]'' displayNameToolTip = [String]'' } $CustomObject = New-Object -TypeName PSObject -Prop $properties $script:ConfigurationsAssignmentsObservableCollection += $CustomObject } $lastDeviceConfigurationId = $ConfigurationPolicyReportState.PolicyId } # Filter out duplicate Policies # Intune shows applied policies to system (device) and possibly all users logged in to device # Combine same context/policy/state/assignmentGroup/Filter policies to one policy entry # DEBUG #$script:ConfigurationsAssignmentsObservableCollection | ConvertTo-Json -Depth 5 | Set-Clipboard # Get unique Policies eg. remove duplicates # Challenge is that -Unique selects first object from all duplicate objects # and that first object can have any value in userPrincipalName property $script:ConfigurationsAssignmentsObservableCollectionUnique = $script:ConfigurationsAssignmentsObservableCollection | Sort-Object -Property id,context,odatatype,displayName,IncludeExclude,state,assignmentGroup,filter,filterMode -Unique # Change PrimaryUser UPN to if found from assignments # Secondary change to device (which is empty value) foreach($PolicyInGrid in $script:ConfigurationsAssignmentsObservableCollectionUnique) { if(($script:PrimaryUser) -and ($PolicyInGrid.userPrincipalName -eq $script:PrimaryUser.userPrincipalName)) { # Policy UPN value is same than Intune device Primary User and PrimaryUser does exist # No change needed so continue to next policy in foreach loop Continue } elseif((-not $script:PrimaryUser) -and ($PolicyInGrid.userPrincipalName -eq $Script:LatestCheckedinUser.UserPrincipalName)) { # Policy UPN value is same than latest checked-in user and there is NO PrimaryUser # No change needed so continue to next policy in foreach loop Continue } else { # Policy UPN and Primary User values are different # Get duplicate policies from original list $DuplicatePolicyObjects = $script:ConfigurationsAssignmentsObservableCollection | Where-Object { ($_.id -eq $PolicyInGrid.id) -and ($_.context -eq $PolicyInGrid.context) -and ($_.odatatype -eq $PolicyInGrid.odatatype) -and ($_.displayName -eq $PolicyInGrid.displayName) -and ($_.IncludeExclude -eq $PolicyInGrid.IncludeExclude) -and ($_.state -eq $PolicyInGrid.state) -and ($_.assignmentGroup -eq $PolicyInGrid.assignmentGroup) -and ($_.filter -eq $PolicyInGrid.filter) -and ($_.filterMode -eq $PolicyInGrid.filterMode) } # Get userPrincipalNames in duplicate entries $UserPrincipalNames = $DuplicatePolicyObjects | Select-Object -ExpandProperty userPrincipalName # Check if primaryUser UPN was listed in duplicate policy entries if(($script:PrimaryUser) -and ($UserPrincipalNames -contains $script:PrimaryUser.userPrincipalName)) { $PolicyInGrid.userPrincipalName = $script:PrimaryUser.userPrincipalName } elseif((-not $script:PrimaryUser) -and ($UserPrincipalNames -contains $Script:LatestCheckedinUser.UserPrincipalName)) { $PolicyInGrid.userPrincipalName = $Script:LatestCheckedinUser.UserPrincipalName } else { # If primary user was not listed in duplicate policy entries, # use any available UPN from the duplicates (shows which user was logged on when policy was evaluated) $nonEmptyUPN = $UserPrincipalNames | Where-Object { -not [string]::IsNullOrWhiteSpace($_) } | Select-Object -First 1 if ($nonEmptyUPN) { $PolicyInGrid.userPrincipalName = $nonEmptyUPN } else { $PolicyInGrid.userPrincipalName = '' } } } } if($script:ConfigurationsAssignmentsObservableCollectionUnique.Count -gt 1) { # ItemsSource works if we are sorting 2 or more objects return $script:ConfigurationsAssignmentsObservableCollectionUnique | Sort-Object displayName,userPrincipalName } else { # Only 1 object so we can't do sorting # If we try to sort here then our object array breaks and it does not work for ItemsSource # Cast as array because otherwise it will fail return [array]$script:ConfigurationsAssignmentsObservableCollectionUnique } #return $ConfigurationPoliciesReportForDevice } function Get-RemediationScriptsReport { param( [string]$IntuneDeviceId, [array]$DeviceGroups, [array]$PrimaryUserGroups, [array]$LatestUserGroups, [object]$PrimaryUser, [object]$LatestUser ) # Initialize script IDs tracking array for extended report $script:ScriptIdsToDownload = @() Write-Host "Get Remediation scripts for device..." -ForegroundColor Cyan $url = "https://graph.microsoft.com/beta/deviceManagement/managedDevices/$($IntuneDeviceId)/deviceHealthScriptStates" $remediationScriptsForDevice = Invoke-MGGraphGetRequestWithMSGraphAllPages $url if ($remediationScriptsForDevice) { Write-Host "Found $($remediationScriptsForDevice.Count) remediation script states" } else { Write-Host "No remediation script states found for device" $remediationScriptsForDevice = @() } # Download all Remediation scripts with assignments $script:RemediationScriptsWithAssignments = Get-RemediationScriptsWithAssignments -ReloadCacheData:$ReloadCache Write-Host "Found $($script:RemediationScriptsWithAssignments.Count) total remediation scripts" # Download all Platform scripts with assignments $script:PlatformScriptsWithAssignments = Get-PlatformScriptsWithAssignments -ReloadCacheData:$ReloadCache Write-Host "Found $($script:PlatformScriptsWithAssignments.Count) total platform scripts" $results = @() foreach ($scriptState in $remediationScriptsForDevice) { # Get the script details $scriptInfo = $script:RemediationScriptsWithAssignments | Where-Object { $_.id -eq $scriptState.policyId } # Detection status $detectionStatus = switch ($scriptState.detectionState) { 'success' { 'Without issues' } 'fail' { 'With issues' } 'notApplicable' { 'Not applicable' } default { $scriptState.detectionState } } # Remediation status $remediationStatus = switch ($scriptState.remediationState) { 'success' { 'Issue fixed' } 'fail' { 'With issues' } 'skipped' { 'Not run' } 'unknown' { 'Not run' } default { $scriptState.remediationState } } # Status update time $lastUpdate = $scriptState.lastStateUpdateDateTime $statusUpdateTime = '' $statusUpdateTimeTooltip = '' if ($lastUpdate) { $timespan = New-TimeSpan (Get-Date $lastUpdate) (Get-Date) if ($timespan.Days -gt 0) { $statusUpdateTime = "$($timespan.Days) days ago" } elseif ($timespan.Hours -gt 0) { $statusUpdateTime = "$($timespan.Hours) hours ago" } else { $statusUpdateTime = "$($timespan.Minutes) mins ago" } $statusUpdateTimeTooltip = (Get-Date $lastUpdate -Format "yyyy-MM-dd HH:mm:ss.fff") } # Detection tooltip $detectionTooltip = $scriptState.preRemediationDetectionScriptOutput if ([string]::IsNullOrWhiteSpace($detectionTooltip)) { $detectionTooltip = 'No output' } # Remediation tooltip $remediationTooltip = $scriptState.postRemediationDetectionScriptOutput if ([string]::IsNullOrWhiteSpace($remediationTooltip)) { $remediationTooltip = 'No output' } # User principal name (from script state) $userPrincipalName = $scriptState.userName # Get assignments for this script $assignments = $scriptInfo.assignments $anyAssignmentFound = $false if ($assignments -and $assignments.Count -gt 0) { foreach ($assignment in $assignments) { $thisAssignmentMatches = $false $context = '_unknown' $assignmentGroup = $null $assignmentGroupId = $null $groupType = '' $groupMembers = 'N/A' $assignmentGroupTooltip = '' $filterName = '' $filterMode = '' $filterTooltip = '' # Get filter information $filterId = $assignment.target.deviceAndAppManagementAssignmentFilterId $filterType = $assignment.target.deviceAndAppManagementAssignmentFilterType if ($filterType -and $filterType -ne 'none') { $filterMode = $filterType $filterObj = Get-ObjectFromGUID -Id $filterId if ($filterObj) { $filterName = $filterObj.displayName $filterTooltip = $filterObj.rule } else { $filterName = $filterId } } # Check assignment type if ($assignment.target.'@odata.type' -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { $assignmentGroup = 'All Users' $context = 'User' $assignmentGroupTooltip = 'Built-in All Users group' $groupMembers = '' $thisAssignmentMatches = $true } elseif ($assignment.target.'@odata.type' -eq '#microsoft.graph.allDevicesAssignmentTarget') { $assignmentGroup = 'All Devices' $context = 'Device' $assignmentGroupTooltip = 'Built-in All Devices group' $groupMembers = '' $thisAssignmentMatches = $true } elseif ($assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget') { $groupId = $assignment.target.groupId # Check if device is member of this group $deviceGroupObj = $Script:deviceGroupMemberships | Where-Object { $_.id -eq $groupId } if ($deviceGroupObj) { $assignmentGroup = $deviceGroupObj.displayName $assignmentGroupId = $groupId $context = 'Device' $groupType = $deviceGroupObj.YodamiittiCustomMembershipType $assignmentGroupTooltip = $deviceGroupObj.membershipRule $devCount = $deviceGroupObj.YodamiittiCustomGroupMembersCountDevices $userCount = $deviceGroupObj.YodamiittiCustomGroupMembersCountUsers $groupMembers = '' if ($devCount -gt 0) { $groupMembers += "$devCount devices " } if ($userCount -gt 0) { $groupMembers += "$userCount users " } $thisAssignmentMatches = $true } # Check if primary user is member of this group if ($Script:PrimaryUserGroupsMemberOf | Where-Object { $_.id -eq $groupId }) { if ($assignmentGroup) { $context = '_Device/User' } else { $primaryGroupObj = $Script:PrimaryUserGroupsMemberOf | Where-Object { $_.id -eq $groupId } $assignmentGroup = $primaryGroupObj.displayName $assignmentGroupId = $groupId $context = 'User' $groupType = $primaryGroupObj.YodamiittiCustomMembershipType $assignmentGroupTooltip = $primaryGroupObj.membershipRule $devCount = $primaryGroupObj.YodamiittiCustomGroupMembersCountDevices $userCount = $primaryGroupObj.YodamiittiCustomGroupMembersCountUsers $groupMembers = '' if ($devCount -gt 0) { $groupMembers += "$devCount devices " } if ($userCount -gt 0) { $groupMembers += "$userCount users " } } $thisAssignmentMatches = $true } # Check if latest user is member of this group (if different from primary) if ($Script:PrimaryUser.id -ne $Script:LatestCheckedinUser.id) { if ($Script:LatestCheckedInUserGroupsMemberOf | Where-Object { $_.id -eq $groupId }) { if ($assignmentGroup) { if ($context -eq 'Device') { $context = '_Device/User' } } else { $latestGroupObj = $Script:LatestCheckedInUserGroupsMemberOf | Where-Object { $_.id -eq $groupId } $assignmentGroup = $latestGroupObj.displayName $assignmentGroupId = $groupId $context = 'User' $groupType = $latestGroupObj.YodamiittiCustomMembershipType $assignmentGroupTooltip = $latestGroupObj.membershipRule $devCount = $latestGroupObj.YodamiittiCustomGroupMembersCountDevices $userCount = $latestGroupObj.YodamiittiCustomGroupMembersCountUsers $groupMembers = '' if ($devCount -gt 0) { $groupMembers += "$devCount devices " } if ($userCount -gt 0) { $groupMembers += "$userCount users " } } $thisAssignmentMatches = $true } } } # Add schedule info to tooltip if ($assignment.runSchedule) { $scheduleType = $assignment.runSchedule.'@odata.type' $scheduleInterval = $assignment.runSchedule.interval $scheduleTime = $assignment.runSchedule.time if ($scheduleType -eq '#microsoft.graph.deviceHealthScriptRunOnceSchedule') { $scheduleDate = $assignment.runSchedule.date $assignmentGroupTooltip += "`n`nRemediation schedule:`nRun once`n$scheduleTime`n$scheduleDate" } elseif ($scheduleType -eq '#microsoft.graph.deviceHealthScriptHourlySchedule') { $assignmentGroupTooltip += "`n`nRemediation schedule:`nRun every $scheduleInterval hours" } elseif ($scheduleType -eq '#microsoft.graph.deviceHealthScriptDailySchedule') { $assignmentGroupTooltip += "`n`nRemediation schedule:`nRun every $scheduleInterval days" } else { $assignmentGroupTooltip += "`n`nRemediation schedule:`n$scheduleType`n$scheduleInterval`n$scheduleTime" } } # Only add if this specific assignment matches the device/user if ($thisAssignmentMatches) { $anyAssignmentFound = $true # Track remediation scripts for extended report download if ($ExtendedReport -and $scriptState.policyId) { if ($script:ScriptIdsToDownload -notcontains $scriptState.policyId) { Write-Verbose "Tracking remediation script ID: $($scriptState.policyId)" $script:ScriptIdsToDownload += $scriptState.policyId } } $results += [PSCustomObject]@{ id = $scriptState.policyId context = $context scriptType = 'Remediation' displayName = if ($scriptInfo) { $scriptInfo.displayName } else { $scriptState.policyId } detectionStatus = $detectionStatus detectionStatusTooltip = $detectionTooltip remediationStatus = $remediationStatus remediationStatusTooltip = $remediationTooltip userPrincipalName = $userPrincipalName statusUpdateTime = $statusUpdateTime statusUpdateTimeTooltip = $statusUpdateTimeTooltip groupType = $groupType assignmentGroup = $assignmentGroup assignmentGroupTooltip = $assignmentGroupTooltip groupMembers = $groupMembers filter = $filterName filterMode = $filterMode filterTooltip = $filterTooltip } } } } # If no assignments matched, add entry without assignment info if (-not $anyAssignmentFound) { # Track remediation scripts for extended report download if ($ExtendedReport -and $scriptState.policyId) { if ($script:ScriptIdsToDownload -notcontains $scriptState.policyId) { Write-Verbose "Tracking remediation script ID (no assignment): $($scriptState.policyId)" $script:ScriptIdsToDownload += $scriptState.policyId } } $results += [PSCustomObject]@{ id = $scriptState.policyId context = '' scriptType = 'Remediation' displayName = if ($scriptInfo) { $scriptInfo.displayName } else { $scriptState.policyId } detectionStatus = $detectionStatus detectionStatusTooltip = $detectionTooltip remediationStatus = $remediationStatus remediationStatusTooltip = $remediationTooltip userPrincipalName = $userPrincipalName statusUpdateTime = $statusUpdateTime statusUpdateTimeTooltip = $statusUpdateTimeTooltip groupType = '' assignmentGroup = 'No assignments' assignmentGroupTooltip = '' groupMembers = '' filter = '' filterMode = '' filterTooltip = '' } } } # Process platform scripts assignments (no device-specific state, just assignments) foreach ($platformScript in $script:PlatformScriptsWithAssignments) { $anyAssignmentFound = $false $assignments = $platformScript.assignments if ($assignments -and $assignments.Count -gt 0) { foreach ($assignment in $assignments) { $thisAssignmentMatches = $false $context = '_unknown' $assignmentGroup = $null $assignmentGroupId = $null $groupType = '' $groupMembers = 'N/A' $assignmentGroupTooltip = '' $filterName = '' $filterMode = '' $filterTooltip = '' # Get filter information $filterId = $assignment.target.deviceAndAppManagementAssignmentFilterId $filterType = $assignment.target.deviceAndAppManagementAssignmentFilterType if ($filterType -and $filterType -ne 'none') { $filterMode = $filterType $filterObj = Get-ObjectFromGUID -Id $filterId if ($filterObj) { $filterName = $filterObj.displayName $filterTooltip = $filterObj.rule } else { $filterName = $filterId } } # Check assignment type if ($assignment.target.'@odata.type' -eq '#microsoft.graph.allLicensedUsersAssignmentTarget') { $assignmentGroup = 'All Users' $context = 'User' $assignmentGroupTooltip = 'Built-in All Users group' $groupMembers = '' $thisAssignmentMatches = $true } elseif ($assignment.target.'@odata.type' -eq '#microsoft.graph.allDevicesAssignmentTarget') { $assignmentGroup = 'All Devices' $context = 'Device' $assignmentGroupTooltip = 'Built-in All Devices group' $groupMembers = '' $thisAssignmentMatches = $true } elseif ($assignment.target.'@odata.type' -eq '#microsoft.graph.groupAssignmentTarget') { $groupId = $assignment.target.groupId # Check if device is member of this group $deviceGroupObj = $Script:deviceGroupMemberships | Where-Object { $_.id -eq $groupId } if ($deviceGroupObj) { $assignmentGroup = $deviceGroupObj.displayName $assignmentGroupId = $groupId $context = 'Device' $groupType = $deviceGroupObj.YodamiittiCustomMembershipType $assignmentGroupTooltip = $deviceGroupObj.membershipRule $devCount = $deviceGroupObj.YodamiittiCustomGroupMembersCountDevices $userCount = $deviceGroupObj.YodamiittiCustomGroupMembersCountUsers $groupMembers = '' if ($devCount -gt 0) { $groupMembers += "$devCount devices " } if ($userCount -gt 0) { $groupMembers += "$userCount users " } $thisAssignmentMatches = $true } # Check if primary user is member of this group if ($Script:PrimaryUserGroupsMemberOf | Where-Object { $_.id -eq $groupId }) { if ($assignmentGroup) { $context = '_Device/User' } else { $primaryGroupObj = $Script:PrimaryUserGroupsMemberOf | Where-Object { $_.id -eq $groupId } $assignmentGroup = $primaryGroupObj.displayName $assignmentGroupId = $groupId $context = 'User' $groupType = $primaryGroupObj.YodamiittiCustomMembershipType $assignmentGroupTooltip = $primaryGroupObj.membershipRule $devCount = $primaryGroupObj.YodamiittiCustomGroupMembersCountDevices $userCount = $primaryGroupObj.YodamiittiCustomGroupMembersCountUsers $groupMembers = '' if ($devCount -gt 0) { $groupMembers += "$devCount devices " } if ($userCount -gt 0) { $groupMembers += "$userCount users " } } $thisAssignmentMatches = $true } # Check if latest user is member of this group (if different from primary) if ($Script:PrimaryUser.id -ne $Script:LatestCheckedinUser.id) { if ($Script:LatestCheckedInUserGroupsMemberOf | Where-Object { $_.id -eq $groupId }) { if ($assignmentGroup) { if ($context -eq 'Device') { $context = '_Device/User' } } else { $latestGroupObj = $Script:LatestCheckedInUserGroupsMemberOf | Where-Object { $_.id -eq $groupId } $assignmentGroup = $latestGroupObj.displayName $assignmentGroupId = $groupId $context = 'User' $groupType = $latestGroupObj.YodamiittiCustomMembershipType $assignmentGroupTooltip = $latestGroupObj.membershipRule $devCount = $latestGroupObj.YodamiittiCustomGroupMembersCountDevices $userCount = $latestGroupObj.YodamiittiCustomGroupMembersCountUsers $groupMembers = '' if ($devCount -gt 0) { $groupMembers += "$devCount devices " } if ($userCount -gt 0) { $groupMembers += "$userCount users " } } $thisAssignmentMatches = $true } } } # Only add if this specific assignment matches the device/user if ($thisAssignmentMatches) { # Check if script platform matches device OS # Skip scripts that don't match the device OS (e.g., don't show macOS scripts on Windows devices) # This applies to ALL assignments (device and user) because Intune filters by OS at deployment $scriptPlatform = $platformScript.ScriptPlatform $deviceOS = $script:IntuneManagedDevice.operatingSystem $platformMatches = $true # Check OS compatibility for all assignment types if ($scriptPlatform -eq 'Windows' -and $deviceOS -notlike 'Windows*') { $platformMatches = $false } elseif ($scriptPlatform -eq 'macOS' -and $deviceOS -ne 'macOS') { $platformMatches = $false } elseif ($scriptPlatform -eq 'Linux' -and $deviceOS -ne 'Linux') { $platformMatches = $false } if (-not $platformMatches) { Write-Verbose "Skipping $scriptPlatform script '$($platformScript.displayName)' - doesn't match device OS: $deviceOS" continue } $anyAssignmentFound = $true # Determine script type based on platform $scriptType = "Platform ($($platformScript.ScriptPlatform))" # Get display name (different property for Linux scripts) $displayName = if ($platformScript.displayName) { $platformScript.displayName } elseif ($platformScript.name) { $platformScript.name } else { $platformScript.id } # Track Windows PowerShell scripts and macOS shell scripts for extended report download if ($ExtendedReport -and ($platformScript.ScriptPlatform -eq 'Windows' -or $platformScript.ScriptPlatform -eq 'macOS')) { if ($platformScript.id -and $script:ScriptIdsToDownload -notcontains $platformScript.id) { Write-Verbose "Tracking platform script ID: $($platformScript.id) - $($displayName) (Platform: $($platformScript.ScriptPlatform))" $script:ScriptIdsToDownload += $platformScript.id } } $results += [PSCustomObject]@{ id = $platformScript.id context = $context scriptType = $scriptType displayName = $displayName detectionStatus = 'N/A' detectionStatusTooltip = 'Platform scripts do not have detection state' remediationStatus = 'N/A' remediationStatusTooltip = 'Platform scripts do not have remediation state' userPrincipalName = '' statusUpdateTime = '' statusUpdateTimeTooltip = '' groupType = $groupType assignmentGroup = $assignmentGroup assignmentGroupTooltip = $assignmentGroupTooltip groupMembers = $groupMembers filter = $filterName filterMode = $filterMode filterTooltip = $filterTooltip } } } } } return $results } function New-IntuneDeviceHtmlReport { param( [hashtable]$Context ) $device = $Context.ManagedDevice $azureDevice = $Context.AzureDevice $primaryContext = $Context.PrimaryUser $primaryUser = if ($primaryContext) { $primaryContext.User } else { $null } $primaryUserGroups = if ($primaryContext -and $primaryContext.Groups) { $primaryContext.Groups } else { @() } $latestContext = $Context.LatestUser $latestUser = if ($latestContext) { $latestContext.LatestUser } else { $null } $latestUserGroups = if ($latestContext -and $latestContext.LatestGroups) { $latestContext.LatestGroups } else { @() } $appAssignments = if ($Context.AppAssignments) { $Context.AppAssignments.Items } else { @() } $configPolicies = $Context.ConfigurationPolicies $deviceGroups = $Context.DeviceGroups $autopilotContext = $Context.Autopilot $autopilotDetail = if ($autopilotContext) { $autopilotContext.Detail } else { $null } $autopilotDeviceInfo = if ($autopilotContext) { $autopilotContext.Device } else { $null } $espContext = $Context.EnrollmentStatusPage function ConvertTo-FriendlyBytes { param($Bytes) if ($null -eq $Bytes) { return $null } try { $value = [double]$Bytes } catch { return $null } $units = @('B','KB','MB','GB','TB','PB') $index = 0 while ($value -ge 1024 -and $index -lt $units.Count - 1) { $value /= 1024 $index++ } return ('{0:N0} {1}' -f $value, $units[$index]) } function Get-StorageSummary { param($TotalBytes,$FreeBytes) try { $total = if ($null -ne $TotalBytes) { [double]$TotalBytes } else { $null } } catch { $total = $null } try { $free = if ($null -ne $FreeBytes) { [double]$FreeBytes } else { $null } } catch { $free = $null } if ($total -and $free) { return ('{0} / {1}' -f (ConvertTo-FriendlyBytes $free), (ConvertTo-FriendlyBytes $total)) } elseif ($total) { return ConvertTo-FriendlyBytes $total } return $null } function New-DeviceDetailCards { param([array]$Items) if (-not $Items -or $Items.Count -eq 0) { return '' } $encode = { param($value) if ($null -eq $value) { return 'n/a' } $text = [string]$value if ([string]::IsNullOrWhiteSpace($text)) { $text = 'n/a' } return [System.Net.WebUtility]::HtmlEncode($text) } $cards = foreach ($item in $Items) { $label = & $encode $item.Label $value = & $encode $item.Value $tooltipAttr = '' if ($item.Tooltip) { $tooltipAttr = " data-tooltip=`"$(& $encode $item.Tooltip)`"" } $accentClass = if ($item.Accent) { " $($item.Accent)" } else { '' } $secondary = if ($item.Secondary) { "
No primary user assigned to this device, so no user group memberships are available.
' } if (-not $Groups -or $Groups.Count -eq 0) { return 'No primary user group memberships resolved.
' } $encode = { param($value) if ($null -eq $value) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$value) } $rows = foreach ($group in ($Groups | Sort-Object -Property displayName)) { $display = & $encode $group.displayName $devices = & $encode $group.YodamiittiCustomGroupMembersCountDevices $users = & $encode $group.YodamiittiCustomGroupMembersCountUsers $type = & $encode $group.YodamiittiCustomGroupType $security = & $encode $group.securityEnabled $membershipType = & $encode $group.YodamiittiCustomMembershipType $rule = & $encode $group.membershipRule $descriptionTooltip = if ([string]::IsNullOrWhiteSpace($group.description)) { '' } else { & $encode $group.description } $descriptionAttr = if ($descriptionTooltip) { " title='$descriptionTooltip'" } else { '' } $ruleTooltip = if ([string]::IsNullOrWhiteSpace($group.membershipRule)) { '' } else { & $encode $group.membershipRule } $ruleAttr = if ($ruleTooltip) { " title='$ruleTooltip'" } else { '' } $rowClass = '' if ($group.YodamiittiCustomGroupType -eq 'DirectoryRole') { $rowClass = 'role-directory' } if ($group.displayName -eq 'Global Administrator') { $rowClass = 'role-globaladmin' } $rowClassAttr = if ($rowClass) { " class='$rowClass'" } else { '' } "| Display name | ' $table += 'Devices | ' $table += 'Users | ' $table += 'Group type | ' $table += 'Security enabled | ' $table += 'Membership type | ' $table += 'Membership rule | ' $table += '
|---|
No latest logged-on user available.
' } if (-not $Groups -or $Groups.Count -eq 0) { return 'No latest user group memberships resolved.
' } $encode = { param($value) if ($null -eq $value) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$value) } $rows = foreach ($group in ($Groups | Sort-Object -Property displayName)) { $display = & $encode $group.displayName $devices = & $encode $group.YodamiittiCustomGroupMembersCountDevices $users = & $encode $group.YodamiittiCustomGroupMembersCountUsers $type = & $encode $group.YodamiittiCustomGroupType $security = & $encode $group.securityEnabled $membershipType = & $encode $group.YodamiittiCustomMembershipType $rule = & $encode $group.membershipRule $descriptionTooltip = if ([string]::IsNullOrWhiteSpace($group.description)) { '' } else { & $encode $group.description } $descriptionAttr = if ($descriptionTooltip) { " title='$descriptionTooltip'" } else { '' } $ruleTooltip = if ([string]::IsNullOrWhiteSpace($group.membershipRule)) { '' } else { & $encode $group.membershipRule } $ruleAttr = if ($ruleTooltip) { " title='$ruleTooltip'" } else { '' } $rowClass = '' if ($group.YodamiittiCustomGroupType -eq 'DirectoryRole') { $rowClass = 'role-directory' } if ($group.displayName -eq 'Global Administrator') { $rowClass = 'role-globaladmin' } $rowClassAttr = if ($rowClass) { " class='$rowClass'" } else { '' } "| Display name | ' $table += 'Devices | ' $table += 'Users | ' $table += 'Group type | ' $table += 'Security enabled | ' $table += 'Membership type | ' $table += 'Membership rule | ' $table += '
|---|
No device group memberships found.
' } $encode = { param($value) if ($null -eq $value) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$value) } $rows = foreach ($group in ($Groups | Sort-Object -Property displayName)) { $display = & $encode $group.displayName $devices = & $encode $group.YodamiittiCustomGroupMembersCountDevices $users = & $encode $group.YodamiittiCustomGroupMembersCountUsers $type = & $encode $group.YodamiittiCustomGroupType $security = & $encode $group.securityEnabled $membershipType = & $encode $group.YodamiittiCustomMembershipType $rule = & $encode $group.membershipRule $descriptionTooltip = if ([string]::IsNullOrWhiteSpace($group.description)) { '' } else { & $encode $group.description } $descriptionAttr = if ($descriptionTooltip) { " title='$descriptionTooltip'" } else { '' } $ruleTooltip = if ([string]::IsNullOrWhiteSpace($group.membershipRule)) { '' } else { & $encode $group.membershipRule } $ruleAttr = if ($ruleTooltip) { " title='$ruleTooltip'" } else { '' } $rowClass = '' if ($group.YodamiittiCustomGroupType -eq 'DirectoryRole') { $rowClass = 'role-directory' } if ($group.displayName -eq 'Global Administrator') { $rowClass = 'role-globaladmin' } $rowClassAttr = if ($rowClass) { " class='$rowClass'" } else { '' } "| Display name | ' $table += 'Devices | ' $table += 'Users | ' $table += 'Group type | ' $table += 'Security enabled | ' $table += 'Membership type | ' $table += 'Membership rule | ' $table += '
|---|
No application assignments resolved.
' } $encode = { param($value) if ($null -eq $value) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$value) } $tooltipAttr = { param($value) if ([string]::IsNullOrWhiteSpace($value)) { return '' } return " title=`"$(& $encode $value)`"" } $table = @() $table += '| Context | ' $table += 'Application type | ' $table += 'Display name | ' $table += 'Version | ' $table += 'Intent | ' $table += 'Include/Exclude | ' $table += 'Install state | ' $table += 'Group type | ' $table += 'Assignment group | ' $table += 'Group members | ' $table += 'Filter | ' $table += 'Filter mode | ' $table += '$context | " $rowCells += "$odata | " $rowCells += "$display | " $rowCells += "$version | " $rowCells += "$intent | " $rowCells += "$includeExclude | " $rowCells += "$installState | " $rowCells += "$groupType | " $rowCells += "$assignmentGroup | " $rowCells += "$groupMembers | " $rowCells += "$filter | " $rowCells += "$filterMode | " $table += "
|---|---|---|---|---|---|---|---|---|---|---|---|
No configuration policy data returned.
' } $encode = { param($value) if ($null -eq $value) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$value) } $tooltipAttr = { param($value) if ([string]::IsNullOrWhiteSpace($value)) { return '' } return " title=`"$(& $encode $value)`"" } $table = @() $table += '| Context | ' $table += 'Configuration type | ' $table += 'Display name | ' $table += 'User principal name | ' $table += 'Include/Exclude | ' $table += 'State | ' $table += 'Group type | ' $table += 'Assignment group | ' $table += 'Group members | ' $table += 'Filter | ' $table += 'Filter mode | ' $table += '$context | " $rowCells += "$odata | " $rowCells += "$display | " $rowCells += "$upn | " $rowCells += "$includeExclude | " $rowCells += "$state | " $rowCells += "$groupType | " $rowCells += "$assignmentGroup | " $rowCells += "$groupMembers | " $rowCells += "$filter | " $rowCells += "$filterMode | " $table += "
|---|---|---|---|---|---|---|---|---|---|---|
No remediation scripts found for this device.
' } $encode = { param($value) if ($null -eq $value) { return '' } return [System.Net.WebUtility]::HtmlEncode([string]$value) } $tooltipAttr = { param($value) if ([string]::IsNullOrWhiteSpace($value)) { return '' } return " title=`"$(& $encode $value)`"" } $table = @() $table += '| Context | ' $table += 'Script type | ' $table += 'Script name | ' $table += 'Detection status | ' $table += 'Remediation status | ' $table += 'User principal name | ' $table += 'Status updated | ' $table += 'Group type | ' $table += 'Assignment group | ' $table += 'Group members | ' $table += 'Filter | ' $table += 'Filter mode | ' $table += '$context | " $rowCells += "$scriptType | " $rowCells += "$displayName | " $rowCells += "$detectionStatus | " $rowCells += "$remediationStatus | " $rowCells += "$upn | " $rowCells += "$statusUpdateTime | " $rowCells += "$groupType | " $rowCells += "$assignmentGroup | " $rowCells += "$groupMembers | " $rowCells += "$filter | " $rowCells += "$filterMode | " $table += "
|---|---|---|---|---|---|---|---|---|---|---|---|
💡 Click any table row to view detailed information in a popup window
Some assignments could not be resolved (possible nested groups or stale cache).
' } else { '' })$deviceJson
$entraDeviceJson
$autopilotJson
$primaryUserJson
$latestUserJson
$autopilotDeploymentProfileJson
$autopilotDevicePrepJson
$espJson
$appleEnrollmentProfileJson