Public/activedirectory/Get-ADStaleAccount.ps1

#Requires -Version 5.1

function Get-ADStaleAccount {
    <#
    .SYNOPSIS
        Finds Active Directory accounts that have been inactive for a specified number of days
 
    .DESCRIPTION
        Scans Active Directory for user and/or computer accounts that have not logged in
        within the specified number of days. Accounts that have never logged in are included
        by default. Results are sorted by days since last logon in descending order.
 
    .PARAMETER DaysInactive
        The number of days of inactivity to use as the threshold. Accounts with a last
        logon date older than this value will be returned. Valid range is 1 to 3650.
 
    .PARAMETER AccountType
        The type of accounts to search for. Valid values are User, Computer, or Both.
        Defaults to Both.
 
    .PARAMETER SearchBase
        The distinguished name of the OU to search within. If omitted, searches the entire domain.
 
    .PARAMETER IncludeDisabled
        When specified, includes disabled accounts in the results. By default only enabled
        accounts are returned.
 
    .PARAMETER Server
        Specifies the Active Directory Domain Services instance to connect to.
 
    .PARAMETER Credential
        Specifies the credentials to use for the Active Directory query.
 
    .EXAMPLE
        Get-ADStaleAccount -DaysInactive 90
 
        Finds all enabled user and computer accounts inactive for more than 90 days.
 
    .EXAMPLE
        Get-ADStaleAccount -DaysInactive 180 -AccountType User -Server 'dc01.contoso.com'
 
        Finds stale user accounts only from a specific domain controller.
 
    .EXAMPLE
        Get-ADStaleAccount -DaysInactive 60 -IncludeDisabled -SearchBase 'OU=Workstations,DC=contoso,DC=com'
 
        Finds stale accounts including disabled ones within a specific OU.
 
    .OUTPUTS
        PSWinOps.ADStaleAccount
        Returns objects with account identity, type, last logon information, and days
        since last logon sorted by most stale first.
 
    .NOTES
        Author: Franck SALLET
        Version: 1.0.0
        Last Modified: 2026-04-03
        Requires: PowerShell 5.1+ / Windows only
        Requires: ActiveDirectory module
 
    .LINK
        https://github.com/k9fr4n/PSWinOps
 
    .LINK
        https://learn.microsoft.com/en-us/powershell/module/activedirectory/get-aduser
    #>

    [CmdletBinding()]
    [OutputType([PSCustomObject])]
    param(
        [Parameter(Mandatory = $true)]
        [ValidateRange(1, 3650)]
        [int]$DaysInactive,

        [Parameter()]
        [ValidateSet('User', 'Computer', 'Both')]
        [string]$AccountType = 'Both',

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$SearchBase,

        [Parameter()]
        [switch]$IncludeDisabled,

        [Parameter()]
        [ValidateNotNullOrEmpty()]
        [string]$Server,

        [Parameter()]
        [ValidateNotNull()]
        [System.Management.Automation.PSCredential]$Credential
    )

    begin {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Starting"

        try {
            Import-Module -Name 'ActiveDirectory' -ErrorAction Stop
        }
        catch {
            throw "[$($MyInvocation.MyCommand)] Failed to import ActiveDirectory module: $_"
        }

        $adParams = @{}
        if ($PSBoundParameters.ContainsKey('Server')) {
            $adParams['Server'] = $Server
        }
        if ($PSBoundParameters.ContainsKey('Credential')) {
            $adParams['Credential'] = $Credential
        }

        $searchBaseParam = @{}
        if ($PSBoundParameters.ContainsKey('SearchBase')) {
            $searchBaseParam['SearchBase'] = $SearchBase
        }

        $cutoffDate = (Get-Date).AddDays(-$DaysInactive)
        $adProperties = @('LastLogonDate', 'WhenCreated', 'Enabled', 'Description', 'DistinguishedName')

        $allResults = [System.Collections.Generic.List[object]]::new()
    }

    process {
        try {
            # Query stale user accounts
            if ($AccountType -in @('User', 'Both')) {
                Write-Verbose -Message "[$($MyInvocation.MyCommand)] Searching stale user accounts (inactive > $DaysInactive days)"

                $userFilter = if ($IncludeDisabled) {
                    "LastLogonDate -lt '$cutoffDate' -or -not LastLogonDate -like '*'"
                }
                else {
                    "(LastLogonDate -lt '$cutoffDate' -or -not LastLogonDate -like '*') -and Enabled -eq `$true"
                }

                $staleUsers = Get-ADUser -Filter $userFilter -Properties $adProperties @searchBaseParam @adParams -ErrorAction Stop

                foreach ($user in $staleUsers) {
                    $daysSinceLogon = if ($user.LastLogonDate) {
                        [math]::Round(((Get-Date) - $user.LastLogonDate).TotalDays)
                    }
                    else { $null }

                    $allResults.Add([PSCustomObject]@{
                        PSTypeName        = 'PSWinOps.ADStaleAccount'
                        Name              = $user.Name
                        SamAccountName    = $user.SamAccountName
                        AccountType       = 'User'
                        Enabled           = $user.Enabled
                        LastLogonDate     = $user.LastLogonDate
                        DaysSinceLogon    = $daysSinceLogon
                        WhenCreated       = $user.WhenCreated
                        Description       = $user.Description
                        DistinguishedName = $user.DistinguishedName
                        Timestamp         = Get-Date -Format 'o'
                    })
                }
            }

            # Query stale computer accounts
            if ($AccountType -in @('Computer', 'Both')) {
                Write-Verbose -Message "[$($MyInvocation.MyCommand)] Searching stale computer accounts (inactive > $DaysInactive days)"

                $computerFilter = if ($IncludeDisabled) {
                    "LastLogonDate -lt '$cutoffDate' -or -not LastLogonDate -like '*'"
                }
                else {
                    "(LastLogonDate -lt '$cutoffDate' -or -not LastLogonDate -like '*') -and Enabled -eq `$true"
                }

                $staleComputers = Get-ADComputer -Filter $computerFilter -Properties $adProperties @searchBaseParam @adParams -ErrorAction Stop

                foreach ($computer in $staleComputers) {
                    $daysSinceLogon = if ($computer.LastLogonDate) {
                        [math]::Round(((Get-Date) - $computer.LastLogonDate).TotalDays)
                    }
                    else { $null }

                    $allResults.Add([PSCustomObject]@{
                        PSTypeName        = 'PSWinOps.ADStaleAccount'
                        Name              = $computer.Name
                        SamAccountName    = $computer.SamAccountName
                        AccountType       = 'Computer'
                        Enabled           = $computer.Enabled
                        LastLogonDate     = $computer.LastLogonDate
                        DaysSinceLogon    = $daysSinceLogon
                        WhenCreated       = $computer.WhenCreated
                        Description       = $computer.Description
                        DistinguishedName = $computer.DistinguishedName
                        Timestamp         = Get-Date -Format 'o'
                    })
                }
            }

            # Output sorted by DaysSinceLogon descending (nulls first = never logged in)
            $allResults | Sort-Object -Property @{Expression = { if ($null -eq $_.DaysSinceLogon) { [int]::MaxValue } else { $_.DaysSinceLogon } }; Descending = $true }
        }
        catch {
            Write-Error -Message "[$($MyInvocation.MyCommand)] Search failed: $_"
        }
    }

    end {
        Write-Verbose -Message "[$($MyInvocation.MyCommand)] Completed — found $($allResults.Count) stale account(s)"
    }
}