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.
Six findings, one CSV.
Every account with a ServicePrincipalName is an offline password-cracking target. The script surfaces all of them.
PasswordNeverExpires on a service account usually means the password is years old — and shared in a wiki somewhere.
Accounts whose password predates your retention policy, flagged with configurable thresholds.
Service accounts sitting in Domain Admins or other privileged groups — the finding auditors write up first.
Enabled accounts with no recent logon: forgotten integrations that still authenticate if someone finds the password.
Unconstrained and constrained delegation settings that turn one compromised account into many.
- 01Review the script
It is ~150 lines of read-only Get-AD* queries. Read it — you should never run an unreviewed script against your domain.
- 02Run 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.
- 03Triage the CSV
Results are sorted by risk-flag count. Start at the top: privileged accounts with stale, never-expiring passwords.
Find-ServiceAccounts.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."
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.
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.