THE PROBLEM

Everything you audit in Active Directory stops at the domain boundary. Local accounts live on the other side of it.

They sit on every Windows machine you own, outside every AD report you run, outside PIM, outside conditional access, outside MFA. They don’t appear in your privileged group monitoring because they’re not in a domain group. They don’t show up in your stale account audit because that query runs against AD.

And they accumulate exactly the way everything else does. A vendor creates one during an install. A previous admin makes a break-glass account and doesn’t document it. A monitoring agent provisions a service account locally. An imaging process bakes one into the gold image, which means it now exists on all 200 machines built from it — with the same password.

That last one is the real danger. A single local password shared across your fleet means compromising one machine gives an attacker working credentials on every other machine built from that image. It’s the exact scenario LAPS exists to solve, and the exact scenario that persists in environments where LAPS was deployed to workstations but never to servers.

The built-in Administrator account deserves its own mention. Renaming it doesn’t help much — it keeps the well-known SID of -500 regardless of name, and any tool worth worrying about enumerates by SID.

This week we inventory every local account on every machine.

THE SCRIPT

Save the script below to:

C:\Scripts\LocalAccountAudit.ps1

powershell

# Local Account Inventory & Password Age Audit
# Automate & Operate — automateandoperate.com

# Email settings — update these
$from = "[email protected]"
$to = "[email protected]"
$smtpServer = "your.smtp.server"
$smtpPort = 587
$username = "[email protected]"
$password = ConvertTo-SecureString "yourpassword" -AsPlainText -Force
$credential = New-Object System.Management.Automation.PSCredential($username, $password)

$logFile = "C:\Logs\LocalAccountAudit.log"
$reportFile = "C:\Logs\LocalAccountReport.csv"
$baselineFile = "C:\Logs\LocalAccountBaseline.csv"

# Machines to audit. Leave empty to auto-discover enabled servers from AD.
$computers = @()

# Accounts expected to exist everywhere — won't be flagged as unexpected.
# Built-in SIDs are matched by suffix, so they work regardless of rename.
$expectedSidSuffixes = @(
    "-500",  # Administrator
    "-501",  # Guest
    "-503",  # DefaultAccount
    "-504"   # WDAGUtilityAccount
)

# Named accounts you know about and have approved.
$approvedAccounts = @(
    "svc_monitoring",
    "svc_backup"
)

# Password age thresholds in days
$passwordWarningDays = 365
$passwordCriticalDays = 730

# Flag enabled accounts that have never logged on
$flagNeverLoggedOn = $true

# Create log folder if missing
if (-not (Test-Path "C:\Logs")) {
    New-Item -ItemType Directory -Path "C:\Logs" -Force | Out-Null
}

$timestamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$dateStamp = Get-Date -Format "yyyy-MM-dd"

$report = @()
$critical = @()
$warnings = @()
$unexpected = @()
$sharedPasswordRisk = @()
$unreachable = @()

# ---------- DISCOVER COMPUTERS ----------
if ($computers.Count -eq 0) {
    try {
        Import-Module ActiveDirectory -ErrorAction Stop
        $computers = (Get-ADComputer -Filter {
            Enabled -eq $true -and OperatingSystem -like "*Server*"
        } -Properties OperatingSystem).Name | Sort-Object

        Add-Content -Path $logFile -Value "$timestamp — Discovered $($computers.Count) server(s) from AD"
    } catch {
        Add-Content -Path $logFile -Value "$timestamp — AD discovery failed: $_ — using localhost"
        $computers = @("localhost")
    }
}

