From 0f242e51e70b2d05f625bac6957be21f8803ef89 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Thu, 7 Aug 2025 23:24:22 +0200 Subject: [PATCH 1/3] Add service account cleanup support --- CleanupMonster.psd1 | 2 +- Private/Get-ADServiceAccountsToProcess.ps1 | 53 ++++++ Private/Request-ADServiceAccountsDelete.ps1 | 36 ++++ Private/Request-ADServiceAccountsDisable.ps1 | 36 ++++ Public/Invoke-ADServiceAccountsCleanup.ps1 | 179 ++++++++++++++++++ .../Invoke-ADServiceAccountsCleanup.Tests.ps1 | 28 +++ 6 files changed, 333 insertions(+), 1 deletion(-) create mode 100644 Private/Get-ADServiceAccountsToProcess.ps1 create mode 100644 Private/Request-ADServiceAccountsDelete.ps1 create mode 100644 Private/Request-ADServiceAccountsDisable.ps1 create mode 100644 Public/Invoke-ADServiceAccountsCleanup.ps1 create mode 100644 Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 diff --git a/CleanupMonster.psd1 b/CleanupMonster.psd1 index 11f446c..8721135 100644 --- a/CleanupMonster.psd1 +++ b/CleanupMonster.psd1 @@ -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.5' PowerShellVersion = '5.1' diff --git a/Private/Get-ADServiceAccountsToProcess.ps1 b/Private/Get-ADServiceAccountsToProcess.ps1 new file mode 100644 index 0000000..ba8914b --- /dev/null +++ b/Private/Get-ADServiceAccountsToProcess.ps1 @@ -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) { + 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 } + } + } + 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 +} diff --git a/Private/Request-ADServiceAccountsDelete.ps1 b/Private/Request-ADServiceAccountsDelete.ps1 new file mode 100644 index 0000000..e664ab2 --- /dev/null +++ b/Private/Request-ADServiceAccountsDelete.ps1 @@ -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 + } +} diff --git a/Private/Request-ADServiceAccountsDisable.ps1 b/Private/Request-ADServiceAccountsDisable.ps1 new file mode 100644 index 0000000..3d1d4a6 --- /dev/null +++ b/Private/Request-ADServiceAccountsDisable.ps1 @@ -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 + } +} diff --git a/Public/Invoke-ADServiceAccountsCleanup.ps1 b/Public/Invoke-ADServiceAccountsCleanup.ps1 new file mode 100644 index 0000000..78fe4e1 --- /dev/null +++ b/Public/Invoke-ADServiceAccountsCleanup.ps1 @@ -0,0 +1,179 @@ +function Invoke-ADServiceAccountsCleanup { + <# + .SYNOPSIS + Cleans up stale Active Directory service accounts. + + .DESCRIPTION + Enumerates managed service accounts in Active Directory and disables or deletes + accounts based on inactivity or age criteria. + + .PARAMETER Forest + Forest to use when connecting to Active Directory. + + .PARAMETER IncludeDomains + List of domains to include in the process. + + .PARAMETER ExcludeDomains + List of domains to exclude from the process. + + .PARAMETER IncludeAccounts + Include only service accounts that match these names (supports wildcards). + + .PARAMETER ExcludeAccounts + Exclude service accounts that match these names (supports wildcards). + + .PARAMETER Disable + Enable disabling of matching service accounts. + + .PARAMETER Delete + Enable deletion of matching service accounts. + + .PARAMETER DisableLastLogonDateMoreThan + Disable accounts that have not logged on for the specified number of days. + + .PARAMETER DisablePasswordLastSetMoreThan + Disable accounts where password has not been changed for the specified number of days. + + .PARAMETER DisableWhenCreatedMoreThan + Disable accounts created more than the specified number of days ago. + + .PARAMETER DeleteLastLogonDateMoreThan + Delete accounts that have not logged on for the specified number of days. + + .PARAMETER DeletePasswordLastSetMoreThan + Delete accounts where password has not been changed for the specified number of days. + + .PARAMETER DeleteWhenCreatedMoreThan + Delete accounts created more than the specified number of days ago. + + .PARAMETER ReportOnly + Only report accounts that would be processed. + + .PARAMETER ReportPath + Path to save optional HTML report. + + .PARAMETER ShowHTML + Show HTML report in a browser. + + .PARAMETER Online + Use online resources (CDN) for HTML report. + + .PARAMETER WhatIfDisable + Shows what would happen if accounts were disabled. + + .PARAMETER WhatIfDelete + Shows what would happen if accounts were deleted. + + .EXAMPLE + Invoke-ADServiceAccountsCleanup -Disable -DisableLastLogonDateMoreThan 90 -ReportOnly + + .EXAMPLE + Invoke-ADServiceAccountsCleanup -Delete -DeleteLastLogonDateMoreThan 180 -WhatIfDelete + #> + [CmdletBinding(SupportsShouldProcess)] + param( + [string] $Forest, + [alias('Domain')][string[]] $IncludeDomains, + [string[]] $ExcludeDomains, + [string[]] $IncludeAccounts, + [string[]] $ExcludeAccounts, + [switch] $Disable, + [switch] $Delete, + [int] $DisableLastLogonDateMoreThan, + [int] $DisablePasswordLastSetMoreThan, + [int] $DisableWhenCreatedMoreThan, + [int] $DeleteLastLogonDateMoreThan, + [int] $DeletePasswordLastSetMoreThan, + [int] $DeleteWhenCreatedMoreThan, + [switch] $ReportOnly, + [string] $ReportPath, + [switch] $ShowHTML, + [switch] $Online, + [switch] $WhatIfDisable, + [switch] $WhatIfDelete, + [switch] $DontWriteToEventLog, + [switch] $Suppress, + [string] $LogPath, + [int] $LogMaximum = 5, + [switch] $LogShowTime, + [string] $LogTimeFormat + ) + + Set-LoggingCapabilities -LogPath $LogPath -LogMaximum $LogMaximum -ShowTime:$LogShowTime -TimeFormat $LogTimeFormat -ScriptPath $MyInvocation.ScriptName + + $Export = [ordered]@{ + Date = Get-Date + Version = Get-GitHubVersion -Cmdlet 'Invoke-ADServiceAccountsCleanup' -RepositoryOwner 'evotecit' -RepositoryName 'CleanupMonster' + CurrentRun = @() + History = @() + } + + Write-Color -Text "[i] ", "Started process of cleaning up service accounts" -Color Yellow, White + 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 + } catch { + Write-Color -Text "[i] ", "Couldn't get forest. Terminating. Error: $($_.Exception.Message)." -Color Yellow, Red + return + } + + if (-not $Disable -and -not $Delete) { + Write-Color -Text "[i] ", "No action can be taken. Enable Disable and/or Delete." -Color Yellow, Red + return + } + + $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 + [Array]$Accounts = Get-ADServiceAccount -Filter * -Server $Server -Properties SamAccountName,Enabled,LastLogonDate,PasswordLastSet,WhenCreated,DistinguishedName,ObjectClass | Where-Object { $_.ObjectClass -in 'msDS-ManagedServiceAccount','msDS-GroupManagedServiceAccount' } + foreach ($Account in $Accounts) { + $Account | Add-Member -NotePropertyName DomainName -NotePropertyValue $Domain + $Account | Add-Member -NotePropertyName Server -NotePropertyValue $Server + } + if ($IncludeAccounts) { + $Accounts = foreach ($A in $Accounts) { + foreach ($Inc in $IncludeAccounts) { if ($A.SamAccountName -like $Inc) { $A; break } } + } + } + if ($ExcludeAccounts) { + $Accounts = foreach ($A in $Accounts) { + $Skip = $false + foreach ($Exc in $ExcludeAccounts) { if ($A.SamAccountName -like $Exc) { $Skip = $true; break } } + if (-not $Skip) { $A } + } + } + $Report[$Domain] = [ordered]@{ Server = $Server; Accounts = $Accounts } + } + + $Today = Get-Date + [Array]$Processed = @() + foreach ($Domain in $Report.Keys) { + $Accounts = $Report[$Domain]['Accounts'] + if ($Disable) { + $DisableOnlyIf = @{ LastLogonDateMoreThan = $DisableLastLogonDateMoreThan; PasswordLastSetMoreThan = $DisablePasswordLastSetMoreThan; WhenCreatedMoreThan = $DisableWhenCreatedMoreThan } + $ToDisable = Get-ADServiceAccountsToProcess -Type 'Disable' -Accounts $Accounts -ActionIf $DisableOnlyIf -Exclusions $ExcludeAccounts + $Report[$Domain]['AccountsToBeDisabled'] = $ToDisable.Count + $Processed += Request-ADServiceAccountsDisable -Accounts $ToDisable -ReportOnly:$ReportOnly -WhatIfDisable:$WhatIfDisable -Today $Today -DontWriteToEventLog:$DontWriteToEventLog + } + if ($Delete) { + $DeleteOnlyIf = @{ LastLogonDateMoreThan = $DeleteLastLogonDateMoreThan; PasswordLastSetMoreThan = $DeletePasswordLastSetMoreThan; WhenCreatedMoreThan = $DeleteWhenCreatedMoreThan } + $ToDelete = Get-ADServiceAccountsToProcess -Type 'Delete' -Accounts $Accounts -ActionIf $DeleteOnlyIf -Exclusions $ExcludeAccounts + $Report[$Domain]['AccountsToBeDeleted'] = $ToDelete.Count + $Processed += Request-ADServiceAccountsDelete -Accounts $ToDelete -ReportOnly:$ReportOnly -WhatIfDelete:$WhatIfDelete -Today $Today -DontWriteToEventLog:$DontWriteToEventLog + } + } + + $Export.CurrentRun = $Processed + $Export.History = $Processed + + if ($ReportPath) { + New-HTML -Title 'Service Accounts Cleanup' -FilePath $ReportPath -Online:$Online.IsPresent -ShowHTML:$ShowHTML.IsPresent { + New-HTMLTable -DataTable $Export.CurrentRun -Filtering -ScrollX + } + } + + Write-Color -Text "[i] Finished process of cleaning up service accounts" -Color Green + if (-not $Suppress) { $Export } +} diff --git a/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 b/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 new file mode 100644 index 0000000..87748e6 --- /dev/null +++ b/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 @@ -0,0 +1,28 @@ +BeforeAll { + $Public = Join-Path $PSScriptRoot '..' 'Public' 'Invoke-ADServiceAccountsCleanup.ps1' + $Private = Get-ChildItem -Path (Join-Path $PSScriptRoot '..' 'Private') -Filter '*ADServiceAccounts*.ps1' + . $Public + foreach ($P in $Private) { . $P.FullName } + + function Write-Color { param([Parameter(ValueFromRemainingArguments=$true)]$Text,[object[]]$Color) } + function Set-LoggingCapabilities {} + function Get-GitHubVersion { param($Cmdlet,$RepositoryOwner,$RepositoryName) '0.0.0' } + function Get-WinADForestDetails { param([string]$Forest,[string[]]$IncludeDomains,[string[]]$ExcludeDomains) @{ Domains=@('domain.local'); QueryServers=@{ 'domain.local'=@{ HostName=@('localhost') } }; DomainsExtended=@{}; Forest='domain.local' } } + function Get-ADServiceAccount { param([string]$Filter,[string]$Server,[string[]]$Properties) @() } + function New-HTML { param([scriptblock]$Body,[string]$FilePath,[switch]$Online,[switch]$ShowHTML) & $Body } +} + +Describe 'Invoke-ADServiceAccountsCleanup' { + It 'exports the function' { + Get-Command Invoke-ADServiceAccountsCleanup -ErrorAction Stop | Should -Not -BeNullOrEmpty + } + It 'filters service accounts by last logon date' { + $acc1 = [pscustomobject]@{ SamAccountName='gmsa1'; DistinguishedName='CN=gmsa1,DC=lab,DC=local'; LastLogonDate=(Get-Date).AddDays(-100); PasswordLastSet=(Get-Date).AddDays(-100); WhenCreated=(Get-Date).AddYears(-1) } + $acc2 = [pscustomobject]@{ SamAccountName='gmsa2'; DistinguishedName='CN=gmsa2,DC=lab,DC=local'; LastLogonDate=(Get-Date).AddDays(-5); PasswordLastSet=(Get-Date).AddDays(-5); WhenCreated=(Get-Date).AddDays(-10) } + $res = Get-ADServiceAccountsToProcess -Type 'Disable' -Accounts @($acc1,$acc2) -ActionIf @{ LastLogonDateMoreThan = 30 } + $res.SamAccountName | Should -Be @('gmsa1') + } + It 'supports WhatIf' { + { Invoke-ADServiceAccountsCleanup -Disable -ReportOnly -WhatIfDisable } | Should -Not -Throw + } +} From afae39a425d3f50a27ae606276c58d549173e964 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 8 Aug 2025 22:16:54 +0200 Subject: [PATCH 2/3] enhance service account cleanup reporting --- Private/New-HTMLProcessedServiceAccounts.ps1 | 153 ++++++++++++++++++ Public/Invoke-ADServiceAccountsCleanup.ps1 | 30 +++- .../Invoke-ADServiceAccountsCleanup.Tests.ps1 | 8 +- 3 files changed, 186 insertions(+), 5 deletions(-) create mode 100644 Private/New-HTMLProcessedServiceAccounts.ps1 diff --git a/Private/New-HTMLProcessedServiceAccounts.ps1 b/Private/New-HTMLProcessedServiceAccounts.ps1 new file mode 100644 index 0000000..f8fbb3b --- /dev/null +++ b/Private/New-HTMLProcessedServiceAccounts.ps1 @@ -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 + } + } + } 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 +} + diff --git a/Public/Invoke-ADServiceAccountsCleanup.ps1 b/Public/Invoke-ADServiceAccountsCleanup.ps1 index 78fe4e1..94056b5 100644 --- a/Public/Invoke-ADServiceAccountsCleanup.ps1 +++ b/Public/Invoke-ADServiceAccountsCleanup.ps1 @@ -166,12 +166,34 @@ function Invoke-ADServiceAccountsCleanup { } $Export.CurrentRun = $Processed - $Export.History = $Processed + $Export.History = $Processed - if ($ReportPath) { - New-HTML -Title 'Service Accounts Cleanup' -FilePath $ReportPath -Online:$Online.IsPresent -ShowHTML:$ShowHTML.IsPresent { - New-HTMLTable -DataTable $Export.CurrentRun -Filtering -ScrollX + foreach ($Domain in $Report.Keys) { + if ($Disable) { + Write-Color -Text "[i] ", "Accounts to be disabled for domain $Domain`: ", $Report[$Domain]['AccountsToBeDisabled'] -Color Yellow, Cyan, Green + } + if ($Delete) { + Write-Color -Text "[i] ", "Accounts to be deleted for domain $Domain`: ", $Report[$Domain]['AccountsToBeDeleted'] -Color Yellow, Cyan, Green + } + } + + if ($Export -and $ReportPath) { + [Array] $AccountsToProcess = foreach ($Domain in $Report.Keys) { $Report[$Domain]['Accounts'] } + $newHTMLProcessedServiceAccountsSplat = @{ + Export = $Export + FilePath = $ReportPath + Online = $Online.IsPresent + ShowHTML = $ShowHTML.IsPresent + LogFile = $LogPath + AccountsToProcess = $AccountsToProcess + DisableOnlyIf = $DisableOnlyIf + DeleteOnlyIf = $DeleteOnlyIf + Disable = $Disable + Delete = $Delete + ReportOnly = $ReportOnly } + Write-Color -Text "[i] ", "Generating HTML report ($ReportPath)" -Color Yellow, Magenta + New-HTMLProcessedServiceAccounts @newHTMLProcessedServiceAccountsSplat } Write-Color -Text "[i] Finished process of cleaning up service accounts" -Color Green diff --git a/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 b/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 index 87748e6..41cf880 100644 --- a/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 +++ b/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 @@ -1,6 +1,6 @@ BeforeAll { $Public = Join-Path $PSScriptRoot '..' 'Public' 'Invoke-ADServiceAccountsCleanup.ps1' - $Private = Get-ChildItem -Path (Join-Path $PSScriptRoot '..' 'Private') -Filter '*ADServiceAccounts*.ps1' + $Private = Get-ChildItem -Path (Join-Path $PSScriptRoot '..' 'Private') -Filter '*ServiceAccounts*.ps1' . $Public foreach ($P in $Private) { . $P.FullName } @@ -10,6 +10,7 @@ BeforeAll { function Get-WinADForestDetails { param([string]$Forest,[string[]]$IncludeDomains,[string[]]$ExcludeDomains) @{ Domains=@('domain.local'); QueryServers=@{ 'domain.local'=@{ HostName=@('localhost') } }; DomainsExtended=@{}; Forest='domain.local' } } function Get-ADServiceAccount { param([string]$Filter,[string]$Server,[string[]]$Properties) @() } function New-HTML { param([scriptblock]$Body,[string]$FilePath,[switch]$Online,[switch]$ShowHTML) & $Body } + function New-HTMLProcessedServiceAccounts {} } Describe 'Invoke-ADServiceAccountsCleanup' { @@ -25,4 +26,9 @@ Describe 'Invoke-ADServiceAccountsCleanup' { It 'supports WhatIf' { { Invoke-ADServiceAccountsCleanup -Disable -ReportOnly -WhatIfDisable } | Should -Not -Throw } + It 'generates HTML report when ReportPath is specified' { + Mock -CommandName New-HTMLProcessedServiceAccounts + Invoke-ADServiceAccountsCleanup -Disable -ReportOnly -ReportPath 'out.html' + Assert-MockCalled New-HTMLProcessedServiceAccounts -Times 1 + } } From fa568f5fcbc2ee7bd6c73d512750012d8a406912 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Przemys=C5=82aw=20K=C5=82ys?= Date: Fri, 8 Aug 2025 22:16:59 +0200 Subject: [PATCH 3/3] Refactor service account retrieval --- Private/Get-InitialADServiceAccounts.ps1 | 25 +++++++++++++++++++ Public/Invoke-ADServiceAccountsCleanup.ps1 | 24 +----------------- .../Invoke-ADServiceAccountsCleanup.Tests.ps1 | 12 +++++++++ 3 files changed, 38 insertions(+), 23 deletions(-) create mode 100644 Private/Get-InitialADServiceAccounts.ps1 diff --git a/Private/Get-InitialADServiceAccounts.ps1 b/Private/Get-InitialADServiceAccounts.ps1 new file mode 100644 index 0000000..d730cfb --- /dev/null +++ b/Private/Get-InitialADServiceAccounts.ps1 @@ -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)$" } + } + $Report[$Domain] = [ordered]@{ Server = $Server; Accounts = @($Accounts) } + } + $Report +} diff --git a/Public/Invoke-ADServiceAccountsCleanup.ps1 b/Public/Invoke-ADServiceAccountsCleanup.ps1 index 94056b5..98ad485 100644 --- a/Public/Invoke-ADServiceAccountsCleanup.ps1 +++ b/Public/Invoke-ADServiceAccountsCleanup.ps1 @@ -123,29 +123,7 @@ function Invoke-ADServiceAccountsCleanup { return } - $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 - [Array]$Accounts = Get-ADServiceAccount -Filter * -Server $Server -Properties SamAccountName,Enabled,LastLogonDate,PasswordLastSet,WhenCreated,DistinguishedName,ObjectClass | Where-Object { $_.ObjectClass -in 'msDS-ManagedServiceAccount','msDS-GroupManagedServiceAccount' } - foreach ($Account in $Accounts) { - $Account | Add-Member -NotePropertyName DomainName -NotePropertyValue $Domain - $Account | Add-Member -NotePropertyName Server -NotePropertyValue $Server - } - if ($IncludeAccounts) { - $Accounts = foreach ($A in $Accounts) { - foreach ($Inc in $IncludeAccounts) { if ($A.SamAccountName -like $Inc) { $A; break } } - } - } - if ($ExcludeAccounts) { - $Accounts = foreach ($A in $Accounts) { - $Skip = $false - foreach ($Exc in $ExcludeAccounts) { if ($A.SamAccountName -like $Exc) { $Skip = $true; break } } - if (-not $Skip) { $A } - } - } - $Report[$Domain] = [ordered]@{ Server = $Server; Accounts = $Accounts } - } + $Report = Get-InitialADServiceAccounts -ForestInformation $ForestInformation -IncludeAccounts $IncludeAccounts -ExcludeAccounts $ExcludeAccounts $Today = Get-Date [Array]$Processed = @() diff --git a/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 b/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 index 41cf880..d599b37 100644 --- a/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 +++ b/Tests/Invoke-ADServiceAccountsCleanup.Tests.ps1 @@ -31,4 +31,16 @@ Describe 'Invoke-ADServiceAccountsCleanup' { Invoke-ADServiceAccountsCleanup -Disable -ReportOnly -ReportPath 'out.html' Assert-MockCalled New-HTMLProcessedServiceAccounts -Times 1 } + + It 'respects include and exclude account patterns' { + Mock -CommandName Get-ADServiceAccount -MockWith { + @( + [pscustomobject]@{ SamAccountName='gmsa1'; DistinguishedName='CN=gmsa1,DC=lab,DC=local'; LastLogonDate=$null; PasswordLastSet=$null; WhenCreated=$null }, + [pscustomobject]@{ SamAccountName='gmsa2'; DistinguishedName='CN=gmsa2,DC=lab,DC=local'; LastLogonDate=$null; PasswordLastSet=$null; WhenCreated=$null } + ) + } + $forest = @{ Domains=@('lab.local'); QueryServers=@{ 'lab.local'=@{ HostName=@('dc1') } } } + $report = Get-InitialADServiceAccounts -ForestInformation $forest -IncludeAccounts @('gmsa*') -ExcludeAccounts @('gmsa2') + $report['lab.local'].Accounts.SamAccountName | Should -Be @('gmsa1') + } }