CleanupMonster.psm1
function ConvertFrom-DistinguishedName { <# .SYNOPSIS Converts a Distinguished Name to CN, OU, Multiple OUs or DC .DESCRIPTION Converts a Distinguished Name to CN, OU, Multiple OUs or DC .PARAMETER DistinguishedName Distinguished Name to convert .PARAMETER ToOrganizationalUnit Converts DistinguishedName to Organizational Unit .PARAMETER ToDC Converts DistinguishedName to DC .PARAMETER ToDomainCN Converts DistinguishedName to Domain Canonical Name (CN) .PARAMETER ToCanonicalName Converts DistinguishedName to Canonical Name .EXAMPLE $DistinguishedName = 'CN=Przemyslaw Klys,OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' ConvertFrom-DistinguishedName -DistinguishedName $DistinguishedName -ToOrganizationalUnit Output: OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz .EXAMPLE $DistinguishedName = 'CN=Przemyslaw Klys,OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' ConvertFrom-DistinguishedName -DistinguishedName $DistinguishedName Output: Przemyslaw Klys .EXAMPLE ConvertFrom-DistinguishedName -DistinguishedName 'OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToMultipleOrganizationalUnit -IncludeParent Output: OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz OU=Production,DC=ad,DC=evotec,DC=xyz .EXAMPLE ConvertFrom-DistinguishedName -DistinguishedName 'OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToMultipleOrganizationalUnit Output: OU=Production,DC=ad,DC=evotec,DC=xyz .EXAMPLE $Con = @( 'CN=Windows Authorization Access Group,CN=Builtin,DC=ad,DC=evotec,DC=xyz' 'CN=Mmm,DC=elo,CN=nee,DC=RootDNSServers,CN=MicrosoftDNS,CN=System,DC=ad,DC=evotec,DC=xyz' 'CN=e6d5fd00-385d-4e65-b02d-9da3493ed850,CN=Operations,CN=DomainUpdates,CN=System,DC=ad,DC=evotec,DC=xyz' 'OU=Domain Controllers,DC=ad,DC=evotec,DC=pl' 'OU=Microsoft Exchange Security Groups,DC=ad,DC=evotec,DC=xyz' ) ConvertFrom-DistinguishedName -DistinguishedName $Con -ToLastName Output: Windows Authorization Access Group Mmm e6d5fd00-385d-4e65-b02d-9da3493ed850 Domain Controllers Microsoft Exchange Security Groups .EXAMPLEE ConvertFrom-DistinguishedName -DistinguishedName 'DC=ad,DC=evotec,DC=xyz' -ToCanonicalName ConvertFrom-DistinguishedName -DistinguishedName 'OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToCanonicalName ConvertFrom-DistinguishedName -DistinguishedName 'CN=test,OU=Users,OU=Production,DC=ad,DC=evotec,DC=xyz' -ToCanonicalName Output: ad.evotec.xyz ad.evotec.xyz\Production\Users ad.evotec.xyz\Production\Users\test .NOTES General notes #> [CmdletBinding(DefaultParameterSetName = 'Default')] param( [Parameter(ParameterSetName = 'ToOrganizationalUnit')] [Parameter(ParameterSetName = 'ToMultipleOrganizationalUnit')] [Parameter(ParameterSetName = 'ToDC')] [Parameter(ParameterSetName = 'ToDomainCN')] [Parameter(ParameterSetName = 'Default')] [Parameter(ParameterSetName = 'ToLastName')] [Parameter(ParameterSetName = 'ToCanonicalName')] [alias('Identity', 'DN')][Parameter(ValueFromPipeline, ValueFromPipelineByPropertyName, Position = 0)][string[]] $DistinguishedName, [Parameter(ParameterSetName = 'ToOrganizationalUnit')][switch] $ToOrganizationalUnit, [Parameter(ParameterSetName = 'ToMultipleOrganizationalUnit')][alias('ToMultipleOU')][switch] $ToMultipleOrganizationalUnit, [Parameter(ParameterSetName = 'ToMultipleOrganizationalUnit')][switch] $IncludeParent, [Parameter(ParameterSetName = 'ToDC')][switch] $ToDC, [Parameter(ParameterSetName = 'ToDomainCN')][switch] $ToDomainCN, [Parameter(ParameterSetName = 'ToLastName')][switch] $ToLastName, [Parameter(ParameterSetName = 'ToCanonicalName')][switch] $ToCanonicalName ) Process { foreach ($Distinguished in $DistinguishedName) { if ($ToDomainCN) { $DN = $Distinguished -replace '.*?((DC=[^=]+,)+DC=[^=]+)$', '$1' $CN = $DN -replace ',DC=', '.' -replace "DC=" if ($CN) { $CN } } elseif ($ToOrganizationalUnit) { $Value = [Regex]::Match($Distinguished, '(?=OU=)(.*\n?)(?<=.)').Value if ($Value) { $Value } } elseif ($ToMultipleOrganizationalUnit) { if ($IncludeParent) { $Distinguished } while ($true) { #$dn = $dn -replace '^.+?,(?=CN|OU|DC)' $Distinguished = $Distinguished -replace '^.+?,(?=..=)' if ($Distinguished -match '^DC=') { break } $Distinguished } } elseif ($ToDC) { #return [Regex]::Match($DistinguishedName, '(?=DC=)(.*\n?)(?<=.)').Value # return [Regex]::Match($DistinguishedName, '.*?(DC=.*)').Value $Value = $Distinguished -replace '.*?((DC=[^=]+,)+DC=[^=]+)$', '$1' if ($Value) { $Value } #return [Regex]::Match($DistinguishedName, 'CN=.*?(DC=.*)').Groups[1].Value } elseif ($ToLastName) { # Would be best if it worked, but there is too many edge cases so hand splits seems to be the best solution # Feel free to change it back to regex if you know how ;) <# https://stackoverflow.com/questions/51761894/regex-extract-ou-from-distinguished-name $Regex = "^(?:(?<cn>CN=(?<name>.*?)),)?(?<parent>(?:(?<path>(?:CN|OU).*?),)?(?<domain>(?:DC=.*)+))$" $Found = $Distinguished -match $Regex if ($Found) { $Matches.name } #> $NewDN = $Distinguished -split ",DC=" if ($NewDN[0].Contains(",OU=")) { [Array] $ChangedDN = $NewDN[0] -split ",OU=" } elseif ($NewDN[0].Contains(",CN=")) { [Array] $ChangedDN = $NewDN[0] -split ",CN=" } else { [Array] $ChangedDN = $NewDN[0] } if ($ChangedDN[0].StartsWith('CN=')) { $ChangedDN[0] -replace 'CN=', '' } else { $ChangedDN[0] -replace 'OU=', '' } } elseif ($ToCanonicalName) { $Domain = $null $Rest = $null foreach ($O in $Distinguished -split '(?<!\\),') { if ($O -match '^DC=') { $Domain += $O.Substring(3) + '.' } else { $Rest = $O.Substring(3) + '\' + $Rest } } if ($Domain -and $Rest) { $Domain.Trim('.') + '\' + ($Rest.TrimEnd('\') -replace '\\,', ',') } elseif ($Domain) { $Domain.Trim('.') } elseif ($Rest) { $Rest.TrimEnd('\') -replace '\\,', ',' } } else { $Regex = '^CN=(?<cn>.+?)(?<!\\),(?<ou>(?:(?:OU|CN).+?(?<!\\),)+(?<dc>DC.+?))$' #$Output = foreach ($_ in $Distinguished) { $Found = $Distinguished -match $Regex if ($Found) { $Matches.cn } #} #$Output.cn } } } } function ConvertTo-OperatingSystem { <# .SYNOPSIS Allows easy conversion of OperatingSystem, Operating System Version to proper Windows 10 naming based on WMI or AD .DESCRIPTION Allows easy conversion of OperatingSystem, Operating System Version to proper Windows 10 naming based on WMI or AD .PARAMETER OperatingSystem Operating System as returned by Active Directory .PARAMETER OperatingSystemVersion Operating System Version as returned by Active Directory .EXAMPLE $Computers = Get-ADComputer -Filter * -Properties OperatingSystem, OperatingSystemVersion | ForEach-Object { $OPS = ConvertTo-OperatingSystem -OperatingSystem $_.OperatingSystem -OperatingSystemVersion $_.OperatingSystemVersion Add-Member -MemberType NoteProperty -Name 'OperatingSystemTranslated' -Value $OPS -InputObject $_ -Force $_ } $Computers | Select-Object DNS*, Name, SamAccountName, Enabled, OperatingSystem*, DistinguishedName | Format-Table .EXAMPLE $Registry = Get-PSRegistry -ComputerName 'AD1' -RegistryPath 'HKEY_LOCAL_MACHINE\SOFTWARE\Microsoft\Windows NT\CurrentVersion' ConvertTo-OperatingSystem -OperatingSystem $Registry.ProductName -OperatingSystemVersion $Registry.CurrentBuildNumber .NOTES General notes #> [CmdletBinding()] param( [string] $OperatingSystem, [string] $OperatingSystemVersion ) if ($OperatingSystem -like 'Windows 10*' -or $OperatingSystem -like 'Windows 11*') { $Systems = @{ # This is how it's written in AD '10.0 (22000)' = 'Windows 11 21H2' '10.0 (19043)' = 'Windows 10 21H1' '10.0 (19042)' = 'Windows 10 20H2' '10.0 (19041)' = 'Windows 10 2004' '10.0 (18898)' = 'Windows 10 Insider Preview' '10.0 (18363)' = "Windows 10 1909" '10.0 (18362)' = "Windows 10 1903" '10.0 (17763)' = "Windows 10 1809" '10.0 (17134)' = "Windows 10 1803" '10.0 (16299)' = "Windows 10 1709" '10.0 (15063)' = "Windows 10 1703" '10.0 (14393)' = "Windows 10 1607" '10.0 (10586)' = "Windows 10 1511" '10.0 (10240)' = "Windows 10 1507" # This is how WMI/CIM stores it '10.0.22000' = 'Windows 11 21H2' '10.0.19043' = 'Windows 10 21H1' '10.0.19042' = 'Windows 10 20H2' '10.0.19041' = 'Windows 10 2004' '10.0.18898' = 'Windows 10 Insider Preview' '10.0.18363' = "Windows 10 1909" '10.0.18362' = "Windows 10 1903" '10.0.17763' = "Windows 10 1809" '10.0.17134' = "Windows 10 1803" '10.0.16299' = "Windows 10 1709" '10.0.15063' = "Windows 10 1703" '10.0.14393' = "Windows 10 1607" '10.0.10586' = "Windows 10 1511" '10.0.10240' = "Windows 10 1507" # This is how it's written in registry '22000' = 'Windows 11 21H2' '19043' = 'Windows 10 21H1' '19042' = 'Windows 10 20H2' '19041' = 'Windows 10 2004' '18898' = 'Windows 10 Insider Preview' '18363' = "Windows 10 1909" '18362' = "Windows 10 1903" '17763' = "Windows 10 1809" '17134' = "Windows 10 1803" '16299' = "Windows 10 1709" '15063' = "Windows 10 1703" '14393' = "Windows 10 1607" '10586' = "Windows 10 1511" '10240' = "Windows 10 1507" } $System = $Systems[$OperatingSystemVersion] if (-not $System) { $System = $OperatingSystem } } elseif ($OperatingSystem -like 'Windows Server*') { # May need updates https://docs.microsoft.com/en-us/windows-server/get-started/windows-server-release-info # to detect Core $Systems = @{ # This is how it's written in AD '10.0 (20348)' = 'Windows Server 2022' '10.0 (19042)' = 'Windows Server 2019 20H2' '10.0 (19041)' = 'Windows Server 2019 2004' '10.0 (18363)' = 'Windows Server 2019 1909' '10.0 (18362)' = "Windows Server 2019 1903" # (Datacenter Core, Standard Core) '10.0 (17763)' = "Windows Server 2019 1809" # (Datacenter, Essentials, Standard) '10.0 (17134)' = "Windows Server 2016 1803" # (Datacenter, Standard) '10.0 (14393)' = "Windows Server 2016 1607" '6.3 (9600)' = 'Windows Server 2012 R2' '6.1 (7601)' = 'Windows Server 2008 R2' '5.2 (3790)' = 'Windows Server 2003' # This is how WMI/CIM stores it '10.0.20348' = 'Windows Server 2022' '10.0.19042' = 'Windows Server 2019 20H2' '10.0.19041' = 'Windows Server 2019 2004' '10.0.18363' = 'Windows Server 2019 1909' '10.0.18362' = "Windows Server 2019 1903" # (Datacenter Core, Standard Core) '10.0.17763' = "Windows Server 2019 1809" # (Datacenter, Essentials, Standard) '10.0.17134' = "Windows Server 2016 1803" ## (Datacenter, Standard) '10.0.14393' = "Windows Server 2016 1607" '6.3.9600' = 'Windows Server 2012 R2' '6.1.7601' = 'Windows Server 2008 R2' # i think '5.2.3790' = 'Windows Server 2003' # i think # This is how it's written in registry '20348' = 'Windows Server 2022' '19042' = 'Windows Server 2019 20H2' '19041' = 'Windows Server 2019 2004' '18363' = 'Windows Server 2019 1909' '18362' = "Windows Server 2019 1903" # (Datacenter Core, Standard Core) '17763' = "Windows Server 2019 1809" # (Datacenter, Essentials, Standard) '17134' = "Windows Server 2016 1803" # (Datacenter, Standard) '14393' = "Windows Server 2016 1607" '9600' = 'Windows Server 2012 R2' '7601' = 'Windows Server 2008 R2' '3790' = 'Windows Server 2003' } $System = $Systems[$OperatingSystemVersion] if (-not $System) { $System = $OperatingSystem } } else { $System = $OperatingSystem } if ($System) { $System } else { 'Unknown' } } function Get-GitHubVersion { <# .SYNOPSIS Get the latest version of a GitHub repository and compare with local version .DESCRIPTION Get the latest version of a GitHub repository and compare with local version .PARAMETER Cmdlet Cmdlet to find module for .PARAMETER RepositoryOwner Repository owner .PARAMETER RepositoryName Repository name .EXAMPLE Get-GitHubVersion -Cmdlet 'Start-DelegationModel' -RepositoryOwner 'evotecit' -RepositoryName 'DelegationModel' .NOTES General notes #> [cmdletBinding()] param( [Parameter(Mandatory)][string] $Cmdlet, [Parameter(Mandatory)][string] $RepositoryOwner, [Parameter(Mandatory)][string] $RepositoryName ) $App = Get-Command -Name $Cmdlet -ErrorAction SilentlyContinue if ($App) { [Array] $GitHubReleases = (Get-GitHubLatestRelease -Url "https://api.github.com/repos/$RepositoryOwner/$RepositoryName/releases" -Verbose:$false) $LatestVersion = $GitHubReleases[0] if (-not $LatestVersion.Errors) { if ($App.Version -eq $LatestVersion.Version) { "Current/Latest: $($LatestVersion.Version) at $($LatestVersion.PublishDate)" } elseif ($App.Version -lt $LatestVersion.Version) { "Current: $($App.Version), Published: $($LatestVersion.Version) at $($LatestVersion.PublishDate). Update?" } elseif ($App.Version -gt $LatestVersion.Version) { "Current: $($App.Version), Published: $($LatestVersion.Version) at $($LatestVersion.PublishDate). Lucky you!" } } else { "Current: $($App.Version)" } } } function Remove-EmptyValue { [alias('Remove-EmptyValues')] [CmdletBinding()] param( [alias('Splat', 'IDictionary')][Parameter(Mandatory)][System.Collections.IDictionary] $Hashtable, [string[]] $ExcludeParameter, [switch] $Recursive, [int] $Rerun, [switch] $DoNotRemoveNull, [switch] $DoNotRemoveEmpty, [switch] $DoNotRemoveEmptyArray, [switch] $DoNotRemoveEmptyDictionary ) foreach ($Key in [string[]] $Hashtable.Keys) { if ($Key -notin $ExcludeParameter) { if ($Recursive) { if ($Hashtable[$Key] -is [System.Collections.IDictionary]) { if ($Hashtable[$Key].Count -eq 0) { if (-not $DoNotRemoveEmptyDictionary) { $Hashtable.Remove($Key) } } else { Remove-EmptyValue -Hashtable $Hashtable[$Key] -Recursive:$Recursive } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } else { if (-not $DoNotRemoveNull -and $null -eq $Hashtable[$Key]) { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmpty -and $Hashtable[$Key] -is [string] -and $Hashtable[$Key] -eq '') { $Hashtable.Remove($Key) } elseif (-not $DoNotRemoveEmptyArray -and $Hashtable[$Key] -is [System.Collections.IList] -and $Hashtable[$Key].Count -eq 0) { $Hashtable.Remove($Key) } } } } if ($Rerun) { for ($i = 0; $i -lt $Rerun; $i++) { Remove-EmptyValue -Hashtable $Hashtable -Recursive:$Recursive } } } function Write-Color { <# .SYNOPSIS Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. .DESCRIPTION Write-Color is a wrapper around Write-Host delivering a lot of additional features for easier color options. It provides: - Easy manipulation of colors, - Logging output to file (log) - Nice formatting options out of the box. - Ability to use aliases for parameters .PARAMETER Text Text to display on screen and write to log file if specified. Accepts an array of strings. .PARAMETER Color Color of the text. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER BackGroundColor Color of the background. Accepts an array of colors. If more than one color is specified it will loop through colors for each string. If there are more strings than colors it will start from the beginning. Available colors are: Black, DarkBlue, DarkGreen, DarkCyan, DarkRed, DarkMagenta, DarkYellow, Gray, DarkGray, Blue, Green, Cyan, Red, Magenta, Yellow, White .PARAMETER StartTab Number of tabs to add before text. Default is 0. .PARAMETER LinesBefore Number of empty lines before text. Default is 0. .PARAMETER LinesAfter Number of empty lines after text. Default is 0. .PARAMETER StartSpaces Number of spaces to add before text. Default is 0. .PARAMETER LogFile Path to log file. If not specified no log file will be created. .PARAMETER DateTimeFormat Custom date and time format string. Default is yyyy-MM-dd HH:mm:ss .PARAMETER LogTime If set to $true it will add time to log file. Default is $true. .PARAMETER LogRetry Number of retries to write to log file, in case it can't write to it for some reason, before skipping. Default is 2. .PARAMETER Encoding Encoding of the log file. Default is Unicode. .PARAMETER ShowTime Switch to add time to console output. Default is not set. .PARAMETER NoNewLine Switch to not add new line at the end of the output. Default is not set. .PARAMETER NoConsoleOutput Switch to not output to console. Default all output goes to console. .EXAMPLE Write-Color -Text "Red ", "Green ", "Yellow " -Color Red,Green,Yellow .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan .EXAMPLE Write-Color -Text "This is text in Green ", "followed by red ", "and then we have Magenta... ", "isn't it fun? ", "Here goes DarkCyan" -Color Green,Red,Magenta,White,DarkCyan -StartTab 3 -LinesBefore 1 -LinesAfter 1 .EXAMPLE Write-Color "1. ", "Option 1" -Color Yellow, Green Write-Color "2. ", "Option 2" -Color Yellow, Green Write-Color "3. ", "Option 3" -Color Yellow, Green Write-Color "4. ", "Option 4" -Color Yellow, Green Write-Color "9. ", "Press 9 to exit" -Color Yellow, Gray -LinesBefore 1 .EXAMPLE Write-Color -LinesBefore 2 -Text "This little ","message is ", "written to log ", "file as well." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" -TimeFormat "yyyy-MM-dd HH:mm:ss" Write-Color -Text "This can get ","handy if ", "want to display things, and log actions to file ", "at the same time." ` -Color Yellow, White, Green, Red, Red -LogFile "C:\testing.txt" .EXAMPLE Write-Color -T "My text", " is ", "all colorful" -C Yellow, Red, Green -B Green, Green, Yellow Write-Color -t "my text" -c yellow -b green Write-Color -text "my text" -c red .EXAMPLE Write-Color -Text "Testuję czy się ładnie zapisze, czy będą problemy" -Encoding unicode -LogFile 'C:\temp\testinggg.txt' -Color Red -NoConsoleOutput .NOTES Understanding Custom date and time format strings: https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings Project support: https://github.com/EvotecIT/PSWriteColor Original idea: Josh (https://stackoverflow.com/users/81769/josh) #> [alias('Write-Colour')] [CmdletBinding()] param ( [alias ('T')] [String[]]$Text, [alias ('C', 'ForegroundColor', 'FGC')] [ConsoleColor[]]$Color = [ConsoleColor]::White, [alias ('B', 'BGC')] [ConsoleColor[]]$BackGroundColor = $null, [alias ('Indent')][int] $StartTab = 0, [int] $LinesBefore = 0, [int] $LinesAfter = 0, [int] $StartSpaces = 0, [alias ('L')] [string] $LogFile = '', [Alias('DateFormat', 'TimeFormat')][string] $DateTimeFormat = 'yyyy-MM-dd HH:mm:ss', [alias ('LogTimeStamp')][bool] $LogTime = $true, [int] $LogRetry = 2, [ValidateSet('unknown', 'string', 'unicode', 'bigendianunicode', 'utf8', 'utf7', 'utf32', 'ascii', 'default', 'oem')][string]$Encoding = 'Unicode', [switch] $ShowTime, [switch] $NoNewLine, [alias('HideConsole')][switch] $NoConsoleOutput ) if (-not $NoConsoleOutput) { $DefaultColor = $Color[0] if ($null -ne $BackGroundColor -and $BackGroundColor.Count -ne $Color.Count) { Write-Error "Colors, BackGroundColors parameters count doesn't match. Terminated." return } if ($LinesBefore -ne 0) { for ($i = 0; $i -lt $LinesBefore; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line before if ($StartTab -ne 0) { for ($i = 0; $i -lt $StartTab; $i++) { Write-Host -Object "`t" -NoNewline } } # Add TABS before text if ($StartSpaces -ne 0) { for ($i = 0; $i -lt $StartSpaces; $i++) { Write-Host -Object ' ' -NoNewline } } # Add SPACES before text if ($ShowTime) { Write-Host -Object "[$([datetime]::Now.ToString($DateTimeFormat))] " -NoNewline } # Add Time before output if ($Text.Count -ne 0) { if ($Color.Count -ge $Text.Count) { # the real deal coloring if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } } else { for ($i = 0; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } } } else { if ($null -eq $BackGroundColor) { for ($i = 0; $i -lt $Color.Length ; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -NoNewline } } else { for ($i = 0; $i -lt $Color.Length ; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $Color[$i] -BackgroundColor $BackGroundColor[$i] -NoNewline } for ($i = $Color.Length; $i -lt $Text.Length; $i++) { Write-Host -Object $Text[$i] -ForegroundColor $DefaultColor -BackgroundColor $BackGroundColor[0] -NoNewline } } } } if ($NoNewLine -eq $true) { Write-Host -NoNewline } else { Write-Host } # Support for no new line if ($LinesAfter -ne 0) { for ($i = 0; $i -lt $LinesAfter; $i++) { Write-Host -Object "`n" -NoNewline } } # Add empty line after } if ($Text.Count -and $LogFile) { # Save to file $TextToFile = "" for ($i = 0; $i -lt $Text.Length; $i++) { $TextToFile += $Text[$i] } $Saved = $false $Retry = 0 Do { $Retry++ try { if ($LogTime) { "[$([datetime]::Now.ToString($DateTimeFormat))] $TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } else { "$TextToFile" | Out-File -FilePath $LogFile -Encoding $Encoding -Append -ErrorAction Stop -WhatIf:$false } $Saved = $true } catch { if ($Saved -eq $false -and $Retry -eq $LogRetry) { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Tried ($Retry/$LogRetry))" } else { Write-Warning "Write-Color - Couldn't write to log file $($_.Exception.Message). Retrying... ($Retry/$LogRetry)" } } } Until ($Saved -eq $true -or $Retry -ge $LogRetry) } } function Get-GitHubLatestRelease { <# .SYNOPSIS Gets one or more releases from GitHub repository .DESCRIPTION Gets one or more releases from GitHub repository .PARAMETER Url Url to github repository .EXAMPLE Get-GitHubLatestRelease -Url "https://api.github.com1/repos/evotecit/Testimo/releases" | Format-Table .NOTES General notes #> [CmdLetBinding()] param( [parameter(Mandatory)][alias('ReleasesUrl')][uri] $Url ) $ProgressPreference = 'SilentlyContinue' $Responds = Test-Connection -ComputerName $URl.Host -Quiet -Count 1 if ($Responds) { Try { [Array] $JsonOutput = (Invoke-WebRequest -Uri $Url -ErrorAction Stop | ConvertFrom-Json) foreach ($JsonContent in $JsonOutput) { [PSCustomObject] @{ PublishDate = [DateTime] $JsonContent.published_at CreatedDate = [DateTime] $JsonContent.created_at PreRelease = [bool] $JsonContent.prerelease Version = [version] ($JsonContent.name -replace 'v', '') Tag = $JsonContent.tag_name Branch = $JsonContent.target_commitish Errors = '' } } } catch { [PSCustomObject] @{ PublishDate = $null CreatedDate = $null PreRelease = $null Version = $null Tag = $null Branch = $null Errors = $_.Exception.Message } } } else { [PSCustomObject] @{ PublishDate = $null CreatedDate = $null PreRelease = $null Version = $null Tag = $null Branch = $null Errors = "No connection (ping) to $($Url.Host)" } } $ProgressPreference = 'Continue' } function ConvertTo-PreparedComputer { [CmdletBinding()] param( [Microsoft.ActiveDirectory.Management.ADComputer[]] $Computers, [System.Collections.IDictionary] $AzureInformationCache, [System.Collections.IDictionary] $JamfInformationCache, [switch] $IncludeAzureAD, [switch] $IncludeIntune, [switch] $IncludeJamf ) foreach ($Computer in $Computers) { if ($IncludeAzureAD) { $AzureADComputer = $AzureInformationCache['AzureAD']["$($Computer.Name)"] $DataAzureAD = [ordered] @{ 'AzureLastSeen' = $AzureADComputer.LastSeen 'AzureLastSeenDays' = $AzureADComputer.LastSeenDays 'AzureLastSync' = $AzureADComputer.LastSynchronized 'AzureLastSyncDays' = $AzureADComputer.LastSynchronizedDays 'AzureOwner' = $AzureADComputer.OwnerDisplayName 'AzureOwnerStatus' = $AzureADComputer.OwnerEnabled 'AzureOwnerUPN' = $AzureADComputer.OwnerUserPrincipalName } } if ($IncludeIntune) { # data was requested from Intune $IntuneComputer = $AzureInformationCache['Intune']["$($Computer.Name)"] $DataIntune = [ordered] @{ 'IntuneLastSeen' = $IntuneComputer.LastSeen 'IntuneLastSeenDays' = $IntuneComputer.LastSeenDays 'IntuneUser' = $IntuneComputer.UserDisplayName 'IntuneUserUPN' = $IntuneComputer.UserPrincipalName 'IntuneUserEmail' = $IntuneComputer.EmailAddress } } if ($IncludeJamf) { $JamfComputer = $JamfInformationCache["$($Computer.Name)"] $DataJamf = [ordered] @{ JamfLastContactTime = $JamfComputer.lastContactTime JamfLastContactTimeDays = $JamfComputer.lastContactTimeDays JamfCapableUsers = $JamfComputer.mdmCapableCapableUsers } } $LastLogonDays = if ($null -ne $Computer.LastLogonDate) { - $($Computer.LastLogonDate - $Today).Days } else { $null } $PasswordLastChangedDays = if ($null -ne $Computer.PasswordLastSet) { - $($Computer.PasswordLastSet - $Today).Days } else { $null } $DataStart = [ordered] @{ 'DNSHostName' = $Computer.DNSHostName 'SamAccountName' = $Computer.SamAccountName 'Enabled' = $Computer.Enabled 'Action' = 'Not required' 'ActionStatus' = $null 'ActionDate' = $null 'ActionComment' = $null 'OperatingSystem' = $Computer.OperatingSystem 'OperatingSystemVersion' = $Computer.OperatingSystemVersion 'OperatingSystemLong' = ConvertTo-OperatingSystem -OperatingSystem $Computer.OperatingSystem -OperatingSystemVersion $Computer.OperatingSystemVersion 'LastLogonDate' = $Computer.LastLogonDate 'LastLogonDays' = $LastLogonDays 'PasswordLastSet' = $Computer.PasswordLastSet 'PasswordLastChangedDays' = $PasswordLastChangedDays } $DataEnd = [ordered] @{ 'PasswordExpired' = $Computer.PasswordExpired 'LogonCount' = $Computer.logonCount 'ManagedBy' = $Computer.ManagedBy 'DistinguishedName' = $Computer.DistinguishedName 'OrganizationalUnit' = ConvertFrom-DistinguishedName -DistinguishedName $Computer.DistinguishedName -ToOrganizationalUnit 'Description' = $Computer.Description 'WhenCreated' = $Computer.WhenCreated 'WhenChanged' = $Computer.WhenChanged 'ServicePrincipalName' = $Computer.servicePrincipalName #-join [System.Environment]::NewLine } if ($IncludeAzureAD -and $IncludeIntune -and $IncludeJamf) { $Data = $DataStart + $DataAzureAD + $DataIntune + $DataJamf + $DataEnd } elseif ($IncludeAzureAD -and $IncludeIntune) { $Data = $DataStart + $DataAzureAD + $DataIntune + $DataEnd } elseif ($IncludeAzureAD -and $IncludeJamf) { $Data = $DataStart + $DataAzureAD + $DataJamf + $DataEnd } elseif ($IncludeIntune -and $IncludeJamf) { $Data = $DataStart + $DataIntune + $DataJamf + $DataEnd } elseif ($IncludeAzureAD) { $Data = $DataStart + $DataAzureAD + $DataEnd } elseif ($IncludeIntune) { $Data = $DataStart + $DataIntune + $DataEnd } elseif ($IncludeJamf) { $Data = $DataStart + $DataJamf + $DataEnd } else { $Data = $DataStart + $DataEnd } [PSCustomObject] $Data } } function Get-ADComputersToDelete { [cmdletBinding()] param( [Array] $Computers, [System.Collections.IDictionary] $DeleteOnlyIf, [Array] $Exclusions = @('OU=Domain Controllers'), [Microsoft.ActiveDirectory.Management.ADDomain] $DomainInformation, [System.Collections.IDictionary] $ProcessedComputers, [System.Collections.IDictionary] $AzureInformationCache, [System.Collections.IDictionary] $JamfInformationCache, [switch] $IncludeAzureAD, [switch] $IncludeIntune, [switch] $IncludeJamf ) $Count = 0 $Today = Get-Date :SkipComputer foreach ($Computer in $Computers) { if ($null -ne $DeleteOnlyIf.ListProcessedMoreThan) { # if more then 0 this means computer has to be on list of disabled computers for that number of days. if ($ProcessedComputers.Count -gt 0) { $FoundComputer = $ProcessedComputers["$($Computer.DistinguishedName)"] if ($FoundComputer) { if ($FoundComputer.ActionDate -is [DateTime]) { $TimeSpan = New-TimeSpan -Start $FoundComputer.ActionDate -End $Today if ($TimeSpan.Days -gt $DeleteOnlyIf.ListProcessedMoreThan) { } else { continue SkipComputer } } else { continue SkipComputer } } else { continue SkipComputer } } else { # ListProcessed doesn't have members, and it's part of requirement break } } foreach ($PartialExclusion in $Exclusions) { if ($Computer.DistinguishedName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.SamAccountName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.DNSHostName -like "$PartialExclusion") { continue SkipComputer } } if ($DeleteOnlyIf.ExcludeSystems.Count -gt 0) { foreach ($Exclude in $DeleteOnlyIf.ExcludeSystems) { if ($Computer.OperatingSystem -like $Exclude) { continue SkipComputer } } } if ($DeleteOnlyIf.IncludeSystems.Count -gt 0) { $FoundInclude = $false foreach ($Include in $DeleteOnlyIf.IncludeSystems) { if ($Computer.OperatingSystem -like $Include) { $FoundInclude = $true break } } # If not found in includes we need to skip the computer if (-not $FoundInclude) { continue SkipComputer } } if ($DeleteOnlyIf.IsEnabled -eq $true) { # Delete computer only if it's Enabled if ($Computer.Enabled -eq $false) { continue SkipComputer } } elseif ($DeleteOnlyIf.IsEnabled -eq $false) { # Delete computer only if it's Disabled if ($Computer.Enabled -eq $true) { continue SkipComputer } } if ($DeleteOnlyIf.NoServicePrincipalName -eq $true) { # Delete computer only if it has no service principal names defined if ($Computer.servicePrincipalName.Count -gt 0) { continue SkipComputer } } elseif ($DeleteOnlyIf.NoServicePrincipalName -eq $false) { # Delete computer only if it has service principal names defined if ($Computer.servicePrincipalName.Count -eq 0) { continue SkipComputer } } if ($DeleteOnlyIf.LastLogonDateMoreThan) { # This runs only if more than 0 if ($Computer.LastLogonDate) { # We ignore empty $TimeToCompare = ($Computer.LastLogonDate).AddDays($DeleteOnlyIf.LastLogonDateMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($DeleteOnlyIf.PasswordLastSetMoreThan) { # This runs only if more than 0 if ($Computer.PasswordLastSet) { # We ignore empty $TimeToCompare = ($Computer.PasswordLastSet).AddDays($DeleteOnlyIf.PasswordLastSetMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($DeleteOnlyIf.PasswordLastSetOlderThan) { # This runs only if not null if ($Computer.PasswordLastSet) { # We ignore empty if ($DeleteOnlyIf.PasswordLastSetOlderThan -le $Computer.PasswordLastSet) { continue SkipComputer } } } if ($DeleteOnlyIf.LastLogonDateOlderThan) { # This runs only if not null if ($Computer.LastLogonDate) { # We ignore empty if ($DeleteOnlyIf.LastLogonDateOlderThan -le $Computer.LastLogonDate) { continue SkipComputer } } } if ($IncludeAzureAD) { if ($null -ne $DeleteOnlyIf.LastSeenAzureMoreThan -and $null -ne $Computer.AzureLastSeenDays) { if ($DeleteOnlyIf.LastSeenAzureMoreThan -le $Computer.AzureLastSeenDays) { continue SkipComputer } } if ($null -ne $DeleteOnlyIf.LastSyncAzureMoreThan -and $null -ne $Computer.AzureLastSyncDays) { if ($DeleteOnlyIf.LastSyncAzureMoreThan -le $Computer.AzureLastSyncDays) { continue SkipComputer } } } if ($IncludeIntune) { if ($null -ne $DeleteOnlyIf.LastSeenIntuneMoreThan -and $null -ne $Computer.IntuneLastSeenDays) { if ($DeleteOnlyIf.LastSeenIntuneMoreThan -le $Computer.IntuneLastSeenDays) { continue SkipComputer } } } if ($IncludeJamf) { if ($null -ne $DeleteOnlyIf.LastContactJamfMoreThan -and $null -ne $Computer.JamfLastContactTimeDays) { if ($DeleteOnlyIf.LastContactJamfMoreThan -le $Computer.JamfLastContactTimeDays) { continue SkipComputer } } } $Computer.'Action' = 'Delete' $Count++ } $Count } function Get-ADComputersToDisable { [cmdletBinding()] param( [Array] $Computers, [System.Collections.IDictionary] $DisableOnlyIf, [Array] $Exclusions = @('OU=Domain Controllers'), [string] $Filter = '*', [Microsoft.ActiveDirectory.Management.ADDomain] $DomainInformation, [System.Collections.IDictionary] $ProcessedComputers, [System.Collections.IDictionary] $AzureInformationCache, [System.Collections.IDictionary] $JamfInformationCache, [switch] $IncludeAzureAD, [switch] $IncludeIntune, [switch] $IncludeJamf ) $Count = 0 $Today = Get-Date :SkipComputer foreach ($Computer in $Computers) { if ($ProcessedComputers.Count -gt 0) { $FoundComputer = $ProcessedComputers["$($Computer.DistinguishedName)"] if ($FoundComputer) { if ($Computer.Enabled -eq $true) { # We checked and it seems the computer has been enabled since it was added to list, we remove it from the list and reprocess Write-Color -Text "[*] Removing computer from pending list (computer is enabled) ", $FoundComputer.SamAccountName, " ($($FoundComputer.DistinguishedName))" -Color DarkMagenta, Green, DarkMagenta $ProcessedComputers.Remove("$($Computer.DistinguishedName)") } else { # we skip adding to disabled because it's already on the list for removing continue SkipComputer } } } if ($DisableOnlyIf.IsEnabled -eq $true) { # Disable computer only if it's Enabled if ($Computer.Enabled -eq $false) { continue SkipComputer } } elseif ($DisableOnlyIf.IsEnabled -eq $false) { # Disable computer only if it's Disabled # a bit useless as it's already disable right... # so we skip computer both times as it's already done if ($Computer.Enabled -eq $true) { continue SkipComputer } else { continue SkipComputer } } else { # If null it should ignore condition } foreach ($PartialExclusion in $Exclusions) { if ($Computer.DistinguishedName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.SamAccountName -like "$PartialExclusion") { continue SkipComputer } if ($Computer.DNSHostName -like "$PartialExclusion") { continue SkipComputer } } if ($DisableOnlyIf.ExcludeSystems.Count -gt 0) { foreach ($Exclude in $DisableOnlyIf.ExcludeSystems) { if ($Computer.OperatingSystem -like $Exclude) { continue SkipComputer } } } if ($DisableOnlyIf.IncludeSystems.Count -gt 0) { $FoundInclude = $false foreach ($Include in $DisableOnlyIf.IncludeSystems) { if ($Computer.OperatingSystem -like $Include) { $FoundInclude = $true break } } # If not found in includes we need to skip the computer if (-not $FoundInclude) { continue SkipComputer } } if ($DisableOnlyIf.NoServicePrincipalName -eq $true) { # Disable computer only if it has no service principal names defined if ($Computer.servicePrincipalName.Count -gt 0) { continue SkipComputer } } elseif ($DisableOnlyIf.NoServicePrincipalName -eq $false) { # Disable computer only if it has service principal names defined if ($Computer.servicePrincipalName.Count -eq 0) { continue SkipComputer } } else { # If null it should ignore confition } if ($DisableOnlyIf.LastLogonDateMoreThan) { # This runs only if more than 0 if ($Computer.LastLogonDate) { # We ignore empty $TimeToCompare = ($Computer.LastLogonDate).AddDays($DisableOnlyIf.LastLogonDateMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($DisableOnlyIf.PasswordLastSetMoreThan) { # This runs only if more than 0 if ($Computer.PasswordLastSet) { # We ignore empty $TimeToCompare = ($Computer.PasswordLastSet).AddDays($DisableOnlyIf.PasswordLastSetMoreThan) if ($TimeToCompare -gt $Today) { continue SkipComputer } } } if ($DisableOnlyIf.PasswordLastSetOlderThan) { # This runs only if not null if ($Computer.PasswordLastSet) { # We ignore empty if ($DisableOnlyIf.PasswordLastSetOlderThan -le $Computer.PasswordLastSet) { continue SkipComputer } } } if ($DisableOnlyIf.LastLogonDateOlderThan) { # This runs only if not null if ($Computer.LastLogonDate) { # We ignore empty if ($DisableOnlyIf.LastLogonDateOlderThan -le $Computer.LastLogonDate) { continue SkipComputer } } } if ($IncludeAzureAD) { if ($null -ne $DisableOnlyIf.LastSeenAzureMoreThan -and $null -ne $Computer.AzureLastSeenDays) { if ($DisableOnlyIf.LastSeenAzureMoreThan -le $Computer.AzureLastSeenDays) { continue SkipComputer } } if ($null -ne $DisableOnlyIf.LastSyncAzureMoreThan -and $null -ne $Computer.AzureLastSyncDays) { if ($DisableOnlyIf.LastSyncAzureMoreThan -le $Computer.AzureLastSyncDays) { continue SkipComputer } } } if ($IncludeIntune) { if ($null -ne $DisableOnlyIf.LastSeenIntuneMoreThan -and $null -ne $Computer.IntuneLastSeenDays) { if ($DisableOnlyIf.LastSeenIntuneMoreThan -le $Computer.IntuneLastSeenDays) { continue SkipComputer } } } if ($IncludeJamf) { if ($null -ne $DisableOnlyIf.LastContactJamfMoreThan -and $null -ne $Computer.JamfLastContactTimeDays) { if ($DisableOnlyIf.LastContactJamfMoreThan -le $Computer.JamfLastContactTimeDays) { continue SkipComputer } } } $Computer.'Action' = 'Disable' $Count++ } $Count } function Get-InitialADComputers { [CmdletBinding()] param( [System.Collections.IDictionary] $Report, [Microsoft.ActiveDirectory.Management.ADForest] $Forest, [string] $Filter, [string[]] $Properties, [bool] $Disable, [bool] $Delete, [nullable[int]] $DisableLastLogonDateMoreThan, [nullable[int]] $DeleteLastLogonDateMoreThan, [nullable[bool]] $DeleteNoServicePrincipalName, [nullable[bool]] $DisableNoServicePrincipalName, [nullable[bool]] $DeleteIsEnabled, [nullable[bool]] $DisableIsEnabled, [nullable[int]] $DisablePasswordLastSetMoreThan, [nullable[int]] $DeletePasswordLastSetMoreThan, [System.Collections.IDictionary] $DisableOnlyIf, [System.Collections.IDictionary] $DeleteOnlyIf, [Array] $Exclusions, [System.Collections.IDictionary] $ProcessedComputers, [nullable[int]] $SafetyADLimit, [System.Collections.IDictionary] $AzureInformationCache, [System.Collections.IDictionary] $JamfInformationCache ) $AllComputers = [ordered] @{} $AzureRequired = $false $IntuneRequired = $false $JamfRequired = $false if ($DisableOnlyIf) { if ($null -ne $DisableOnlyIf.LastSyncAzureMoreThan -or $null -ne $DisableOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $DisableOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $DisableOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } if ($DeleteOnlyIf) { if ($null -ne $DeleteOnlyIf.LastSyncAzureMoreThan -or $null -ne $DeleteOnlyIf.LastSeenAzureMoreThan) { $AzureRequired = $true } if ($null -ne $DeleteOnlyIf.LastContactJamfMoreThan) { $JamfRequired = $true } if ($null -ne $DeleteOnlyIf.LastSeenIntuneMoreThan) { $IntuneRequired = $true } } foreach ($Domain in $Forest.Domains) { $Report["$Domain"] = [ordered] @{ } $DC = Get-ADDomainController -Discover -DomainName $Domain $Server = $DC.HostName[0] $DomainInformation = Get-ADDomain -Identity $Domain -Server $Server $Report["$Domain"]['Server'] = $Server Write-Color "[i] Getting all computers for domain ", $Domain -Color Yellow, Magenta, Yellow [Array] $Computers = Get-ADComputer -Filter $Filter -Server $Server -Properties $Properties #| Where-Object { $_.SamAccountName -like 'Windows2012*' } foreach ($Computer in $Computers) { # we will be using it later to just check if computer exists in AD $AllComputers[$($Computer.DistinguishedName)] = $Computer } $Report["$Domain"]['Computers'] = @( $convertToPreparedComputerSplat = @{ Computers = $Computers AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache IncludeAzureAD = $AzureRequired IncludeJamf = $JamfRequired IncludeIntune = $IntuneRequired } ConvertTo-PreparedComputer @convertToPreparedComputerSplat ) Write-Color "[i] ", "Computers found for domain $Domain`: ", $($Computers.Count) -Color Yellow, Cyan, Green if ($Disable) { Write-Color "[i] ", "Processing computers to disable for domain $Domain" -Color Yellow, Cyan, Green Write-Color "[i] ", "Looking for computers with LastLogonDate more than ", $DisableLastLogonDateMoreThan, " days" -Color Yellow, Cyan, Green, Cyan Write-Color "[i] ", "Looking for computers with PasswordLastSet more than ", $DisablePasswordLastSetMoreThan, " days" -Color Yellow, Cyan, Green, Cyan if ($DisableNoServicePrincipalName) { Write-Color "[i] ", "Looking for computers with no ServicePrincipalName" -Color Yellow, Cyan, Green } $getADComputersToDisableSplat = @{ Computers = $Report["$Domain"]['Computers'] DisableOnlyIf = $DisableOnlyIf Exclusions = $Exclusions DomainInformation = $DomainInformation ProcessedComputers = $ProcessedComputers AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache IncludeAzureAD = $AzureRequired IncludeJamf = $JamfRequired IncludeIntune = $IntuneRequired } $Report["$Domain"]['ComputersToBeDisabled'] = Get-ADComputersToDisable @getADComputersToDisableSplat } if ($Delete) { Write-Color "[i] ", "Processing computers to delete for domain $Domain" -Color Yellow, Cyan, Green Write-Color "[i] ", "Looking for computers with LastLogonDate more than ", $DeleteLastLogonDateMoreThan, " days" -Color Yellow, Cyan, Green, Cyan Write-Color "[i] ", "Looking for computers with PasswordLastSet more than ", $DeletePasswordLastSetMoreThan, " days" -Color Yellow, Cyan, Green, Cyan if ($DeleteNoServicePrincipalName) { Write-Color "[i] ", "Looking for computers with no ServicePrincipalName" -Color Yellow, Cyan, Green } if ($null -ne $DeleteIsEnabled) { if ($DeleteIsEnabled) { Write-Color "[i] ", "Looking for computers that are enabled" -Color Yellow, Cyan, Green } else { Write-Color "[i] ", "Looking for computers that are disabled" -Color Yellow, Cyan, Green } } $getADComputersToDeleteSplat = @{ Computers = $Report["$Domain"]['Computers'] DeleteOnlyIf = $DeleteOnlyIf Exclusions = $Exclusions DomainInformation = $DomainInformation ProcessedComputers = $ProcessedComputers AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache IncludeAzureAD = $AzureRequired IncludeJamf = $JamfRequired IncludeIntune = $IntuneRequired } $Report["$Domain"]['ComputersToBeDeleted'] = Get-ADComputersToDelete @getADComputersToDeleteSplat } } if ($null -ne $SafetyADLimit -and $AllComputers.Count -lt $SafetyADLimit) { Write-Color "[e] ", "Only ", $($AllComputers.Count), " computers found in AD, this is less than the safety limit of ", $SafetyADLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } $AllComputers } function Get-InitialGraphComputers { [CmdletBinding()] param( [nullable[int]] $SafetyAzureADLimit, [nullable[int]] $SafetyIntuneLimit, [nullable[int]] $DeleteLastSeenAzureMoreThan, [nullable[int]] $DeleteLastSeenIntuneMoreThan, [nullable[int]] $DeleteLastSyncAzureMoreThan, [nullable[int]] $DisableLastSeenAzureMoreThan, [nullable[int]] $DisableLastSeenIntuneMoreThan, [nullable[int]] $DisableLastSyncAzureMoreThan ) $AzureInformationCache = [ordered] @{ AzureAD = [ordered] @{} Intune = [ordered] @{} } if ($PSBoundParameters.ContainsKey('DisableLastSeenAzureMoreThan') -or $PSBoundParameters.ContainsKey('DisableLastSyncAzureMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastSeenAzureMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastSyncAzureMoreThan')) { Write-Color "[i] ", "Getting all computers from AzureAD" -Color Yellow, Cyan, Green [Array] $Devices = Get-MyDevice -Synchronized -WarningAction SilentlyContinue -WarningVariable WarningVar if ($WarningVar) { Write-Color "[e] ", "Error getting computers from AzureAD: ", $WarningVar, " Terminating!" -Color Yellow, Red, Yellow, Red return $false } if ($Devices.Count -eq 0) { Write-Color "[e] ", "No computers found in AzureAD, terminating! Please disable Azure AD integration or fix connectivity." -Color Yellow, Red return $false } foreach ($Device in $Devices) { $AzureInformationCache.AzureAD[$Device.Name] = $Device } if ($null -ne $SafetyAzureADLimit -and $Devices.Count -lt $SafetyAzureADLimit) { Write-Color "[e] ", "Only ", $($Devices.Count), " computers found in AzureAD, this is less than the safety limit of ", $SafetyAzureADLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } Write-Color "[i] ", "Synchronized Computers found in AzureAD`: ", $($Devices.Count) -Color Yellow, Cyan, Green } if ($PSBoundParameters.ContainsKey('DisableLastSeenIntuneMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastSeenIntuneMoreThan')) { Write-Color "[i] ", "Getting all computers from Intune" -Color Yellow, Cyan, Green [Array] $DevicesIntune = Get-MyDeviceIntune -WarningAction SilentlyContinue -WarningVariable WarningVar if ($WarningVar) { Write-Color "[e] ", "Error getting computers from Intune: ", $WarningVar, " Terminating!" -Color Yellow, Red, Yellow, Red return $false } if ($DevicesIntune.Count -eq 0) { Write-Color "[e] ", "No computers found in Intune, terminating! Please disable Intune integration or fix connectivity." -Color Yellow, Red return $false } foreach ($device in $DevicesIntune) { $AzureInformationCache.Intune[$Device.Name] = $device } if ($null -ne $SafetyIntuneLimit -and $DevicesIntune.Count -lt $SafetyIntuneLimit) { Write-Color "[e] ", "Only ", $($DevicesIntune.Count), " computers found in Intune, this is less than the safety limit of ", $SafetyIntuneLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } Write-Color "[i] ", "Synchronized Computers found in Intune`: ", $($DevicesIntune.Count) -Color Yellow, Cyan, Green } $AzureInformationCache } function Get-InitialJamfComputers { [CmdletBinding()] param( [bool] $DisableLastContactJamfMoreThan, [bool] $DeleteLastContactJamfMoreThan, [nullable[int]] $SafetyJamfLimit ) $JamfCache = [ordered] @{} if ($PSBoundParameters.ContainsKey('DisableLastContactJamfMoreThan') -or $PSBoundParameters.ContainsKey('DeleteLastContactJamfMoreThan')) { Write-Color "[i] ", "Getting all computers from Jamf" -Color Yellow, Cyan, Green [Array] $Jamf = Get-JamfDevice -WarningAction SilentlyContinue -WarningVariable WarningVar if ($WarningVar) { Write-Color "[e] ", "Error getting computers from Jamf: ", $WarningVar, " Terminating!" -Color Yellow, Red, Yellow, Red return $false } if ($Jamf.Count -eq 0) { Write-Color "[e] ", "No computers found in Jamf, terminating! Please disable Jamf integration or fix connectivity." -Color Yellow, Red return $false } else { Write-Color "[i] ", "Computers found in Jamf`: ", $($Jamf.Count) -Color Yellow, Cyan, Green } if ($null -ne $SafetyJamfLimit -and $Jamf.Count -lt $SafetyJamfLimit) { Write-Color "[e] ", "Only ", $($Jamf.Count), " computers found in Jamf, this is less than the safety limit of ", $SafetyJamfLimit, ". Terminating!" -Color Yellow, Cyan, Red, Cyan return $false } foreach ($device in $Jamf) { $JamfCache[$Device.Name] = $device } } $JamfCache } function Import-ComputersData { [CmdletBinding()] param( [string] $DataStorePath, [System.Collections.IDictionary] $Export ) $ProcessedComputers = [ordered] @{ } try { if ($DataStorePath -and (Test-Path -LiteralPath $DataStorePath)) { $FileImport = Import-Clixml -LiteralPath $DataStorePath -ErrorAction Stop if ($FileImport.PendingDeletion) { if ($FileImport.PendingDeletion.GetType().Name -ne 'Hashtable') { Write-Color -Text "[e] ", "Incorrecting XML format. PendingDeletion is not a hashtable. Terminating." -Color Yellow, Red return } } if ($FileImport.History) { if ($FileImport.History.GetType().Name -ne 'ArrayList') { Write-Color -Text "[e] ", "Incorrecting XML format. History is not a ArrayList. Terminating." -Color Yellow, Red return } } $ProcessedComputers = $FileImport.PendingDeletion $Export['History'] = $FileImport.History } if (-not $ProcessedComputers) { $ProcessedComputers = [ordered] @{ } } } catch { Write-Color -Text "[e] ", "Couldn't read the list or wrong format. Error: $($_.Exception.Message)" -Color Yellow, Red return } $ProcessedComputers } function New-ADComputersStatistics { [CmdletBinding()] param( [Array] $ComputersToProcess ) $Statistics = [ordered] @{ All = $ComputersToProcess.Count ToDisable = 0 ToDelete = 0 ToDisableComputerWorkstation = 0 ToDisableComputerServer = 0 ToDeleteComputerWorkstation = 0 ToDeleteComputerServer = 0 ToDisableComputerUnknown = 0 ToDeleteComputerUnknown = 0 TotalWindowsServers = 0 TotalWindowsWorkstations = 0 TotalMacOS = 0 TotalLinux = 0 TotalUnknown = 0 Delete = [ordered] @{ LastLogonDays = [ordered ]@{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } Disable = [ordered] @{ LastLogonDays = [ordered] @{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } 'Not required' = [ordered] @{ LastLogonDays = [ordered] @{} PasswordLastChangedDays = [ordered] @{} Systems = [ordered] @{} } } foreach ($Computer in $ComputersToProcess) { if ($Computer.OperatingSystem -like "Windows Server*") { $Statistics.TotalWindowsServers++ } elseif ($Computer.OperatingSystem -notlike "Windows Server*" -and $Computer.OperatingSystem -like "Windows*") { $Statistics.TotalWindowsWorkstations++ } elseif ($Computer.OperatingSystem -like "Mac*") { $Statistics.TotalMacOS++ } elseif ($Computer.OperatingSystem -like "Linux*") { $Statistics.TotalLinux++ } else { $Statistics.TotalUnknown++ } if ($Computer.Action -eq 'Disable') { $Statistics.ToDisable++ if ($Computer.OperatingSystem -like "Windows Server*") { $Statistics.ToDisableComputerServer++ } elseif ($Computer.OperatingSystem -notlike "Windows Server*" -and $Computer.OperatingSystem -like "Windows*") { $Statistics.ToDisableComputerWorkstation++ } else { $Statistics.ToDisableComputerUnknown++ } } elseif ($Computer.Action -eq 'Delete') { $Statistics.ToDelete++ if ($Computer.OperatingSystem -like "Windows Server*") { $Statistics.ToDeleteComputerServer++ } elseif ($Computer.OperatingSystem -notlike "Windows Server*" -and $Computer.OperatingSystem -like "Windows*") { $Statistics.ToDeleteComputerWorkstation++ } else { $Statistics.ToDeleteComputerUnknown++ } } if ($Computer.OperatingSystem) { $Statistics[$Computer.Action]['Systems'][$Computer.OperatingSystem]++ } else { $Statistics[$Computer.Action]['Systems']['Unknown']++ } if ($Computer.LastLogonDays -gt 720) { $Statistics[$Computer.Action]['LastLogonDays']['Over 720 days']++ } elseif ($Computer.LastLogonDays -gt 360) { $Statistics[$Computer.Action]['LastLogonDays']['Over 360 days']++ } elseif ($Computer.LastLogonDays -gt 180) { $Statistics[$Computer.Action]['LastLogonDays']['Over 180 days']++ } elseif ($Computer.LastLogonDays -gt 90) { $Statistics[$Computer.Action]['LastLogonDays']['Over 90 days']++ } elseif ($Computer.LastLogonDays -gt 30) { $Statistics[$Computer.Action]['LastLogonDays']['Over 30 days']++ } else { $Statistics[$Computer.Action]['LastLogonDays']['Under 30 days']++ } if ($Computer.PasswordLastChangedDays -gt 720) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 720 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 360) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 360 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 180) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 180 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 90) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 90 days']++ } elseif ($Computer.PasswordLastChangedDays -gt 30) { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Over 30 days']++ } else { $Statistics[$Computer.Action]['PasswordLastChangedDays']['Under 30 days']++ } } $Statistics } function New-HTMLProcessedComputers { [CmdletBinding()] param( [System.Collections.IDictionary] $Export, [System.Collections.IDictionary] $DisableOnlyIf, [System.Collections.IDictionary] $DeleteOnlyIf, [Array] $ComputersToProcess, [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 'Devices 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 "Computers 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 "Computers 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 'Devices 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 "Computers 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 "Computers 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-HTMLTab -Name 'Devices Pending' { New-HTMLTable -DataTable $Export.PendingDeletion.Values -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-HTMLTab -Name 'Devices' { New-HTMLSection { New-HTMLPanel { New-HTMLToast -TextHeader 'Total' -Text "Computers Total: $($ComputersToProcess.Count)" -BarColorLeft MintGreen -IconSolid info-circle -IconColor MintGreen } -Invisible New-HTMLPanel { New-HTMLToast -TextHeader 'To disable' -Text "Computers to be disabled: $($Export.Statistics.ToDisable)" -BarColorLeft OrangePeel -IconSolid info-circle -IconColor OrangePeel } -Invisible New-HTMLPanel { New-HTMLToast -TextHeader 'To delete' -Text "Computers to be deleted: $($Export.Statistics.ToDelete)" -BarColorLeft OrangeRed -IconSolid info-circle -IconColor OrangeRed } -Invisible } -Invisible New-HTMLSection -HeaderText 'General statistics' -CanCollapse { New-HTMLPanel { New-HTMLChart { New-ChartPie -Name 'To be disabled' -Value $Export.Statistics.ToDisable New-ChartPie -Name 'To be deleted' -Value $Export.Statistics.ToDelete } -Title "Computers to be disabled or deleted" } if ($Export.Statistics.ToDisableComputerWorkstation -or $Export.Statistics.ToDisableComputerServer -or $Export.Statistics.ToDisableComputerUnknown) { New-HTMLPanel { New-HTMLChart { New-ChartPie -Name "Disable workstations" -Value $Export.Statistics.ToDisableComputerWorkstation New-ChartPie -Name "Disable servers" -Value $Export.Statistics.ToDisableComputerServer New-ChartPie -Name "Disable unknown" -Value $Export.Statistics.ToDisableComputerUnknown } -Title "Computers to be disabled by type" } } if ($Export.Statistics.ToDeleteComputerWorkstation -or $Export.Statistics.ToDeleteComputerServer -or $Export.Statistics.ToDeleteComputerUnknown) { New-HTMLPanel { New-HTMLChart { New-ChartPie -Name "Delete workstations" -Value $Export.Statistics.ToDeleteComputerWorkstation New-ChartPie -Name "Delete servers" -Value $Export.Statistics.ToDeleteComputerServer New-ChartPie -Name "Delete unknown" -Value $Export.Statistics.ToDeleteComputerUnknown } -Title "Computers to be deleted by type" } } } New-HTMLText -LineBreak New-HTMLHeading -Heading h3 -HeadingText "Full list of computers that will be processed if there are no limits to processing. " New-HTMLText -LineBreak New-HTMLSection -Invisible { New-HTMLPanel { if ($Disable) { New-HTMLText -Text "Computers will be disabled only if: " -FontWeight bold New-HTMLList { foreach ($Key in $DisableOnlyIf.Keys) { New-HTMLListItem -Text @( if ($null -eq $DisableOnlyIf[$Key] -or $DisableOnlyIf[$Key].Count -eq 0) { $($Key), " is ", 'Not Set' $ColorInUse = 'Cinnabar' } else { if ($Key -in 'LastLogonDateMoreThan', 'LastLogonDateOlderThan') { $($Key), " is ", $($DisableOnlyIf[$Key]), " or ", "Never logged on" } elseif ($Key -in 'PasswordLastSetMoreThan', 'PasswordLastSetOlderThan') { $($Key), " is ", $($DisableOnlyIf[$Key]), " or ", "Never changed" } elseif ($Key -in 'LastSeenAzureMoreThan', 'LastSeenIntuneMoreThan', 'LastSyncAzureMoreThan', 'LastContactJamfMoreThan') { $($Key), " is ", $($DisableOnlyIf[$Key]), " or ", "Never synced/seen" } else { $($Key), " is ", $($DisableOnlyIf[$Key]) } $ColorInUse = 'Apple' } ) -FontWeight bold, normal, bold, normal, bold -Color $ColorInUse, None, CornflowerBlue, None, CornflowerBlue } } } else { New-HTMLText -Text "Computers will not be disabled, as the disable functionality was not enabled. " -FontWeight bold } } New-HTMLPanel { if ($Delete) { New-HTMLText -Text "Computers will be deleted only if: " -FontWeight bold New-HTMLList { foreach ($Key in $DeleteOnlyIf.Keys) { New-HTMLListItem -Text @( if ($null -eq $DeleteOnlyIf[$Key] -or $DeleteOnlyIf[$Key].Count -eq 0) { $($Key), " is ", 'Not Set' $ColorInUse = 'Cinnabar' } else { if ($Key -in 'LastLogonDateMoreThan', 'LastLogonDateOlderThan') { $($Key), " is ", $($DeleteOnlyIf[$Key]), " or ", "Never logged on" } elseif ($Key -in 'PasswordLastSetMoreThan', 'PasswordLastSetOlderThan') { $($Key), " is ", $($DeleteOnlyIf[$Key]), " or ", "Never changed" } elseif ($Key -in 'LastSeenAzureMoreThan', 'LastSeenIntuneMoreThan', 'LastSyncAzureMoreThan', 'LastContactJamfMoreThan') { $($Key), " is ", $($DeleteOnlyIf[$Key]), " or ", "Never synced/seen" } else { $($Key), " is ", $($DeleteOnlyIf[$Key]) } $ColorInUse = 'Apple' } ) -FontWeight bold, normal, bold, normal, bold -Color $ColorInUse, None, CornflowerBlue, None, CornflowerBlue } } } else { New-HTMLText -Text "Computers will not be deleted, as the delete functionality was not enabled. " -FontWeight bold } } } New-HTMLTable -DataTable $ComputersToProcess -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 } if ($LogFile -and (Test-Path -LiteralPath $LogFile)) { $LogContent = Get-Content -Raw -LiteralPath $LogFile New-HTMLTab -Name 'Log' { New-HTMLCodeBlock -Code $LogContent -Style generic } } } -FilePath $FilePath -Online:$Online.IsPresent -ShowHTML:$ShowHTML.IsPresent } function Request-ADComputersDelete { [cmdletBinding(SupportsShouldProcess)] param( [System.Collections.IDictionary] $Report, [switch] $ReportOnly, [switch] $WhatIfDelete, [int] $DeleteLimit, [System.Collections.IDictionary] $ProcessedComputers, [DateTime] $Today ) $CountDeleteLimit = 0 # :top means name of the loop, so we can break it :topLoop foreach ($Domain in $Report.Keys) { foreach ($Computer in $Report["$Domain"]['Computers']) { $Server = $Report["$Domain"]['Server'] if ($Computer.Action -ne 'Delete') { continue } if ($ReportOnly) { $Computer } else { Write-Color -Text "[i] Deleting computer ", $Computer.SamAccountName, ' DN: ', $Computer.DistinguishedName, ' Enabled: ', $Computer.Enabled, ' Operating System: ', $Computer.OperatingSystem, ' LastLogon: ', $Computer.LastLogonDate, " / " , $Computer.LastLogonDays , ' days, PasswordLastSet: ', $Computer.PasswordLastSet, " / ", $Computer.PasswordLastChangedDays, " days" -Color Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green try { $Success = $true Remove-ADObject -Identity $Computer.DistinguishedName -Recursive -WhatIf:$WhatIfDelete -Server $Server -ErrorAction Stop -Confirm:$false Write-Color -Text "[+] Deleting computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfDelete.IsPresent)) successful." -Color Yellow, Green, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Warning -Category 1000 -Source 'CleanupComputers' -Message "Deleting computer $($Computer.SamAccountName) successful." -AdditionalFields @('Delete', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDelete) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } } catch { $Success = $false Write-Color -Text "[-] Deleting computer ", $Computer.DistinguishedName, " (WhatIf: $($WhatIfDelete.IsPresent)) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Error -Category 1000 -Source 'CleanupComputers' -Message "Deleting computer $($Computer.SamAccountName) failed." -AdditionalFields @('Delete', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDelete, $($_.Exception.Message)) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } $Computer.ActionComment = $_.Exception.Message } $Computer.ActionDate = $Today if ($WhatIfDelete.IsPresent) { $Computer.ActionStatus = 'WhatIf' } else { $Computer.ActionStatus = $Success } $ProcessedComputers.Remove("$($Computer.DistinguishedName)") # return computer to $ReportDeleted so we can see summary just in case $Computer $CountDeleteLimit++ if ($DeleteLimit) { if ($DeleteLimit -eq $CountDeleteLimit) { break topLoop # this breaks top loop } } } } } } function Request-ADComputersDisable { [cmdletbinding(SupportsShouldProcess)] param( [System.Collections.IDictionary] $Report, [switch] $WhatIfDisable, [switch] $DisableModifyDescription, [switch] $DisableModifyAdminDescription, [int] $DisableLimit, [switch] $ReportOnly, [DateTime] $Today ) $CountDisable = 0 # :top means name of the loop, so we can break it :topLoop foreach ($Domain in $Report.Keys) { Write-Color "[i] ", "Starting process of disabling computers for domain $Domain" -Color Yellow, Green foreach ($Computer in $Report["$Domain"]['Computers']) { $Server = $Report["$Domain"]['Server'] if ($Computer.Action -ne 'Disable') { continue } if ($ReportOnly) { $Computer } else { Write-Color -Text "[i] Disabling computer ", $Computer.SamAccountName, ' DN: ', $Computer.DistinguishedName, ' Enabled: ', $Computer.Enabled, ' Operating System: ', $Computer.OperatingSystem, ' LastLogon: ', $Computer.LastLogonDate, " / " , $Computer.LastLogonDays , ' days, PasswordLastSet: ', $Computer.PasswordLastSet, " / ", $Computer.PasswordLastChangedDays, " days" -Color Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green, Yellow, Green try { Disable-ADAccount -Identity $Computer.DistinguishedName -Server $Server -WhatIf:$WhatIfDisable -ErrorAction Stop Write-Color -Text "[+] Disabling computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) successful." -Color Yellow, Green, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Information -Category 1000 -Source 'CleanupComputers' -Message "Disabling computer $($Computer.SamAccountName) successful." -AdditionalFields @('Disable', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDisable) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } $Success = $true } catch { $Computer.ActionComment = $_.Exception.Message $Success = $false Write-Color -Text "[-] Disabling computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow Write-Event -ID 10 -LogName 'Application' -EntryType Error -Category 1001 -Source 'CleanupComputers' -Message "Disabling computer $($Computer.SamAccountName) failed. Error: $($_.Exception.Message)" -AdditionalFields @('Disable', $Computer.SamAccountName, $Computer.DistinguishedName, $Computer.Enabled, $Computer.OperatingSystem, $Computer.LastLogonDate, $Computer.PasswordLastSet, $WhatIfDisable, $($_.Exception.Message)) -WarningAction SilentlyContinue -WarningVariable warnings foreach ($W in $Warnings) { Write-Color -Text "[-] ", "Warning: ", $W -Color Yellow, Cyan, Red } } if ($Success) { if ($DisableModifyDescription -eq $true) { $DisableModifyDescriptionText = "Disabled by a script, LastLogon $($Computer.LastLogonDate) ($($DisableOnlyIf.LastLogonDateMoreThan)), PasswordLastSet $($Computer.PasswordLastSet) ($($DisableOnlyIf.PasswordLastSetMoreThan))" try { Set-ADComputer -Identity $Computer.DistinguishedName -Description $DisableModifyDescriptionText -WhatIf:$WhatIfDisable -ErrorAction Stop -Server $Server Write-Color -Text "[+] ", "Setting description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) successful. Set to: ", $DisableModifyDescriptionText -Color Yellow, Green, Yellow, Green, Yellow } catch { $Computer.ActionComment = $Computer.ActionComment + [System.Environment]::NewLine + $_.Exception.Message Write-Color -Text "[-] ", "Setting description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow } } if ($DisableModifyAdminDescription) { $DisableModifyAdminDescriptionText = "Disabled by a script, LastLogon $($Computer.LastLogonDate) ($($DisableOnlyIf.LastLogonDateMoreThan)), PasswordLastSet $($Computer.PasswordLastSet) ($($DisableOnlyIf.PasswordLastSetMoreThan))" try { Set-ADObject -Identity $Computer.DistinguishedName -Replace @{ AdminDescription = $DisableModifyAdminDescriptionText } -WhatIf:$WhatIfDisable -ErrorAction Stop -Server $Server Write-Color -Text "[+] ", "Setting admin description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) successful. Set to: ", $DisableModifyAdminDescriptionText -Color Yellow, Green, Yellow, Green, Yellow } catch { $Computer.ActionComment + [System.Environment]::NewLine + $_.Exception.Message Write-Color -Text "[-] ", "Setting admin description on disabled computer ", $Computer.DistinguishedName, " (WhatIf: $WhatIfDisable) failed. Error: $($_.Exception.Message)" -Color Yellow, Red, Yellow } } } # this is to store actual disabling time - we can't trust WhenChanged date $Computer.ActionDate = $Today if ($WhatIfDisable.IsPresent) { $Computer.ActionStatus = 'WhatIf' } else { $Computer.ActionStatus = $Success } $ProcessedComputers["$($Computer.DistinguishedName)"] = $Computer # return computer to $ReportDisabled so we can see summary just in case $Computer $CountDisable++ if ($DisableLimit) { if ($DisableLimit -eq $CountDisable) { break topLoop # this breaks top loop } } } } } } function Set-LoggingCapabilities { [CmdletBinding()] param( [string] $LogPath, [int] $LogMaximum, [switch] $ShowTime, [string] $TimeFormat ) $Script:PSDefaultParameterValues = @{ "Write-Color:LogFile" = $LogPath "Write-Color:ShowTime" = if ($PSBoundParameters.ContainsKey('ShowTime')) { $ShowTime.IsPresent } else { $null } "Write-Color:TimeFormat" = $TimeFormat } Remove-EmptyValue -Hashtable $Script:PSDefaultParameterValues if ($LogPath) { $FolderPath = [io.path]::GetDirectoryName($LogPath) if (-not (Test-Path -LiteralPath $FolderPath)) { $null = New-Item -Path $FolderPath -ItemType Directory -Force -WhatIf:$false } if ($LogMaximum -gt 0) { $CurrentLogs = Get-ChildItem -LiteralPath $FolderPath | Sort-Object -Property CreationTime -Descending | Select-Object -Skip $LogMaximum if ($CurrentLogs) { Write-Color -Text '[i] ', "Logs directory has more than ", $LogMaximum, " log files. Cleanup required..." -Color Yellow, DarkCyan, Red, DarkCyan foreach ($Log in $CurrentLogs) { try { Remove-Item -LiteralPath $Log.FullName -Confirm:$false -WhatIf:$false Write-Color -Text '[+] ', "Deleted ", "$($Log.FullName)" -Color Yellow, White, Green } catch { Write-Color -Text '[-] ', "Couldn't delete log file $($Log.FullName). Error: ', "$($_.Exception.Message) -Color Yellow, White, Red } } } } else { Write-Color -Text '[i] ', "LogMaximum is set to 0 (Unlimited). No log files will be deleted." -Color Yellow, DarkCyan } } } function Set-ReportingCapabilities { [CmdletBinding()] param( [string] $ReportPath, [int] $ReportMaximum ) if ($ReportPath) { $FolderPath = [io.path]::GetDirectoryName($ReportPath) if (-not (Test-Path -LiteralPath $FolderPath)) { $null = New-Item -Path $FolderPath -ItemType Directory -Force -WhatIf:$false } if ($ReportMaximum -gt 0) { $CurrentLogs = Get-ChildItem -LiteralPath $FolderPath | Sort-Object -Property CreationTime -Descending | Select-Object -Skip $ReportMaximum if ($CurrentLogs) { Write-Color -Text '[i] ', "Reporting directory has more than ", $ReportMaximum, " report files. Cleanup required..." -Color Yellow, DarkCyan, Red, DarkCyan foreach ($Report in $CurrentLogs) { try { Remove-Item -LiteralPath $Report.FullName -Confirm:$false -WhatIf:$false Write-Color -Text '[+] ', "Deleted ", "$($Report.FullName)" -Color Yellow, White, Green } catch { Write-Color -Text '[-] ', "Couldn't delete report file $($Report.FullName). Error: ', "$($_.Exception.Message) -Color Yellow, White, Red } } } } else { Write-Color -Text '[i] ', "ReportMaximum is set to 0 (Unlimited). No report files will be deleted." -Color Yellow, DarkCyan } } } function Invoke-ADComputersCleanup { <# .SYNOPSIS Active Directory Cleanup function that can disable or delete computers that have not been logged on for a certain amount of time. .DESCRIPTION Active Directory Cleanup function that can disable or delete computers that have not been logged on for a certain amount of time. It has many options to customize the cleanup process. .PARAMETER Disable Enable the disable process, meaning the computers that meet the criteria will be disabled. .PARAMETER Delete Enable the delete process, meaning the computers that meet the criteria will be deleted. .PARAMETER DisableIsEnabled Disable computer only if it's Enabled or only if it's Disabled. By default it will try to disable all computers that are either disabled or enabled. While counter-intuitive for already disabled computers, this is useful if you want preproceess computers for deletion and need to get them on the list. .PARAMETER DisableNoServicePrincipalName Disable computer only if it has a ServicePrincipalName or only if it doesn't have a ServicePrincipalName. By default it doesn't care if it has a ServicePrincipalName or not. .PARAMETER DisableLastLogonDateMoreThan Disable computer only if it has a LastLogonDate that is more than the specified number of days. .PARAMETER DisablePasswordLastSetMoreThan Disable computer only if it has a PasswordLastSet that is more than the specified number of days. .PARAMETER DisablePasswordLastSetOlderThan Disable computer only if it has a PasswordLastSet that is older than the specified date. .PARAMETER DisableLastLogonDateOlderThan Disable computer only if it has a LastLogonDate that is older than the specified date. .PARAMETER DisableLastSeenAzureMoreThan Disable computer only if it Last Seen in Azure is more than the specified number of days. Please note that you need to make connection to Azure using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DisableLastSeenIntuneMoreThan Disable computer only if it Last Seen in Intune is more than the specified number of days. Please note that you need to make connection to Intune using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DisableLastSyncAzureMoreThan Disable computer only if it Last Synced in Azure is more than the specified number of days. Please note that you need to make connection to Azure AD using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DisableLastContactJamfMoreThan Disable computer only if it Last Contacted in Jamf is more than the specified number of days. Please note that you need to make connection to Jamf using PowerJamf PowerShell Module first. Additionally you will need PowerJamf PowerShell Module installed. .PARAMETER DisableExcludeSystems Disable computer only if it's not on the list of excluded operating systems. If you want to exclude Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. It's using OperatingSystem property of the computer object for comparison. .PARAMETER DisableIncludeSystems Disable computer only if it's on the list of included operating systems. If you want to include Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. .PARAMETER DeleteIsEnabled Delete computer only if it's Enabled or only if it's Disabled. By default it will try to delete all computers that are either disabled or enabled. .PARAMETER DeleteNoServicePrincipalName Delete computer only if it has a ServicePrincipalName or only if it doesn't have a ServicePrincipalName. By default it doesn't care if it has a ServicePrincipalName or not. .PARAMETER DeleteLastLogonDateMoreThan Delete computer only if it has a LastLogonDate that is more than the specified number of days. .PARAMETER DeletePasswordLastSetMoreThan Delete computer only if it has a PasswordLastSet that is more than the specified number of days. .PARAMETER DeleteListProcessedMoreThan Delete computer only if it has been processed by this script more than the specified number of days ago. This is useful if you want to delete computers that have been disabled for a certain amount of time. It uses XML file to store the list of processed computers, so please make sure to not remove it or it will start over. .PARAMETER DeletePasswordLastSetOlderThan Delete computer only if it has a PasswordLastSet that is older than the specified date. .PARAMETER DeleteLastLogonDateOlderThan Delete computer only if it has a LastLogonDate that is older than the specified date. .PARAMETER DeleteLastSeenAzureMoreThan Delete computer only if it Last Seen in Azure is more than the specified number of days. Please note that you need to make connection to Azure using Connect-MgGraph with proper permissions first. Additionally yopu will need GraphEssentials PowerShell Module installed. .PARAMETER DeleteLastSeenIntuneMoreThan Delete computer only if it Last Seen in Intune is more than the specified number of days. Please note that you need to make connection to Intune using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DeleteLastSyncAzureMoreThan Delete computer only if it Last Synced in Azure is more than the specified number of days. Please note that you need to make connection to Azure AD using Connect-MgGraph with proper permissions first. Additionally you will need GraphEssentials PowerShell Module installed. .PARAMETER DeleteLastContactJamfMoreThan Delete computer only if it Last Contacted in Jamf is more than the specified number of days. Please note that you need to make connection to Jamf using PowerJamf PowerShell Module first. Additionally you will need PowerJamf PowerShell Module installed. .PARAMETER DeleteExcludeSystems Delete computer only if it's not on the list of excluded operating systems. If you want to exclude Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. It's using OperatingSystem property of the computer object for comparison. .PARAMETER DeleteIncludeSystems Delete computer only if it's on the list of included operating systems. If you want to include Windows 10, you can specify 'Windows 10' or 'Windows 10*' or 'Windows 10*' or '*Windows 10*' or '*Windows 10*'. You can also specify multiple operating systems by separating them with a comma. It's using the -like operator, so you can use wildcards. .PARAMETER DeleteLimit Limit the number of computers that will be deleted. 0 = unlimited. Default is 1. This is to prevent accidental deletion of all computers that meet the criteria. Adjust the limit to your needs. .PARAMETER DisableLimit Limit the number of computers that will be disabled. 0 = unlimited. Default is 1. This is to prevent accidental disabling of all computers that meet the criteria. Adjust the limit to your needs. .PARAMETER Exclusions List of computers to exclude from the process. You can specify multiple computers by separating them with a comma. It's using the -like operator, so you can use wildcards. You can use SamAccoutName (remember about ending $), DistinguishedName, or DNSHostName property of the computer object for comparison. .PARAMETER DisableModifyDescription Modify the description of the computer object to include the date and time when it was disabled. By default it will not modify the description. .PARAMETER DisableModifyAdminDescription Modify the admin description of the computer object to include the date and time when it was disabled. By default it will not modify the admin description. .PARAMETER Filter Filter to use when searching for computers in Get-ADComputer cmdlet. Default is '*' .PARAMETER DataStorePath Path to the XML file that will be used to store the list of processed computers, current run, and history data. Default is $PSScriptRoot\ProcessedComputers.xml .PARAMETER ReportOnly Only generate the report, don't disable or delete computers. .PARAMETER ReportMaximum Maximum number of reports to keep. Default is Unlimited (0). .PARAMETER WhatIfDelete WhatIf parameter for the Delete process. It's not nessessary to specify this parameter if you use WhatIf parameter which applies to both processes. .PARAMETER WhatIfDisable WhatIf parameter for the Disable process. It's not nessessary to specify this parameter if you use WhatIf parameter which applies to both processes. .PARAMETER LogPath Path to the log file. Default is no logging to file. .PARAMETER LogMaximum Maximum number of log files to keep. Default is 5. .PARAMETER Suppress Suppress output of the object and only display to console .PARAMETER ShowHTML Show HTML report in the browser once the function is complete .PARAMETER Online Online parameter causes HTML report to use CDN for CSS and JS files. This can be useful to minimize the size of the HTML report. Otherwise the report will start with at least 2MB in size. .PARAMETER ReportPath Path to the HTML report file. Default is $PSScriptRoot\ProcessedComputers.html .PARAMETER SafetyADLimit Minimum number of computers that must be returned by AD cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with AD. .PARAMETER SafetyAzureADLimit Minimum number of computers that must be returned by AzureAD cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with AzureAD. It only applies if Azure AD parameters are used. .PARAMETER SafetyIntuneLimit Minimum number of computers that must be returned by Intune cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with Intune. It only applies if Intune parameters are used. .PARAMETER SafetyJamfLimit Minimum number of computers that must be returned by Jamf cmdlets to proceed with the process. Default is not to check. This is there to prevent accidental deletion of all computers if there is a problem with Jamf. It only applies if Jamf parameters are used. .EXAMPLE $Output = Invoke-ADComputersCleanup -DeleteIsEnabled $false -Delete -WhatIfDelete -ShowHTML -ReportOnly -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 $Output .EXAMPLE $Output = Invoke-ADComputersCleanup -DeleteListProcessedMoreThan 100 -Disable -DeleteIsEnabled $false -Delete -WhatIfDelete -ShowHTML -ReportOnly -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 $Output .EXAMPLE # this is a fresh run and it will provide report only according to it's defaults $Output = Invoke-ADComputersCleanup -WhatIf -ReportOnly -Disable -Delete -ShowHTML $Output .EXAMPLE # this is a fresh run and it will try to disable computers according to it's defaults # read documentation to understand what it does $Output = Invoke-ADComputersCleanup -Disable -ShowHTML -WhatIfDisable -WhatIfDelete -Delete $Output .EXAMPLE # this is a fresh run and it will try to delete computers according to it's defaults # read documentation to understand what it does $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 $Output .EXAMPLE # 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 DisableAdminModifyDescription = $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 $Output .NOTES General notes #> [CmdletBinding(SupportsShouldProcess)] param( [switch] $Disable, [switch] $Delete, [nullable[bool]] $DisableIsEnabled, [nullable[bool]] $DisableNoServicePrincipalName, [nullable[int]] $DisableLastLogonDateMoreThan = 180, [nullable[int]] $DisablePasswordLastSetMoreThan = 180, [nullable[DateTime]] $DisablePasswordLastSetOlderThan, [nullable[DateTime]] $DisableLastLogonDateOlderThan, [nullable[int]] $DisableLastSeenAzureMoreThan, [nullable[int]] $DisableLastSeenIntuneMoreThan, [nullable[int]] $DisableLastSyncAzureMoreThan, [nullable[int]] $DisableLastContactJamfMoreThan, [Array] $DisableExcludeSystems = @(), [Array] $DisableIncludeSystems = @(), [nullable[bool]] $DeleteIsEnabled, [nullable[bool]] $DeleteNoServicePrincipalName, [nullable[int]] $DeleteLastLogonDateMoreThan = 180, [nullable[int]] $DeletePasswordLastSetMoreThan = 180, [nullable[int]] $DeleteListProcessedMoreThan, [nullable[DateTime]] $DeletePasswordLastSetOlderThan, [nullable[DateTime]] $DeleteLastLogonDateOlderThan, [nullable[int]] $DeleteLastSeenAzureMoreThan, [nullable[int]] $DeleteLastSeenIntuneMoreThan, [nullable[int]] $DeleteLastSyncAzureMoreThan, [nullable[int]] $DeleteLastContactJamfMoreThan, [Array] $DeleteExcludeSystems = @(), [Array] $DeleteIncludeSystems = @(), [int] $DeleteLimit = 1, # 0 = unlimited [int] $DisableLimit = 1, # 0 = unlimited [Array] $Exclusions = @( # default exclusions '*OU=Domain Controllers*' ), [switch] $DisableModifyDescription, [alias('DisableAdminModifyDescription')][switch] $DisableModifyAdminDescription, [string] $Filter = '*', [string] $DataStorePath, [switch] $ReportOnly, [int] $ReportMaximum, [switch] $WhatIfDelete, [switch] $WhatIfDisable, [string] $LogPath, [int] $LogMaximum = 5, [switch] $Suppress, [switch] $ShowHTML, [switch] $Online, [string] $ReportPath, [nullable[int]] $SafetyADLimit, [nullable[int]] $SafetyAzureADLimit, [nullable[int]] $SafetyIntuneLimit, [nullable[int]] $SafetyJamfLimit ) # we will use it to check for intune/azuread/jamf functionality $Script:CleanupOptions = [ordered] @{} # just in case user wants to use -WhatIf instead of -WhatIfDelete and -WhatIfDisable if (-not $WhatIfDelete -and -not $WhatIfDisable) { $WhatIfDelete = $WhatIfDisable = $WhatIfPreference } # lets enable global logging Set-LoggingCapabilities -LogPath $LogPath -LogMaximum $LogMaximum # prepare configuration $DisableOnlyIf = [ordered] @{ # Active directory IsEnabled = $DisableIsEnabled NoServicePrincipalName = $DisableNoServicePrincipalName LastLogonDateMoreThan = $DisableLastLogonDateMoreThan PasswordLastSetMoreThan = $DisablePasswordLastSetMoreThan ExcludeSystems = $DisableExcludeSystems IncludeSystems = $DisableIncludeSystems PasswordLastSetOlderThan = $DisablePasswordLastSetOlderThan LastLogonDateOlderThan = $DisableLastLogonDateOlderThan # Intune LastSeenIntuneMoreThan = $DisableLastSeenIntuneMoreThan # Azure LastSyncAzureMoreThan = $DisableLastSyncAzureMoreThan LastSeenAzureMoreThan = $DisableLastSeenAzureMoreThan # Jamf LastContactJamfMoreThan = $DisableLastContactJamfMoreThan } $DeleteOnlyIf = [ordered] @{ # Active directory IsEnabled = $DeleteIsEnabled NoServicePrincipalName = $DeleteNoServicePrincipalName LastLogonDateMoreThan = $DeleteLastLogonDateMoreThan PasswordLastSetMoreThan = $DeletePasswordLastSetMoreThan ListProcessedMoreThan = $DeleteListProcessedMoreThan ExcludeSystems = $DeleteExcludeSystems IncludeSystems = $DeleteIncludeSystems PasswordLastSetOlderThan = $DeletePasswordLastSetOlderThan LastLogonDateOlderThan = $DeleteLastLogonDateOlderThan # Intune LastSeenIntuneMoreThan = $DeleteLastSeenIntuneMoreThan # Azure LastSeenAzureMoreThan = $DeleteLastSeenAzureMoreThan LastSyncAzureMoreThan = $DeleteLastSyncAzureMoreThan # Jamf LastContactJamfMoreThan = $DeleteLastContactJamfMoreThan } if (-not $DataStorePath) { $DataStorePath = $($MyInvocation.PSScriptRoot) + '\ProcessedComputers.xml' } if (-not $ReportPath) { $ReportPath = $($MyInvocation.PSScriptRoot) + '\ProcessedComputers.html' } # lets create report path, reporting is enabled by default Set-ReportingCapabilities -ReportPath $ReportPath -ReportMaximum $ReportMaximum $Today = Get-Date $Properties = 'DistinguishedName', 'DNSHostName', 'SamAccountName', 'Enabled', 'OperatingSystem', 'OperatingSystemVersion', 'LastLogonDate', 'PasswordLastSet', 'PasswordExpired', 'servicePrincipalName', 'logonCount', 'ManagedBy', 'Description', 'WhenCreated', 'WhenChanged' $Export = [ordered] @{ Version = Get-GitHubVersion -Cmdlet 'Invoke-ADComputersCleanup' -RepositoryOwner 'evotecit' -RepositoryName 'CleanupMonster' CurrentRun = $null History = $null PendingDeletion = $null } Write-Color '[i] ', "[CleanupMonster] ", 'Version', ' [Informative] ', $Export['Version'] -Color Yellow, DarkGray, Yellow, DarkGray, Magenta Write-Color -Text "[i] Started process of cleaning up stale computers" -Color Green Write-Color -Text "[i] Executed by: ", $Env:USERNAME, ' from domain ', $Env:USERDNSDOMAIN -Color Green try { $Forest = Get-ADForest } catch { Write-Color -Text "[i] ", "Couldn't get forest. Terminating. Lack of domain contact? Error: $($_.Exception.Message)." -Color Yellow, Red return } if (-not $ReportOnly) { $ProcessedComputers = Import-ComputersData -Export $Export -DataStorePath $DataStorePath } if (-not $Disable -and -not $Delete) { Write-Color -Text "[i] ", "No action can be taken. You need to enable Disable or/and Delete feature to have any action." -Color Yellow, Red return } $Report = [ordered] @{} $getInitialGraphComputersSplat = [ordered] @{ SafetyAzureADLimit = $SafetyAzureADLimit SafetyIntuneLimit = $SafetyIntuneLimit DeleteLastSeenAzureMoreThan = $DeleteLastSeenAzureMoreThan DeleteLastSeenIntuneMoreThan = $DeleteLastSeenIntuneMoreThan DeleteLastSyncAzureMoreThan = $DeleteLastSyncAzureMoreThan DisableLastSeenAzureMoreThan = $DisableLastSeenAzureMoreThan DisableLastSeenIntuneMoreThan = $DisableLastSeenIntuneMoreThan DisableLastSyncAzureMoreThan = $DisableLastSyncAzureMoreThan } Remove-EmptyValue -Hashtable $getInitialGraphComputersSplat $AzureInformationCache = Get-InitialGraphComputers @getInitialGraphComputersSplat if ($AzureInformationCache -eq $false) { return } $getInitialJamf = @{ DisableLastContactJamfMoreThan = $DisableLastContactJamfMoreThan DeleteLastContactJamfMoreThan = $DeleteLastContactJamfMoreThan SafetyJamfLimit = $SafetyJamfLimit } Remove-EmptyValue -Hashtable $getInitialJamf $JamfInformationCache = Get-InitialJamfComputers @getInitialJamf if ($JamfInformationCache -eq $false) { return } $SplatADComputers = [ordered] @{ Report = $Report Forest = $Forest Filter = $Filter Properties = $Properties Disable = $Disable Delete = $Delete DisableLastLogonDateMoreThan = $DisableLastLogonDateMoreThan DeleteLastLogonDateMoreThan = $DeleteLastLogonDateMoreThan DeleteNoServicePrincipalName = $DeleteNoServicePrincipalName DisableNoServicePrincipalName = $DisableNoServicePrincipalName DeleteIsEnabled = $DeleteIsEnabled DisableIsEnabled = $DisableIsEnabled DisablePasswordLastSetMoreThan = $DisablePasswordLastSetMoreThan DeletePasswordLastSetMoreThan = $DeletePasswordLastSetMoreThan DisableOnlyIf = $DisableOnlyIf DeleteOnlyIf = $DeleteOnlyIf Exclusions = $Exclusions ProcessedComputers = $ProcessedComputers SafetyADLimit = $SafetyADLimit AzureInformationCache = $AzureInformationCache JamfInformationCache = $JamfInformationCache } $AllComputers = Get-InitialADComputers @SplatADComputers if ($AllComputers -eq $false) { return } foreach ($Domain in $Report.Keys) { if ($Disable) { if ($DisableLimit -eq 0) { $DisableLimitText = 'Unlimited' } else { $DisableLimitText = $DisableLimit } Write-Color "[i] ", "Computers to be disabled for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDisabled'], ". Current disable limit: ", $DisableLimitText -Color Yellow, Cyan, Green, Cyan, Yellow } if ($Delete) { if ($DeleteLimit -eq 0) { $DeleteLimitText = 'Unlimited' } else { $DeleteLimitText = $DeleteLimit } Write-Color "[i] ", "Computers to be deleted for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDeleted'], ". Current delete limit: ", $DeleteLimitText -Color Yellow, Cyan, Green, Cyan, Yellow } } if ($Disable) { $requestADComputersDisableSplat = @{ Report = $Report WhatIfDisable = $WhatIfDisable WhatIf = $WhatIfPreference DisableModifyDescription = $DisableModifyDescription.IsPresent DisableModifyAdminDescription = $DisableModifyAdminDescription.IsPresent DisableLimit = $DisableLimit ReportOnly = $ReportOnly Today = $Today } [Array] $ReportDisabled = Request-ADComputersDisable @requestADComputersDisableSplat } if ($Delete) { $requestADComputersDeleteSplat = @{ Report = $Report WhatIfDelete = $WhatIfDelete WhatIf = $WhatIfPreference DeleteLimit = $DeleteLimit ReportOnly = $ReportOnly Today = $Today } [Array] $ReportDeleted = Request-ADComputersDelete @requestADComputersDeleteSplat } Write-Color "[i] ", "Cleanup process for processed computers that no longer exists in AD" -Color Yellow, Green foreach ($DN in [string[]] $ProcessedComputers.Keys) { if (-not $AllComputers["$($DN)"]) { Write-Color -Text "[*] Removing computer from pending list ", $ProcessedComputers[$DN].SamAccountName, " ($DN)" -Color Yellow, Green, Yellow $ProcessedComputers.Remove("$($DN)") } } # Building up summary $Export.PendingDeletion = $ProcessedComputers $Export.CurrentRun = @($ReportDisabled + $ReportDeleted) $Export.History = @($Export.History + $ReportDisabled + $ReportDeleted) Write-Color "[i] ", "Exporting Processed List" -Color Yellow, Magenta if (-not $ReportOnly) { try { $Export | Export-Clixml -LiteralPath $DataStorePath -Encoding Unicode -WhatIf:$false -ErrorAction Stop } catch { Write-Color -Text "[-] Exporting Processed List failed. Error: $($_.Exception.Message)" -Color Yellow, Red } } Write-Color -Text "[i] ", "Summary of cleaning up stale computers" -Color Yellow, Cyan foreach ($Domain in $Report.Keys | Where-Object { $_ -notin 'ReportPendingDeletion', 'ReportDisabled', 'ReportDeleted' }) { if ($Disable) { Write-Color -Text "[i] ", "Computers to be disabled for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDisabled'] -Color Yellow, Cyan, Green } if ($Delete) { Write-Color -Text "[i] ", "Computers to be deleted for domain $Domain`: ", $Report["$Domain"]['ComputersToBeDeleted'] -Color Yellow, Cyan, Green } } if (-not $ReportOnly) { Write-Color -Text "[i] ", "Computers pending deletion`:", $Report['ReportPendingDeletion'].Count -Color Yellow, Cyan, Green } if ($Disable -and -not $ReportOnly) { Write-Color -Text "[i] ", "Computers disabled in this run`: ", $ReportDisabled.Count -Color Yellow, Cyan, Green } if ($Delete -and -not $ReportOnly) { Write-Color -Text "[i] ", "Computers deleted in this run`: ", $ReportDeleted.Count -Color Yellow, Cyan, Green } if ($Export -and $ReportPath) { Write-Color "[i] ", "Generating HTML report ($ReportPath)" -Color Yellow, Magenta $ComputersToProcess = foreach ($Domain in $Report.Keys | Where-Object { $_ -notin 'ReportPendingDeletion', 'ReportDisabled', 'ReportDeleted' }) { if ($Report["$Domain"]['Computers'].Count -gt 0) { $Report["$Domain"]['Computers'] } } $Export.Statistics = New-ADComputersStatistics -ComputersToProcess $ComputersToProcess $newHTMLProcessedComputersSplat = @{ Export = $Export FilePath = $ReportPath Online = $Online.IsPresent ShowHTML = $ShowHTML.IsPresent LogFile = $LogPath ComputersToProcess = $ComputersToProcess DisableOnlyIf = $DisableOnlyIf DeleteOnlyIf = $DeleteOnlyIf Delete = $Delete Disable = $Disable ReportOnly = $ReportOnly } New-HTMLProcessedComputers @newHTMLProcessedComputersSplat } Write-Color -Text "[i] Finished process of cleaning up stale computers" -Color Green if (-not $Suppress) { $Export } } # Export functions and aliases as required Export-ModuleMember -Function @('Invoke-ADComputersCleanup') -Alias @() # SIG # Begin signature block # MIInPgYJKoZIhvcNAQcCoIInLzCCJysCAQExDzANBglghkgBZQMEAgEFADB5Bgor # BgEEAYI3AgEEoGswaTA0BgorBgEEAYI3AgEeMCYCAwEAAAQQH8w7YFlLCE63JNLG # KX7zUQIBAAIBAAIBAAIBAAIBADAxMA0GCWCGSAFlAwQCAQUABCDHKf5gGFDsEE8v # Z9N+MJLNITsiI07S4yNiOIh0FkVqVaCCITcwggO3MIICn6ADAgECAhAM5+DlF9hG # /o/lYPwb8DA5MA0GCSqGSIb3DQEBBQUAMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQK # EwxEaWdpQ2VydCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNV # BAMTG0RpZ2lDZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTAeFw0wNjExMTAwMDAwMDBa # Fw0zMTExMTAwMDAwMDBaMGUxCzAJBgNVBAYTAlVTMRUwEwYDVQQKEwxEaWdpQ2Vy # dCBJbmMxGTAXBgNVBAsTEHd3dy5kaWdpY2VydC5jb20xJDAiBgNVBAMTG0RpZ2lD # ZXJ0IEFzc3VyZWQgSUQgUm9vdCBDQTCCASIwDQYJKoZIhvcNAQEBBQADggEPADCC # AQoCggEBAK0OFc7kQ4BcsYfzt2D5cRKlrtwmlIiq9M71IDkoWGAM+IDaqRWVMmE8 # tbEohIqK3J8KDIMXeo+QrIrneVNcMYQq9g+YMjZ2zN7dPKii72r7IfJSYd+fINcf # 4rHZ/hhk0hJbX/lYGDW8R82hNvlrf9SwOD7BG8OMM9nYLxj+KA+zp4PWw25EwGE1 # lhb+WZyLdm3X8aJLDSv/C3LanmDQjpA1xnhVhyChz+VtCshJfDGYM2wi6YfQMlqi # uhOCEe05F52ZOnKh5vqk2dUXMXWuhX0irj8BRob2KHnIsdrkVxfEfhwOsLSSplaz # vbKX7aqn8LfFqD+VFtD/oZbrCF8Yd08CAwEAAaNjMGEwDgYDVR0PAQH/BAQDAgGG # MA8GA1UdEwEB/wQFMAMBAf8wHQYDVR0OBBYEFEXroq/0ksuCMS1Ri6enIZ3zbcgP # MB8GA1UdIwQYMBaAFEXroq/0ksuCMS1Ri6enIZ3zbcgPMA0GCSqGSIb3DQEBBQUA # A4IBAQCiDrzf4u3w43JzemSUv/dyZtgy5EJ1Yq6H6/LV2d5Ws5/MzhQouQ2XYFwS # TFjk0z2DSUVYlzVpGqhH6lbGeasS2GeBhN9/CTyU5rgmLCC9PbMoifdf/yLil4Qf # 6WXvh+DfwWdJs13rsgkq6ybteL59PyvztyY1bV+JAbZJW58BBZurPSXBzLZ/wvFv # hsb6ZGjrgS2U60K3+owe3WLxvlBnt2y98/Efaww2BxZ/N3ypW2168RJGYIPXJwS+ # S86XvsNnKmgR34DnDDNmvxMNFG7zfx9jEB76jRslbWyPpbdhAbHSoyahEHGdreLD # +cOZUbcrBwjOLuZQsqf6CkUvovDyMIIFMDCCBBigAwIBAgIQBAkYG1/Vu2Z1U0O1 # b5VQCDANBgkqhkiG9w0BAQsFADBlMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGln # aUNlcnQgSW5jMRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMSQwIgYDVQQDExtE # aWdpQ2VydCBBc3N1cmVkIElEIFJvb3QgQ0EwHhcNMTMxMDIyMTIwMDAwWhcNMjgx # MDIyMTIwMDAwWjByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5j # MRkwFwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQDEyhEaWdpQ2VydCBT # SEEyIEFzc3VyZWQgSUQgQ29kZSBTaWduaW5nIENBMIIBIjANBgkqhkiG9w0BAQEF # AAOCAQ8AMIIBCgKCAQEA+NOzHH8OEa9ndwfTCzFJGc/Q+0WZsTrbRPV/5aid2zLX # cep2nQUut4/6kkPApfmJ1DcZ17aq8JyGpdglrA55KDp+6dFn08b7KSfH03sjlOSR # I5aQd4L5oYQjZhJUM1B0sSgmuyRpwsJS8hRniolF1C2ho+mILCCVrhxKhwjfDPXi # TWAYvqrEsq5wMWYzcT6scKKrzn/pfMuSoeU7MRzP6vIK5Fe7SrXpdOYr/mzLfnQ5 # Ng2Q7+S1TqSp6moKq4TzrGdOtcT3jNEgJSPrCGQ+UpbB8g8S9MWOD8Gi6CxR93O8 # vYWxYoNzQYIH5DiLanMg0A9kczyen6Yzqf0Z3yWT0QIDAQABo4IBzTCCAckwEgYD # VR0TAQH/BAgwBgEB/wIBADAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYB # BQUHAwMweQYIKwYBBQUHAQEEbTBrMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5k # aWdpY2VydC5jb20wQwYIKwYBBQUHMAKGN2h0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0 # LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJvb3RDQS5jcnQwgYEGA1UdHwR6MHgwOqA4 # oDaGNGh0dHA6Ly9jcmw0LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydEFzc3VyZWRJRFJv # b3RDQS5jcmwwOqA4oDaGNGh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dEFzc3VyZWRJRFJvb3RDQS5jcmwwTwYDVR0gBEgwRjA4BgpghkgBhv1sAAIEMCow # KAYIKwYBBQUHAgEWHGh0dHBzOi8vd3d3LmRpZ2ljZXJ0LmNvbS9DUFMwCgYIYIZI # AYb9bAMwHQYDVR0OBBYEFFrEuXsqCqOl6nEDwGD5LfZldQ5YMB8GA1UdIwQYMBaA # FEXroq/0ksuCMS1Ri6enIZ3zbcgPMA0GCSqGSIb3DQEBCwUAA4IBAQA+7A1aJLPz # ItEVyCx8JSl2qB1dHC06GsTvMGHXfgtg/cM9D8Svi/3vKt8gVTew4fbRknUPUbRu # pY5a4l4kgU4QpO4/cY5jDhNLrddfRHnzNhQGivecRk5c/5CxGwcOkRX7uq+1UcKN # JK4kxscnKqEpKBo6cSgCPC6Ro8AlEeKcFEehemhor5unXCBc2XGxDI+7qPjFEmif # z0DLQESlE/DmZAwlCEIysjaKJAL+L3J+HNdJRZboWR3p+nRka7LrZkPas7CM1ekN # 3fYBIM6ZMWM9CBoYs4GbT8aTEAb8B4H6i9r5gkn3Ym6hU/oSlBiFLpKR6mhsRDKy # ZqHnGKSaZFHvMIIFPTCCBCWgAwIBAgIQBNXcH0jqydhSALrNmpsqpzANBgkqhkiG # 9w0BAQsFADByMQswCQYDVQQGEwJVUzEVMBMGA1UEChMMRGlnaUNlcnQgSW5jMRkw # FwYDVQQLExB3d3cuZGlnaWNlcnQuY29tMTEwLwYDVQQDEyhEaWdpQ2VydCBTSEEy # IEFzc3VyZWQgSUQgQ29kZSBTaWduaW5nIENBMB4XDTIwMDYyNjAwMDAwMFoXDTIz # MDcwNzEyMDAwMFowejELMAkGA1UEBhMCUEwxEjAQBgNVBAgMCcWabMSFc2tpZTER # MA8GA1UEBxMIS2F0b3dpY2UxITAfBgNVBAoMGFByemVteXPFgmF3IEvFgnlzIEVW # T1RFQzEhMB8GA1UEAwwYUHJ6ZW15c8WCYXcgS8WCeXMgRVZPVEVDMIIBIjANBgkq # hkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAv7KB3iyBrhkLUbbFe9qxhKKPBYqDBqln # r3AtpZplkiVjpi9dMZCchSeT5ODsShPuZCIxJp5I86uf8ibo3vi2S9F9AlfFjVye # 3dTz/9TmCuGH8JQt13ozf9niHecwKrstDVhVprgxi5v0XxY51c7zgMA2g1Ub+3ti # i0vi/OpmKXdL2keNqJ2neQ5cYly/GsI8CREUEq9SZijbdA8VrRF3SoDdsWGf3tZZ # zO6nWn3TLYKQ5/bw5U445u/V80QSoykszHRivTj+H4s8ABiforhi0i76beA6Ea41 # zcH4zJuAp48B4UhjgRDNuq8IzLWK4dlvqrqCBHKqsnrF6BmBrv+BXQIDAQABo4IB # xTCCAcEwHwYDVR0jBBgwFoAUWsS5eyoKo6XqcQPAYPkt9mV1DlgwHQYDVR0OBBYE # FBixNSfoHFAgJk4JkDQLFLRNlJRmMA4GA1UdDwEB/wQEAwIHgDATBgNVHSUEDDAK # BggrBgEFBQcDAzB3BgNVHR8EcDBuMDWgM6Axhi9odHRwOi8vY3JsMy5kaWdpY2Vy # dC5jb20vc2hhMi1hc3N1cmVkLWNzLWcxLmNybDA1oDOgMYYvaHR0cDovL2NybDQu # ZGlnaWNlcnQuY29tL3NoYTItYXNzdXJlZC1jcy1nMS5jcmwwTAYDVR0gBEUwQzA3 # BglghkgBhv1sAwEwKjAoBggrBgEFBQcCARYcaHR0cHM6Ly93d3cuZGlnaWNlcnQu # Y29tL0NQUzAIBgZngQwBBAEwgYQGCCsGAQUFBwEBBHgwdjAkBggrBgEFBQcwAYYY # aHR0cDovL29jc3AuZGlnaWNlcnQuY29tME4GCCsGAQUFBzAChkJodHRwOi8vY2Fj # ZXJ0cy5kaWdpY2VydC5jb20vRGlnaUNlcnRTSEEyQXNzdXJlZElEQ29kZVNpZ25p # bmdDQS5jcnQwDAYDVR0TAQH/BAIwADANBgkqhkiG9w0BAQsFAAOCAQEAmr1sz4ls # LARi4wG1eg0B8fVJFowtect7SnJUrp6XRnUG0/GI1wXiLIeow1UPiI6uDMsRXPHU # F/+xjJw8SfIbwava2eXu7UoZKNh6dfgshcJmo0QNAJ5PIyy02/3fXjbUREHINrTC # vPVbPmV6kx4Kpd7KJrCo7ED18H/XTqWJHXa8va3MYLrbJetXpaEPpb6zk+l8Rj9y # G4jBVRhenUBUUj3CLaWDSBpOA/+sx8/XB9W9opYfYGb+1TmbCkhUg7TB3gD6o6ES # Jre+fcnZnPVAPESmstwsT17caZ0bn7zETKlNHbc1q+Em9kyBjaQRcEQoQQNpezQu # g9ufqExx6lHYDjCCBY0wggR1oAMCAQICEA6bGI750C3n79tQ4ghAGFowDQYJKoZI # hvcNAQEMBQAwZTELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZ # MBcGA1UECxMQd3d3LmRpZ2ljZXJ0LmNvbTEkMCIGA1UEAxMbRGlnaUNlcnQgQXNz # dXJlZCBJRCBSb290IENBMB4XDTIyMDgwMTAwMDAwMFoXDTMxMTEwOTIzNTk1OVow # YjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290 # IEc0MIICIjANBgkqhkiG9w0BAQEFAAOCAg8AMIICCgKCAgEAv+aQc2jeu+RdSjww # IjBpM+zCpyUuySE98orYWcLhKac9WKt2ms2uexuEDcQwH/MbpDgW61bGl20dq7J5 # 8soR0uRf1gU8Ug9SH8aeFaV+vp+pVxZZVXKvaJNwwrK6dZlqczKU0RBEEC7fgvMH # hOZ0O21x4i0MG+4g1ckgHWMpLc7sXk7Ik/ghYZs06wXGXuxbGrzryc/NrDRAX7F6 # Zu53yEioZldXn1RYjgwrt0+nMNlW7sp7XeOtyU9e5TXnMcvak17cjo+A2raRmECQ # ecN4x7axxLVqGDgDEI3Y1DekLgV9iPWCPhCRcKtVgkEy19sEcypukQF8IUzUvK4b # A3VdeGbZOjFEmjNAvwjXWkmkwuapoGfdpCe8oU85tRFYF/ckXEaPZPfBaYh2mHY9 # WV1CdoeJl2l6SPDgohIbZpp0yt5LHucOY67m1O+SkjqePdwA5EUlibaaRBkrfsCU # tNJhbesz2cXfSwQAzH0clcOP9yGyshG3u3/y1YxwLEFgqrFjGESVGnZifvaAsPvo # ZKYz0YkH4b235kOkGLimdwHhD5QMIR2yVCkliWzlDlJRR3S+Jqy2QXXeeqxfjT/J # vNNBERJb5RBQ6zHFynIWIgnffEx1P2PsIV/EIFFrb7GrhotPwtZFX50g/KEexcCP # orF+CiaZ9eRpL5gdLfXZqbId5RsCAwEAAaOCATowggE2MA8GA1UdEwEB/wQFMAMB # Af8wHQYDVR0OBBYEFOzX44LScV1kTN8uZz/nupiuHA9PMB8GA1UdIwQYMBaAFEXr # oq/0ksuCMS1Ri6enIZ3zbcgPMA4GA1UdDwEB/wQEAwIBhjB5BggrBgEFBQcBAQRt # MGswJAYIKwYBBQUHMAGGGGh0dHA6Ly9vY3NwLmRpZ2ljZXJ0LmNvbTBDBggrBgEF # BQcwAoY3aHR0cDovL2NhY2VydHMuZGlnaWNlcnQuY29tL0RpZ2lDZXJ0QXNzdXJl # ZElEUm9vdENBLmNydDBFBgNVHR8EPjA8MDqgOKA2hjRodHRwOi8vY3JsMy5kaWdp # Y2VydC5jb20vRGlnaUNlcnRBc3N1cmVkSURSb290Q0EuY3JsMBEGA1UdIAQKMAgw # BgYEVR0gADANBgkqhkiG9w0BAQwFAAOCAQEAcKC/Q1xV5zhfoKN0Gz22Ftf3v1cH # vZqsoYcs7IVeqRq7IviHGmlUIu2kiHdtvRoU9BNKei8ttzjv9P+Aufih9/Jy3iS8 # UgPITtAq3votVs/59PesMHqai7Je1M/RQ0SbQyHrlnKhSLSZy51PpwYDE3cnRNTn # f+hZqPC/Lwum6fI0POz3A8eHqNJMQBk1RmppVLC4oVaO7KTVPeix3P0c2PR3WlxU # jG/voVA9/HYJaISfb8rbII01YBwCA8sgsKxYoA5AY8WYIsGyWfVVa88nq2x2zm8j # LfR+cWojayL/ErhULSd+2DrZ8LaHlv1b0VysGMNNn3O3AamfV6peKOK5lDCCBq4w # ggSWoAMCAQICEAc2N7ckVHzYR6z9KGYqXlswDQYJKoZIhvcNAQELBQAwYjELMAkG # A1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQd3d3LmRp # Z2ljZXJ0LmNvbTEhMB8GA1UEAxMYRGlnaUNlcnQgVHJ1c3RlZCBSb290IEc0MB4X # DTIyMDMyMzAwMDAwMFoXDTM3MDMyMjIzNTk1OVowYzELMAkGA1UEBhMCVVMxFzAV # BgNVBAoTDkRpZ2lDZXJ0LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVk # IEc0IFJTQTQwOTYgU0hBMjU2IFRpbWVTdGFtcGluZyBDQTCCAiIwDQYJKoZIhvcN # AQEBBQADggIPADCCAgoCggIBAMaGNQZJs8E9cklRVcclA8TykTepl1Gh1tKD0Z5M # om2gsMyD+Vr2EaFEFUJfpIjzaPp985yJC3+dH54PMx9QEwsmc5Zt+FeoAn39Q7SE # 2hHxc7Gz7iuAhIoiGN/r2j3EF3+rGSs+QtxnjupRPfDWVtTnKC3r07G1decfBmWN # lCnT2exp39mQh0YAe9tEQYncfGpXevA3eZ9drMvohGS0UvJ2R/dhgxndX7RUCyFo # bjchu0CsX7LeSn3O9TkSZ+8OpWNs5KbFHc02DVzV5huowWR0QKfAcsW6Th+xtVhN # ef7Xj3OTrCw54qVI1vCwMROpVymWJy71h6aPTnYVVSZwmCZ/oBpHIEPjQ2OAe3Vu # JyWQmDo4EbP29p7mO1vsgd4iFNmCKseSv6De4z6ic/rnH1pslPJSlRErWHRAKKtz # Q87fSqEcazjFKfPKqpZzQmiftkaznTqj1QPgv/CiPMpC3BhIfxQ0z9JMq++bPf4O # uGQq+nUoJEHtQr8FnGZJUlD0UfM2SU2LINIsVzV5K6jzRWC8I41Y99xh3pP+OcD5 # sjClTNfpmEpYPtMDiP6zj9NeS3YSUZPJjAw7W4oiqMEmCPkUEBIDfV8ju2TjY+Cm # 4T72wnSyPx4JduyrXUZ14mCjWAkBKAAOhFTuzuldyF4wEr1GnrXTdrnSDmuZDNIz # tM2xAgMBAAGjggFdMIIBWTASBgNVHRMBAf8ECDAGAQH/AgEAMB0GA1UdDgQWBBS6 # FtltTYUvcyl2mi91jGogj57IbzAfBgNVHSMEGDAWgBTs1+OC0nFdZEzfLmc/57qY # rhwPTzAOBgNVHQ8BAf8EBAMCAYYwEwYDVR0lBAwwCgYIKwYBBQUHAwgwdwYIKwYB # BQUHAQEEazBpMCQGCCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20w # QQYIKwYBBQUHMAKGNWh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2Vy # dFRydXN0ZWRSb290RzQuY3J0MEMGA1UdHwQ8MDowOKA2oDSGMmh0dHA6Ly9jcmwz # LmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRSb290RzQuY3JsMCAGA1UdIAQZ # MBcwCAYGZ4EMAQQCMAsGCWCGSAGG/WwHATANBgkqhkiG9w0BAQsFAAOCAgEAfVmO # wJO2b5ipRCIBfmbW2CFC4bAYLhBNE88wU86/GPvHUF3iSyn7cIoNqilp/GnBzx0H # 6T5gyNgL5Vxb122H+oQgJTQxZ822EpZvxFBMYh0MCIKoFr2pVs8Vc40BIiXOlWk/ # R3f7cnQU1/+rT4osequFzUNf7WC2qk+RZp4snuCKrOX9jLxkJodskr2dfNBwCnzv # qLx1T7pa96kQsl3p/yhUifDVinF2ZdrM8HKjI/rAJ4JErpknG6skHibBt94q6/ae # sXmZgaNWhqsKRcnfxI2g55j7+6adcq/Ex8HBanHZxhOACcS2n82HhyS7T6NJuXdm # kfFynOlLAlKnN36TU6w7HQhJD5TNOXrd/yVjmScsPT9rp/Fmw0HNT7ZAmyEhQNC3 # EyTN3B14OuSereU0cZLXJmvkOHOrpgFPvT87eK1MrfvElXvtCl8zOYdBeHo46Zzh # 3SP9HSjTx/no8Zhf+yvYfvJGnXUsHicsJttvFXseGYs2uJPU5vIXmVnKcPA3v5gA # 3yAWTyf7YGcWoWa63VXAOimGsJigK+2VQbc61RWYMbRiCQ8KvYHZE/6/pNHzV9m8 # BPqC3jLfBInwAM1dwvnQI38AC+R2AibZ8GV2QqYphwlHK+Z/GqSFD/yYlvZVVCsf # gPrA8g4r5db7qS9EFUrnEw4d2zc4GqEr9u3WfPwwggbAMIIEqKADAgECAhAMTWly # S5T6PCpKPSkHgD1aMA0GCSqGSIb3DQEBCwUAMGMxCzAJBgNVBAYTAlVTMRcwFQYD # VQQKEw5EaWdpQ2VydCwgSW5jLjE7MDkGA1UEAxMyRGlnaUNlcnQgVHJ1c3RlZCBH # NCBSU0E0MDk2IFNIQTI1NiBUaW1lU3RhbXBpbmcgQ0EwHhcNMjIwOTIxMDAwMDAw # WhcNMzMxMTIxMjM1OTU5WjBGMQswCQYDVQQGEwJVUzERMA8GA1UEChMIRGlnaUNl # cnQxJDAiBgNVBAMTG0RpZ2lDZXJ0IFRpbWVzdGFtcCAyMDIyIC0gMjCCAiIwDQYJ # KoZIhvcNAQEBBQADggIPADCCAgoCggIBAM/spSY6xqnya7uNwQ2a26HoFIV0Mxom # rNAcVR4eNm28klUMYfSdCXc9FZYIL2tkpP0GgxbXkZI4HDEClvtysZc6Va8z7GGK # 6aYo25BjXL2JU+A6LYyHQq4mpOS7eHi5ehbhVsbAumRTuyoW51BIu4hpDIjG8b7g # L307scpTjUCDHufLckkoHkyAHoVW54Xt8mG8qjoHffarbuVm3eJc9S/tjdRNlYRo # 44DLannR0hCRRinrPibytIzNTLlmyLuqUDgN5YyUXRlav/V7QG5vFqianJVHhoV5 # PgxeZowaCiS+nKrSnLb3T254xCg/oxwPUAY3ugjZNaa1Htp4WB056PhMkRCWfk3h # 3cKtpX74LRsf7CtGGKMZ9jn39cFPcS6JAxGiS7uYv/pP5Hs27wZE5FX/NurlfDHn # 88JSxOYWe1p+pSVz28BqmSEtY+VZ9U0vkB8nt9KrFOU4ZodRCGv7U0M50GT6Vs/g # 9ArmFG1keLuY/ZTDcyHzL8IuINeBrNPxB9ThvdldS24xlCmL5kGkZZTAWOXlLimQ # prdhZPrZIGwYUWC6poEPCSVT8b876asHDmoHOWIZydaFfxPZjXnPYsXs4Xu5zGcT # B5rBeO3GiMiwbjJ5xwtZg43G7vUsfHuOy2SJ8bHEuOdTXl9V0n0ZKVkDTvpd6kVz # HIR+187i1Dp3AgMBAAGjggGLMIIBhzAOBgNVHQ8BAf8EBAMCB4AwDAYDVR0TAQH/ # BAIwADAWBgNVHSUBAf8EDDAKBggrBgEFBQcDCDAgBgNVHSAEGTAXMAgGBmeBDAEE # AjALBglghkgBhv1sBwEwHwYDVR0jBBgwFoAUuhbZbU2FL3MpdpovdYxqII+eyG8w # HQYDVR0OBBYEFGKK3tBh/I8xFO2XC809KpQU31KcMFoGA1UdHwRTMFEwT6BNoEuG # SWh0dHA6Ly9jcmwzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJTQTQw # OTZTSEEyNTZUaW1lU3RhbXBpbmdDQS5jcmwwgZAGCCsGAQUFBwEBBIGDMIGAMCQG # CCsGAQUFBzABhhhodHRwOi8vb2NzcC5kaWdpY2VydC5jb20wWAYIKwYBBQUHMAKG # TGh0dHA6Ly9jYWNlcnRzLmRpZ2ljZXJ0LmNvbS9EaWdpQ2VydFRydXN0ZWRHNFJT # QTQwOTZTSEEyNTZUaW1lU3RhbXBpbmdDQS5jcnQwDQYJKoZIhvcNAQELBQADggIB # AFWqKhrzRvN4Vzcw/HXjT9aFI/H8+ZU5myXm93KKmMN31GT8Ffs2wklRLHiIY1UJ # RjkA/GnUypsp+6M/wMkAmxMdsJiJ3HjyzXyFzVOdr2LiYWajFCpFh0qYQitQ/Bu1 # nggwCfrkLdcJiXn5CeaIzn0buGqim8FTYAnoo7id160fHLjsmEHw9g6A++T/350Q # p+sAul9Kjxo6UrTqvwlJFTU2WZoPVNKyG39+XgmtdlSKdG3K0gVnK3br/5iyJpU4 # GYhEFOUKWaJr5yI+RCHSPxzAm+18SLLYkgyRTzxmlK9dAlPrnuKe5NMfhgFknADC # 6Vp0dQ094XmIvxwBl8kZI4DXNlpflhaxYwzGRkA7zl011Fk+Q5oYrsPJy8P7mxNf # arXH4PMFw1nfJ2Ir3kHJU7n/NBBn9iYymHv+XEKUgZSCnawKi8ZLFUrTmJBFYDOA # 4CPe+AOk9kVH5c64A0JH6EE2cXet/aLol3ROLtoeHYxayB6a1cLwxiKoT5u92Bya # UcQvmvZfpyeXupYuhVfAYOd4Vn9q78KVmksRAsiCnMkaBXy6cbVOepls9Oie1FqY # yJ+/jbsYXEP10Cro4mLueATbvdH7WwqocH7wl4R44wgDXUcsY6glOJcB0j862uXl # 9uab3H4szP8XTE0AotjWAQ64i+7m4HJViSwnGWH2dwGMMYIFXTCCBVkCAQEwgYYw # cjELMAkGA1UEBhMCVVMxFTATBgNVBAoTDERpZ2lDZXJ0IEluYzEZMBcGA1UECxMQ # d3d3LmRpZ2ljZXJ0LmNvbTExMC8GA1UEAxMoRGlnaUNlcnQgU0hBMiBBc3N1cmVk # IElEIENvZGUgU2lnbmluZyBDQQIQBNXcH0jqydhSALrNmpsqpzANBglghkgBZQME # AgEFAKCBhDAYBgorBgEEAYI3AgEMMQowCKACgAChAoAAMBkGCSqGSIb3DQEJAzEM # BgorBgEEAYI3AgEEMBwGCisGAQQBgjcCAQsxDjAMBgorBgEEAYI3AgEVMC8GCSqG # SIb3DQEJBDEiBCCnfd0Kzrw7LzuRbdWaL8SwE8YxtP3YMsMPqB/4oasR+zANBgkq # hkiG9w0BAQEFAASCAQBmmHPYOpyYgmDUrQA8gaLEZSTLGBujWn5zxo2B5clysneY # pcu8SmUUyiOlhkWDnqojm5osta3lJ9YpWDsqLQqa6ffyFm5UcyQti2E6rArvStn5 # oCu/a9nmEaY1SUp+lW+1m9PYkFMp9LoiaY1miqdlpUCWa4ir/KQn7xECeVt5bdFm # aA4RcumXxouDVYR7JLLLRboWeHtyJF6H4+BnHFRRz4XwPXbJmE9GppG1GdDkqOIv # 8CFAOUsqifxVCBSR7o5EjJLCJyIIZmw8k/zjJXefpXT7cutkTft+Hq1z7MPQp0JU # MnwHFScMVwazSmNlfVQ1wKK0YIWQZbhsvJ/yLRhRoYIDIDCCAxwGCSqGSIb3DQEJ # BjGCAw0wggMJAgEBMHcwYzELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDkRpZ2lDZXJ0 # LCBJbmMuMTswOQYDVQQDEzJEaWdpQ2VydCBUcnVzdGVkIEc0IFJTQTQwOTYgU0hB # MjU2IFRpbWVTdGFtcGluZyBDQQIQDE1pckuU+jwqSj0pB4A9WjANBglghkgBZQME # AgEFAKBpMBgGCSqGSIb3DQEJAzELBgkqhkiG9w0BBwEwHAYJKoZIhvcNAQkFMQ8X # DTIzMDUwODEwMDA1OFowLwYJKoZIhvcNAQkEMSIEICbXN5pzsaexEKD85HSjt02Q # MdLBNsZtjWU7ZedQsCoxMA0GCSqGSIb3DQEBAQUABIICAH/qrX1CeRACUsajcqBY # +TzE2MAyDDcE6XKT+YOwwjnHQR5RffT4HhIJ1XABZcKrO51vDNuxZauXRUxMsBHw # TnZ4y0Mz2parqcwlKCjW3LH7HBTBxxsOuR/7R8/wo/f5A39YVwPqZtT9QnyXigSw # q9A6lqKpG10oTqeHkqWuoNHCdUSESoX9sRKpgw0UxiCGGFFwfmYW9jucW+Q1or9a # /BAV79y3AEO7FzOtru7XHz8NCX+3PKHaHBQn3iFTBbaNX/sSZg4dM8ryuFr3uqQu # McWfq/mcck26MNWXcId6D0IGYCLdv5Sxc+56PHoSJtQgcX7OOuRX37Q6beEqZHrQ # cAdbHXj9xJTxJg2QHuU0kQcGi04edhADkZuq3G7q2UyGDZGNxrl/UL5kCK2tNCJ7 # +qG6u/0Qtespy4xmM7kxfK3tYwLab0WyfZZ784Vv1j5t5XL8r17jjy+fZPY1GHJv # 9KnwLmiwoxliPKQmDjCVyPirBzs8UfhlsqCjhV5Vfq1q50CcY159ieyef4Hll2PY # x4GmxgDW9ke+uZgzRuzV1uEBfBlA81tyGL/3zibS3t+fhIJNTGxUr8thwsf/WOee # QAo3yO1IOzBamNNuZ0H1HyzBBseyIvBn/ig8rJJQG8ACJsYujOvlZxitsVguCE6C # t801zGktpYadXnZADEUS6F28 # SIG # End signature block |