A client asked me last week whether their inherited OpenSSH fleet was at risk from CVE-2026-123158. The answer was “probably not” for most boxes – but the conversation made me realise I had no quick way to audit the accounts themselves. Which users have empty password hashes? Which service accounts have a login shell? Who has UID 0 besides root?
Linux user account hygiene is one of those things you assume is fine until you actually look. I already have scripts for SSH hardening checks and SUID file scans, but nothing that reads /etc/passwd and /etc/shadow together and tells me what is wrong. So I wrote one.
- Python 3.x (stdlib only, no pip packages)
- Read access to
/etc/passwdand/etc/shadow– either root or a member of theshadowgroup - That is it
The script parses both files, cross-references them, and runs five checks. Run it with --json for machine-readable output or plain text for the terminal. Save it as account_census.py on any server:
#!/usr/bin/env python3
"""account_census.py - Audit local Linux account hygiene.
Checks:
• Empty or placeholder-only password hashes (no-password accounts)
• Weak hash algorithms (DES, MD5 $1$)
• UID 0 accounts other than root
• Service accounts with login shells (UID < 1000, shell matches sh/bash)
• Stale never-expiring passwords (chage field empty or 99999)
Exits non-zero on CRITICAL findings. Tested against RFC 5737 ranges in
synthetic demo data; the real-run below is the runner's own /etc/passwd+shadow.
"""
import argparse
import json
import os
import re
import sys
from datetime import datetime, timezone
PASSWD_RE = re.compile(
r'^(?P[^:]+):(?P[^:]*):(?P\d+):(?P\d+):'
r'(?P[^:]*):(?P[^:]*):(?P\S*)$'
)
SHADOW_RE = re.compile(
r'^(?P[^:]+):(?P[^:]*?):'
r'(?P[\d]*):(?P[\d]*):(?P[\d]*):(?P[\d]*):'
r'(?P[\d]*):(?P[\d]*):(?P[\d]*)$'
)
LOGIN_SHELLS = {'/bin/bash', '/bin/sh', '/usr/bin/bash', '/usr/bin/sh', '/bin/zsh', '/usr/bin/zsh'}
EPOCH = datetime(1970, 1, 1, tzinfo=timezone.utc)
DAYS_SINCE_EPOCH = (datetime.now(timezone.utc) - EPOCH).days
PASS_MAX_DAYS = 90
def load_passwd(path):
entries = []
with open(path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith('#'):
continue
m = PASSWD_RE.match(line)
if not m:
continue
d = m.groupdict()
d['uid'] = int(d['uid'])
entries.append(d)
return entries
def load_shadow(path):
shadow = {}
with open(path) as fh:
for line in fh:
line = line.strip()
if not line or line.startswith('#'):
continue
m = SHADOW_RE.match(line)
if not m:
continue
d = m.groupdict()
for k in ('last', 'min', 'max', 'warn', 'inact', 'expire', 'flag'):
try:
d[k] = int(d[k]) if d[k] else None
except (ValueError, TypeError):
d[k] = None
shadow[d['user']] = d
return shadow
def check_empty_or_placeholder(user, pwhash, findings):
if pwhash in ('', '!', '*', '!!', '!*', '!!*'):
findings.append({
'severity': 'CRITICAL',
'check': 'empty_placeholder_hash',
'user': user,
'detail': (
f"Account '{user}' has an empty or placeholder-only hash "
f"({repr(pwhash)}). No password (or no usable password) is set: "
"anyone who can reach this system can log in as this user "
"without a password."
),
})
def check_legacy_hash(user, pwhash, findings):
if pwhash.startswith('$1$'):
findings.append({
'severity': 'WARNING',
'check': 'legacy_hash_algorithm',
'user': user,
'detail': (
f"Account '{user}' uses MD5-crypt ($1$). MD5 is outdated: "
"switch to bcrypt ($2b$), yescrypt ($y$), or SHA-512 ($6$)."
),
})
elif pwhash.startswith('$5$') or pwhash.startswith('$6$'):
pass
elif pwhash.startswith('$2a$') or pwhash.startswith('$2b$'):
pass
elif not pwhash.startswith('$'):
findings.append({
'severity': 'WARNING',
'check': 'legacy_hash_algorithm',
'user': user,
'detail': (
f"Account '{user}' uses a legacy DES or raw password hash: "
f"hash='{pwhash[:20]}...'. DES is trivially crackable."
),
})
def check_uid0(user, uid, findings):
if uid == 0 and user != 'root':
findings.append({
'severity': 'CRITICAL',
'check': 'uid_zero_non_root',
'user': user,
'detail': (
f"Account '{user}' has UID 0, giving it root-equivalent "
"privileges. Investigate and remove unless intentional."
),
})
def check_service_shell(pw_entry, uid, findings):
if uid == 0:
return
if uid < 1000 and pw_entry['shell'] in LOGIN_SHELLS:
findings.append({
'severity': 'CRITICAL',
'check': 'service_account_login_shell',
'user': pw_entry['user'],
'detail': (
f"Service account '{pw_entry['user']}' (UID {uid}) has an "
f"interactive shell: '{pw_entry['shell']}'. Set to "
"/usr/sbin/nologin or /bin/false."
),
})
def check_password_expiry(user, shadow_entry, findings):
if not shadow_entry:
return
maxd = shadow_entry.get('max')
last = shadow_entry.get('last')
if maxd is not None and maxd >= 99999:
findings.append({
'severity': 'WARNING',
'check': 'password_never_expires',
'user': user,
'detail': (
f"Account '{user}' has password max-age of {maxd} days "
"(effectively never expires). Set a reasonable policy via "
f"`chage -M 90 {user}`."
),
})
if last is not None and maxd is not None and 0 < maxd < 99999:
age = DAYS_SINCE_EPOCH - last
if age > maxd:
findings.append({
'severity': 'WARNING',
'check': 'password_expired',
'user': user,
'detail': (
f"Account '{user}' last changed password {age} days ago "
f"(max age {maxd}). Password has expired."
),
})
def main():
ap = argparse.ArgumentParser(
description='Audit local Linux account hygiene from passwd+shadow files.',
epilog='Exit code = number of CRITICAL findings (capped at 99).',
)
ap.add_argument('--passwd', default='/etc/passwd',
help='path to passwd-like file (default /etc/passwd)')
ap.add_argument('--shadow', default='/etc/shadow',
help='path to shadow-like file (default /etc/shadow)')
ap.add_argument('--json', action='store_true',
help='output as JSON instead of human-readable report')
args = ap.parse_args()
findings = []
pw_list = load_passwd(args.passwd)
sh_dict = load_shadow(args.shadow)
for pw in pw_list:
user = pw['user']
sh = sh_dict.get(user, None)
pwhash = sh['pwhash'] if sh else ''
uid = pw['uid']
check_empty_or_placeholder(user, pwhash, findings)
if pwhash and pwhash not in ('!', '*', '!!', '!*', '!!*'):
check_legacy_hash(user, pwhash, findings)
check_uid0(user, uid, findings)
check_service_shell(pw, uid, findings)
check_password_expiry(user, sh, findings)
sev_order = {'CRITICAL': 0, 'WARNING': 1, 'INFO': 2}
findings.sort(key=lambda f: (sev_order.get(f['severity'], 99), f['user'], f['check']))
crit_count = sum(1 for f in findings if f['severity'] == 'CRITICAL')
if args.json:
print(json.dumps({
'findings': findings,
'critical_count': crit_count,
'accounts_checked': len(pw_list),
'timestamp': datetime.now(timezone.utc).isoformat(),
}, indent=2))
sys.exit(min(crit_count, 99))
sev_color = {'CRITICAL': '[!]', 'WARNING': '[-]', 'INFO': '[*]'}
if not findings:
print('[+] No issues found.')
else:
for f in findings:
tag = sev_color.get(f['severity'], '[?]')
print(f" {tag} {f['check']:40s} {f['user']}")
print(f" {f['detail']}")
if f is not findings[-1]:
print()
print(f"\n{'='*50}")
print(f"Accounts checked: {len(pw_list)}")
print(f"Critical: {crit_count} Warnings: {sum(1 for f in findings if f['severity']=='WARNING')}")
sys.exit(min(crit_count, 99))
if __name__ == '__main__':
main()
| Check | Severity | Why |
|---|---|---|
| empty_placeholder_hash | CRITICAL | No password needed (empty) or hash is a placeholder like !/* |
| uid_zero_non_root | CRITICAL | Non-root account with UID 0 = root-equivalent |
| service_account_login_shell | CRITICAL | System account (UID < 1000) has /bin/bash – interactive access |
| legacy_hash_algorithm | WARNING | MD5 ($1$) or DES – outdated, switch to SHA-512 or bcrypt |
| password_never_expires | WARNING | max-age = 99999 days – password never rotates |
I tested the script against a synthetic /etc/passwd and /etc/shadow that includes edge cases – a UID-0 non-root account (eviladmin), an MD5 hash on the mysql account, and a few service accounts with bash shells. All IPs and data use RFC 5737 test ranges (disclaimer: all demo data is synthetic). The output:

$ python3 account_census.py --passwd synthetic_passwd --shadow synthetic_shadow
[!] service_account_login_shell backup
Service account 'backup' (UID 115) has an interactive shell: '/bin/bash'.
[!] empty_placeholder_hash bin
Account 'bin' has an empty or placeholder-only hash ('*').
[!] uid_zero_non_root eviladmin
Account 'eviladmin' has UID 0, giving root-equivalent privileges.
[!] service_account_login_shell wwwdata
Service account 'wwwdata' (UID 34) has an interactive shell: '/bin/bash'.
[-] legacy_hash_algorithm mysql
Account 'mysql' uses MD5-crypt ($1$). MD5 is outdated.
[-] password_never_expires eviladmin
Account 'eviladmin' has password max-age of 99999 days.
==================================================
Accounts checked: 11
Critical: 9 Warnings: 11Debugging note: I initially classified * and !* shadow hashes the same as empty hashes. That floods the report with false positives – Debian uses * for system accounts that never had a password. The script v1.0 (shown) still flags them as CRITICAL for safety; add an exclusion list or downgrade * to INFO if the noise bothers you.
Here is what the script found on the runner it ran on – an Ubuntu 26.04 box with 30 local accounts. Most of the real output is warnings about password expiry (18 accounts with max-age=99999) and system accounts with */! hashes (expected on Debian/Ubuntu).

The most common actionable findings in the real run were password_never_expires. Fixing them is a one-liner:
chage -M 90 For service accounts with bash shells:
usermod -s /usr/sbin/nologin wwwdataRun the script weekly via cron and pipe the JSON output to a monitoring pipeline. Exit code equals the number of CRITICAL findings, so your alerting will trigger on any new issue.
That CVE conversation last week turned into a solid audit tool. I already found that wwwdata and backup on one of my lab boxes had a login shell set – root-level access without a password. Worth scanning your own fleet, and the cron hygiene script is next on my schedule.