Public/Get-ServiceAccountAge.ps1

function Get-ServiceAccountAge {
    <#
    .SYNOPSIS
        Analyzes service account password age and assigns risk ratings.
 
    .DESCRIPTION
        Focused analysis of service account password age across Active Directory.
        Assigns risk ratings based on how long ago the password was last changed:
          CRITICAL - Password older than 3 years (1095 days)
          HIGH - Password older than 2 years (730 days)
          MEDIUM - Password older than 1 year (365 days)
          LOW - Password changed within the last year
 
    .PARAMETER SearchBase
        The AD distinguished name to scope the search. Defaults to the current domain root.
 
    .PARAMETER DaysOldThreshold
        The minimum age in days to include in results. Default: 365.
 
    .PARAMETER NamingPattern
        Comma-separated name patterns to match service accounts.
        Default: "svc*,service*"
 
    .EXAMPLE
        Get-ServiceAccountAge -DaysOldThreshold 730
        Returns only accounts with passwords older than 2 years.
 
    .EXAMPLE
        Get-ServiceAccountAge -SearchBase "OU=ServiceAccounts,DC=contoso,DC=com"
    #>

    [CmdletBinding()]
    param(
        [Parameter()]
        [string]$SearchBase,

        [Parameter()]
        [ValidateRange(1, [int]::MaxValue)]
        [int]$DaysOldThreshold = 365,

        [Parameter()]
        [string]$NamingPattern = 'svc*,service*'
    )

    begin {
        Write-Verbose "Starting password age analysis (threshold: $DaysOldThreshold days)"

        $ADProperties = @(
            'SAMAccountName'
            'DisplayName'
            'DistinguishedName'
            'Enabled'
            'PasswordLastSet'
            'PasswordNeverExpires'
            'LastLogonDate'
            'ServicePrincipalName'
            'Description'
            'ManagedBy'
        )

        $Results = [System.Collections.Generic.List[PSObject]]::new()
        $Now = Get-Date
    }

    process {
        $Patterns = $NamingPattern -split ',' | ForEach-Object { $_.Trim() }
        $AllAccounts = [System.Collections.Generic.Dictionary[string, Microsoft.ActiveDirectory.Management.ADUser]]::new(
            [StringComparer]::OrdinalIgnoreCase
        )

        foreach ($Pattern in $Patterns) {
            Write-Verbose "Querying pattern: $Pattern"
            $Params = @{
                Filter     = "SAMAccountName -like '$Pattern'"
                Properties = $ADProperties
            }
            if ($SearchBase) { $Params['SearchBase'] = $SearchBase }

            try {
                $Found = Get-ADUser @Params -ErrorAction SilentlyContinue
                foreach ($Account in $Found) {
                    if (-not $AllAccounts.ContainsKey($Account.SAMAccountName)) {
                        $AllAccounts[$Account.SAMAccountName] = $Account
                    }
                }
            }
            catch {
                Write-Warning "Search failed for pattern '$Pattern': $_"
            }
        }

        # Also include accounts with PasswordNeverExpires
        $NeverExpiresParams = @{
            Filter     = "PasswordNeverExpires -eq `$true"
            Properties = $ADProperties
        }
        if ($SearchBase) { $NeverExpiresParams['SearchBase'] = $SearchBase }
        try {
            $Found = Get-ADUser @NeverExpiresParams -ErrorAction SilentlyContinue
            foreach ($Account in $Found) {
                if (-not $AllAccounts.ContainsKey($Account.SAMAccountName)) {
                    $AllAccounts[$Account.SAMAccountName] = $Account
                }
            }
        }
        catch {
            Write-Warning "PasswordNeverExpires search failed: $_"
        }

        # Also include accounts with SPNs (service accounts by definition)
        $SPNParams = @{
            Filter     = "ServicePrincipalName -like '*'"
            Properties = $ADProperties
        }
        if ($SearchBase) { $SPNParams['SearchBase'] = $SearchBase }
        try {
            $Found = Get-ADUser @SPNParams -ErrorAction SilentlyContinue
            foreach ($Account in $Found) {
                if (-not $AllAccounts.ContainsKey($Account.SAMAccountName)) {
                    $AllAccounts[$Account.SAMAccountName] = $Account
                }
            }
        }
        catch {
            Write-Warning "SPN search failed: $_"
        }

        Write-Verbose "Evaluating $($AllAccounts.Count) accounts for password age"

        foreach ($Account in $AllAccounts.Values) {
            # Calculate password age
            if ($Account.PasswordLastSet) {
                $PasswordAge = [math]::Round(($Now - $Account.PasswordLastSet).TotalDays)
            }
            else {
                # Password never set or cannot be determined - treat as maximum risk
                $PasswordAge = [int]::MaxValue
            }

            # Apply threshold filter
            if ($PasswordAge -lt $DaysOldThreshold) {
                continue
            }

            # Assign risk rating
            $RiskRating = if ($PasswordAge -ge 1095) {
                'CRITICAL'
            }
            elseif ($PasswordAge -ge 730) {
                'HIGH'
            }
            elseif ($PasswordAge -ge 365) {
                'MEDIUM'
            }
            else {
                'LOW'
            }

            # Build finding description
            $PasswordDisplay = if ($Account.PasswordLastSet) {
                "$PasswordAge days ($($Account.PasswordLastSet.ToString('yyyy-MM-dd')))"
            }
            else {
                'NEVER SET'
            }

            $HasSPN = ($null -ne $Account.ServicePrincipalName -and $Account.ServicePrincipalName.Count -gt 0)

            $Findings = [System.Collections.Generic.List[string]]::new()
            $Findings.Add("$RiskRating - Password age: $PasswordDisplay")

            if ($Account.PasswordNeverExpires) {
                $Findings.Add('NEVER EXPIRES')
            }
            if ($HasSPN) {
                $Findings.Add('HAS SPN (Kerberoastable)')
            }
            if ($Account.Enabled -eq $false) {
                $Findings.Add('ACCOUNT DISABLED')
            }

            $Result = [PSCustomObject]@{
                SAMAccountName       = $Account.SAMAccountName
                DisplayName          = $Account.DisplayName
                DistinguishedName    = $Account.DistinguishedName
                Enabled              = $Account.Enabled
                PasswordLastSet      = $Account.PasswordLastSet
                PasswordAge          = $PasswordAge
                PasswordNeverExpires = $Account.PasswordNeverExpires
                LastLogonDate        = $Account.LastLogonDate
                HasSPN               = $HasSPN
                RiskRating           = $RiskRating
                Description          = $Account.Description
                ManagedBy            = $Account.ManagedBy
                Finding              = ($Findings -join '; ')
            }
            $Result.PSObject.TypeNames.Insert(0, 'ServiceAccountAudit.PasswordAge')
            $Results.Add($Result)
        }
    }

    end {
        Write-Verbose "Password age analysis complete. $($Results.Count) accounts above threshold."

        # Sort by password age descending (oldest first = highest risk)
        $Results | Sort-Object -Property PasswordAge -Descending
    }
}