foreach ($computer in $computers) {

    try {
        $accounts = Invoke-Command -ComputerName $computer -ScriptBlock {

            $localUsers = Get-LocalUser -ErrorAction SilentlyContinue

            # Local Administrators group membership, for cross-reference
            $adminMembers = @()
            try {
                $adminMembers = (Get-LocalGroupMember -Group "Administrators" -ErrorAction SilentlyContinue |
                    Where-Object { $_.PrincipalSource -eq "Local" }).Name
            } catch { }

            # Check whether LAPS is managing the local admin password
            $lapsManaged = $false
            $lapsExpiry = $null
            try {
                # Legacy LAPS
                if (Test-Path "HKLM:\SOFTWARE\Policies\Microsoft Services\AdmPwd") {
                    $admPwd = Get-ItemProperty -Path "HKLM:\SOFTWARE\Policies\Microsoft Services\AdmPwd" -ErrorAction SilentlyContinue
                    if ($admPwd.AdmPwdEnabled -eq 1) { $lapsManaged = $true }
                }
                # Windows LAPS
                if (Test-Path "HKLM:\SOFTWARE\Microsoft\Policies\LAPS") {
                    $winLaps = Get-ItemProperty -Path "HKLM:\SOFTWARE\Microsoft\Policies\LAPS" -ErrorAction SilentlyContinue
                    if ($winLaps.BackupDirectory -gt 0) { $lapsManaged = $true }
                }
            } catch { }

            $result = foreach ($user in $localUsers) {
                [PSCustomObject]@{
                    Name                 = $user.Name
                    SID                  = $user.SID.Value
                    Enabled              = $user.Enabled
                    Description          = $user.Description
                    LastLogon            = $user.LastLogon
                    PasswordLastSet      = $user.PasswordLastSet
                    PasswordExpires      = $user.PasswordExpires
                    PasswordChangeable   = $user.PasswordChangeableDate
                    PasswordRequired     = $user.PasswordRequired
                    UserMayChangePassword = $user.UserMayChangePassword
                    IsLocalAdmin         = ($adminMembers -contains $user.Name) -or
                                           ($adminMembers -contains "$env:COMPUTERNAME\$($user.Name)")
                }
            }

            [PSCustomObject]@{
                Users       = $result
                LapsManaged = $lapsManaged
                OSCaption   = (Get-CimInstance Win32_OperatingSystem).Caption
            }

        } -ErrorAction Stop

        foreach ($user in $accounts.Users) {

            $flags = @()

            # Is this an expected built-in?
            $isBuiltIn = $false
            foreach ($suffix in $expectedSidSuffixes) {
                if ($user.SID -like "*$suffix") { $isBuiltIn = $true; break }
            }

            $isApproved = $approvedAccounts -contains $user.Name

            # ---- Password age ----
            $pwdAgeDays = "N/A"
            if ($user.PasswordLastSet) {
                $pwdAgeDays = (New-TimeSpan -Start $user.PasswordLastSet -End (Get-Date)).Days

                if ($user.Enabled) {
                    if ($pwdAgeDays -ge $passwordCriticalDays) {
                        $flags += "PASSWORD $pwdAgeDays DAYS OLD"
                    } elseif ($pwdAgeDays -ge $passwordWarningDays) {
                        $flags += "PASSWORD AGING ($pwdAgeDays days)"
                    }
                }
            } elseif ($user.Enabled) {
                $flags += "PASSWORD NEVER SET"
            }

            # ---- Never expires ----
            if ($user.Enabled -and $null -eq $user.PasswordExpires -and $user.PasswordRequired) {
                $flags += "PASSWORD NEVER EXPIRES"
            }

            # ---- No password required ----
            if ($user.Enabled -and $user.PasswordRequired -eq $false) {
                $flags += "NO PASSWORD REQUIRED"
            }

            # ---- Never logged on ----
            if ($flagNeverLoggedOn -and $user.Enabled -and $null -eq $user.LastLogon -and -not $isBuiltIn) {
                $flags += "NEVER LOGGED ON"
            }

            # ---- Unexpected account ----
            if (-not $isBuiltIn -and -not $isApproved -and $user.Enabled) {
                $flags += "UNEXPECTED ACCOUNT"
            }

            # ---- Local admin that isn't the built-in ----
            if ($user.IsLocalAdmin -and $user.Enabled -and $user.SID -notlike "*-500") {
                $flags += "LOCAL ADMIN"
            }

            # ---- Built-in admin enabled without LAPS ----
            if ($user.SID -like "*-500" -and $user.Enabled -and -not $accounts.LapsManaged) {
                $flags += "BUILT-IN ADMIN ENABLED, NO LAPS"
            }

            # ---- Guest enabled ----
            if ($user.SID -like "*-501" -and $user.Enabled) {
                $flags += "GUEST ACCOUNT ENABLED"
            }

            $status = if ($flags -match "NO PASSWORD REQUIRED|GUEST ACCOUNT ENABLED|PASSWORD NEVER SET") {
                "CRITICAL"
            } elseif ($flags -match "UNEXPECTED ACCOUNT|LOCAL ADMIN|PASSWORD $passwordCriticalDays|BUILT-IN ADMIN ENABLED") {
                "CRITICAL"
            } elseif ($flags.Count -gt 0) {
                "WARNING"
            } else {
                "OK"
            }

            $entry = [PSCustomObject]@{
                Date            = $dateStamp
                Computer        = $computer
                AccountName     = $user.Name
                SID             = $user.SID
                Enabled         = $user.Enabled
                IsBuiltIn       = $isBuiltIn
                IsLocalAdmin    = $user.IsLocalAdmin
                Description     = $user.Description
                PasswordLastSet = if ($user.PasswordLastSet) { $user.PasswordLastSet.ToString("yyyy-MM-dd") } else { "NEVER" }
                PasswordAgeDays = $pwdAgeDays
                LastLogon       = if ($user.LastLogon) { $user.LastLogon.ToString("yyyy-MM-dd") } else { "NEVER" }
                LapsManaged     = $accounts.LapsManaged
                Status          = $status
                Flags           = if ($flags.Count -gt 0) { $flags -join "; " } else { "OK" }
            }

            $report += $entry
            if ($status -eq "CRITICAL") { $critical += $entry }
            if ($status -eq "WARNING")  { $warnings += $entry }
            if ($flags -contains "UNEXPECTED ACCOUNT") { $unexpected += $entry }
        }

        Add-Content -Path $logFile -Value "$timestamp — $computer — $($accounts.Users.Count) local account(s), LAPS: $($accounts.LapsManaged)"

    } catch {
        $entry = [PSCustomObject]@{
            Date            = $dateStamp
            Computer        = $computer
            AccountName     = "UNREACHABLE"
            SID             = "N/A"
            Enabled         = "N/A"
            IsBuiltIn       = "N/A"
            IsLocalAdmin    = "N/A"
            Description     = $_.Exception.Message
            PasswordLastSet = "N/A"
            PasswordAgeDays = "N/A"
            LastLogon       = "N/A"
            LapsManaged     = "N/A"
            Status          = "ERROR"
            Flags           = "UNREACHABLE"
        }
        $report += $entry
        $unreachable += $entry
        Add-Content -Path $logFile -Value "$timestamp — $computer — ERROR: $_"
    }
}

