Free tool · PowerShell

Find the service accounts nobody owns.

A read-only PowerShell script that inventories your Active Directory service accounts and flags the risks auditors — and attackers — look for first. No agent, no telemetry, results stay on your machine.

MIT licensed · read-only Get-AD* queries · ~150 lines
What it flags

Six findings, one CSV.

Kerberoastable accounts

Every account with a ServicePrincipalName is an offline password-cracking target. The script surfaces all of them.

Passwords that never expire

PasswordNeverExpires on a service account usually means the password is years old — and shared in a wiki somewhere.

Stale credentials

Accounts whose password predates your retention policy, flagged with configurable thresholds.

Privileged service accounts

Service accounts sitting in Domain Admins or other privileged groups — the finding auditors write up first.

Dormant but enabled

Enabled accounts with no recent logon: forgotten integrations that still authenticate if someone finds the password.

Dangerous delegation

Unconstrained and constrained delegation settings that turn one compromised account into many.

How to run it
  1. 01
    Review the script

    It is ~150 lines of read-only Get-AD* queries. Read it — you should never run an unreviewed script against your domain.

  2. 02
    Run it from any domain-joined machine

    Requires the RSAT ActiveDirectory module and ordinary read access. No elevated rights, no changes to AD, no network calls.

  3. 03
    Triage the CSV

    Results are sorted by risk-flag count. Start at the top: privileged accounts with stale, never-expiring passwords.

The source

Find-ServiceAccounts.ps1

Download .ps1
<#
.SYNOPSIS
  Find-ServiceAccounts.ps1 — read-only Active Directory service-account discovery.

.DESCRIPTION
  Inventories likely service accounts in Active Directory and flags common
  privileged-access risks:

    * Accounts with ServicePrincipalNames (kerberoast surface)
    * Accounts matching service-style naming patterns (svc*, *service*, sa-*)
    * PasswordNeverExpires accounts
    * Stale passwords (older than -StalePasswordYears)
    * Enabled accounts with no recent logon
    * Membership in privileged groups (Domain Admins, etc.)
    * Unconstrained / constrained delegation

  READ-ONLY: uses Get-AD* cmdlets exclusively. Makes no changes to AD.
  No data leaves your network — results are written to a local CSV.

.REQUIREMENTS
  RSAT ActiveDirectory PowerShell module; read access to the domain.

.EXAMPLE
  .\Find-ServiceAccounts.ps1
  .\Find-ServiceAccounts.ps1 -StalePasswordYears 2 -OutputPath .\svc-audit.csv

