CleanupMonster is a PowerShell module to that helps you clean up Active Directory.
It has multiple functionalities currently & planned:
- Cleanup stale Computer objects from Active Directory
- Cleanup SID History from Active Directory
- Cleanup stale User objects from Active Directory
- Cleanup stale Group objects from Active Directory
- Cleanup GMSA/MSA objects from Active Directory
There are 2 blog posts that explain how to use the module:
- Mastering Active Directory Hygiene: Automating Stale Computer Cleanup with CleanupMonster
- Mastering Active Directory Hygiene: Automating SID History Cleanup with CleanupMonster
The solution is really thought through and has many options to customize it to your needs. It's a complete solution for cleaning up Active Directory. Please make sure to run this module with proper permissions or you may get wrong results. By default Active Directory domain allows a standard user to read LastLogonDate and LastPasswordSet attributes. If you have changed those settings you may need to run the module with elevated permissions even for reporting needs.
If you find this project helpful, please consider supporting its development. Your sponsorship will help the maintainers dedicate more time to maintenance and new feature development for everyone.
It takes a lot of time and effort to create and maintain this project. By becoming a sponsor, you can help ensure that it stays free and accessible to everyone who needs it.
To become a sponsor, you can choose from the following options:
Your sponsorship is completely optional and not required for using this project. We want this project to remain open-source and available for anyone to use for free, regardless of whether they choose to sponsor it or not.
If you work for a company that uses our .NET libraries or PowerShell Modules, please consider asking your manager or marketing team if your company would be interested in supporting this project. Your company's support can help us continue to maintain and improve this project for the benefit of everyone.
Thank you for considering supporting this project!
Install-Module -Name CleanupMonster -Force -VerboseCleanupMonster is built for teams that want Active Directory cleanup to feel deliberate, staged, and reviewable instead of risky and opaque. Every cleanup path starts with discovery, can generate HTML output for review, and can be wrapped in limits, pending lists, and WhatIf controls before anything destructive happens.
Whether you are retiring stale computers, pruning unused MSA / gMSA objects, or removing legacy SID history after migrations, the module is designed to help you choose the right cleanup track for the problem in front of you.
Warning
Run the module with the permissions required to read the attributes you rely on. In a default AD configuration that often includes LastLogonDate and PasswordLastSet, but hardened environments may require elevated rights even for reporting-only runs.
CleanupMonster is easiest to understand as three end-to-end cleanup paths. They all start with discovery and reporting, but each one is optimized for a different cleanup problem.
Tip
If you are new to the module, start with -ReportOnly and -WhatIf, keep limits low, and validate the generated HTML output before enabling destructive actions.
flowchart LR
classDef computer fill:#D9F2E6,stroke:#1F7A4D,stroke-width:2px,color:#123524;
classDef service fill:#FFF0CC,stroke:#B7791F,stroke-width:2px,color:#5B3A00;
classDef sid fill:#DCEBFF,stroke:#2B6CB0,stroke-width:2px,color:#153E75;
classDef action fill:#F7FAFC,stroke:#4A5568,stroke-width:1px,color:#1A202C;
A["CleanupMonster"] --> B["Computer cleanup"]
A --> C["Service-account cleanup"]
A --> D["SID history cleanup"]
B --> B1["Disable"]
B --> B2["Move"]
B --> B3["DisableAndMove"]
B --> B4["Delete after pending age"]
C --> C1["Disable"]
C --> C2["Delete"]
C --> C3["Explicit selectors and low limits"]
D --> D1["Discover SID history"]
D --> D2["Filter by OU / object type / SID domain"]
D --> D3["Remove with small limits"]
class B,B1,B2,B3,B4 computer;
class C,C1,C2,C3 service;
class D,D1,D2,D3 sid;
class A action;
| Area | Cmdlet | Best for | Typical actions | Recommended mindset |
|---|---|---|---|---|
| Computer cleanup | Invoke-ADComputersCleanup |
Stale computer lifecycle management in AD, optionally cross-checked against Azure AD, Intune, and Jamf | Disable, move, disable-and-move, delete | Stage changes across multiple runs |
| Service-account cleanup | Invoke-ADServiceAccountsCleanup |
Reviewing and cleaning stale MSA / gMSA objects with stronger destructive-run guardrails |
Disable, delete | Start narrow and require explicit selectors |
| SID history cleanup | Invoke-ADSIDHistoryCleanup |
Removing legacy SID history entries with detailed before/after reporting | Report, selective remove | Start with discovery and tiny limits |
Recommended first commands for each path:
# Computer cleanup preview
Invoke-ADComputersCleanup -Disable -ReportOnly -WhatIf -ShowHTML
# Service-account cleanup preview
Invoke-ADServiceAccountsCleanup -Disable -ReportOnly -WhatIf -IncludeAccounts 'gmsa-*'
# SID history cleanup preview
Invoke-ADSIDHistoryCleanup -ReportOnly -WhatIfComputer cleanup is the richest workflow in the module. It is built for staged remediation of stale computer objects rather than one immediate destructive pass.
Use it when you want to:
- find stale AD computer objects
- optionally require Azure AD, Intune, or Jamf inactivity before actioning AD
- disable first and delete later
- move devices to a quarantine OU before final deletion
- keep a pending list as a safety buffer across multiple runs
Typical lifecycle:
flowchart LR
classDef stage fill:#D9F2E6,stroke:#1F7A4D,stroke-width:2px,color:#123524;
classDef gate fill:#FFF7E6,stroke:#B7791F,stroke-width:2px,color:#5B3A00;
classDef outcome fill:#EDF2F7,stroke:#4A5568,stroke-width:1px,color:#1A202C;
A["Discover stale computers"] --> B{"Matches disable filters?"}
B -- "No" --> Z["Remain untouched"]
B -- "Yes" --> C["Disable"]
C --> D{"DisableAndMove enabled?"}
D -- "Yes" --> E["Move to quarantine OU"]
D -- "No" --> F["Add to pending list"]
E --> F
F --> G{"Later matches move or delete gates?"}
G -- "Move path" --> H["Move-only follow-up"]
G -- "Delete path" --> I["Delete after pending-age threshold"]
class A,C,E,F,H,I stage;
class B,D,G gate;
class Z outcome;
What matters most:
-WhatIfnow flows to move actions as well as disable/delete actions-Moveis a first-class path, not just a side effect of disable-DisableAndMovebehaves like a disable workflow during discovery and then performs the move during execution- pending-list timing in the datastore lets you separate disable, move, and delete into different operational windows
Good starter profiles:
- pilot:
-ReportOnly -WhatIf -Disable - staged remediation:
-Disablenow, then-DeleteListProcessedMoreThanlater - quarantine-first:
-DisableAndMove -DisableMoveTargetOrganizationalUnit ... - cloud-aware cleanup: add Azure AD / Intune / Jamf inactivity thresholds and safety limits
Relevant examples:
Examples/DeleteComputers.ps1Examples/DeleteComputersWithMoveAndEmail.ps1Examples/DeleteComputersWithO365.ps1Examples/DeleteComputersWithO365andJAMF.ps1
Service-account cleanup is intentionally narrower and safer than computer cleanup. It is designed for MSA and gMSA hygiene where you want low limits and explicit destructive intent.
Use it when you want to:
- review stale managed service accounts
- limit scope with age filters or
IncludeAccounts - keep disable/delete limits very small during rollout
- avoid disabling and deleting the same account in the same run
Typical lifecycle:
flowchart LR
classDef stage fill:#FFF0CC,stroke:#B7791F,stroke-width:2px,color:#5B3A00;
classDef gate fill:#FFF7E6,stroke:#B7791F,stroke-width:2px,color:#5B3A00;
classDef outcome fill:#EDF2F7,stroke:#4A5568,stroke-width:1px,color:#1A202C;
A["Discover service accounts"] --> B{"Explicit selectors or age filters?"}
B -- "No destructive criteria" --> Z["Allow preview only"]
B -- "Yes" --> C["Find disable candidates"]
C --> D["Disable with low limits"]
D --> E["Find delete candidates"]
E --> F{"Already scheduled for disable?"}
F -- "Yes" --> G["Skip delete in this run"]
F -- "No" --> H["Delete with low limits"]
class A,C,D,E,G,H stage;
class B,F gate;
class Z outcome;
What matters most:
- destructive disable/delete runs require explicit selection criteria
IncludeAccountscounts as an explicit selector- preview scenarios with
-ReportOnly,-WhatIf,-WhatIfDisable, or-WhatIfDeleteare allowed even without destructive selectors - accounts scheduled for disable in a run are skipped from delete in that same run
Good starter profiles:
- preview by name pattern:
-Disable -ReportOnly -WhatIf -IncludeAccounts 'gmsa-*' - safer scheduled run: explicit include/exclude patterns plus low
DisableLimitandDeleteLimit - age-based hygiene: combine
LastLogonDateMoreThan,PasswordLastSetMoreThan, andWhenCreatedMoreThan
Relevant examples:
Examples/CleanupServiceAccounts.ps1
SID history cleanup is for post-migration and trust-hygiene work. It is optimized for discovering targeted SID history entries, filtering them carefully, and removing them with very small limits at first.
Use it when you want to:
- discover which objects still carry SID history
- narrow scope by source SID domain, OU, object type, or trust type
- clean up legacy migration artifacts with strong before/after reporting
- remove only a few SIDs or a few objects per run until the results are proven
Typical lifecycle:
flowchart LR
classDef stage fill:#DCEBFF,stroke:#2B6CB0,stroke-width:2px,color:#153E75;
classDef gate fill:#EAF4FF,stroke:#2B6CB0,stroke-width:2px,color:#153E75;
classDef outcome fill:#EDF2F7,stroke:#4A5568,stroke-width:1px,color:#1A202C;
A["Discover objects with SID history"] --> B["Filter by OU / object type / SID domain / trust type"]
B --> C{"ReportOnly or WhatIf?"}
C -- "Yes" --> Z["Review targeted objects and counts"]
C -- "No" --> D["Remove targeted SID values"]
D --> E["Refresh object after each SID removal"]
E --> F{"Reached object or SID limit?"}
F -- "No" --> D
F -- "Yes" --> G["Stop and report current state"]
class A,B,D,E,G stage;
class C,F gate;
class Z outcome;
What matters most:
- you can filter by object type, OU, SID source domain, and trust type
RemoveLimitSIDandRemoveLimitObjectlet you keep the blast radius intentionally small- each SID removal is tracked with before/after state so the report remains reviewable even for multi-SID objects
Good starter profiles:
- full discovery:
-ReportOnly -WhatIf - migration cleanup by domain SID: use
IncludeSIDHistoryDomain - targeted validation run: set both
RemoveLimitSIDandRemoveLimitObjectto1or2
Relevant examples:
Examples/DeleteSIDHistory.ps1
CleanupMonster is designed to be used with multiple layers of safety. You do not need every guard in every environment, but you should choose a guard set deliberately.
These are good defaults almost everywhere:
-ReportOnlyfor first-pass validation- top-level
-WhatIffor broad previews - action-specific
-WhatIfDisable,-WhatIfMove, and-WhatIfDeletewhen you want mixed behavior - low action limits during rollout, then widen later
- HTML reporting plus log files for every scheduled run
These help ensure your data sources are healthy before any action happens:
SafetyADLimitto stop if AD returns fewer objects than expectedSafetyAzureADLimit,SafetyIntuneLimit, andSafetyJamfLimitwhen cloud/device-source validation mattersTargetServerswhen you want deterministic domain-controller selection
These reduce risk in destructive cleanup patterns:
- pending-list aging via
DeleteListProcessedMoreThanorMoveListProcessedMoreThan - OU quarantine with
-Moveor-DisableAndMove - exclusions for known-sensitive OUs, names, or systems
- service-account explicit selectors before destructive runs
- protected-from-accidental-deletion checks for move/delete operations
| Environment maturity | Suggested guard set |
|---|---|
| First lab / pilot | -ReportOnly, top-level -WhatIf, low limits, HTML report, log file |
| Early production rollout | Explicit exclusions, low limits, AD safety limit, pending-list staging, action-specific WhatIf for risky steps |
| Mature production automation | AD and cloud safety limits, staged disable/delete windows, OU quarantine, reports/logs, explicit target servers where needed |
If you are introducing CleanupMonster into an environment for the first time, this is a good default operating model:
- Run report-only previews and validate the HTML output with your AD owners.
- Enable disable-only or disable-and-move with low limits.
- Review pending-list behavior for several runs.
- Add delete or move-after-delay thresholds only after the staged output is stable.
- Increase limits gradually once the rules are proven.
For service accounts, start even narrower:
- use
IncludeAccountsor very explicit age filters - keep
DisableLimit/DeleteLimitlow - review the report
- then automate
For SID history, start by discovering:
- run in report mode
- filter to a known source SID domain, OU, or object type
- set low
RemoveLimitObject/RemoveLimitSID - then expand when the before/after report looks right
The sections below go deeper into each cleanup path with concrete commands, screenshots, and scheduled-task examples you can adapt for your own environment.
Note
The earlier sections help you pick the right cleanup track. The walkthroughs below show how to operate each track in practice once you know which one you want.
This is the full staged device-cleanup path, from first report to scheduled automation and cloud-aware validation.
The first thing you should do is to run the module in a report only mode. It will show you how many computers are there to disable and delete.
$Output = Invoke-ADComputersCleanup -WhatIf -ReportOnly -Disable -Delete -ShowHTML
$OutputKeep in mind it works with default values such as 180 days for LastLogonDate and LastPasswordSet. You can change those values by using parameters.
This is a sample script that you can use to run the module interactively. It's good idea to run it interactively first to clean your AD and then run it in a scheduled task.
# this is a fresh run and it will try to disable computers according to it's defaults
$Output = Invoke-ADComputersCleanup -Disable -WhatIfDisable -ShowHTML
$OutputWhen you run cleanup the module will deliver HTML report on every run. It will show you:
- Devices in Current Run (Actioned)
- Devices in Previous Runs (History)
- Devices on Pending List (Pending deletion)
- All Devices (All) remaining
Another example with log settings and custom report path
# this is a fresh run and it will try to delete computers according to it's defaults
$Output = Invoke-ADComputersCleanup -Delete -WhatIfDelete -ShowHTML -LogPath $PSScriptRoot\Logs\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log -ReportPath $PSScriptRoot\Reports\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html
$OutputIf a computer is protected from accidental deletion, -RemoveProtectedFromAccidentalDeletionFlag now applies only to move and delete actions.
Disable-only runs will leave that protection in place.
$Output = Invoke-ADComputersCleanup `
-Disable `
-DisableAndMove `
-DisableMoveTargetOrganizationalUnit 'OU=Disabled,DC=contoso,DC=com' `
-RemoveProtectedFromAccidentalDeletionFlag `
-WhatIfDisable `
-ShowHTML
$OutputThis is a sample script that you can use to run the module in a scheduled task. It's a good idea to run it as a scheduled task as it will log all the actions and you can easily review them. It's very advanced with many options and you can easily customize it to your needs.
# Run the script
$Configuration = @{
Disable = $true
DisableNoServicePrincipalName = $null
DisableIsEnabled = $true
DisableLastLogonDateMoreThan = 90
DisablePasswordLastSetMoreThan = 90
DisableExcludeSystems = @(
# 'Windows Server*'
)
DisableIncludeSystems = @()
DisableLimit = 2 # 0 means unlimited, ignored for reports
DisableModifyDescription = $false
DisableModifyAdminDescription = $true
Delete = $true
DeleteIsEnabled = $false
DeleteNoServicePrincipalName = $null
DeleteLastLogonDateMoreThan = 180
DeletePasswordLastSetMoreThan = 180
DeleteListProcessedMoreThan = 90 # 90 days since computer was added to list
DeleteExcludeSystems = @(
# 'Windows Server*'
)
DeleteIncludeSystems = @(
)
DeleteLimit = 2 # 0 means unlimited, ignored for reports
Exclusions = @(
'*OU=Domain Controllers*'
'*OU=Servers,OU=Production*'
'EVOMONSTER$'
'EVOMONSTER.AD.EVOTEC.XYZ'
)
Filter = '*'
WhatIfDisable = $true
WhatIfDelete = $true
LogPath = "$PSScriptRoot\Logs\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log"
DataStorePath = "$PSScriptRoot\DeleteComputers_ListProcessed.xml"
ReportPath = "$PSScriptRoot\Reports\DeleteComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html"
ShowHTML = $true
}
# Run one time as admin: Write-Event -ID 10 -LogName 'Application' -EntryType Information -Category 0 -Message 'Initialize' -Source 'CleanupComputers'
$Output = Invoke-ADComputersCleanup @Configuration
$OutputThis is a sample script that you can use to run the module in a scheduled task. It's a good idea to run it as a scheduled task as it will log all the actions and you can easily review them. It's very advanced with many options and you can easily customize it to your needs.
Thi example shows how to use AzureAD, Intune and Jamf to clean up computers in Active Directory where computer also needs needs to be non-existant in AzureAD, Intune and Jamf or have last seen date matches in AzureAD, Intune and Jamf.
This example also moves computers to different OU's as part of the disable process.
When RemoveProtectedFromAccidentalDeletionFlag is enabled, the flag is only cleared for the move/delete steps that require it.
For long computer names, cloud matching now falls back through DNSHostName and related aliases so truncated AD names can still match Azure AD and Intune records.
# connect to graph for Azure AD, Intune (requires GraphEssentials module)
Connect-MgGraph -Scopes Device.Read.All, DeviceManagementManagedDevices.Read.All, Directory.ReadWrite.All, DeviceManagementConfiguration.Read.All
# connect to jamf (requires PowerJamf module)
Connect-Jamf -Organization 'aaa' -UserName 'aaa' -Suppress -Force -PasswordEncrypted 'aaaaa'
$invokeADComputersCleanupSplat = @{
# safety limits (minimum amount of computers that has to be returned from each source)
SafetyADLimit = 30
SafetyAzureADLimit = 5
SafetyIntuneLimit = 3
SafetyJamfLimit = 50
# disable settings
Disable = $true
DisableLimit = 3
DisableLastLogonDateMoreThan = 90
DisablePasswordLastSetMoreThan = 90
DisableLastSeenAzureMoreThan = 90
DisableLastSyncAzureMoreThan = 90
DisableLastContactJamfMoreThan = 90
DisableLastSeenIntuneMoreThan = 90
DisableAndMove = $true
DisableMoveTargetOrganizationalUnit = @{
'ad.evotec.xyz' = 'OU=Disabled,OU=Computers,OU=Devices,OU=Production,DC=ad,DC=evotec,DC=xyz'
'ad.evotec.pl' = 'OU=Disabled,OU=Computers,OU=Devices,OU=Production,DC=ad,DC=evotec,DC=pl'
}
# delete settings
Delete = $true
DeleteLimit = 3
DeleteLastLogonDateMoreThan = 180
DeletePasswordLastSetMoreThan = 180
DeleteLastSeenAzureMoreThan = 180
DeleteLastSyncAzureMoreThan = 180
DeleteLastContactJamfMoreThan = 180
DeleteLastSeenIntuneMoreThan = 180
DeleteListProcessedMoreThan = 90 # disabled computer has to spend 90 days in list before it can be deleted
DeleteIsEnabled = $false # Computer has to be disabled to be deleted
# global exclusions
Exclusions = @(
'*OU=Domain Controllers*' # exclude Domain Controllers
)
# filter for AD search
Filter = '*'
# logs, reports and datastores
LogPath = "$PSScriptRoot\Logs\CleanupComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).log"
DataStorePath = "$PSScriptRoot\CleanupComputers_ListProcessed.xml"
ReportPath = "$PSScriptRoot\Reports\CleanupComputers_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html"
# WhatIf settings
#ReportOnly = $true
WhatIfDisable = $true
WhatIfDelete = $true
ShowHTML = $true
RemoveProtectedFromAccidentalDeletionFlag = $true
}
$Output = Invoke-ADComputersCleanup @invokeADComputersCleanupSplat
$OutputService-account cleanup supports report-only reviews, disable/delete staging, and explicit safety guardrails.
If an account matches both sets of criteria in the same run, it stays in the disable stage and is skipped from delete.
By default DisableLimit and DeleteLimit are both 1.
$Output = Invoke-ADServiceAccountsCleanup -Disable -Delete -DisableLastLogonDateMoreThan 90 -DeleteLastLogonDateMoreThan 180 -ReportOnly
$Output.CurrentRun$invokeADServiceAccountsCleanupSplat = @{
Disable = $true
DisableLastLogonDateMoreThan = 90
DisablePasswordLastSetMoreThan = 90
DisableLimit = 2
Delete = $true
DeleteLastLogonDateMoreThan = 180
DeletePasswordLastSetMoreThan = 180
DeleteLimit = 1
SafetyADLimit = 10
IncludeAccounts = @('gmsa-*', 'msa-*')
ExcludeAccounts = @('gmsa-keep-*')
ReportPath = "$PSScriptRoot\Reports\ServiceAccounts_$((Get-Date).ToString('yyyy-MM-dd_HH_mm_ss')).html"
WhatIfDisable = $true
WhatIfDelete = $true
}
$Output = Invoke-ADServiceAccountsCleanup @invokeADServiceAccountsCleanupSplat
$Output$Output = Invoke-ADServiceAccountsCleanup `
-Disable `
-Delete `
-DisableLastLogonDateMoreThan 90 `
-DeleteLastLogonDateMoreThan 180 `
-WhatIfDisable `
-WhatIfDelete `
-ReportPath "$PSScriptRoot\Reports\ServiceAccounts.html"
$OutputThis path is focused on reviewable, targeted SID history removal with strong filtering and very small starter limits.
The first thing you should do is to run the module in a report only mode. It will show you how many SID History entries are there to remove.
$Output = Invoke-ADSIDHistoryCleanup -WhatIf -ReportOnlyCleanup of specific SID History entries along with report output and email-friendly reporting.
$invokeADSIDHistoryCleanupSplat = @{
Verbose = $true
WhatIf = $true
IncludeSIDHistoryDomain = @(
'S-1-5-21-3661168273-3802070955-2987026695'
'S-1-5-21-853615985-2870445339-3163598659'
)
# Process only specific object classes
IncludeObjectType = @('Computer', 'Group')
#ExcludeObjectType = 'User'
#IncludeType = 'External'
RemoveLimitSID = 2
RemoveLimitObject = 2
SafetyADLimit = 1
ShowHTML = $true
Online = $true
DisabledOnly = $false
LogPath = "$PSScriptROot\ProcessedSIDHistory.log"
ReportPath = "$PSScriptRoot\ProcessedSIDHistory.html"
DataStorePath = "$PSScriptRoot\ProcessedSIDHistory.xml"
}
# Run the script
$Output = Invoke-ADSIDHistoryCleanup @invokeADSIDHistoryCleanupSplat
$Output | Format-Table -AutoSize
# Lets send an email
$EmailBody = $Output.EmailBody
# Send an email with the report
Connect-MgGraph -Scopes 'Mail.Send' -NoWelcome
Send-EmailMessage -To '[email protected]' -From '[email protected]' -MgGraphRequest -Subject "Automated SID Cleanup Report" -Body $EmailBody -Priority Low -Verbose