# ---------- SHARED PASSWORD RISK DETECTION ----------
# Same account name with the same PasswordLastSet across many machines
# strongly suggests a shared password baked into an image.
$grouped = $report |
    Where-Object { $_.Enabled -eq $true -and $_.PasswordLastSet -ne "NEVER" -and $_.AccountName -ne "UNREACHABLE" } |
    Group-Object AccountName, PasswordLastSet

foreach ($group in $grouped) {
    if ($group.Count -ge 3) {
        $parts = $group.Name -split ', '
        $sharedPasswordRisk += [PSCustomObject]@{
            AccountName     = $parts[0]
            PasswordLastSet = $parts[1]
            MachineCount    = $group.Count
            Machines        = ($group.Group.Computer | Select-Object -First 10) -join ", "
        }
    }
}

# ---------- COMPARE AGAINST BASELINE ----------
$newAccounts = @()

if (Test-Path $baselineFile) {
    try {
        $baseline = Import-Csv -Path $baselineFile
        $baselineKeys = $baseline | ForEach-Object { "$($_.Computer)|$($_.SID)" }

        foreach ($entry in ($report | Where-Object { $_.AccountName -ne "UNREACHABLE" })) {
            $key = "$($entry.Computer)|$($entry.SID)"
            if ($baselineKeys -notcontains $key) { $newAccounts += $entry }
        }
    } catch {
        Add-Content -Path $logFile -Value "$timestamp — Baseline comparison failed: $_"
    }
} else {
    Add-Content -Path $logFile -Value "$timestamp — No baseline found. Creating one now."
}

