-
-
Notifications
You must be signed in to change notification settings - Fork 18
Add Active Directory service account cleanup capabilities #32
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Conversation
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Pull request overview
This PR adds Active Directory service account cleanup capabilities to the CleanupMonster module, enabling administrators to identify, disable, and delete stale or inactive managed service accounts based on various criteria like last logon date, password age, and account creation date.
Key changes:
- Introduces
Invoke-ADServiceAccountsCleanupas the main entry point for service account cleanup operations - Implements filtering and action logic through helper functions for retrieving, processing, disabling, and deleting service accounts
- Adds comprehensive HTML reporting capabilities with detailed account status tracking
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
Public/Invoke-ADServiceAccountsCleanup.ps1 |
Main entry point function with parameters for account filtering, disable/delete actions, and reporting options |
Private/Get-InitialADServiceAccounts.ps1 |
Retrieves service accounts from AD domains and applies initial include/exclude filtering |
Private/Get-ADServiceAccountsToProcess.ps1 |
Filters accounts based on age/inactivity criteria to determine which should be disabled or deleted |
Private/Request-ADServiceAccountsDisable.ps1 |
Executes disable operations on service accounts with WhatIf support |
Private/Request-ADServiceAccountsDelete.ps1 |
Executes delete operations on service accounts with WhatIf support |
Private/New-HTMLProcessedServiceAccounts.ps1 |
Generates HTML reports showing processed accounts with statistics and filtering conditions |
Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 |
Pester tests covering function export, filtering logic, WhatIf support, and HTML report generation |
CleanupMonster.psd1 |
Adds new function to module's exported functions list |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| } | ||
| $Account.ActionDate = $Today | ||
| if ($WhatIfDisable.IsPresent) { | ||
| $Account.ActionStatus = 'WhatIf' | ||
| } else { | ||
| $Account.ActionStatus = $Success | ||
| } | ||
| $Account |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
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.
| } | |
| $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 | |
| } |
| } | ||
| $Account.ActionDate = $Today | ||
| if ($WhatIfDelete.IsPresent) { | ||
| $Account.ActionStatus = 'WhatIf' | ||
| } else { | ||
| $Account.ActionStatus = $Success | ||
| } | ||
| $Account |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
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.
| } | |
| $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 | |
| } |
| $Today = Get-Date | ||
| [Array] $Output = foreach ($Account in $Accounts) { | ||
| foreach ($Exclude in $Exclusions) { | ||
| if ($Account.DistinguishedName -like $Exclude -or $Account.Name -like $Exclude) { |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
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.
| if ($Account.DistinguishedName -like $Exclude -or $Account.Name -like $Exclude) { | |
| if ($Account.DistinguishedName -like $Exclude -or $Account.SamAccountName -like $Exclude) { |
| 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 | ||
| } | ||
| } |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
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.
| if ($ExcludeAccounts) { | ||
| $ExcludePattern = [string]::Join('|', ($ExcludeAccounts | ForEach-Object { [regex]::Escape($_).Replace('\*','.*') })) | ||
| $Accounts = $Accounts | Where-Object { $_.SamAccountName -notmatch "^($ExcludePattern)$" } | ||
| } |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
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.
| if ($ExcludeAccounts) { | |
| $ExcludePattern = [string]::Join('|', ($ExcludeAccounts | ForEach-Object { [regex]::Escape($_).Replace('\*','.*') })) | |
| $Accounts = $Accounts | Where-Object { $_.SamAccountName -notmatch "^($ExcludePattern)$" } | |
| } |
| Write-Color -Text "[i] ", "Executed by: ", $Env:USERNAME, ' from domain ', $Env:USERDNSDOMAIN -Color Yellow, White, Green, White | ||
|
|
||
| try { | ||
| $ForestInformation = Get-WinADForestDetails -PreferWritable -Forest $Forest -IncludeDomains $IncludeDomains -ExcludeDomains $ExcludeDomains |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The -PreferWritable parameter is passed to Get-WinADForestDetails but is not defined in the function's parameter block. This will cause an error when the function is executed. Either add -PreferWritable as a parameter to this function, or remove it from this call if it's not needed.
| $ForestInformation = Get-WinADForestDetails -PreferWritable -Forest $Forest -IncludeDomains $IncludeDomains -ExcludeDomains $ExcludeDomains | |
| $ForestInformation = Get-WinADForestDetails -Forest $Forest -IncludeDomains $IncludeDomains -ExcludeDomains $ExcludeDomains |
| DisableOnlyIf = $DisableOnlyIf | ||
| DeleteOnlyIf = $DeleteOnlyIf |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variables $DisableOnlyIf and $DeleteOnlyIf are only defined within the conditional blocks (lines 133 and 139) but are referenced outside their scope on lines 167-168 when generating the HTML report. If neither -Disable nor -Delete is specified, or if only one is specified, the undefined variable will be passed to the report generation function, potentially causing errors. Consider defining these variables before the foreach loop with default empty hashtables.
| 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 } | ||
| } |
Copilot
AI
Nov 23, 2025
There was a problem hiding this comment.
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.
Summary
Testing
pwsh -NoLogo -NoProfile -Command "Invoke-Pester -Path Tests"pwsh -NoLogo -NoProfile -Command "Invoke-ModuleBuild"(fails: The term 'Invoke-ModuleBuild' is not recognized)https://chatgpt.com/codex/tasks/task_e_689512658dfc832ea137c46cb44c1562