Audit Linux Local Accounts With a Python Script

– min read

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?


Links: Python | GitHub


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.


  1. Python 3.x (stdlib only, no pip packages)
  2. Read access to /etc/passwd and /etc/shadow – either root or a member of the shadow group
  3. 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()


CheckSeverityWhy
empty_placeholder_hashCRITICALNo password needed (empty) or hash is a placeholder like !/*
uid_zero_non_rootCRITICALNon-root account with UID 0 = root-equivalent
service_account_login_shellCRITICALSystem account (UID < 1000) has /bin/bash – interactive access
legacy_hash_algorithmWARNINGMD5 ($1$) or DES – outdated, switch to SHA-512 or bcrypt
password_never_expiresWARNINGmax-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:

Synthetic demo: account_census.py output showing 9 critical and 11 warnings
Demo run against synthetic passwd+shadow data (RFC 5737 ranges). Shows CRITICAL and WARNING findings.


$ 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: 11


Debugging 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).

account_census.py real run on Ubuntu 26.04
Live run against /etc/passwd and /etc/shadow on an Ubuntu 26.04 server.


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 wwwdata


Run 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.

Mohamad ZulfahmyCybersecurity · OffSec · Entrepreneur
About me ↗
ON THIS PAGE
On this page