# ---------- EXPORT ----------
$report | Sort-Object Status, Computer, AccountName | Export-Csv -Path $reportFile -NoTypeInformation
$report | Where-Object { $_.AccountName -ne "UNREACHABLE" } | Export-Csv -Path $baselineFile -NoTypeInformation

# ---------- BUILD EMAIL ----------
$lapsCoverage = $report | Where-Object { $_.AccountName -ne "UNREACHABLE" } |
    Select-Object Computer, LapsManaged -Unique
$lapsYes = ($lapsCoverage | Where-Object { $_.LapsManaged -eq $true }).Count
$lapsNo  = ($lapsCoverage | Where-Object { $_.LapsManaged -eq $false }).Count

$emailBody  = "Local Account Audit — $timestamp`n"
$emailBody += "Machines audited: $($computers.Count)  |  Unreachable: $($unreachable.Count)`n"
$emailBody += "Local accounts found: $(($report | Where-Object { $_.AccountName -ne 'UNREACHABLE' }).Count)`n"
$emailBody += "LAPS managed: $lapsYes  |  Not managed: $lapsNo`n`n"
$emailBody += "CRITICAL: $($critical.Count)  |  WARNING: $($warnings.Count)`n"
$emailBody += "Unexpected accounts: $($unexpected.Count)`n"
$emailBody += "New since last run: $($newAccounts.Count)`n"
$emailBody += "Possible shared passwords: $($sharedPasswordRisk.Count) pattern(s)`n`n"

if ($newAccounts.Count -gt 0) {
    $emailBody += "*** NEW LOCAL ACCOUNTS SINCE LAST RUN ***`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($n in $newAccounts) {
        $emailBody += "Machine:     $($n.Computer)`n"
        $emailBody += "Account:     $($n.AccountName)`n"
        $emailBody += "Enabled:     $($n.Enabled)  |  Local admin: $($n.IsLocalAdmin)`n"
        $emailBody += "Description: $($n.Description)`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($sharedPasswordRisk.Count -gt 0) {
    $emailBody += "*** POSSIBLE SHARED LOCAL PASSWORD ***`n"
    $emailBody += "Same account name and identical password-set date across multiple machines.`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($s in ($sharedPasswordRisk | Sort-Object MachineCount -Descending)) {
        $emailBody += "Account:      $($s.AccountName)`n"
        $emailBody += "Password set: $($s.PasswordLastSet)`n"
        $emailBody += "Machines:     $($s.MachineCount)`n"
        $emailBody += "Examples:     $($s.Machines)`n"
        $emailBody += "-" * 40 + "`n"
    }
    $emailBody += "`n"
}

if ($unexpected.Count -gt 0) {
    $emailBody += "UNEXPECTED ENABLED ACCOUNTS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($u in ($unexpected | Sort-Object Computer)) {
        $emailBody += "$($u.Computer) | $($u.AccountName) | admin: $($u.IsLocalAdmin) | pwd age: $($u.PasswordAgeDays)d | $($u.Description)`n"
    }
    $emailBody += "`n"
}

if ($critical.Count -gt 0) {
    $emailBody += "CRITICAL FINDINGS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($c in ($critical | Sort-Object Computer)) {
        $emailBody += "$($c.Computer) | $($c.AccountName)`n"
        $emailBody += "   $($c.Flags)`n"
    }
    $emailBody += "`n"
}

if ($warnings.Count -gt 0) {
    $emailBody += "WARNINGS:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($w in ($warnings | Sort-Object Computer)) {
        $emailBody += "$($w.Computer) | $($w.AccountName) | $($w.Flags)`n"
    }
    $emailBody += "`n"
}

if ($lapsNo -gt 0) {
    $emailBody += "MACHINES WITHOUT LAPS:`n"
    $emailBody += "=" * 60 + "`n"
    ($lapsCoverage | Where-Object { $_.LapsManaged -eq $false }) | ForEach-Object {
        $emailBody += "  $($_.Computer)`n"
    }
    $emailBody += "`n"
}

if ($unreachable.Count -gt 0) {
    $emailBody += "UNREACHABLE MACHINES:`n"
    $emailBody += "=" * 60 + "`n"
    foreach ($u in $unreachable) {
        $emailBody += "$($u.Computer) — $($u.Description)`n"
    }
    $emailBody += "`n"
}

