IntuneHousekeeper.psm1
|
#Requires -Version 7.0 # Intune Housekeeper. Read-only inventory of Windows Intune objects. # The exported command is Export-IntuneHousekeeperReport; everything else here is # internal and deliberately not exported. $ErrorActionPreference = 'Stop' # Module-scope run state. In a script these were per-execution; in a module they persist # for the life of the session, so every one of them is reset at the start of each run. # Without that, a second call in the same session double-counts and inherits the first # run's referenced groups, unrecognised types and failure flags. $script:AllReferencedGroupIds = [System.Collections.Generic.HashSet[string]]::new() $script:UnrecognisedTypes = @{} $script:GraphReadIncomplete = $false # Priority cell shading. Warm for rows that need action, neutral for parked ones, and # nothing at all for healthy objects, so the eye is drawn only to what matters. A blank # Priority is not in this map and is therefore left unshaded. $script:PriorityFill = @{ 'High' = '#F4C7C3' 'Medium' = '#FCE0B6' 'Low' = '#FDF3CD' 'Watch' = '#EDEDED' } $script:ReferenceReadIncomplete = $false $script:ConnectionOwned = $false $script:ObjectsExamined = 0 $script:TestNameMatches = 0 $script:GraphAuthVersion = $null $script:ConfigPathUsed = $null function Reset-RunState { $script:AllReferencedGroupIds = [System.Collections.Generic.HashSet[string]]::new() $script:UnrecognisedTypes = @{} $script:GraphReadIncomplete = $false $script:ReferenceReadIncomplete = $false $script:ConnectionOwned = $false $script:ObjectsExamined = 0 $script:TestNameMatches = 0 $script:GraphAuthVersion = $null $script:ConfigPathUsed = $null } # --------------------------------------------------------------------------- # Settings file # --------------------------------------------------------------------------- # Identifiers and preferences only. There is nothing else to store: the tool signs in # through a public client flow, which has no secret. If a future change ever needs one, # it does not belong in this file, because a settings file that holds a credential is a # credential store with none of the protections one needs. $script:ConfigSettingNames = @( 'ClientId' 'TenantId' 'OutputFolder' 'NewAppGraceMonths' 'RetainedVersionMonths' 'TestNameRegex' 'GroupNamePrefix' 'GroupOwnerUpns' 'HeaderColor' ) function Get-DefaultConfigPath { $base = if ($env:APPDATA) { $env:APPDATA } else { Join-Path $HOME '.config' } return (Join-Path (Join-Path $base 'IntuneHousekeeper') 'settings.json') } function Read-ConfigFile { # Returns a hashtable of stored settings, empty when there is no file. A key that is # present wins even when its value is 0 or an empty string, because both are # meaningful settings: 0 disables a window, '' disables test detection. Only an # absent key falls through to the parameter default. param([string]$Path) $result = @{} if (-not $Path -or -not (Test-Path -LiteralPath $Path)) { return $result } try { $raw = Get-Content -LiteralPath $Path -Raw -ErrorAction Stop if (-not $raw.Trim()) { return $result } $json = $raw | ConvertFrom-Json -ErrorAction Stop } catch { throw ("Settings file '{0}' could not be read: {1}" -f $Path, $_.Exception.Message) } foreach ($name in $script:ConfigSettingNames) { if (($json.PSObject.Properties.Name -contains $name) -and ($null -ne $json.$name)) { $result[$name] = $json.$name } } return $result } function Get-IntuneHousekeeperConfig { <# .SYNOPSIS Reads the saved Intune Housekeeper settings. .DESCRIPTION Returns the settings currently stored, along with the path they were read from. Every setting is optional and only the ones that have been set are present, so an unset value falls through to the command's own default. .PARAMETER ConfigPath Settings file to read. Defaults to %APPDATA%\IntuneHousekeeper\settings.json. .EXAMPLE Get-IntuneHousekeeperConfig Shows what is stored, and where. #> [CmdletBinding()] param([string]$ConfigPath) if (-not $ConfigPath) { $ConfigPath = Get-DefaultConfigPath } $stored = Read-ConfigFile -Path $ConfigPath $out = [ordered]@{ Path = $ConfigPath; Exists = (Test-Path -LiteralPath $ConfigPath) } foreach ($name in $script:ConfigSettingNames) { $out[$name] = $(if ($stored.ContainsKey($name)) { $stored[$name] } else { $null }) } return [pscustomobject]$out } function Set-IntuneHousekeeperConfig { <# .SYNOPSIS Saves Intune Housekeeper settings so they do not have to be typed each run. .DESCRIPTION Writes only the settings you pass, leaving anything already stored untouched. Values are validated on save, so a malformed regex or an out-of-range month count fails here rather than part way through a run three weeks later. Nothing secret is stored. The tool signs in through a public client flow, so the file holds identifiers and preferences only. .PARAMETER RemoveSetting Names of settings to delete from the file, returning them to their defaults. .PARAMETER ConfigPath Settings file to write. Defaults to %APPDATA%\IntuneHousekeeper\settings.json. The folder is created if needed. .PARAMETER PassThru Return the resulting settings. .EXAMPLE Set-IntuneHousekeeperConfig -ClientId "<app id>" -TenantId "<tenant id>" Stores the identifiers, so later runs need no parameters at all. .EXAMPLE Set-IntuneHousekeeperConfig -TestNameRegex '(^|[-_ (\[])TEST([-_ )\]]|$)' -OutputFolder 'C:\Reports\Intune' Adds a naming convention and an output folder to whatever is already stored. .EXAMPLE Set-IntuneHousekeeperConfig -RemoveSetting GroupOwnerUpns, GroupNamePrefix Stops the Entra group section running by default. #> [CmdletBinding(SupportsShouldProcess = $true)] param( [string] $ClientId, [string] $TenantId, [string] $OutputFolder, [ValidateRange(0, 120)] [int] $NewAppGraceMonths, [ValidateRange(0, 120)] [int] $RetainedVersionMonths, [string] $TestNameRegex, [string] $GroupNamePrefix, [string[]] $GroupOwnerUpns, [string] $HeaderColor, [string[]] $RemoveSetting, [string] $ConfigPath, [switch] $PassThru ) if (-not $ConfigPath) { $ConfigPath = Get-DefaultConfigPath } if ($PSBoundParameters.ContainsKey('TestNameRegex') -and $TestNameRegex) { try { $null = [regex]::new($TestNameRegex) } catch { throw ("-TestNameRegex is not a valid regular expression: {0}" -f $_.Exception.Message) } } foreach ($bad in ($RemoveSetting | Where-Object { $_ -and ($script:ConfigSettingNames -notcontains $_) })) { throw ("'{0}' is not a setting. Valid names: {1}" -f $bad, ($script:ConfigSettingNames -join ', ')) } $stored = Read-ConfigFile -Path $ConfigPath foreach ($name in $script:ConfigSettingNames) { if ($PSBoundParameters.ContainsKey($name)) { $stored[$name] = $PSBoundParameters[$name] } } foreach ($name in $RemoveSetting) { if ($stored.ContainsKey($name)) { [void]$stored.Remove($name) } } $ordered = [ordered]@{} foreach ($name in $script:ConfigSettingNames) { if ($stored.ContainsKey($name)) { $ordered[$name] = $stored[$name] } } if ($PSCmdlet.ShouldProcess($ConfigPath, 'Write Intune Housekeeper settings')) { $folder = Split-Path -Parent $ConfigPath if ($folder -and -not (Test-Path -LiteralPath $folder)) { $null = New-Item -ItemType Directory -Path $folder -Force } ([pscustomobject]$ordered | ConvertTo-Json -Depth 4) | Set-Content -LiteralPath $ConfigPath -Encoding UTF8 Write-Host ("Settings written to {0}" -f $ConfigPath) } if ($PassThru) { Get-IntuneHousekeeperConfig -ConfigPath $ConfigPath } } # --------------------------------------------------------------------------- # Helper functions # --------------------------------------------------------------------------- function Invoke-GraphPaged { param([Parameter(Mandatory)][string]$Uri) $results = New-Object System.Collections.Generic.List[object] $next = $Uri while ($next) { try { $resp = Invoke-MgGraphRequest -Method GET -Uri $next -OutputType PSObject -ErrorAction Stop } catch { # $_.Exception.Message for a Graph failure is usually just the status code. # The response body carries the reason, which is the difference between # 'Forbidden' and a message naming the missing permission or licence. $detail = [string]$_.Exception.Message if ($_.ErrorDetails -and $_.ErrorDetails.Message) { $body = [string]$_.ErrorDetails.Message try { $parsed = $body | ConvertFrom-Json -ErrorAction Stop if ($parsed.error -and $parsed.error.message) { $body = [string]$parsed.error.message } } catch { } if ($body -and $body -ne $detail) { $detail = ('{0} - {1}' -f $detail, $body) } } Write-Warning ("Graph GET failed for {0}: {1}" -f $next, $detail) $script:GraphReadIncomplete = $true break } if ($resp.PSObject.Properties.Name -contains 'value') { foreach ($v in $resp.value) { $results.Add($v) } $next = $resp.'@odata.nextLink' } else { $results.Add($resp) $next = $null } } return $results } function Add-ReferencedGroup { param($GroupId) if ($GroupId) { [void]$script:AllReferencedGroupIds.Add([string]$GroupId) } } function Add-AssignmentGroupReference { # Records every group ID an assignment collection targets. Called on the RAW result # of each endpoint, before the Windows filter, so a group used only by a macOS, iOS, # Android or Linux object is still counted as referenced. Reporting stays Windows # only; referencing must not, or the group section recommends removing groups that # are deploying software on another platform. param($Assignments) if (-not $Assignments) { return } foreach ($a in $Assignments) { if ($a.target -and ($a.target.PSObject.Properties.Name -contains 'groupId')) { Add-ReferencedGroup $a.target.groupId } } } # Endpoints this tool does not report on, but whose assignments still reference groups. # Confirmed necessary in a live tenant: a group targeted only by a macOS shell script was # reported as referenced by nothing, with a suggested action against it. Reporting stays # Windows only; the referenced-group set has to be tenant wide or the group section # recommends action on groups that are deploying something. $script:ReferenceOnlyEndpoints = @( 'deviceManagement/deviceShellScripts' 'deviceManagement/deviceCustomAttributeShellScripts' 'deviceManagement/windowsAutopilotDeploymentProfiles' 'deviceManagement/deviceEnrollmentConfigurations' 'deviceManagement/windowsFeatureUpdateProfiles' 'deviceManagement/windowsQualityUpdateProfiles' 'deviceManagement/windowsDriverUpdateProfiles' 'deviceAppManagement/mobileAppConfigurations' 'deviceAppManagement/targetedManagedAppConfigurations' 'deviceAppManagement/iosManagedAppProtections' 'deviceAppManagement/androidManagedAppProtections' 'deviceAppManagement/windowsManagedAppProtections' ) function Add-ReferenceOnlyGroups { # Only worth the calls when the Entra group section is actually going to run. A # failure here marks the collection incomplete, which skips that section: a partial # reference set makes 'nothing references this group' unsafe to assert. Write-Host 'Collecting group references from object types outside the report...' # A failure here says nothing about the inventory: these endpoints are never reported # on. It only means the referenced-group set is partial, which disables the group # section. Keeping the two apart stops a complete Windows inventory being stamped # INCOMPLETE because Autopilot could not be read. $inventoryStateBefore = $script:GraphReadIncomplete foreach ($e in $script:ReferenceOnlyEndpoints) { $objects = Invoke-GraphPaged -Uri ("https://graph.microsoft.com/beta/{0}?`$expand=assignments" -f $e) foreach ($o in $objects) { Add-AssignmentGroupReference $o.assignments } } if ($script:GraphReadIncomplete -ne $inventoryStateBefore) { $script:ReferenceReadIncomplete = $true $script:GraphReadIncomplete = $inventoryStateBefore Write-Warning 'The failures above are on object types read only to establish group references, so the Windows inventory is unaffected. Autopilot profiles and enrolment configurations need DeviceManagementServiceConfig.Read.All; scripts need DeviceManagementScripts.Read.All. Without them the Entra group section cannot run safely and is skipped.' } } function Get-AppVersion { # Last dotted-numeric token in a display name, as [version]. Null when there is none. param([string]$Name) if (-not $Name) { return $null } $m = [regex]::Matches([string]$Name, '\d+(\.\d+)+') if ($m.Count -eq 0) { return $null } try { return [version]$m[$m.Count - 1].Value } catch { return $null } } function Test-SupersededByName { # True when an unassigned application looks like a retained previous version: its # name carries a version, and some other app shares its base name with BOTH a higher # version AND a live inclusion assignment. All three conditions are required, so two # unassigned versions of a retired app are not demoted. param( [string]$DisplayName, [string]$Id, $Index ) $thisVer = Get-AppVersion $DisplayName if (-not $thisVer) { return $false } $thisBase = Get-AppBaseName $DisplayName foreach ($cand in $Index) { if ([string]$cand.Id -eq [string]$Id) { continue } if (-not $cand.HasInclusion) { continue } if ($cand.Base -ne $thisBase) { continue } if ($cand.Version -and $cand.Version -gt $thisVer) { return $true } } return $false } function Get-AppBaseName { # Display name with dotted-numeric tokens removed, so 'App 1.2.3 (x64)' and # 'App 1.3.0 (x64)' share a base. Architecture and edition markers survive, because # they contain no dotted number, which keeps x64 and x86 packages distinct. param([string]$Name) $b = [string]$Name $b = $b -replace '\d+(\.\d+)+', ' ' $b = $b -replace '\s+', ' ' return $b.Trim() } function Get-AssignmentInfo { param($Assignments) $info = [pscustomobject]@{ Count = 0 AllDevices = $false AllUsers = $false IncludeGroups = [System.Collections.Generic.List[string]]::new() ExcludeGroups = [System.Collections.Generic.List[string]]::new() HasInclusion = $false Overlap = $false } if (-not $Assignments) { return $info } $list = @($Assignments) $info.Count = $list.Count # Per-intent include/exclude sets. App assignments carry an intent # (required/available/uninstall); other object types do not, so all their # assignments share the '' bucket and behave as a single set. $incByIntent = @{} $excByIntent = @{} foreach ($a in $list) { $t = [string]$a.target.'@odata.type' $gid = $null if ($a.target.PSObject.Properties.Name -contains 'groupId') { $gid = [string]$a.target.groupId } $intent = '' if ($a.PSObject.Properties.Name -contains 'intent' -and $a.intent) { $intent = [string]$a.intent } if ($t -like '*allDevicesAssignmentTarget') { $info.AllDevices = $true; $info.HasInclusion = $true } elseif ($t -like '*allLicensedUsersAssignmentTarget') { $info.AllUsers = $true; $info.HasInclusion = $true } elseif ($t -like '*exclusionGroupAssignmentTarget') { if ($gid) { $info.ExcludeGroups.Add($gid) if (-not $excByIntent.ContainsKey($intent)) { $excByIntent[$intent] = New-Object System.Collections.Generic.List[string] } $excByIntent[$intent].Add($gid) } } elseif ($t -like '*groupAssignmentTarget') { if ($gid) { $info.IncludeGroups.Add($gid) if (-not $incByIntent.ContainsKey($intent)) { $incByIntent[$intent] = New-Object System.Collections.Generic.List[string] } $incByIntent[$intent].Add($gid) } $info.HasInclusion = $true } } # Overlap only counts within the SAME intent. Cross-intent reuse of a group # (e.g. excluded from Required, included in Uninstall) is a normal, deliberate # app lifecycle pattern and must not be flagged. foreach ($k in $incByIntent.Keys) { if ($info.Overlap) { break } if ($excByIntent.ContainsKey($k)) { foreach ($g in $incByIntent[$k]) { if ($excByIntent[$k].Contains($g)) { $info.Overlap = $true; break } } } } return $info } function Format-Assignment { param($ai) if ($ai.Count -eq 0) { return 'Unassigned' } $parts = New-Object System.Collections.Generic.List[string] if ($ai.AllDevices) { $parts.Add('All Devices') } if ($ai.AllUsers) { $parts.Add('All Users') } if ($ai.IncludeGroups.Count -gt 0) { $parts.Add(("{0} include group(s)" -f $ai.IncludeGroups.Count)) } if ($ai.ExcludeGroups.Count -gt 0) { $parts.Add(("{0} exclude group(s)" -f $ai.ExcludeGroups.Count)) } if ($parts.Count -eq 0) { return 'Unassigned' } return ($parts -join ', ') } function Test-Stale { param($DateString, [int]$Months) if (-not $DateString) { return $false } try { return ([datetime]$DateString -lt (Get-Date).AddMonths(-$Months)) } catch { return $false } } function New-InventoryRow { param( [string]$DisplayName, [string]$ObjectType, [string]$Id, $Created, $LastModified, $AssignmentInfo, [string[]]$ExtraFlags = @(), [hashtable]$NameCounts ) $flags = New-Object System.Collections.Generic.List[string] if ($AssignmentInfo.Count -eq 0) { $flags.Add('Unassigned') } elseif (-not $AssignmentInfo.HasInclusion) { $flags.Add('OnlyExclusions') } # Test-object detection is opt-in. An empty pattern must never be passed to -match: # it matches every string, which would flag the entire estate as test objects. $script:ObjectsExamined++ if ($TestNameRegex -and $DisplayName -match $TestNameRegex) { $script:TestNameMatches++ $flags.Add('TestNamed') if ($AssignmentInfo.AllDevices -or $AssignmentInfo.AllUsers) { $flags.Add('TestNamedBroadAssign') } } # DuplicateName is informational only. It annotates but does not drive risk. $isDuplicate = ($NameCounts -and $NameCounts.ContainsKey($DisplayName) -and $NameCounts[$DisplayName] -gt 1) if ($isDuplicate) { $flags.Add('DuplicateName') } # A group in both include and exclude within the SAME assignment intent: the # exclusion wins, so the group is silently not targeted. Cross-intent reuse # (excluded from Required, included in Uninstall) is deliberate and not flagged. if ($AssignmentInfo.Overlap) { $flags.Add('IncludeExcludeOverlap') } # Apps only. An unassigned application created within the operator-chosen grace # window is parked rather than queued: it is either still being worked on, or a # retained previous version kept for rollback. Tag it so the worklist and the # actionable counts exclude it. -NewAppGraceMonths 0 disables the window. $isApp = ($ObjectType -match '^App') $recentApp = ($isApp -and $AssignmentInfo.Count -eq 0 -and -not (Test-Stale $Created $NewAppGraceMonths)) if ($recentApp) { $flags.Add('RecentlyCreated') } foreach ($e in $ExtraFlags) { if ($e) { $flags.Add([string]$e) } } $flagText = ($flags -join '; ') # A name-matched retained version counts as referenced only while it is still within # the rollback window. Once expired it is ordinary unassigned clutter again. $referenced = ($flagText -match 'SupersededByNewer|HasDependents|ReferencedByRelationship') if (($flagText -match 'SupersededByName') -and ($flagText -notmatch 'RetainedVersionExpired')) { $referenced = $true } # Priority drives the actionable cleanup list. Informational-only flags # (DuplicateName) never raise it above blank on their own. $risk = '' if ($flagText -match 'TestNamedBroadAssign|IncludeExcludeOverlap') { $risk = 'High' } elseif (($AssignmentInfo.Count -eq 0) -or ($flagText -match 'OnlyExclusions')) { if ($recentApp) { $risk = 'Watch' } elseif ($referenced) { $risk = 'Low' } else { $risk = 'Medium' } } elseif ($flagText -match 'TestNamed') { $risk = 'Low' } return [pscustomobject][ordered]@{ DisplayName = $DisplayName ObjectType = $ObjectType Platform = 'Windows' Id = $Id Created = $Created LastModified = $LastModified Assignment = (Format-Assignment $AssignmentInfo) Priority = $risk Flags = $flagText } } function Get-NameCounts { param($Objects, [string]$NameProperty) $counts = @{} foreach ($o in $Objects) { $n = $o.$NameProperty if (-not $n) { $n = $o.displayName } if ($n) { $key = [string]$n if ($counts.ContainsKey($key)) { $counts[$key]++ } else { $counts[$key] = 1 } } } return $counts } # Object types that belong to another platform. Matched before any Windows test, so a # type that happens to contain a Windows-ish word (macOSOfficeSuiteApp) is never # mistaken for a Windows object. $script:OtherPlatformPattern = 'macos|ios|android|aosp|linux|windowsphone' # Windows application types, matched exactly. An allow-list rather than a substring # pattern: substring matching silently swallows any type Microsoft adds later, and the # operator never learns it exists. Anything not on this list and not another platform is # reported as unrecognised and left out. $script:WindowsAppTypes = @( '#microsoft.graph.win32LobApp' '#microsoft.graph.win32CatalogApp' '#microsoft.graph.winGetApp' '#microsoft.graph.officeSuiteApp' '#microsoft.graph.windowsAppX' '#microsoft.graph.windowsUniversalAppX' '#microsoft.graph.windowsMobileMSI' '#microsoft.graph.windowsMicrosoftEdgeApp' '#microsoft.graph.windowsStoreApp' '#microsoft.graph.microsoftStoreForBusinessApp' ) function Add-UnrecognisedType { param([string]$Area, [string]$Value) $key = ('{0}|{1}' -f $Area, $Value) if ($script:UnrecognisedTypes.ContainsKey($key)) { $script:UnrecognisedTypes[$key]++ } else { $script:UnrecognisedTypes[$key] = 1 } } function Get-PlatformClass { # Returns 'windows', 'other', or 'unrecognised'. Fail closed: only a positive Windows # match is collected, and an unrecognised value is skipped rather than guessed at. param( [string]$Value, [string]$WindowsPattern, [string[]]$WindowsTypes ) $v = [string]$Value if (-not $v) { return 'unrecognised' } if ($WindowsTypes) { foreach ($t in $WindowsTypes) { if ($v -eq $t) { return 'windows' } } } if ($v -match $script:OtherPlatformPattern) { return 'other' } if ($WindowsPattern -and $v -match $WindowsPattern) { return 'windows' } return 'unrecognised' } function Select-WindowsObject { # Filters a raw Graph collection down to Windows objects, recording anything it does # not recognise. -ClassifyProperty is '@odata.type' everywhere except the settings # catalog, where the platform lives in 'platforms'. param( $Objects, [Parameter(Mandatory)][string]$Area, [string]$ClassifyProperty = '@odata.type', [string]$WindowsPattern, [string[]]$WindowsTypes ) $kept = New-Object System.Collections.Generic.List[object] foreach ($o in $Objects) { $val = [string]$o.$ClassifyProperty $class = Get-PlatformClass -Value $val -WindowsPattern $WindowsPattern -WindowsTypes $WindowsTypes if ($class -eq 'windows') { $kept.Add($o) } elseif ($class -eq 'unrecognised') { Add-UnrecognisedType -Area $Area -Value $(if ($val) { $val } else { '(empty)' }) } } return , $kept } function Write-UnrecognisedTypeWarning { if ($script:UnrecognisedTypes.Count -eq 0) { return } Write-Warning 'Object types not recognised as Windows or as another platform were left out of the report:' foreach ($k in ($script:UnrecognisedTypes.Keys | Sort-Object)) { $parts = $k -split '\|', 2 Write-Warning (" {0}: {1} ({2} object(s))" -f $parts[0], $parts[1], $script:UnrecognisedTypes[$k]) } Write-Warning 'If any of these should be in scope, open an issue with the type name so it can be added.' } function Add-ExpandableInventory { # Appends rows into the shared Collector for endpoints that support $expand=assignments. param( [ValidateNotNull()][System.Collections.Generic.List[object]]$Collector, [Parameter(Mandatory)][string]$Uri, [Parameter(Mandatory)][string]$TypeLabel, [string]$ClassifyProperty = '@odata.type', [string]$WindowsPattern, [string]$NameProperty = 'displayName', [switch]$AppendODataType ) $raw = Invoke-GraphPaged -Uri $Uri # Before filtering: every assignment on every platform contributes to the referenced # group set. Reporting is Windows only; referencing must be tenant wide. foreach ($o in $raw) { Add-AssignmentGroupReference $o.assignments } if ($WindowsPattern) { $raw = Select-WindowsObject -Objects $raw -Area $TypeLabel ` -ClassifyProperty $ClassifyProperty -WindowsPattern $WindowsPattern } $counts = Get-NameCounts -Objects $raw -NameProperty $NameProperty foreach ($o in $raw) { $dn = $o.$NameProperty if (-not $dn) { $dn = $o.displayName } if (-not $dn) { $dn = '(no name)' } $dn = [string]$dn $ai = Get-AssignmentInfo $o.assignments foreach ($g in $ai.IncludeGroups) { Add-ReferencedGroup $g } foreach ($g in $ai.ExcludeGroups) { Add-ReferencedGroup $g } $label = $TypeLabel if ($AppendODataType) { $label = $TypeLabel + ' - ' + ([string]$o.'@odata.type' -replace '#microsoft.graph.', '') } $Collector.Add( (New-InventoryRow -DisplayName $dn -ObjectType $label -Id ([string]$o.id) ` -Created $o.createdDateTime -LastModified $o.lastModifiedDateTime ` -AssignmentInfo $ai -NameCounts $counts) ) } } function Get-ExportRows { # An empty sheet exported with no rows loses its header row, so a single # placeholder is written instead. -Worklist keeps the placeholder column set # matching the Worklist sheet rather than the inventory sheets. param($Rows, [switch]$Worklist) if (-not $Rows -or (Get-RowCount $Rows) -eq 0) { if ($Worklist) { return , ([pscustomobject][ordered]@{ Priority='(none found)'; DisplayName=''; ObjectType=''; Id=''; Assignment=''; LastModified=''; Reason=''; SuggestedAction=''; Decision=''; Owner=''; DateActioned=''; Notes='' }) } return , ([pscustomobject][ordered]@{ DisplayName='(none found)'; ObjectType=''; Platform=''; Id=''; Created=''; LastModified=''; Assignment=''; Priority=''; Flags='' }) } return , $Rows } function Get-ActionableCount { param($Rows) if ($null -eq $Rows) { return [int]0 } $n = 0 # Actionable means the operator is expected to do something: High and Medium only. # Low is 'keep or confirm' and Watch is parked; counting either inflates the queue. foreach ($r in $Rows) { if ((([string]$r.Priority) -eq 'High') -or (([string]$r.Priority) -eq 'Medium')) { $n++ } } return [int]$n } function Get-PriorityCounts { param($Rows) $c = [ordered]@{ High = 0; Medium = 0; Low = 0; Watch = 0; Healthy = 0 } foreach ($r in $Rows) { $p = [string]$r.Priority if ($p -eq 'High') { $c.High++ } elseif ($p -eq 'Medium') { $c.Medium++ } elseif ($p -eq 'Low') { $c.Low++ } elseif ($p -eq 'Watch') { $c.Watch++ } else { $c.Healthy++ } } return $c } function Get-RowCount { param($Rows) if ($null -eq $Rows) { return [int]0 } if ($Rows -is [System.Collections.ICollection]) { return [int]$Rows.Count } $n = 0 foreach ($r in $Rows) { $n++ } return [int]$n } function Export-IntuneHousekeeperReport { <# .SYNOPSIS Intune Housekeeper. Read-only inventory of unassigned, mis-assigned and leftover Windows Intune objects, plus optional owner-scoped Entra ID assignment groups. Produces an Excel decision tracker. .DESCRIPTION Windows scope only. Delegated Graph access through your own Entra app registration. This script NEVER modifies, unassigns or deletes anything in your tenant. It issues GET requests only. A human operator reviews the tracker and makes any change by hand in the portal. All Intune calls use the Microsoft Graph beta endpoint, which Microsoft may change without notice. Windows object types covered: - Applications (mobileApps, Windows types only) - Configuration profiles (deviceConfigurations, configurationPolicies, groupPolicyConfigurations) - Compliance policies (deviceCompliancePolicies, Windows) - Security baselines (intents) - Remediations (deviceHealthScripts) - Platform scripts (deviceManagementScripts) - Entra ID groups (owner-scoped assignment groups only, matched by -GroupNamePrefix; unused in Intune OR 0 members) Not collected: Autopilot deployment profiles, enrolment status page and other enrolment configurations, assignment filters, feature/quality/driver update profiles. Assignment filters are also not evaluated, so an object assigned through a filter that matches nothing is reported as healthy. Priority (order of cleanup action, not device risk): High -TEST object assigned to All Devices or All Users, or a group both included and excluded within the same assignment intent Medium unassigned or exclusion-only, nothing protecting it (the cleanup queue) Low unassigned but referenced, or -TEST scoped to a group (keep/investigate) Watch unassigned app created within the last -NewAppGraceMonths (parked, not counted as actionable) (blank) assigned and healthy Object flags: Unassigned object has no assignments at all OnlyExclusions object has only exclusion targets, no inclusion TestNamed display name matches -TestNameRegex TestNamedBroadAssign matches -TestNameRegex AND assigned to All Devices or All Users DuplicateName display name occurs more than once within its type (informational) IncludeExcludeOverlap same group in include and exclude within the SAME assignment intent; exclusion wins (live mistake, High). Cross-intent (exclude Required / include Uninstall) is a normal app pattern and is not flagged. SupersededByNewer (apps) a newer version supersedes this one SupersededByName (apps) unassigned, but a newer version of the same application is assigned. Matched on display name and version, for workflows that do not create supersedence RetainedVersionExpired (apps) SupersededByName, but created more than -RetainedVersionMonths ago. The rollback window has passed, so it returns to the cleanup queue HasDependents (apps) another app depends on this one RecentlyCreated (apps) unassigned but created within the last -NewAppGraceMonths Group flags: ZeroMembers group has 0 direct members NoAssignmentFound group ID is not referenced by any assignment found. All platforms count, and assignments are also read from object types outside the report (macOS shell and custom attribute scripts, Autopilot, enrolment configurations, update rings, app configuration and app protection policies) purely to establish references. Still verify in the portal: any object type Microsoft adds is invisible until added here .PARAMETER ConfigPath Settings file to read, written by Set-IntuneHousekeeperConfig. Defaults to %APPDATA%\IntuneHousekeeper\settings.json. Explicit parameters win over stored values, and a stored value wins over the parameter default. .PARAMETER ClientId Client ID of your own Entra app registration (public client / native flow enabled). Required, from this parameter or from the settings file. .PARAMETER TenantId Directory (tenant) ID. Required, from this parameter or from the settings file. .PARAMETER OutputFolder Folder the workbook is written to. Created if missing. .PARAMETER NewAppGraceMonths Grace window for unassigned applications. An unassigned app created within this many months is flagged RecentlyCreated, set to Watch and left out of the worklist and the actionable counts. Set it to the age of the newest version you expect to retain, or to 0 to flag every unassigned app regardless of creation date. .PARAMETER RetainedVersionMonths How long a retained previous version stays out of the cleanup queue. An unassigned app with a newer assigned version of the same name is kept at Low while it is newer than this, and returns to Medium beyond it, flagged RetainedVersionExpired. Set it to how long a rollback is realistically useful in your environment, or to 0 to disable the leniency and queue every retained copy. Applies only to name-matched copies: a real Intune supersedence relationship is always kept, because the newer app's configuration depends on it. .PARAMETER TestNameRegex Regex identifying a test object by display name, in your own naming convention. Empty by default, which skips test-object detection entirely and reports it once. There is no built-in convention: a wrong pattern would report no test objects and read as a clean result. Examples: '-TEST$' for a suffix, '^TEST[-_]' for a prefix, '(^|[-_ ])TEST([-_ ]|$)' for either. .PARAMETER GroupNamePrefix Restricts the Entra group section to your assignment group naming convention. Empty by default; leaving it empty reports every owned group that matches the flags, which is usually noise. .PARAMETER GroupOwnerUpns Owner accounts whose groups are checked. Empty by default, which skips the section. .PARAMETER HeaderColor Worksheet header fill colour. .EXAMPLE Export-IntuneHousekeeperReport -ClientId "<app id>" -TenantId "<tenant id>" Smallest useful run. Inventories Windows Intune objects and writes the workbook to your Documents folder. Test-object detection and the Entra group section are both skipped, because neither has a naming convention supplied. .EXAMPLE Export-IntuneHousekeeperReport -ClientId "<app id>" -TenantId "<tenant id>" ` -TestNameRegex '-TEST$' Adds test-object detection for names ending in -TEST. Use single quotes: in double quotes PowerShell would try to expand $' as a variable. This is the run that finds a test policy left on All Devices, which is the only High finding that describes something actually reaching devices. .EXAMPLE Export-IntuneHousekeeperReport -ClientId "<app id>" -TenantId "<tenant id>" ` -TestNameRegex '^TEST[-_]' The same check for a prefix convention: TEST-Wifi or TEST_Wifi. For either position, use '(^|[-_ ])TEST([-_ ]|$)'. .EXAMPLE Export-IntuneHousekeeperReport -ClientId "<app id>" -TenantId "<tenant id>" ` -GroupOwnerUpns "alice@contoso.com","bob@contoso.com" ` -GroupNamePrefix "<your-prefix>-" Adds the Entra group section. Reports groups owned by those accounts whose names start with the prefix and that are either empty or referenced by no Windows Intune assignment. Both parameters are required for the section to run: owner scoping alone would pull in every Teams and Microsoft 365 group the owners happen to hold. .EXAMPLE Export-IntuneHousekeeperReport -ClientId "<app id>" -TenantId "<tenant id>" ` -NewAppGraceMonths 0 Flags every unassigned application regardless of creation date. Use this when you do not retain previous versions for rollback, or when you want to see the full picture once. The default of 6 parks recently created unassigned apps as Watch instead. .EXAMPLE Export-IntuneHousekeeperReport -ClientId "<app id>" -TenantId "<tenant id>" ` -OutputFolder "C:\Reports\Intune" -HeaderColor '#31708F' Writes the workbook somewhere other than Documents and changes the header fill. -HeaderColor takes any HTML colour string. .EXAMPLE Export-IntuneHousekeeperReport -ClientId "<app id>" -TenantId "<tenant id>" ` -TestNameRegex '-TEST$' ` -GroupOwnerUpns "alice@contoso.com" -GroupNamePrefix "<your-prefix>-" ` -NewAppGraceMonths 3 -OutputFolder "C:\Reports\Intune" Everything switched on, with a shorter grace window for unassigned applications. This is the shape of a regular review run. .EXAMPLE Set-IntuneHousekeeperConfig -ClientId "<app id>" -TenantId "<tenant id>" ` -TestNameRegex '(^|[-_ (\[])TEST([-_ )\]]|$)' Export-IntuneHousekeeperReport Save once, then run with no parameters at all. Anything passed explicitly on a later run overrides the stored value for that run only. .NOTES Required delegated permissions on the app registration, admin-consented: DeviceManagementApps.Read.All applications and their assignments DeviceManagementConfiguration.Read.All profiles, compliance, baselines DeviceManagementScripts.Read.All remediations, platform scripts, and script assignments read for the group check Group.Read.All Entra group section only User.Read.All Entra group section only DeviceManagementServiceConfig.Read.All Entra group section only: Autopilot and enrolment configurations are read for their group references App registration: public client / native flow. Under Authentication, Add Redirect URI, Mobile and desktop applications, add both ms-appx-web://Microsoft.AAD.BrokerPlugin/<client id> and http://localhost. The first is required for broker (WAM) sign-in, which a tenant enforcing Conditional Access token protection needs. Broker sign-in is enabled by default on Windows in current releases of Microsoft.Graph.Authentication and no longer needs to be turned on in code. If a fresh interactive sign-in fails with AADSTS530084, update that module and check the broker redirect URI above. The device must also be joined or registered and compliant, or token protection fails regardless. An existing Graph session for the same tenant and client ID is reused rather than replaced, and only a session this script opened is disconnected at the end. Running the script repeatedly therefore costs one sign-in, not one per run, which matters under Conditional Access token protection where every sign-in is a broker prompt. -Scopes is deliberately not passed to Connect-MgGraph. With a custom -ClientId, MSAL treats requested scopes as a new authorization and triggers a consent prompt; the token must carry what is already consented on the app registration. The script verifies the granted scopes instead. Required modules: Microsoft.Graph.Authentication ImportExcel (does not require Excel to be installed) Windows and PowerShell 7. Windows PowerShell 5.1 is not supported: .NET Framework allows one Microsoft.Identity.Client per process with no isolation, so an admin workstation carrying several Microsoft.Graph module versions fails at sign-in with 'Could not load type ... Microsoft.Identity.Client'. PowerShell 7 loads the SDK dependencies in an isolated context and does not have this problem. A read-only app registration is recommended. The tool only issues GET, but a registration consented for ReadWrite holds a token that could change your tenant. Project: https://github.com/kbentis/IntuneHousekeeper Notes on endpoint management: https://kbentis.cloud ASCII-only file. No non-ASCII characters anywhere (no em-dashes, no smart quotes). Read-only: no PATCH, POST or DELETE calls are made against Graph. #> [CmdletBinding()] param( # Not mandatory, because mandatory binding happens before this function runs and # would prompt for a value the settings file already holds. Both are checked # after the settings merge, with a message naming Set-IntuneHousekeeperConfig. [string] $ClientId, [string] $TenantId, # Settings file to read. Explicit parameters always win over stored values. [string] $ConfigPath, # Completes existing directories as you type, so the folder is right before the run # starts rather than after sign-in. [ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $w = [string]$wordToComplete $w = $w.Trim("'").Trim('"') if ([string]::IsNullOrWhiteSpace($w)) { $search = Join-Path (Get-Location).Path '*' } else { $search = $w + '*' } foreach ($d in (Get-ChildItem -Path $search -Directory -ErrorAction SilentlyContinue)) { $full = [string]$d.FullName $text = $full if ($full -match '\s') { $text = "'" + $full + "'" } [System.Management.Automation.CompletionResult]::new($text, $d.Name, 'ProviderContainer', $full) } })] [string] $OutputFolder = "$env:USERPROFILE\Documents", [ValidateRange(0, 120)] [int] $NewAppGraceMonths = 6, # How long a name-matched previous version is still worth keeping for rollback. # Beyond this it is flagged RetainedVersionExpired and returns to the cleanup queue: # a rollback copy nobody has needed in a year is not a rollback copy any more. # 0 disables the leniency entirely, so every name-matched copy stays in the queue. [ValidateRange(0, 120)] [int] $RetainedVersionMonths = 12, # There is no default naming convention, so the completer offers the common shapes # already quoted correctly. A pattern like -TEST$ must be single-quoted: in double # quotes PowerShell tries to expand the $ sequence. [ArgumentCompleter({ param($commandName, $parameterName, $wordToComplete, $commandAst, $fakeBoundParameters) $suggestions = @( @{ Pattern = '-TEST$'; Tip = 'Suffix: Wifi-TEST' } @{ Pattern = '^TEST[-_]'; Tip = 'Prefix: TEST-Wifi or TEST_Wifi' } @{ Pattern = '(^|[-_ (\[])TEST([-_ )\]]|$)'; Tip = 'Any position, word boundary. Does not match Latest or Attestation' } @{ Pattern = '\(test\)$'; Tip = 'Bracketed suffix: Wifi (test)' } ) $w = ([string]$wordToComplete).Trim("'").Trim('"') foreach ($s in $suggestions) { if ($w -and -not $s.Pattern.StartsWith($w, [System.StringComparison]::OrdinalIgnoreCase)) { continue } $text = "'" + $s.Pattern + "'" [System.Management.Automation.CompletionResult]::new($text, $s.Pattern, 'ParameterValue', $s.Tip) } })] [string] $TestNameRegex = '', [string] $GroupNamePrefix = '', [string] $HeaderColor = '#404040', [string[]] $GroupOwnerUpns = @() ) Reset-RunState # Settings precedence: an explicit parameter beats the settings file, which beats the # parameter default. $PSBoundParameters is the only way to tell '-NewAppGraceMonths 6' # from the default of 6, and getting that wrong would let a saved setting silently # override what the operator just typed. if (-not $ConfigPath) { $ConfigPath = Get-DefaultConfigPath } $script:ConfigPathUsed = $ConfigPath $stored = Read-ConfigFile -Path $ConfigPath foreach ($name in $script:ConfigSettingNames) { if ($PSBoundParameters.ContainsKey($name)) { continue } if (-not $stored.ContainsKey($name)) { continue } try { # Assigning through the parameter variable re-applies its type and any # ValidateRange attribute, so a bad stored value is caught here. Set-Variable -Name $name -Value $stored[$name] -Scope 0 -ErrorAction Stop } catch { throw ("Setting '{0}' in '{1}' is not valid: {2}" -f $name, $ConfigPath, $_.Exception.Message) } } if (-not $ClientId -or -not $TenantId) { throw "ClientId and TenantId are required. Pass -ClientId and -TenantId, or save them once with: Set-IntuneHousekeeperConfig -ClientId '<app id>' -TenantId '<tenant id>'. Both are on the Overview page of your app registration in the Entra admin center." } # Fail early on a malformed pattern rather than part way through the inventory. An # empty -TestNameRegex is valid and means the operator has not supplied a naming # convention, so test-object detection is skipped. if ($TestNameRegex) { try { $null = [regex]::new($TestNameRegex) } catch { throw ("-TestNameRegex is not a valid regular expression: {0}" -f $_.Exception.Message) } } # --------------------------------------------------------------------------- # Prerequisites and connection # --------------------------------------------------------------------------- # The manifest declares these in RequiredModules, so they are normally already loaded. # The check stays for anyone running the module from a copied folder rather than an # install, where the manifest's guarantee does not apply. foreach ($m in @('Microsoft.Graph.Authentication', 'ImportExcel')) { if (-not (Get-Module -Name $m) -and -not (Get-Module -ListAvailable -Name $m)) { throw ("Required module '{0}' not found. Install with: Install-Module {0} -Scope CurrentUser" -f $m) } Import-Module $m -ErrorAction Stop } # Multiple side-by-side versions of the Graph modules are common on an admin # workstation. PowerShell loads the first path in PSModulePath that holds the module, # not the newest version, so report what actually loaded rather than what is installed. $script:GraphAuthVersion = (Get-Module Microsoft.Graph.Authentication).Version $installedAuth = @(Get-Module Microsoft.Graph.Authentication -ListAvailable | Select-Object -ExpandProperty Version | Sort-Object -Unique) Write-Host ("Microsoft.Graph.Authentication {0} loaded." -f $script:GraphAuthVersion) if ($installedAuth.Count -gt 1) { Write-Warning ("{0} versions of Microsoft.Graph.Authentication are installed ({1}). PowerShell loads by PSModulePath order, not by version. Mixed Graph module versions are the usual cause of sign-in failing with a 'Could not load type ... Microsoft.Identity.Client' error." -f $installedAuth.Count, ($installedAuth -join ', ')) } try { # Conditional Access token protection (bound tokens) is only satisfied when sign-in # goes through the Windows broker (WAM). Current releases of # Microsoft.Graph.Authentication enable broker sign-in by default on Windows and the # old Set-MgGraphOption -EnableLoginByWAM switch no longer has any effect, so nothing # is set here. If a fresh interactive sign-in fails with AADSTS530084, update # Microsoft.Graph.Authentication and add the broker redirect URI # ms-appx-web://Microsoft.AAD.BrokerPlugin/<client id> to the app registration. # Reuse a session that already matches. Every Connect-MgGraph under Conditional # Access token protection means another broker prompt, and running the script twice # in quick succession made the second sign-in fail with ApplicationCanceled while # the first was still tearing down. Only a session this script opened is closed # again, so a session the operator established stays theirs. $existing = Get-MgContext if ($existing -and ([string]$existing.TenantId -eq [string]$TenantId) -and ([string]$existing.ClientId -eq [string]$ClientId)) { $ctx = $existing Write-Host ("Reusing the existing Graph session for {0}" -f $ctx.Account) } else { if ($existing) { Write-Host 'An existing Graph session is for a different tenant or app registration. Reconnecting.' Disconnect-MgGraph | Out-Null } Write-Host 'Connecting to Microsoft Graph (delegated)...' # Do NOT pass -Scopes here. With a custom -ClientId, MSAL treats requested scopes # as a new authorization and triggers a consent prompt; the token must instead # carry the permissions already consented on the app registration. # # One retry, for one specific failure. Observed repeatedly in a tenant # enforcing token protection: the first sign-in after a previous run # disconnected fails with 'ApplicationCanceled / Current Request already # cancelled', and an immediate second attempt succeeds, because the broker is # still tearing down the old request. Only that signature is retried, and # only once, so a sign-in the operator genuinely cancelled is not forced back # on them repeatedly. try { Connect-MgGraph -ClientId $ClientId -TenantId $TenantId -NoWelcome -ErrorAction Stop } catch { if ([string]$_.Exception.Message -notmatch 'ApplicationCanceled|Current Request already cancelled') { throw } Write-Warning 'The broker cancelled the sign-in request, which usually means it was still closing a previous session. Retrying once.' Start-Sleep -Seconds 3 Connect-MgGraph -ClientId $ClientId -TenantId $TenantId -NoWelcome -ErrorAction Stop } $script:ConnectionOwned = $true $ctx = Get-MgContext if (-not $ctx) { throw 'Failed to establish a Graph context.' } Write-Host ("Connected as {0}" -f $ctx.Account) } # Report the scopes actually carried by the token. A cached session created before # a consent change can persist with fewer scopes than the app registration now # grants, which silently disables the Entra group section. Disconnect-MgGraph and # re-run to refresh the cache if a scope is reported missing. # Group.Read.All and User.Read.All are only needed when the Entra group section will # actually run, so they are not reported as missing on an Intune-only run. $grantedScopes = @() if ($ctx.Scopes) { $grantedScopes = @($ctx.Scopes) } $neededScopes = [System.Collections.Generic.List[string]]::new() $neededScopes.Add('DeviceManagementApps.Read.All') $neededScopes.Add('DeviceManagementConfiguration.Read.All') # Remediations and platform scripts sit behind their own permission, and it also # covers the macOS shell and custom attribute scripts read for group references. $neededScopes.Add('DeviceManagementScripts.Read.All') if ($GroupOwnerUpns.Count -gt 0) { $neededScopes.Add('Group.Read.All') $neededScopes.Add('User.Read.All') # Autopilot profiles and enrolment configurations are read only to establish # group references, and they sit behind their own permission. Without it those # reads fail, the reference set is incomplete, and the section refuses to run. $neededScopes.Add('DeviceManagementServiceConfig.Read.All') } $missingScopes = [System.Collections.Generic.List[string]]::new() foreach ($needed in $neededScopes) { # A ReadWrite grant satisfies the matching Read requirement. The tool only ever # issues GET, but plenty of app registrations are consented ReadWrite for other # tooling, and warning about a permission the token exceeds is just noise. $alt = $needed -replace '\.Read\.', '.ReadWrite.' if (($grantedScopes -notcontains $needed) -and ($grantedScopes -notcontains $alt)) { $missingScopes.Add($needed) Write-Warning ("Token does not carry '{0}' or '{1}'. Calls needing it will fail with 403." -f $needed, $alt) } } if ($missingScopes.Count -gt 0) { # Consent on the app registration and scopes in the token are different # things. A permission added after the last sign-in is not picked up by a # cached refresh token, and Disconnect-MgGraph does not always clear that # cache: it can report 'no application to sign out from' while the cached # token lives on. Asking for the scopes explicitly forces a fresh # authorization, which is the one case where -Scopes with a custom client ID # is the right thing to do. Write-Warning ("To pick up permissions added since the last sign-in, run once: Connect-MgGraph -ClientId '{0}' -TenantId '{1}' -NoWelcome -Scopes {2}. If the scope is still missing afterwards, close all PowerShell windows and delete %LOCALAPPDATA%\.IdentityService\msal.cache." -f $ClientId, $TenantId, (($neededScopes | ForEach-Object { "'$_'" }) -join ',')) } # ----------------------------------------------------------------------- # Inventory - Windows Intune objects # ----------------------------------------------------------------------- if ($TestNameRegex) { Write-Host ("Test-object detection using pattern: {0}" -f $TestNameRegex) } else { Write-Host 'Test-object detection skipped: no -TestNameRegex supplied. Objects left over from testing will not be flagged. Pass your own naming convention to enable it.' } Write-Host 'Collecting applications...' $appsRaw = Invoke-GraphPaged -Uri "https://graph.microsoft.com/beta/deviceAppManagement/mobileApps?`$expand=assignments" foreach ($a in $appsRaw) { Add-AssignmentGroupReference $a.assignments } $appsWin = Select-WindowsObject -Objects $appsRaw -Area 'Applications' -WindowsTypes $script:WindowsAppTypes $appNameCounts = Get-NameCounts -Objects $appsWin -NameProperty 'displayName' # Index every Windows app by base name and version so a retained previous version can # be recognised without a supersedence relationship. Not every publishing workflow # creates supersedence, and where it does not there is no link to follow: an # unassigned 'App 1.2' sitting beside an assigned 'App 1.3' is a rollback copy, not # abandoned clutter. Built from data already collected, so no extra Graph calls. $appIndex = New-Object System.Collections.Generic.List[object] foreach ($app in $appsWin) { $dn = [string]$app.displayName $iai = Get-AssignmentInfo $app.assignments $appIndex.Add([pscustomobject]@{ Id = [string]$app.id Base = (Get-AppBaseName $dn) Version = (Get-AppVersion $dn) HasInclusion = [bool]$iai.HasInclusion }) } $rowsApps = New-Object System.Collections.Generic.List[object] foreach ($app in $appsWin) { $ai = Get-AssignmentInfo $app.assignments # Where a publishing workflow creates Intune supersedence relationships, a # retained previous version can be identified directly: supersedingAppCount > 0 # means a newer app supersedes this one, so it is intended, not a removal # candidate. dependentAppCount > 0 means other apps depend on this one. Both are # read off the app object, which is faster and more reliable than a per-app # relationships call. Where no supersedence exists, -NewAppGraceMonths is the # fallback. $extra = @() $supBy = 0; $dep = 0 if ($app.PSObject.Properties.Name -contains 'supersedingAppCount') { $supBy = [int]$app.supersedingAppCount } if ($app.PSObject.Properties.Name -contains 'dependentAppCount') { $dep = [int]$app.dependentAppCount } if ($supBy -gt 0) { $extra += 'SupersededByNewer' } if ($dep -gt 0) { $extra += 'HasDependents' } # Name-and-version fallback for retained versions. Deliberately narrow: the app # must be unassigned, its name must carry a parseable version, and a sibling with # the same base name must have BOTH a higher version AND a live inclusion # assignment. Two unassigned versions of a retired app stay in the cleanup queue, # which is the correct outcome. if ($ai.Count -eq 0 -and $supBy -eq 0) { if (Test-SupersededByName -DisplayName ([string]$app.displayName) -Id ([string]$app.id) -Index $appIndex) { $extra += 'SupersededByName' # A rollback copy has a shelf life. Past -RetainedVersionMonths the # rollback is not realistic any more and the package returns to the # queue, still labelled so the operator knows what it is. if (Test-Stale $app.createdDateTime $RetainedVersionMonths) { $extra += 'RetainedVersionExpired' } } } $type = 'App - ' + ([string]$app.'@odata.type' -replace '#microsoft.graph.', '') $rowsApps.Add( (New-InventoryRow -DisplayName ([string]$app.displayName) -ObjectType $type -Id ([string]$app.id) ` -Created $app.createdDateTime -LastModified $app.lastModifiedDateTime ` -AssignmentInfo $ai -ExtraFlags $extra -NameCounts $appNameCounts) ) } $rowsConfig = New-Object System.Collections.Generic.List[object] Write-Host 'Collecting configuration profiles (templates)...' Add-ExpandableInventory -Collector $rowsConfig ` -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceConfigurations?`$expand=assignments" ` -TypeLabel 'Config-Template' -AppendODataType ` -WindowsPattern 'windows|sharedPC|editionUpgrade' Write-Host 'Collecting configuration profiles (settings catalog)...' Add-ExpandableInventory -Collector $rowsConfig ` -Uri "https://graph.microsoft.com/beta/deviceManagement/configurationPolicies?`$expand=assignments" ` -TypeLabel 'Config-SettingsCatalog' -NameProperty 'name' ` -ClassifyProperty 'platforms' -WindowsPattern 'windows' Write-Host 'Collecting configuration profiles (ADMX)...' Add-ExpandableInventory -Collector $rowsConfig ` -Uri "https://graph.microsoft.com/beta/deviceManagement/groupPolicyConfigurations?`$expand=assignments" ` -TypeLabel 'Config-ADMX' Write-Host 'Collecting compliance policies...' $rowsCompliance = New-Object System.Collections.Generic.List[object] Add-ExpandableInventory -Collector $rowsCompliance ` -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceCompliancePolicies?`$expand=assignments" ` -TypeLabel 'Compliance' ` -WindowsPattern 'windows' Write-Host 'Collecting remediations (deviceHealthScripts)...' $rowsRemediation = New-Object System.Collections.Generic.List[object] Add-ExpandableInventory -Collector $rowsRemediation ` -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceHealthScripts?`$expand=assignments" ` -TypeLabel 'Remediation' Write-Host 'Collecting platform scripts (deviceManagementScripts)...' $rowsScripts = New-Object System.Collections.Generic.List[object] Add-ExpandableInventory -Collector $rowsScripts ` -Uri "https://graph.microsoft.com/beta/deviceManagement/deviceManagementScripts?`$expand=assignments" ` -TypeLabel 'PlatformScript' Write-Host 'Collecting security baselines (intents)...' $intents = Invoke-GraphPaged -Uri "https://graph.microsoft.com/beta/deviceManagement/intents" $intentNameCounts = Get-NameCounts -Objects $intents -NameProperty 'displayName' $rowsBaseline = New-Object System.Collections.Generic.List[object] foreach ($intent in $intents) { $asgn = Invoke-GraphPaged -Uri "https://graph.microsoft.com/beta/deviceManagement/intents/$($intent.id)/assignments" $ai = Get-AssignmentInfo $asgn foreach ($g in $ai.IncludeGroups) { Add-ReferencedGroup $g } foreach ($g in $ai.ExcludeGroups) { Add-ReferencedGroup $g } $rowsBaseline.Add( (New-InventoryRow -DisplayName ([string]$intent.displayName) -ObjectType 'Security Baseline' -Id ([string]$intent.id) ` -Created $intent.createdDateTime -LastModified $intent.lastModifiedDateTime ` -AssignmentInfo $ai -NameCounts $intentNameCounts) ) } # ----------------------------------------------------------------------- # Inventory - owner-scoped Entra ID groups (unused in Intune OR 0 members) # The referenced-group set is now fully populated from every object above. # ----------------------------------------------------------------------- if ($GroupOwnerUpns.Count -gt 0) { Add-ReferenceOnlyGroups } Write-Host 'Collecting owner-scoped Entra ID groups...' # One reason, accurate. An earlier version emptied -GroupOwnerUpns to disable the # section, which then reported 'no -GroupOwnerUpns supplied' to an operator who # had supplied it. $groupSkipReason = '' if ($GroupOwnerUpns.Count -eq 0) { $groupSkipReason = 'no -GroupOwnerUpns supplied. Pass the owner accounts whose assignment groups you want checked.' } elseif ($script:GraphReadIncomplete -or $script:ReferenceReadIncomplete) { $groupSkipReason = 'at least one Graph read failed, so the set of referenced groups is incomplete. A group could be reported as unreferenced only because the assignment naming it was never read. Resolve the failure above and re-run.' $GroupOwnerUpns = @() } if ($groupSkipReason) { Write-Host (" Skipped: {0}" -f $groupSkipReason) } $ownedGroups = @{} foreach ($upn in $GroupOwnerUpns) { $u = Invoke-GraphPaged -Uri "https://graph.microsoft.com/v1.0/users?`$filter=userPrincipalName eq '$upn'&`$select=id,userPrincipalName" if (@($u).Count -eq 0) { Write-Warning ("Owner account not found: {0}" -f $upn); continue } $uid = [string]$u[0].id $groups = Invoke-GraphPaged -Uri "https://graph.microsoft.com/v1.0/users/$uid/ownedObjects/microsoft.graph.group?`$select=id,displayName,groupTypes,createdDateTime" foreach ($grp in $groups) { $gid = [string]$grp.id if (-not $ownedGroups.ContainsKey($gid)) { $ownedGroups[$gid] = [pscustomobject]@{ Group = $grp; Owners = @($upn) } } else { $ownedGroups[$gid].Owners += $upn } } } # Probe group-read permission once. Reading a group's members requires # Group.Read.All (or GroupMember.Read.All) on the app registration. If that # scope is missing the API returns 403 Forbidden and group display names come # back blank, so there is nothing actionable to report - skip the section. $groupReadOk = $true if ($ownedGroups.Count -gt 0) { $probeId = @($ownedGroups.Keys)[0] try { $null = Invoke-MgGraphRequest -Method GET -OutputType PSObject -ErrorAction Stop ` -Uri "https://graph.microsoft.com/v1.0/groups/$probeId/members?`$top=1&`$select=id" } catch { if ($_.Exception.Message -match 'Forbidden|403') { $groupReadOk = $false } } } if (-not $groupReadOk) { Write-Warning 'Entra group analysis skipped: the app registration is missing Group.Read.All (delegated). Add and admin-consent that scope, then re-run to populate the EntraGroups sheet.' } $rowsGroups = New-Object System.Collections.Generic.List[object] foreach ($kv in $ownedGroups.GetEnumerator()) { if (-not $groupReadOk) { break } $grp = $kv.Value.Group $gid = [string]$grp.id $owners = (($kv.Value.Owners | Select-Object -Unique) -join '; ') # Only Intune assignment groups are in scope. Owners also hold Teams, # M365, and other groups that are not used for assignments, where # 'not used in Intune' is meaningless. Filter by naming convention before # any member lookup so those are never reported. if ($GroupNamePrefix -and ([string]$grp.displayName) -notlike ($GroupNamePrefix + '*')) { continue } $memberState = 'unknown' try { $mUri = "https://graph.microsoft.com/v1.0/groups/$gid/members?`$top=1&`$select=id" $mResp = Invoke-MgGraphRequest -Method GET -Uri $mUri -OutputType PSObject -ErrorAction Stop if (@($mResp.value).Count -eq 0) { $memberState = 'empty' } else { $memberState = 'has-members' } } catch { Write-Warning ("Member read failed for group '{0}': {1}" -f $grp.displayName, $_.Exception.Message) } $usedInIntune = $script:AllReferencedGroupIds.Contains($gid) $flags = @() if ($memberState -eq 'empty') { $flags += 'ZeroMembers' } if (-not $usedInIntune) { $flags += 'NoAssignmentFound' } if ($flags.Count -eq 0) { continue } $usedText = if ($usedInIntune) { 'Yes' } else { 'No' } $asg = "Members: $memberState; Used in Intune: $usedText" $rowsGroups.Add( [pscustomobject][ordered]@{ DisplayName = [string]$grp.displayName ObjectType = 'Entra Group' Platform = 'Entra' Id = $gid Created = $grp.createdDateTime LastModified = '' Assignment = $asg Priority = 'Medium' Flags = ($flags -join '; ') GroupOwners = $owners } ) } # ----------------------------------------------------------------------- # Build the summary and the operator worklist # ----------------------------------------------------------------------- # The workbook has to say how it was produced. With test detection opt-in, a report # containing no test objects is otherwise indistinguishable from a report where the # check never ran. No tenant or account identifiers are recorded, so the sheet is # safe to share or screenshot. $runInfo = New-Object System.Collections.Generic.List[object] $runInfo.Add([pscustomobject][ordered]@{ Setting='Generated'; Value=(Get-Date -Format 'yyyy-MM-dd HH:mm') }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Graph endpoint'; Value='beta' }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Platform scope'; Value='Windows' }) $runInfo.Add([pscustomobject][ordered]@{ Setting='TestNameRegex'; Value=$(if ($TestNameRegex) { $TestNameRegex } else { '(not set - test objects not flagged)' }) }) $runInfo.Add([pscustomobject][ordered]@{ Setting='NewAppGraceMonths'; Value=[string]$NewAppGraceMonths }) $runInfo.Add([pscustomobject][ordered]@{ Setting='RetainedVersionMonths'; Value=$(if ($RetainedVersionMonths -eq 0) { '0 - retained copies not excused' } else { [string]$RetainedVersionMonths }) }) $runInfo.Add([pscustomobject][ordered]@{ Setting='GroupNamePrefix'; Value=$(if ($GroupNamePrefix) { $GroupNamePrefix } else { '(not set)' }) }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Group owners checked'; Value=[string]$GroupOwnerUpns.Count }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Settings file'; Value=$(if ($script:ConfigPathUsed -and (Test-Path -LiteralPath $script:ConfigPathUsed)) { $script:ConfigPathUsed } else { '(none - parameters only)' }) }) $runInfo.Add([pscustomobject][ordered]@{ Setting='PowerShell'; Value=[string]$PSVersionTable.PSVersion }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Graph auth module'; Value=[string]$script:GraphAuthVersion }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Collection complete'; Value=$(if ($script:GraphReadIncomplete) { 'No - a read failed on a reported object type' } else { 'Yes' }) }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Group references'; Value=$(if ($script:ReferenceReadIncomplete) { 'Incomplete - group section skipped' } elseif ($GroupOwnerUpns.Count -eq 0) { 'Not collected - group section not requested' } else { 'Complete' }) }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Actionable means'; Value='High + Medium. Low and Watch are excluded.' }) $runInfo.Add([pscustomobject][ordered]@{ Setting='Test names matched'; Value=$(if ($TestNameRegex) { ('{0} of {1} object names' -f $script:TestNameMatches, $script:ObjectsExamined) } else { 'detection not enabled' }) }) # Per-priority columns: with retained versions parked at Watch and Low, a bare # Total/Actionable pair hides where most of the estate went. $summary = New-Object System.Collections.Generic.List[object] function Add-SummaryRow { param([string]$Category, $Rows) $c = Get-PriorityCounts $Rows $summary.Add([pscustomobject][ordered]@{ Category = $Category Total = (Get-RowCount $Rows) High = $c.High Medium = $c.Medium Low = $c.Low Watch = $c.Watch Healthy = $c.Healthy Actionable = ($c.High + $c.Medium) }) } Add-SummaryRow 'Applications' $rowsApps Add-SummaryRow 'ConfigProfiles' $rowsConfig Add-SummaryRow 'CompliancePolicies' $rowsCompliance Add-SummaryRow 'SecurityBaselines' $rowsBaseline Add-SummaryRow 'Remediations' $rowsRemediation Add-SummaryRow 'PlatformScripts' $rowsScripts Add-SummaryRow 'EntraGroups(flagged)' $rowsGroups $allReal = New-Object System.Collections.Generic.List[object] foreach ($r in $rowsApps) { $allReal.Add($r) } foreach ($r in $rowsConfig) { $allReal.Add($r) } foreach ($r in $rowsCompliance) { $allReal.Add($r) } foreach ($r in $rowsBaseline) { $allReal.Add($r) } foreach ($r in $rowsRemediation) { $allReal.Add($r) } foreach ($r in $rowsScripts) { $allReal.Add($r) } foreach ($r in $rowsGroups) { $allReal.Add($r) } # Worklist: actionable items (High/Medium/Low), pre-sorted by priority, with a # plain-language Reason and a deterministic SuggestedAction for the operator. # Rules are evaluated top-down; first match wins. $bucketHigh = New-Object System.Collections.Generic.List[object] $bucketMed = New-Object System.Collections.Generic.List[object] $bucketLow = New-Object System.Collections.Generic.List[object] foreach ($r in $allReal) { $pri = [string]$r.Priority if (-not $pri -or $pri -eq '' -or $pri -eq 'Watch') { continue } $f = [string]$r.Flags $reason = 'Review manually' $action = 'Investigate' if ($f -match 'IncludeExcludeOverlap') { $reason = 'Same group both included and excluded in the same assignment intent; the exclusion wins and that group is silently skipped' $action = 'Fix assignment' } elseif ($f -match 'SupersededByNewer') { $reason = 'Unassigned, but a newer version supersedes it (retained rollback version)' $action = 'Keep' } elseif ($f -match 'RetainedVersionExpired') { $reason = 'Retained previous version of an application that is still assigned, but created outside the rollback window; a rollback this old is no longer realistic' $action = 'Remove' } elseif ($f -match 'SupersededByName') { $reason = 'Unassigned, but a newer version of the same application is assigned (retained rollback copy, matched by name and version because no supersedence relationship exists)' $action = 'Keep' } elseif ($f -match 'HasDependents') { $reason = 'Unassigned, but another app depends on it' $action = 'Keep' } elseif ($pri -eq 'High') { $reason = 'TEST-named object assigned to All Devices / All Users' $action = 'Rename' } elseif ($f -match 'OnlyExclusions') { $reason = 'Only exclusion assignments; effectively not deployed' $action = 'Investigate' } elseif ($r.ObjectType -eq 'Entra Group') { $parts = @() if ($f -match 'ZeroMembers') { $parts += 'no members' } if ($f -match 'NoAssignmentFound') { $parts += 'no assignment found referencing it' } $reason = 'Owned assignment group with ' + ($parts -join ' and ') + '. Verify in the portal first: the group could still be used by an object type this tool does not read' $action = 'Investigate' } elseif ($pri -eq 'Medium') { $reason = 'Unassigned; nothing references it' $action = 'Remove' } elseif ($f -match 'TestNamed') { $reason = 'TEST-named object scoped to a group; confirm the test is finished' $action = 'Investigate' } # A test-named object that is also unassigned matches the Medium branch first, so # the reason would lose the fact that it is a test object. Keep the label. if (($f -match 'TestNamed') -and ($reason -notmatch 'test')) { $reason = $reason + '; test-named' } if ($f -match 'DuplicateName') { $reason = $reason + '; duplicate display name exists' } $item = [pscustomobject][ordered]@{ Priority = $pri DisplayName = $r.DisplayName ObjectType = $r.ObjectType Id = $r.Id Assignment = $r.Assignment LastModified = $r.LastModified Reason = $reason SuggestedAction = $action Decision = '' Owner = '' DateActioned = '' Notes = '' } if ($pri -eq 'High') { $bucketHigh.Add($item) } elseif ($pri -eq 'Medium') { $bucketMed.Add($item) } else { $bucketLow.Add($item) } } $worklist = New-Object System.Collections.Generic.List[object] foreach ($i in $bucketHigh) { $worklist.Add($i) } # Within Medium, oldest LastModified first: longest-untouched content is the most # confidently abandoned, so the operator starts with the safest removals. # Note (verified in tenant): assignment changes do NOT update lastModifiedDateTime, # so this orders by content abandonment only - it says nothing about when an # object was unassigned. Empty/unparseable dates sort last. $sortedMed = $bucketMed | Sort-Object -Property @{ Expression = { $d = $null if ($_.LastModified) { try { $d = [datetime]$_.LastModified } catch { } } if ($null -eq $d) { [datetime]::MaxValue } else { $d } } } foreach ($i in $sortedMed) { $worklist.Add($i) } foreach ($i in $bucketLow) { $worklist.Add($i) } # ----------------------------------------------------------------------- # Export to Excel decision tracker # ----------------------------------------------------------------------- if (-not (Test-Path $OutputFolder)) { New-Item -ItemType Directory -Path $OutputFolder -Force | Out-Null } $stamp = Get-Date -Format 'yyyyMMdd-HHmm' $xlsx = Join-Path $OutputFolder ("Intune-Housekeeper_{0}.xlsx" -f $stamp) if (Test-Path $xlsx) { Remove-Item $xlsx -Force } $common = @{ AutoSize = $true; AutoFilter = $true; FreezeTopRow = $true; BoldTopRow = $true; PassThru = $true } Write-Host 'Writing Excel tracker...' $pkg = $summary | Export-Excel -Path $xlsx -WorksheetName 'Summary' @common # RunInfo values are labels, and several of them look numeric. In a locale where # '.' is a thousands separator, the export turns the version string 7.6.5 into # 765 and 2.39.0 into 2390 before any cell formatting is applied. Suppressing the # conversion is the only fix; the parameter is checked first so an older # ImportExcel still works, just with the mangling. $runInfoOpts = @{} if ((Get-Command Export-Excel).Parameters.ContainsKey('NoNumberConversion')) { $runInfoOpts['NoNumberConversion'] = '*' } $pkg = $runInfo | Export-Excel -ExcelPackage $pkg -WorksheetName 'RunInfo' @common @runInfoOpts $pkg = (Get-ExportRows $worklist -Worklist) | Export-Excel -ExcelPackage $pkg -WorksheetName 'Worklist' @common $pkg = (Get-ExportRows $rowsApps) | Export-Excel -ExcelPackage $pkg -WorksheetName 'Applications' @common $pkg = (Get-ExportRows $rowsConfig) | Export-Excel -ExcelPackage $pkg -WorksheetName 'ConfigProfiles' @common $pkg = (Get-ExportRows $rowsCompliance) | Export-Excel -ExcelPackage $pkg -WorksheetName 'CompliancePolicies' @common $pkg = (Get-ExportRows $rowsBaseline) | Export-Excel -ExcelPackage $pkg -WorksheetName 'SecurityBaselines' @common $pkg = (Get-ExportRows $rowsRemediation) | Export-Excel -ExcelPackage $pkg -WorksheetName 'Remediations' @common $pkg = (Get-ExportRows $rowsScripts) | Export-Excel -ExcelPackage $pkg -WorksheetName 'PlatformScripts' @common $pkg = (Get-ExportRows $rowsGroups) | Export-Excel -ExcelPackage $pkg -WorksheetName 'EntraGroups' @common # Header styling. -HeaderColor accepts any HTML colour string. try { $brand = [System.Drawing.ColorTranslator]::FromHtml($HeaderColor) foreach ($ws in $pkg.Workbook.Worksheets) { if ($ws.Dimension) { $lastCol = $ws.Dimension.End.Column $lastRow = $ws.Dimension.End.Row $hdr = $ws.Cells[1, 1, 1, $lastCol] $hdr.Style.Fill.PatternType = [OfficeOpenXml.Style.ExcelFillStyle]::Solid $hdr.Style.Fill.BackgroundColor.SetColor($brand) $hdr.Style.Font.Color.SetColor([System.Drawing.Color]::White) $hdr.Style.Font.Bold = $true # Counts are whole objects, not measurements. Without this they render as # 698.00, because the export converts numeric-looking values to doubles. if ($ws.Name -eq 'Summary' -and $lastRow -gt 1 -and $lastCol -gt 1) { $ws.Cells[2, 2, $lastRow, $lastCol].Style.Numberformat.Format = '0' } # RunInfo values are labels, some of which happen to look like numbers. if ($ws.Name -eq 'RunInfo' -and $lastRow -gt 1) { $ws.Cells[2, 2, $lastRow, 2].Style.Numberformat.Format = '@' } # Shade the Priority cell so a long sheet can be scanned at a glance. # A plain fill rather than Excel conditional formatting: these sheets # are a snapshot, and a static fill survives sorting, filtering, and # being pasted into a mail or a ticket, which is what operators # actually do with these rows. $priorityColumn = 0 for ($c = 1; $c -le $lastCol; $c++) { if ([string]$ws.Cells[1, $c].Text -eq 'Priority') { $priorityColumn = $c; break } } if ($priorityColumn -gt 0 -and $lastRow -gt 1) { for ($r = 2; $r -le $lastRow; $r++) { $priorityValue = [string]$ws.Cells[$r, $priorityColumn].Text if ($script:PriorityFill.ContainsKey($priorityValue)) { $priorityCell = $ws.Cells[$r, $priorityColumn] $priorityCell.Style.Fill.PatternType = [OfficeOpenXml.Style.ExcelFillStyle]::Solid $priorityCell.Style.Fill.BackgroundColor.SetColor( [System.Drawing.ColorTranslator]::FromHtml($script:PriorityFill[$priorityValue])) } } } } } } catch { Write-Warning ("Header styling skipped: {0}" -f $_.Exception.Message) } Close-ExcelPackage $pkg Write-Host '' Write-UnrecognisedTypeWarning if ($script:GraphReadIncomplete) { Write-Warning 'This report is INCOMPLETE. A read failed on an object type the report covers (see the warnings above), so one or more categories are missing objects and their totals understate the estate. A 403 usually means the app registration is missing a permission listed in the README. RunInfo records this on the Collection complete row.' } elseif ($script:ReferenceReadIncomplete) { Write-Warning 'The Windows inventory is complete, but the Entra group section was skipped: a read failed on an object type used only to establish group references. Everything else in this workbook is trustworthy.' } if ($TestNameRegex) { Write-Host ("Test-object detection: {0} of {1} object names matched '{2}'." -f $script:TestNameMatches, $script:ObjectsExamined, $TestNameRegex) if ($script:TestNameMatches -eq 0) { Write-Warning ("No object name matched '{0}'. That is either a clean estate or the wrong pattern for your naming convention. Anchor on a word boundary rather than a bare substring: '(^|[-_ (\[])TEST([-_ )\]]|$)' matches Wifi-TEST, Wifi_TEST and Wifi (test), while a bare 'test' also matches Latest and Attestation." -f $TestNameRegex) } } Write-Host ("Done. Decision tracker written to: {0}" -f $xlsx) Write-Host ("Worklist items (High/Medium/Low): {0}" -f (Get-RowCount $worklist)) } catch { $errMessage = [string]$_.Exception.Message # Translate the failures that are environmental rather than tenant problems, so the # operator is not left reading a .NET type-load error and guessing. $likelyCause = '' if ($errMessage -match 'Could not load type|Microsoft\.Identity\.Client|FileLoadException|Could not load file or assembly') { $likelyCause = 'Assembly conflict in this PowerShell session. Several side-by-side versions of the Microsoft.Graph modules share one Microsoft.Identity.Client, and Windows PowerShell 5.1 cannot isolate them. Run this in PowerShell 7, which loads the SDK dependencies in an isolated context.' } elseif ($errMessage -match 'AADSTS530084') { $likelyCause = 'Conditional Access token protection rejected the sign-in. The app registration needs the broker redirect URI ms-appx-web://Microsoft.AAD.BrokerPlugin/<client id>, and the device must be joined or registered and compliant.' } elseif ($errMessage -match 'ApplicationCanceled|user_canceled|access_denied|Current Request already cancelled') { $likelyCause = 'The sign-in prompt was closed, cancelled, or timed out. Re-run and complete the sign-in. If no prompt appeared, check for a broker window behind the console.' } elseif ($errMessage -match 'AADSTS65001|consent') { $likelyCause = 'The app registration has not been admin-consented for the delegated permissions listed in the README.' } elseif ($errMessage -match 'Forbidden|403') { $likelyCause = 'The signed-in account or the app registration lacks a required read permission. Check the scopes reported above.' } Write-Host '' Write-Host '==================== ERROR ====================' Write-Host ("Type : {0}" -f $_.Exception.GetType().FullName) Write-Host ("Message : {0}" -f $errMessage) if ($likelyCause) { Write-Host ("Likely : {0}" -f $likelyCause) } if ($_.InvocationInfo) { Write-Host ("Line # : {0}" -f $_.InvocationInfo.ScriptLineNumber) Write-Host ("Command : {0}" -f ([string]$_.InvocationInfo.Line).Trim()) } Write-Host ("Stack :") Write-Host ($_.ScriptStackTrace) Write-Host '===============================================' } finally { if ($script:ConnectionOwned -and (Get-MgContext)) { Disconnect-MgGraph | Out-Null } } } Export-ModuleMember -Function @( 'Export-IntuneHousekeeperReport' 'Get-IntuneHousekeeperConfig' 'Set-IntuneHousekeeperConfig' ) |