Skip to content
Open
Show file tree
Hide file tree
Changes from all 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 CleanupMonster.psd1
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
CompatiblePSEditions = @('Desktop', 'Core')
Copyright = '(c) 2011 - 2025 Przemyslaw Klys @ Evotec. All rights reserved.'
Description = 'This module provides an easy way to cleanup Active Directory from dead/old objects based on various criteria. It can also disable, move or delete objects. It can utilize Azure AD, Intune and Jamf to get additional information about objects before deleting them.'
FunctionsToExport = @('Invoke-ADComputersCleanup', 'Invoke-ADSIDHistoryCleanup')
FunctionsToExport = @('Invoke-ADComputersCleanup', 'Invoke-ADSIDHistoryCleanup', 'Invoke-ADServiceAccountsCleanup')
GUID = 'cd1f9987-6242-452c-a7db-6337d4a6b639'
ModuleVersion = '3.1.6'
PowerShellVersion = '5.1'
Expand Down
53 changes: 53 additions & 0 deletions Private/Get-ADServiceAccountsToProcess.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
function Get-ADServiceAccountsToProcess {
[CmdletBinding()]
param(
[parameter(Mandatory)][ValidateSet('Disable','Delete')][string] $Type,
[Array] $Accounts,
[alias('DeleteOnlyIf','DisableOnlyIf')][System.Collections.IDictionary] $ActionIf,
[Array] $Exclusions
)
Write-Color -Text "[i] ", "Applying following rules to $Type action: " -Color Yellow, Cyan, Green
foreach ($Key in $ActionIf.Keys) {
if ($null -eq $ActionIf[$Key]) {
Write-Color -Text " [>] ", $Key, " is ", 'Not Set' -Color Yellow, Cyan, Yellow
} else {
Write-Color -Text " [>] ", $Key, " is ", $ActionIf[$Key] -Color Yellow, Cyan, Green
}
}
Write-Color -Text "[i] ", "Looking for service accounts to $Type" -Color Yellow, Cyan, Green
$Today = Get-Date
[Array] $Output = foreach ($Account in $Accounts) {
foreach ($Exclude in $Exclusions) {
if ($Account.DistinguishedName -like $Exclude -or $Account.Name -like $Exclude) {
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The exclusion logic checks if the account's DistinguishedName or Name matches the exclusion pattern, but the Name property may not exist on the account objects. Based on the account retrieval in Get-InitialADServiceAccounts.ps1, the objects have SamAccountName property, not Name. This should likely be $Account.SamAccountName instead of $Account.Name for consistency with the filtering logic elsewhere.

Suggested change
if ($Account.DistinguishedName -like $Exclude -or $Account.Name -like $Exclude) {
if ($Account.DistinguishedName -like $Exclude -or $Account.SamAccountName -like $Exclude) {

Copilot uses AI. Check for mistakes.
continue 2
}
}
$Include = $true
if ($ActionIf.LastLogonDateMoreThan) {
if ($Account.LastLogonDate) {
$LastLogonDays = (New-TimeSpan -Start $Account.LastLogonDate -End $Today).Days
$Account | Add-Member -NotePropertyName LastLogonDays -NotePropertyValue $LastLogonDays -Force
if ($LastLogonDays -le $ActionIf.LastLogonDateMoreThan) { $Include = $false }
}
Comment on lines +26 to +31
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The filtering logic has a potential issue: when a condition is set (e.g., LastLogonDateMoreThan) but the corresponding account property is null (e.g., $Account.LastLogonDate is null), the account will still pass the filter. This means accounts that have never logged on would be included even when specifying LastLogonDateMoreThan. Consider whether accounts with null values should be explicitly handled - either included (as currently) or excluded, and document this behavior clearly.

Copilot uses AI. Check for mistakes.
}
if ($ActionIf.PasswordLastSetMoreThan) {
if ($Account.PasswordLastSet) {
$PasswordDays = (New-TimeSpan -Start $Account.PasswordLastSet -End $Today).Days
$Account | Add-Member -NotePropertyName PasswordLastChangedDays -NotePropertyValue $PasswordDays -Force
if ($PasswordDays -le $ActionIf.PasswordLastSetMoreThan) { $Include = $false }
}
}
if ($ActionIf.WhenCreatedMoreThan) {
if ($Account.WhenCreated) {
$CreatedDays = (New-TimeSpan -Start $Account.WhenCreated -End $Today).Days
$Account | Add-Member -NotePropertyName WhenCreatedDays -NotePropertyValue $CreatedDays -Force
if ($CreatedDays -le $ActionIf.WhenCreatedMoreThan) { $Include = $false }
}
}
if ($Include) {
$Account | Add-Member -NotePropertyName Action -NotePropertyValue $Type -Force
$Account
}
}
$Output
}
25 changes: 25 additions & 0 deletions Private/Get-InitialADServiceAccounts.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
function Get-InitialADServiceAccounts {
[CmdletBinding()]
param(
[System.Collections.IDictionary] $ForestInformation,
[string[]] $IncludeAccounts,
[string[]] $ExcludeAccounts
)
$Report = [ordered]@{}
foreach ($Domain in $ForestInformation.Domains) {
$Server = $ForestInformation['QueryServers'][$Domain].HostName[0]
Write-Color -Text "[i] ", "Getting service accounts for domain ", $Domain, " from ", $Server -Color Yellow, Magenta, Yellow, Magenta
$Accounts = Get-ADServiceAccount -Filter * -Server $Server -Properties SamAccountName,Enabled,LastLogonDate,PasswordLastSet,WhenCreated,DistinguishedName,ObjectClass |
Select-Object *, @{Name='DomainName';Expression={$Domain}}, @{Name='Server';Expression={$Server}}
if ($IncludeAccounts) {
$IncludePattern = [string]::Join('|', ($IncludeAccounts | ForEach-Object { [regex]::Escape($_).Replace('\*','.*') }))
$Accounts = $Accounts | Where-Object { $_.SamAccountName -match "^($IncludePattern)$" }
}
if ($ExcludeAccounts) {
$ExcludePattern = [string]::Join('|', ($ExcludeAccounts | ForEach-Object { [regex]::Escape($_).Replace('\*','.*') }))
$Accounts = $Accounts | Where-Object { $_.SamAccountName -notmatch "^($ExcludePattern)$" }
}
Comment on lines +18 to +21
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] Exclusion filtering is applied twice: first in Get-InitialADServiceAccounts (lines 18-21) which filters by account name patterns, and again in Get-ADServiceAccountsToProcess (lines 20-24) which filters by DistinguishedName or Name patterns. This redundant filtering could lead to confusion. Consider removing the exclusion logic from one of these functions, or clearly document why exclusions need to be applied at both stages.

Suggested change
if ($ExcludeAccounts) {
$ExcludePattern = [string]::Join('|', ($ExcludeAccounts | ForEach-Object { [regex]::Escape($_).Replace('\*','.*') }))
$Accounts = $Accounts | Where-Object { $_.SamAccountName -notmatch "^($ExcludePattern)$" }
}

Copilot uses AI. Check for mistakes.
$Report[$Domain] = [ordered]@{ Server = $Server; Accounts = @($Accounts) }
}
$Report
}
153 changes: 153 additions & 0 deletions Private/New-HTMLProcessedServiceAccounts.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
function New-HTMLProcessedServiceAccounts {
[CmdletBinding()]
param(
[System.Collections.IDictionary] $Export,
[System.Collections.IDictionary] $DisableOnlyIf,
[System.Collections.IDictionary] $DeleteOnlyIf,
[Array] $AccountsToProcess,
[string] $FilePath,
[switch] $Online,
[switch] $ShowHTML,
[string] $LogFile,
[switch] $Disable,
[switch] $Delete,
[switch] $ReportOnly
)

New-HTML {
New-HTMLTabStyle -BorderRadius 0px -TextTransform capitalize -BackgroundColorActive SlateGrey -BackgroundColor BlizzardBlue
New-HTMLSectionStyle -BorderRadius 0px -HeaderBackGroundColor Grey -RemoveShadow
New-HTMLPanelStyle -BorderRadius 0px
New-HTMLTableOption -DataStore JavaScript -BoolAsString -ArrayJoinString ', ' -ArrayJoin

New-HTMLHeader {
New-HTMLSection -Invisible {
New-HTMLSection {
New-HTMLText -Text "Report generated on $(Get-Date)" -Color Blue
} -JustifyContent flex-start -Invisible
New-HTMLSection {
New-HTMLText -Text "Cleanup Monster - $($Export['Version'])" -Color Blue
} -JustifyContent flex-end -Invisible
}
}

if (-not $ReportOnly) {
New-HTMLTab -Name 'Service Accounts Current Run' {
New-HTMLSection {
[Array] $ListAll = $Export.CurrentRun
New-HTMLPanel {
New-HTMLToast -TextHeader 'Total in this run' -Text "Actions (disable & delete): $($ListAll.Count)" -BarColorLeft MintGreen -IconSolid info-circle -IconColor MintGreen
} -Invisible

[Array] $ListDisabled = $Export.CurrentRun | Where-Object { $_.Action -eq 'Disable' }
New-HTMLPanel {
New-HTMLToast -TextHeader 'Disable' -Text "Accounts disabled: $($ListDisabled.Count)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel
} -Invisible

[Array] $ListDeleted = $Export.CurrentRun | Where-Object { $_.Action -eq 'Delete' }
New-HTMLPanel {
New-HTMLToast -TextHeader 'Delete' -Text "Accounts deleted: $($ListDeleted.Count)" -BarColorLeft OrangeRed -IconSolid info-circle -IconColor OrangeRed
} -Invisible
} -Invisible

New-HTMLTable -DataTable $Export.CurrentRun -Filtering -ScrollX {
New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace
New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor EnergyYellow
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'True' -BackgroundColor LightGreen
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'False' -BackgroundColor Salmon
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'WhatIf' -BackgroundColor LightBlue
} -WarningAction SilentlyContinue
}

New-HTMLTab -Name 'Service Accounts History' {
New-HTMLSection {
[Array] $ListAll = $Export.History
New-HTMLPanel {
New-HTMLToast -TextHeader 'Total History' -Text "Actions (disable & delete): $($ListAll.Count)" -BarColorLeft MintGreen -IconSolid info-circle -IconColor MintGreen
} -Invisible

[Array] $ListDisabled = $Export.History | Where-Object { $_.Action -eq 'Disable' }
New-HTMLPanel {
New-HTMLToast -TextHeader 'Disabled History' -Text "Accounts disabled so far: $($ListDisabled.Count)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel
} -Invisible

[Array] $ListDeleted = $Export.History | Where-Object { $_.Action -eq 'Delete' }
New-HTMLPanel {
New-HTMLToast -TextHeader 'Deleted History' -Text "Accounts deleted so far: $($ListDeleted.Count)" -BarColorLeft OrangeRed -IconSolid info-circle -IconColor OrangeRed
} -Invisible
} -Invisible
New-HTMLTable -DataTable $Export.History -Filtering -ScrollX {
New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace
New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor EnergyYellow
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'True' -BackgroundColor LightGreen
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'False' -BackgroundColor Salmon
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'WhatIf' -BackgroundColor LightBlue
} -WarningAction SilentlyContinue -AllProperties
}
}

New-HTMLPanel {
if ($Disable) {
New-HTMLText -Text "Service accounts will be disabled only if: " -FontWeight bold
New-HTMLList {
foreach ($Key in $DisableOnlyIf.Keys) {
New-HTMLListItem -Text @(
if ($null -eq $DisableOnlyIf[$Key]) {
$($Key), " is ", 'Not Set'
$ColorInUse = 'Cinnabar'
} else {
$($Key), " is ", $($DisableOnlyIf[$Key])
$ColorInUse = 'Apple'
}
) -FontWeight bold, normal, bold -Color $ColorInUse, None, CornflowerBlue
}
}
} else {
New-HTMLText -Text "Service accounts will not be disabled, as the disable functionality was not enabled." -FontWeight bold
}
}

New-HTMLPanel {
if ($Delete) {
New-HTMLText -Text "Service accounts will be deleted only if: " -FontWeight bold
New-HTMLList {
foreach ($Key in $DeleteOnlyIf.Keys) {
New-HTMLListItem -Text @(
if ($null -eq $DeleteOnlyIf[$Key]) {
$($Key), " is ", 'Not Set'
$ColorInUse = 'Cinnabar'
} else {
$($Key), " is ", $($DeleteOnlyIf[$Key])
$ColorInUse = 'Apple'
}
) -FontWeight bold, normal, bold -Color $ColorInUse, None, CornflowerBlue
}
}
Comment on lines +90 to +125
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function accesses $DisableOnlyIf.Keys and $DeleteOnlyIf.Keys (lines 93, 114) but doesn't validate that these parameters are not null. If these hashtables are not provided (which can happen based on the bug identified in the main function), this will cause a null reference error. Add null checks before iterating over the Keys.

Copilot uses AI. Check for mistakes.
} else {
New-HTMLText -Text "Service accounts will not be deleted, as the delete functionality was not enabled." -FontWeight bold
}
}

if ($AccountsToProcess) {
New-HTMLTable -DataTable $AccountsToProcess -Filtering -ScrollX {
New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Delete' -BackgroundColor PinkLace
New-HTMLTableCondition -Name 'Action' -ComparisonType string -Value 'Disable' -BackgroundColor EnergyYellow
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'True' -BackgroundColor LightGreen
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'False' -BackgroundColor Salmon
New-HTMLTableCondition -Name 'ActionStatus' -ComparisonType string -Value 'WhatIf' -BackgroundColor LightBlue
} -WarningAction SilentlyContinue -ExcludeProperty 'TimeOnPendingList', 'TimeToLeavePendingList'
}

try {
if ($LogFile -and (Test-Path -LiteralPath $LogFile -ErrorAction Stop)) {
$LogContent = Get-Content -Raw -LiteralPath $LogFile -ErrorAction Stop
New-HTMLTab -Name 'Log' {
New-HTMLCodeBlock -Code $LogContent -Style generic
}
}
} catch {
Write-Color -Text "[e] ", "Couldn't read the log file. Skipping adding log to HTML. Error: $($_.Exception.Message)" -Color Yellow, Red
}
} -FilePath $FilePath -Online:$Online.IsPresent -ShowHTML:$ShowHTML.IsPresent
}

36 changes: 36 additions & 0 deletions Private/Request-ADServiceAccountsDelete.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
function Request-ADServiceAccountsDelete {
[CmdletBinding(SupportsShouldProcess)]
param(
[Array] $Accounts,
[switch] $ReportOnly,
[switch] $WhatIfDelete,
[DateTime] $Today,
[switch] $DontWriteToEventLog
)
foreach ($Account in $Accounts) {
if ($Account.Action -ne 'Delete') { continue }
if ($ReportOnly) {
$Account
continue
}
$Server = $Account.Server
$Success = $false
if ($PSCmdlet.ShouldProcess($Account.DistinguishedName, 'Delete service account')) {
try {
Remove-ADObject -Identity $Account.DistinguishedName -Server $Server -Confirm:$false -WhatIf:$WhatIfDelete -ErrorAction Stop
$Success = $true
Write-Color -Text "[+] ", "Deleting service account ", $Account.SamAccountName, " (WhatIf: $($WhatIfDelete.IsPresent)) successful." -Color Yellow, Green, Yellow
} catch {
Write-Color -Text "[-] ", "Deleting service account ", $Account.SamAccountName, " failed: ", $_.Exception.Message -Color Yellow, Red, Yellow, Red
$Account.ActionComment = $_.Exception.Message
}
}
$Account.ActionDate = $Today
if ($WhatIfDelete.IsPresent) {
$Account.ActionStatus = 'WhatIf'
} else {
$Account.ActionStatus = $Success
}
$Account
Comment on lines +27 to +34
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] When an account is skipped due to not matching the Action type (line 11), the function continues without outputting the account. However, if ReportOnly is false and ShouldProcess returns false (user responds "No" to the confirmation prompt), the account is not output at all. This means there's no record of accounts that were candidates but weren't processed due to user declining the action. Consider outputting the account with an appropriate ActionStatus in this case.

Suggested change
}
$Account.ActionDate = $Today
if ($WhatIfDelete.IsPresent) {
$Account.ActionStatus = 'WhatIf'
} else {
$Account.ActionStatus = $Success
}
$Account
$Account.ActionDate = $Today
if ($WhatIfDelete.IsPresent) {
$Account.ActionStatus = 'WhatIf'
} else {
$Account.ActionStatus = $Success
}
$Account
} else {
$Account.ActionDate = $Today
$Account.ActionStatus = 'Declined'
$Account
}