if ($critical.Count -eq 0 -and $warnings.Count -eq 0) {
    $emailBody += "No local account issues detected.`n`n"
}

$emailBody += "Full report: $reportFile`n`n"
$emailBody += "Useful commands:`n"
$emailBody += "  Get-LocalUser | Format-Table Name, Enabled, PasswordLastSet, LastLogon`n"
$emailBody += "  Disable-LocalUser -Name 'accountname'`n"
$emailBody += "  Get-LocalGroupMember -Group 'Administrators'`n`n"
$emailBody += "Automate & Operate — automateandoperate.com"

$subject = if ($sharedPasswordRisk.Count -gt 0) {
    "LOCAL ACCOUNT ALERT: possible shared password across $(($sharedPasswordRisk | Measure-Object -Property MachineCount -Maximum).Maximum) machines"
} elseif ($newAccounts.Count -gt 0) {
    "LOCAL ACCOUNT ALERT: $($newAccounts.Count) new local account(s) detected"
} elseif ($critical.Count -gt 0) {
    "Local Account Audit — $($critical.Count) critical finding(s)"
} elseif ($warnings.Count -gt 0) {
    "Local Account Audit — $($warnings.Count) warning(s)"
} else {
    "Local Account Audit — No issues found"
}

Send-MailMessage `
    -From $from -To $to `
    -Subject $subject `
    -Body $emailBody `
    -SmtpServer $smtpServer -Port $smtpPort `
    -UseSsl -Credential $credential

Update these lines:

  • $from / $to / $username / $password — your email details

  • $smtpServer — your SMTP server (e.g. smtp.office365.com)

  • $computers — leave empty to auto-discover servers from AD

  • $approvedAccounts — build this after your first run, see below

Requirements: PowerShell remoting and local admin on targets. The Get-LocalUser cmdlet needs PowerShell 5.1 or later, which covers Server 2016 onward. For Server 2012 R2, swap it for Get-WmiObject Win32_UserAccount -Filter "LocalAccount=True" — fewer fields, but it works.

THE SHARED PASSWORD DETECTION

This is the part that finds something nobody expected.

The script groups accounts by name and PasswordLastSet date. If the same account name shows the same password-set timestamp on three or more machines, that’s a strong signal the password came from an image rather than being set per-machine.

It’s inference, not proof — the script never touches password hashes and never could. But in practice the inference is reliable. Passwords set individually land on different dates. Identical dates across a fleet means they were baked in together.

If this section fires with a count in the dozens, you’ve found the thing. One compromised machine means working local admin credentials on every other machine in that group, and lateral movement becomes trivial.

The fix is LAPS, which brings us to the next part.

THE LAPS CHECK

The script looks for both legacy LAPS and Windows LAPS and reports per-machine coverage.

The pattern you’ll often see: LAPS deployed to workstations years ago during a security push, servers never included because “servers are managed differently.” Then the server fleet quietly runs with a shared local admin password for the next five years.

Windows LAPS is built into Windows 11 and Server 2022 and backported to Server 2019 and Windows 10 via updates — no separate MSI, no schema extension if you’re backing up to Entra ID. If your servers show LapsManaged: false and they’re modern enough to support it, that’s a short project with a large payoff.

The MACHINES WITHOUT LAPS section gives you the target list directly.

THE BASELINE DIFF

Same pattern as the privileged group monitor from a few weeks back, applied locally.

A new local account appearing on a server is worth a look every single time. Legitimate reasons exist — a software install provisioned one, somebody set up a service account. But “attacker creates a local account for persistence” is a well-worn technique precisely because local accounts don’t show up in domain-level monitoring.

The first run builds the baseline and reports nothing new. From run two, the NEW LOCAL ACCOUNTS section is the one to read first.

BUILDING YOUR APPROVED LIST

Don’t populate $approvedAccounts by guessing. Run it once with the list as-is and let everything come back flagged as UNEXPECTED.

Then open the CSV and work through each name:

  • Known service accounts for monitoring agents, backup software, RMM tools → add to the approved list

  • Accounts matching a person’s name → almost certainly a leftover from a previous admin, disable them

  • Accounts you cannot identify → investigate before touching. Check the Description field, check LastLogon, ask around. A local account with no logon history is low risk to disable; one logging in daily is holding something together

Add the legitimate ones to $approvedAccounts and re-run. The second report should be short and meaningful.

SETTING UP TASK SCHEDULER

  1. Press Windows key → type Task Scheduler → open it

  2. Click “Create Basic Task” → name it Local Account Audit

  3. Trigger: Weekly → Monday → 6:00am

  4. Action: Start a program

  5. Program: powershell.exe

  6. Arguments:

-ExecutionPolicy Bypass -File "C:\Scripts\LocalAccountAudit.ps1"
  1. Click Next → Finish

Under Properties → General, check “Run with highest privileges” and use an account with remote admin rights across the fleet.

Weekly is appropriate here. Local accounts don’t change often, and a weekly diff catches new ones with reasonable latency without generating noise.

If your fleet is large, be aware this runs serially and each machine takes a second or two. A hundred servers is a few minutes; five hundred starts being worth parallelising with Invoke-Command against the full list at once rather than looping.

TEST IT

Run it as administrator:

powershell

powershell.exe -ExecutionPolicy Bypass -File "C:\Scripts\LocalAccountAudit.ps1"

Check what it sees on one machine first:

powershell

Get-LocalUser | Format-Table Name, Enabled, PasswordLastSet, LastLogon, Description -AutoSize
Get-LocalGroupMember -Group "Administrators"

To test new-account detection, run once to build the baseline, then create a throwaway account and run again:

powershell

$pw = Read-Host -AsSecureString
New-LocalUser -Name "AO_TestAccount" -Password $pw -Description "Test"
# run the script, confirm it appears in NEW LOCAL ACCOUNTS
Remove-LocalUser -Name "AO_TestAccount"

If machines come back UNREACHABLE, enable remoting on each:

powershell

Enable-PSRemoting -Force

BEFORE YOU DISABLE ANYTHING

Local accounts break things in ways that are hard to trace, because the dependency is usually undocumented and lives on one machine.

Disable rather than delete. Disable-LocalUser is reversible in one command; deletion loses the SID, and anything granted rights to that SID silently stops working with no useful error.

Check for dependencies first:

powershell

# Services running as local accounts
Get-CimInstance Win32_Service | Where-Object { $_.StartName -notlike "NT AUTHORITY*" -and $_.StartName -notlike "*\*" } |
    Select-Object Name, StartName, State

# Scheduled tasks running as local accounts
Get-ScheduledTask | Where-Object { $_.Principal.UserId -notlike "*\*" -and $_.Principal.UserId -ne "SYSTEM" } |
    Select-Object TaskName, @{N='RunAs';E={$_.Principal.UserId}}

An account with no logon history and no service or task referencing it is safe to disable. One running a service is not — you need to know what that service does before touching it.

Give it two weeks disabled before deleting. If nothing broke, it’s genuinely dead.

WHY THIS MATTERS

Local accounts are the blind spot that persists even in environments with mature AD governance. You can have PIM, conditional access, MFA on everything, tight privileged group monitoring — and still have a shared local administrator password on two hundred servers that defeats all of it.

They’re also the standard persistence mechanism once an attacker has a foothold. Creating a local account is quiet, survives password resets on domain accounts, and doesn’t appear in any domain-level audit.

The audit itself is simple. The reason it’s rare isn’t difficulty — it’s that local accounts sit outside the boundary of every tool people already run.

THIS WEEK’S ACTION

Run it once across your servers today. Read two sections only: POSSIBLE SHARED LOCAL PASSWORD and MACHINES WITHOUT LAPS. If the first one fires, you’ve found your highest-value security project this quarter. If the second one lists most of your fleet, you’ve found the fix.

Reply to this email if you hit any issues — I read every reply.

Automate & Operate — automateandoperate.com

Leave Granola and get up to 12 months free of Wispr Flow Notetaker + Dictation

If you have paid time left on an individual Granola plan, we'll match it with a Wispr Flow subscription that includes Notetaker and dictation, and add bonus time, up to 12 months total. Sign in or create a Wispr account and submit proof of your plan to check eligibility.