Public/Get-ServiceAccountInventory.ps1

function Get-ServiceAccountInventory {
    <#
    .SYNOPSIS
        Discovers service accounts in Active Directory.
 
    .DESCRIPTION
        Identifies service accounts using multiple heuristics: name pattern matching,
        PasswordNeverExpires flag, accounts in "Service Accounts" OUs, and accounts
        with Service Principal Names (SPNs) registered. Returns a comprehensive
        inventory with security findings.
 
    .PARAMETER SearchBase
        The AD distinguished name to scope the search. Defaults to the current domain root.
 
    .PARAMETER NamingPattern
        Comma-separated name patterns to match service accounts.
        Default: "svc*,service*"
 
    .PARAMETER IncludeMSA
        Include Managed Service Accounts (msDS-ManagedServiceAccount).
 
    .PARAMETER IncludeGMSA
        Include Group Managed Service Accounts (msDS-GroupManagedServiceAccount).
 
    .EXAMPLE
        Get-ServiceAccountInventory -SearchBase "DC=contoso,DC=com"
 
    .EXAMPLE
        Get-ServiceAccountInventory -NamingPattern "svc*,service*,batch*" -IncludeMSA -IncludeGMSA
    #>

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

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

        [Parameter()]
        [switch]$IncludeMSA,

        [Parameter()]
        [switch]$IncludeGMSA
    )

    begin {
        Write-Verbose "Starting service account inventory scan"

        $PrivilegedGroups = @(
            'Domain Admins'
            'Enterprise Admins'
            'Schema Admins'
            'Administrators'
            'Account Operators'
            'Backup Operators'
            'Server Operators'
            'Print Operators'
            'DnsAdmins'
        )

        $ADProperties = @(
            'SAMAccountName'
            'DisplayName'
            'DistinguishedName'
            'Enabled'
            'PasswordLastSet'
            'PasswordNeverExpires'
            'LastLogonDate'
            'MemberOf'
            'ServicePrincipalName'
            'Description'
            'ManagedBy'
            'ObjectClass'
            'msDS-ManagedPasswordInterval'
        )

        $SearchParams = @{
            Properties = $ADProperties
            Filter     = '*'
        }
        if ($SearchBase) {
            $SearchParams['SearchBase'] = $SearchBase
        }

        $AllCandidates = [System.Collections.Generic.HashSet[string]]::new([StringComparer]::OrdinalIgnoreCase)
        $Results = [System.Collections.Generic.List[PSObject]]::new()
    }

    process {
        # Strategy 1: Name pattern matching
        $Patterns = $NamingPattern -split ',' | ForEach-Object { $_.Trim() }
        foreach ($Pattern in $Patterns) {
            Write-Verbose "Searching by name pattern: $Pattern"
            $PatternParams = $SearchParams.Clone()
            $PatternParams['Filter'] = "SAMAccountName -like '$Pattern'"
            try {
                $Found = Get-ADUser @PatternParams -ErrorAction SilentlyContinue
                foreach ($Account in $Found) {
                    [void]$AllCandidates.Add($Account.SAMAccountName)
                }
            }
            catch {
                Write-Warning "Pattern search failed for '$Pattern': $_"
            }
        }

        # Strategy 2: PasswordNeverExpires flag
        Write-Verbose "Searching for accounts with PasswordNeverExpires"
        $NeverExpiresParams = $SearchParams.Clone()
        $NeverExpiresParams['Filter'] = "PasswordNeverExpires -eq `$true"
        try {
            $Found = Get-ADUser @NeverExpiresParams -ErrorAction SilentlyContinue
            foreach ($Account in $Found) {
                [void]$AllCandidates.Add($Account.SAMAccountName)
            }
        }
        catch {
            Write-Warning "PasswordNeverExpires search failed: $_"
        }

        # Strategy 3: Accounts in "Service Accounts" OUs
        Write-Verbose "Searching for accounts in Service Account OUs"
        $ServiceOUParams = $SearchParams.Clone()
        $ServiceOUParams['Filter'] = '*'
        try {
            $ServiceOUs = Get-ADOrganizationalUnit -Filter "Name -like '*Service Account*'" -ErrorAction SilentlyContinue
            foreach ($OU in $ServiceOUs) {
                $OUSearchParams = $SearchParams.Clone()
                $OUSearchParams['SearchBase'] = $OU.DistinguishedName
                $Found = Get-ADUser @OUSearchParams -ErrorAction SilentlyContinue
                foreach ($Account in $Found) {
                    [void]$AllCandidates.Add($Account.SAMAccountName)
                }
            }
        }
        catch {
            Write-Warning "Service Account OU search failed: $_"
        }

        # Strategy 4: Accounts with SPNs
        Write-Verbose "Searching for accounts with Service Principal Names"
        $SPNParams = $SearchParams.Clone()
        $SPNParams['Filter'] = "ServicePrincipalName -like '*'"
        try {
            $Found = Get-ADUser @SPNParams -ErrorAction SilentlyContinue
            foreach ($Account in $Found) {
                [void]$AllCandidates.Add($Account.SAMAccountName)
            }
        }
        catch {
            Write-Warning "SPN search failed: $_"
        }

        # Include MSA/GMSA if requested
        if ($IncludeMSA) {
            Write-Verbose "Including Managed Service Accounts"
            try {
                $MSAParams = @{ Filter = '*'; Properties = $ADProperties }
                if ($SearchBase) { $MSAParams['SearchBase'] = $SearchBase }
                $MSAs = Get-ADServiceAccount @MSAParams -ErrorAction SilentlyContinue
                foreach ($Account in $MSAs) {
                    [void]$AllCandidates.Add($Account.SAMAccountName)
                }
            }
            catch {
                Write-Verbose "MSA search not available or returned no results: $_"
            }
        }

        # Now retrieve full details for each candidate
        Write-Verbose "Processing $($AllCandidates.Count) candidate accounts"

        foreach ($AccountName in $AllCandidates) {
            try {
                $Account = Get-ADUser -Identity $AccountName -Properties $ADProperties -ErrorAction Stop
            }
            catch {
                Write-Warning "Could not retrieve details for $AccountName : $_"
                continue
            }

            # Calculate password age
            $PasswordAge = if ($Account.PasswordLastSet) {
                [math]::Round(((Get-Date) - $Account.PasswordLastSet).TotalDays)
            }
            else {
                -1
            }

            # Determine account type
            $AccountType = switch ($Account.ObjectClass) {
                'msDS-ManagedServiceAccount'          { 'MSA' }
                'msDS-GroupManagedServiceAccount'      { 'GMSA' }
                default                                { 'User' }
            }

            # Identify privileged group memberships
            $PrivilegedMemberships = @()
            if ($Account.MemberOf) {
                foreach ($GroupDN in $Account.MemberOf) {
                    $GroupName = ($GroupDN -split ',')[0] -replace '^CN=', ''
                    if ($PrivilegedGroups -contains $GroupName) {
                        $PrivilegedMemberships += $GroupName
                    }
                }
            }

            # Check for SPNs
            $HasSPN = ($null -ne $Account.ServicePrincipalName -and $Account.ServicePrincipalName.Count -gt 0)

            # Generate findings
            $Findings = [System.Collections.Generic.List[string]]::new()

            if ($PasswordAge -gt 365) {
                $Findings.Add('PASSWORD OLD')
            }
            if ($Account.PasswordNeverExpires) {
                $Findings.Add('NEVER EXPIRES')
            }
            if ($PrivilegedMemberships.Count -gt 0) {
                $Findings.Add('IN PRIVILEGED GROUP')
            }
            if (-not $Account.ManagedBy -and [string]::IsNullOrWhiteSpace($Account.ManagedBy)) {
                $Findings.Add('NO OWNER')
            }
            if (-not $Account.Enabled -and $Account.MemberOf.Count -gt 0) {
                $Findings.Add('DISABLED BUT IN GROUPS')
            }
            if ([string]::IsNullOrWhiteSpace($Account.Description)) {
                $Findings.Add('NO DESCRIPTION')
            }

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

    end {
        Write-Verbose "Inventory complete. Found $($Results.Count) service accounts."
        $Results | Sort-Object -Property SAMAccountName
    }
}