Copilot uses AI. Check for mistakes.
}
}
36 changes: 36 additions & 0 deletions Private/Request-ADServiceAccountsDisable.ps1
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
function Request-ADServiceAccountsDisable {
[CmdletBinding(SupportsShouldProcess)]
param(
[Array] $Accounts,
[switch] $ReportOnly,
[switch] $WhatIfDisable,
[DateTime] $Today,
[switch] $DontWriteToEventLog
)
foreach ($Account in $Accounts) {
if ($Account.Action -ne 'Disable') { continue }
if ($ReportOnly) {
$Account
continue
}
$Server = $Account.Server
$Success = $false
if ($PSCmdlet.ShouldProcess($Account.DistinguishedName, 'Disable service account')) {
try {
Disable-ADAccount -Identity $Account.DistinguishedName -Server $Server -WhatIf:$WhatIfDisable -ErrorAction Stop
$Success = $true
Write-Color -Text "[+] ", "Disabling service account ", $Account.SamAccountName, " (WhatIf: $($WhatIfDisable.IsPresent)) successful." -Color Yellow, Green, Yellow
} catch {
Write-Color -Text "[-] ", "Disabling service account ", $Account.SamAccountName, " failed: ", $_.Exception.Message -Color Yellow, Red, Yellow, Red
$Account.ActionComment = $_.Exception.Message
}
}
$Account.ActionDate = $Today
if ($WhatIfDisable.IsPresent) {
$Account.ActionStatus = 'WhatIf'
} else {
$Account.ActionStatus = $Success
}
$Account
Comment on lines +27 to +34
Copy link

Copilot AI Nov 23, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[nitpick] When an account is skipped due to not matching the Action type (line 11), the function continues without outputting the account. However, if ReportOnly is false and ShouldProcess returns false (user responds "No" to the confirmation prompt), the account is not output at all. This means there's no record of accounts that were candidates but weren't processed due to user declining the action. Consider outputting the account with an appropriate ActionStatus in this case.

Suggested change
}
$Account.ActionDate = $Today
if ($WhatIfDisable.IsPresent) {
$Account.ActionStatus = 'WhatIf'
} else {
$Account.ActionStatus = $Success
}
$Account
$Account.ActionDate = $Today
if ($WhatIfDisable.IsPresent) {
$Account.ActionStatus = 'WhatIf'
} else {
$Account.ActionStatus = $Success
}
$Account
} else {
$Account.ActionDate = $Today
$Account.ActionStatus = 'SkippedByUser'
$Account
}

Copilot uses AI. Check for mistakes.
}
}
Loading