.NOTES
  Provided by Monofor (https://monofor.com) under the MIT license, as-is,
  without warranty. Review before running, as you should with any script.
#>

[CmdletBinding()]
param(
  [int]$StalePasswordYears = 1,
  [int]$StaleLogonDays = 90,
  [string]$OutputPath = ".\service-account-inventory.csv",
  [string]$Server
)

$ErrorActionPreference = 'Stop'

try {
  Import-Module ActiveDirectory
} catch {
  Write-Error "RSAT ActiveDirectory module not found. Install RSAT and retry."
  exit 1
}

$adParams = @{}
if ($Server) { $adParams['Server'] = $Server }

$stalePwdDate   = (Get-Date).AddYears(-$StalePasswordYears)
$staleLogonDate = (Get-Date).AddDays(-$StaleLogonDays)

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

Write-Host "Collecting privileged group membership..." -ForegroundColor Cyan
$privilegedMembers = @{}
foreach ($group in $privilegedGroups) {
  try {
    Get-ADGroupMember -Identity $group -Recursive @adParams |
      Where-Object { $_.objectClass -eq 'user' } |
      ForEach-Object {
        if (-not $privilegedMembers.ContainsKey($_.SamAccountName)) {
          $privilegedMembers[$_.SamAccountName] = @()
        }
        $privilegedMembers[$_.SamAccountName] += $group
      }
  } catch {
    Write-Verbose "Group not found or unreadable: $group"
  }
}

Write-Host "Querying accounts (SPNs, naming patterns, password flags)..." -ForegroundColor Cyan

$properties = @(
  'SamAccountName', 'DisplayName', 'Enabled', 'ServicePrincipalName',
  'PasswordNeverExpires', 'PasswordLastSet', 'LastLogonDate',
  'TrustedForDelegation', 'msDS-AllowedToDelegateTo', 'Description',
  'whenCreated'
)

# Union of: SPN accounts + service-style names + password-never-expires.
$filter = {
  (ServicePrincipalName -like '*') -or
  (SamAccountName -like 'svc*') -or
  (SamAccountName -like 'sa-*') -or
  (SamAccountName -like '*service*') -or
  (PasswordNeverExpires -eq $true)
}

$accounts = Get-ADUser -Filter $filter -Properties $properties @adParams

$results = foreach ($acct in $accounts) {
  $flags = New-Object System.Collections.Generic.List[string]

  $hasSpn = [bool]$acct.ServicePrincipalName
  if ($hasSpn) { $flags.Add('SPN (kerberoastable surface)') }
  if ($acct.PasswordNeverExpires) { $flags.Add('PasswordNeverExpires') }
  if ($acct.PasswordLastSet -and $acct.PasswordLastSet -lt $stalePwdDate) {
    $flags.Add("Password older than $StalePasswordYears y")
  }
  if ($acct.Enabled -and $acct.LastLogonDate -and $acct.LastLogonDate -lt $staleLogonDate) {
    $flags.Add("No logon in $StaleLogonDays d")
  }
  if ($acct.Enabled -and -not $acct.LastLogonDate) {
    $flags.Add('Never logged on')
  }
  if ($acct.TrustedForDelegation) { $flags.Add('UNCONSTRAINED delegation') }
  if ($acct.'msDS-AllowedToDelegateTo') { $flags.Add('Constrained delegation') }
  if ($privilegedMembers.ContainsKey($acct.SamAccountName)) {
    $flags.Add("PRIVILEGED: $($privilegedMembers[$acct.SamAccountName] -join ', ')")
  }

  [pscustomobject]@{
    SamAccountName    = $acct.SamAccountName
    DisplayName       = $acct.DisplayName
    Enabled           = $acct.Enabled
    HasSPN            = $hasSpn
    PwdNeverExpires   = $acct.PasswordNeverExpires
    PasswordLastSet   = $acct.PasswordLastSet
    LastLogonDate     = $acct.LastLogonDate
    Created           = $acct.whenCreated
    Description       = $acct.Description
    RiskFlags         = ($flags -join ' | ')
    RiskCount         = $flags.Count
  }
}

$results = $results | Sort-Object -Property RiskCount -Descending
$results | Export-Csv -Path $OutputPath -NoTypeInformation -Encoding UTF8

$total       = ($results | Measure-Object).Count
$privileged  = ($results | Where-Object { $_.RiskFlags -match 'PRIVILEGED' } | Measure-Object).Count
$stalePwd    = ($results | Where-Object { $_.RiskFlags -match 'older than' } | Measure-Object).Count
$neverExpire = ($results | Where-Object { $_.PwdNeverExpires } | Measure-Object).Count
$delegation  = ($results | Where-Object { $_.RiskFlags -match 'delegation' } | Measure-Object).Count

Write-Host ""
Write-Host "=== Service-account inventory summary ===" -ForegroundColor Green
Write-Host ("  Candidate service accounts : {0}" -f $total)
Write-Host ("  In privileged groups       : {0}" -f $privileged)
Write-Host ("  Password never expires     : {0}" -f $neverExpire)
Write-Host ("  Stale passwords (>{0}y)     : {1}" -f $StalePasswordYears, $stalePwd)
Write-Host ("  Delegation configured      : {0}" -f $delegation)
Write-Host ""
Write-Host ("Full inventory written to: {0}" -f (Resolve-Path $OutputPath)) -ForegroundColor Green
Write-Host "Next step: vault and rotate the flagged credentials before an auditor (or an attacker) finds them."
Provided as-is under the MIT license. Review before running — as you should with any script that touches your directory.

Found something? Now vault it.

The scan gives you the inventory; the fix is discipline around those credentials — vault them, rotate them, and put access behind approvals and recording. Monopam automates exactly that, and imports this CSV as a starting point. New to the topic? Start with what a service account is and credential vaulting.

FAQ

Before you run it.

Is the script safe to run in production?
It is read-only by construction — only Get-AD* cmdlets, no Set-, New-, or Remove- calls, and no network egress. Output is a local CSV. Still, review it first and run it under a normal (non-admin) account with read access, as you would any third-party script.
What do I do with the results?
Vault the flagged credentials, rotate the stale ones, remove service accounts from privileged groups where possible, and put the rest behind time-bound, recorded access. That is exactly the workflow Monopam automates — but the inventory is valuable regardless of what PAM you use.
Why do service accounts matter so much?
They hold standing privilege, their passwords rarely rotate, and nobody owns them. Post-incident reports repeatedly find service accounts as the lateral-movement path — and both KVKK and DORA audits ask for exactly this inventory.
Does anything get sent to Monofor?
No. The script has no telemetry and makes no network calls; results stay in the CSV on your machine. The download is a plain .ps1 you can diff against the code shown on this page.

Ready to start managing
identities the right way?

Spin up a fully-loaded trial tenant in under five minutes. No credit card. No sales gate.