Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Build/Build-Module.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ Import-Module PSPublishModule -Force -ErrorAction Stop
Build-Module -ModuleName 'CleanupMonster' {
# Usual defaults as per standard module
$Manifest = [ordered] @{
ModuleVersion = '3.1.14'
ModuleVersion = '3.1.15'
CompatiblePSEditions = @('Desktop', 'Core')
GUID = 'cd1f9987-6242-452c-a7db-6337d4a6b639'
Author = 'Przemyslaw Klys'
Expand Down
12 changes: 11 additions & 1 deletion CHANGELOG.MD
Original file line number Diff line number Diff line change
@@ -1,4 +1,14 @@
### 3.1.13 - 2026.09.17
### 3.1.15

- Stop Entra deletion when Intune record removal fails during cloud-device cleanup.
- Apply Autopilot identity deletion only to Windows or unknown-OS records.
- Allow optional Intune recency protection for staged Entra disable and deletion while retaining Entra-only candidates.
- Keep ambiguous Windows Autopilot associations out of final deletion when GraphEssentials reports multiple matches.
- Add a staged Entra and Intune cleanup example for Windows, Android, iOS, iPadOS, and macOS.
- Require GraphEssentials 0.0.63 for cloud cleanup so Autopilot match ambiguity is available.
- Skip destructive cloud actions when multiple Intune records link to one Entra device, and limit standalone Autopilot removal to Windows.

### 3.1.13 - 2026.09.17

- Added guarded MSA and gMSA cleanup with reporting, action limits, selection criteria, and `WhatIf` support.
- Added staged cloud-device cleanup for Entra ID, Intune, and Autopilot records, including pending-state tracking, orphan and broken-link handling, and duplicate-device protection.
Expand Down
2 changes: 1 addition & 1 deletion CleanupMonster.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
Description = 'This module provides an easy way to cleanup Active Directory and cloud devices from dead/old objects based on various criteria. It can also disable, move, retire or delete objects. It can utilize Azure AD, Intune and Jamf to get additional information about objects before deleting them.'
FunctionsToExport = @('Invoke-ADComputersCleanup', 'Invoke-ADServiceAccountsCleanup', 'Invoke-ADSIDHistoryCleanup', 'Invoke-CloudDevicesCleanup')
GUID = 'cd1f9987-6242-452c-a7db-6337d4a6b639'
ModuleVersion = '3.1.14'
ModuleVersion = '3.1.15'
PowerShellVersion = '5.1'
PrivateData = @{
PSData = @{
Expand Down
30 changes: 30 additions & 0 deletions Examples/CleanupEntraDevices.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# Entra and Intune device cleanup

Use [CleanupEntraDevices.ps1](CleanupEntraDevices.ps1) as a separate daily job beside the existing AD/Jamf computer cleanup. It follows the same pattern: connect to Microsoft Graph, set one splat, and call CleanupMonster. The operating-system scope includes Windows, Android, iOS, iPadOS, and macOS. The existing AD/Jamf job does not need to change.

## Reuse the existing Graph app

Keep the existing job's `Connect-MgGraph -ClientSecretCredential` block in the private scheduled script. Set `$TenantId` in the example to that job's tenant GUID. Do not copy its encrypted secret into this repository. The example checks the connected tenant before reading inventory or taking action. There is no need for a new app registration or Azure subscription resource if the existing app can be used.

Check the existing app's Microsoft Graph **application permissions** and tenant admin consent:

| Permission | Needed for |
| --- | --- |
| `Device.ReadWrite.All` | Entra device disable and delete |
| `DeviceManagementManagedDevices.ReadWrite.All` | Intune managed-device inventory and final record removal |
| `DeviceManagementServiceConfig.ReadWrite.All` | Autopilot identity inventory and final removal |

Release GraphEssentials **0.0.63** first, then CleanupMonster **3.1.15**. Install both on the scheduled host before running this example. The example requires these versions because they stop ambiguous Autopilot matches, stop Entra deletion after an Intune deletion failure, and protect recently synced Intune devices. Earlier releases do not contain these changes.

## Run it like the old job

1. Keep the existing Graph connection and log/transcript setup. Create the example's report, log, and script folders if they do not exist. Use its **new cloud datastore path**, never the AD job's `CleanupComputers_ListProcessed.xml`.
2. The old job used inventory minimums of `20,000` Entra and `19,000` Intune. The cloud job counts selected join types, so these numbers may not fit. Check its reported counts and adjust the two limits to stable, current cloud baselines before actions. Check the operating-system values in the report, especially for Macs and iPads, and adjust the patterns if this tenant uses different labels. Add approved cloud exclusions and protected Autopilot group tags to the splat.
3. Leave `ReportOnly = $true` for the first 30 daily runs and review the reports. The supplied category counts add up to **37,600**, while the headline says **38,600**; reconcile this before enabling actions. Unknown activity and hybrid joined devices remain outside action scope.
4. To preview actions, set `ReportOnly = $false`, `WhatIfDisable = $true`, and `WhatIfDelete = $true`. Then start disabling with `WhatIfDisable = $false` while leaving `WhatIfDelete = $true`. Enable deletion only after reviewing recovery, duplicates, Autopilot associations, and the first disabled cohort.

The policy disables Entra joined or registered devices on the selected platforms after more than 90 days of known Entra inactivity and registration age. It deletes only after more than 180 days of both, and after a successful CleanupMonster disable has remained in the new datastore for more than 90 days. The old already-disabled backlog is not automatically promoted. Keep the datastore across runs; do not run overlapping instances.

This cloud cmdlet uses Entra and Intune inventory. In this example, **both** Entra activity and, when an Intune record exists, Intune last sync must be older than 90 days for disable and 180 days for delete. A matching Intune record with unknown last sync is excluded. Entra-only records still use the Entra threshold. The cmdlet does not query Jamf or delete Jamf records. A recent Jamf check-in will not block cloud actions for a Mac. The old AD cleanup job continues to use Jamf for its AD computer decisions. The Autopilot identity option applies only to Windows or records whose operating system is unknown.

If an Intune device cleanup rule is set to 90 days, remember that Microsoft describes it as **hiding** stale devices from the portal and reports. It does not replace this Entra cleanup policy or prove an Intune object was deleted. See [Intune device cleanup rules](https://learn.microsoft.com/en-us/intune/governance/configure-cleanup-rules) and [Microsoft Entra stale-device guidance](https://learn.microsoft.com/en-us/entra/identity/devices/manage-stale-devices).
55 changes: 55 additions & 0 deletions Examples/CleanupEntraDevices.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Run the existing scheduled task's Connect-MgGraph block before this script.
# Keep its client secret outside the repository. No Jamf connection is needed.

$TenantId = '<tenant-guid>' # Use the tenant ID from the existing computer cleanup job.
$Today = Get-Date -Format 'yyyy-MM-dd_HH_mm_ss'

Import-Module GraphEssentials -MinimumVersion '0.0.63' -ErrorAction Stop
Import-Module CleanupMonster -MinimumVersion '3.1.15' -ErrorAction Stop

if ([string] (Get-MgContext).TenantId -ne $TenantId) {
throw "Connect-MgGraph to tenant $TenantId before running cloud cleanup."
}

$invokeCloudDevicesCleanupSplat = @{
# Starting values from the old job; cloud-scoped counts may differ.
SafetyEntraLimit = 20000
SafetyIntuneLimit = 19000

Disable = $true
DisableLimit = 10
DisableLastSeenEntraMoreThan = 90
DisableRegisteredMoreThan = 90
DisableListProcessedMoreThan = $null
DisableIncludeEntraOnly = $true

Delete = $true
DeleteLimit = 5
DeleteLastSeenEntraMoreThan = 180
DeleteRegisteredMoreThan = 180
DeleteListProcessedMoreThan = 90
DeleteIncludeEntraOnly = $true
DeleteRemoveIntuneRecord = $true
DeleteAutopilotIdentity = $true

IncludeJoinType = @('AzureAD joined', 'AzureAD registered')
IncludeOperatingSystem = @('Windows*', 'Android*', 'iOS*', 'iPadOS*', 'macOS*', 'Mac OS*')
IncludeCompanyOwned = $true
ProtectRecentIntuneActivity = $true
PreserveDuplicateDeviceNames = $true
Exclusions = @() # Add approved cloud device names or IDs.
ExcludeAutopilotGroupTag = @() # Add protected group tags if needed.

# Use a new, persistent datastore for this cloud policy. Keep it across runs.
DataStorePath = 'E:\Support\Scripts\CleanupCloudDevices_ListProcessed.xml'
ReportPath = "E:\Support\Reporting\Custom\CleanupCloudDevices_$Today.html"
LogPath = "E:\Support\Logs\CleanupComputersCloud\CleanupCloudDevices_$Today.log"

ReportOnly = $true
WhatIfDisable = $false
WhatIfDelete = $false
ShowHTML = $false
}

$Output = Invoke-CloudDevicesCleanup @invokeCloudDevicesCleanupSplat
$Output
12 changes: 7 additions & 5 deletions Private/Assert-CloudDeviceCleanupSettings.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ function Assert-CloudDeviceCleanupSettings {
return $false
}

$minimumVersion = [version] '0.0.57'
$minimumVersion = [version] '0.0.63'
if ($moduleAvailable.Version -lt $minimumVersion) {
Write-Color -Text '[e] ', "'GraphEssentials' module is outdated for cloud-device cleanup. Please update to minimum version '$minimumVersion'. Terminating." -Color Yellow, Red
return $false
Expand Down Expand Up @@ -39,10 +39,12 @@ function Assert-CloudDeviceCleanupSettings {
return $false
}

$inventoryCommand = $resolvedCommands['Get-MyDeviceIntune']
if ($inventoryCommand.ModuleName -ne 'GraphEssentials' -or -not $inventoryCommand.Module -or $inventoryCommand.Module.Version -lt $minimumVersion) {
Write-Color -Text '[e] ', "'Get-MyDeviceIntune' is not provided by the required GraphEssentials version '$minimumVersion' in the current session. Import the updated module and try again. Terminating." -Color Yellow, Red
return $false
foreach ($inventoryCommandName in @('Get-MyDevice', 'Get-MyDeviceIntune')) {
$inventoryCommand = $resolvedCommands[$inventoryCommandName]
if ($inventoryCommand.ModuleName -ne 'GraphEssentials' -or -not $inventoryCommand.Module -or $inventoryCommand.Module.Version -lt $minimumVersion) {
Write-Color -Text '[e] ', "'$inventoryCommandName' is not provided by the required GraphEssentials version '$minimumVersion' in the current session. Import the updated module and try again. Terminating." -Color Yellow, Red
return $false
}
}

$true
Expand Down
15 changes: 14 additions & 1 deletion Private/Get-CloudDevicesToProcess.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,10 @@ function Get-CloudDevicesToProcess {
continue
}

if ($Type -in @('Disable', 'Delete', 'RemoveAutopilotIdentity') -and $device.IntuneMatchAmbiguous) {
continue
}

if ($ActionIf.IntuneLinkState -and $ActionIf.IntuneLinkState -ne 'Any' -and $device.IntuneLinkState -ne $ActionIf.IntuneLinkState) {
continue
}
Expand Down Expand Up @@ -248,7 +252,10 @@ function Get-CloudDevicesToProcess {
continue
}
} elseif ($Type -eq 'RemoveAutopilotIdentity') {
if ($device.AutopilotOnboarded -ne $true -or [string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) {
if ([string] $device.OperatingSystem -notlike 'Windows*' -or
$device.AutopilotMatchAmbiguous -eq $true -or
$device.AutopilotOnboarded -ne $true -or
[string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) {
continue
}
}
Expand Down Expand Up @@ -290,6 +297,12 @@ function Get-CloudDevicesToProcess {
}
}

if ($null -ne $ActionIf.IntuneStaleWhenPresentMoreThan -and $device.HasIntuneRecord) {
if ($null -eq $device.IntuneLastSeenDays -or $device.IntuneLastSeenDays -le $ActionIf.IntuneStaleWhenPresentMoreThan) {
continue
}
}

if ($null -ne $ActionIf.RegisteredMoreThan) {
if ($null -eq $device.RegisteredDays -or $device.RegisteredDays -le $ActionIf.RegisteredMoreThan) {
continue
Expand Down
19 changes: 16 additions & 3 deletions Private/Get-InitialCloudDevices.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -170,10 +170,15 @@ function Get-InitialCloudDevices {
Write-Color -Text '[i] ', 'Cloud devices found in Intune: ', $intuneDevices.Count -Color Yellow, Cyan, Green

$intuneByAzureDeviceId = [System.Collections.Generic.Dictionary[string, object]]::new([System.StringComparer]::OrdinalIgnoreCase)
$ambiguousIntuneAzureDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
$matchedIntuneManagedDeviceIds = [System.Collections.Generic.HashSet[string]]::new([System.StringComparer]::OrdinalIgnoreCase)
foreach ($intuneDevice in $intuneDevices) {
if ($intuneDevice.AzureAdDeviceId -and -not $intuneByAzureDeviceId.ContainsKey($intuneDevice.AzureAdDeviceId)) {
$intuneByAzureDeviceId[$intuneDevice.AzureAdDeviceId] = $intuneDevice
if ($intuneDevice.AzureAdDeviceId) {
if ($intuneByAzureDeviceId.ContainsKey($intuneDevice.AzureAdDeviceId)) {
$null = $ambiguousIntuneAzureDeviceIds.Add($intuneDevice.AzureAdDeviceId)
Comment thread
PrzemyslawKlys marked this conversation as resolved.
Outdated
} else {
$intuneByAzureDeviceId[$intuneDevice.AzureAdDeviceId] = $intuneDevice
}
}
}

Expand Down Expand Up @@ -211,7 +216,11 @@ function Get-InitialCloudDevices {
$entraRegisteredDays = & $getAgeDays $entraDevice.FirstSeen
$intuneRegisteredDays = if ($intuneDevice) { & $getAgeDays $intuneDevice.FirstSeen } else { $null }
$autopilotInventoryLoaded = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotInventoryLoaded'
$autopilotMatchAmbiguous = ($intuneDevice -and $intuneDevice.AutopilotMatchAmbiguous -eq $true) -or ($entraDevice.AutopilotMatchAmbiguous -eq $true)
$autopilotOnboarded = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotOnboarded'
if ($autopilotMatchAmbiguous) {
$autopilotOnboarded = $true
}
$autopilotLastContacted = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotLastContacted'
$autopilotLastContactedDays = & $getFirstNonNullPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotLastContactedDays'
if ($null -eq $autopilotLastContactedDays -and $autopilotLastContacted) {
Expand All @@ -233,6 +242,7 @@ function Get-InitialCloudDevices {
ManagedDeviceId = if ($intuneDevice) { $intuneDevice.ManagedDeviceId } else { $null }
HasEntraRecord = $true
HasIntuneRecord = [bool] $intuneDevice
IntuneMatchAmbiguous = [bool] ($entraDevice.DeviceId -and $ambiguousIntuneAzureDeviceIds.Contains($entraDevice.DeviceId))
RecordState = if ($intuneDevice) { 'Matched' } else { 'EntraOnly' }
RecordSource = if ($intuneDevice) { 'Microsoft Entra ID + Intune' } else { 'Microsoft Entra ID only' }
IntuneLinkState = $intuneLinkState
Expand Down Expand Up @@ -265,8 +275,9 @@ function Get-InitialCloudDevices {
ComplianceState = if ($intuneDevice) { $intuneDevice.ComplianceState } else { $null }
ManagementAgent = if ($intuneDevice) { $intuneDevice.ManagementAgent } else { $null }
AutopilotInventoryLoaded = $autopilotInventoryLoaded
AutopilotMatchAmbiguous = $autopilotMatchAmbiguous
AutopilotOnboarded = $autopilotOnboarded
AutopilotDeviceId = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotDeviceId'
AutopilotDeviceId = if ($autopilotMatchAmbiguous) { $null } else { & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotDeviceId' }
AutopilotManagedDeviceId = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotManagedDeviceId'
AutopilotAzureAdDeviceId = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotAzureAdDeviceId'
AutopilotResourceName = & $getFirstPropertyValue -InputObject @($intuneDevice, $entraDevice) -Name 'AutopilotResourceName'
Expand Down Expand Up @@ -309,6 +320,7 @@ function Get-InitialCloudDevices {
ManagedDeviceId = $intuneDevice.ManagedDeviceId
HasEntraRecord = [bool] $intuneDevice.EntraDeviceObjectId
HasIntuneRecord = $true
IntuneMatchAmbiguous = [bool] ($intuneDevice.AzureAdDeviceId -and $ambiguousIntuneAzureDeviceIds.Contains($intuneDevice.AzureAdDeviceId))
RecordState = 'IntuneOnly'
RecordSource = 'Intune only'
IntuneLinkState = 'IntuneOnly'
Expand Down Expand Up @@ -341,6 +353,7 @@ function Get-InitialCloudDevices {
ComplianceState = $intuneDevice.ComplianceState
ManagementAgent = $intuneDevice.ManagementAgent
AutopilotInventoryLoaded = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotInventoryLoaded'
AutopilotMatchAmbiguous = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotMatchAmbiguous'
AutopilotOnboarded = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotOnboarded'
AutopilotDeviceId = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotDeviceId'
AutopilotManagedDeviceId = Get-CloudDevicePropertyValue -InputObject $intuneDevice -Name 'AutopilotManagedDeviceId'
Expand Down
17 changes: 15 additions & 2 deletions Private/Request-CloudDevicesDelete.ps1
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,21 @@ function Request-CloudDevicesDelete {
$autopilotIdentityRemoved = $false

if (-not $ReportOnly) {
if ($DeleteAutopilotIdentity) {
if ($device.IntuneMatchAmbiguous -eq $true) {
$subActionExecuted = $true
$subActionSuccess = $false
$continueRecordDelete = $false
$subActionMessages.Add('Intune: Multiple records link to this Entra device; record delete was skipped.')
}
if ($continueRecordDelete -and $DeleteAutopilotIdentity) {
$operatingSystem = [string] $device.OperatingSystem
$autopilotMayApply = [string]::IsNullOrWhiteSpace($operatingSystem) -or $operatingSystem -eq 'Unknown' -or $operatingSystem -like 'Windows*'
if ($device.AutopilotOnboarded -eq $true) {
if ($autopilotMayApply -and $device.AutopilotMatchAmbiguous -eq $true) {
$subActionExecuted = $true
$subActionSuccess = $false
$continueRecordDelete = $false
$subActionMessages.Add('Autopilot: Multiple identities match this device; record delete was skipped.')
} elseif ($autopilotMayApply -and $device.AutopilotOnboarded -eq $true) {
$subActionExecuted = $true
if ([string]::IsNullOrWhiteSpace([string] $device.AutopilotDeviceId)) {
$subActionSuccess = $false
Expand Down Expand Up @@ -73,6 +84,8 @@ function Request-CloudDevicesDelete {
}
if (-not $removeIntuneResult.Success -and -not ($WhatIf -or $WhatIfDelete)) {
$subActionSuccess = $false
$continueRecordDelete = $false
$subActionMessages.Add('Entra: Record delete was skipped because Intune removal failed.')
}
}

Expand Down
Loading