the shed // ruby for devops

A backdoor root account, a duplicate UID, or a /etc/shadow entry with an empty password hash won’t show up until someone goes looking for it. This tutorial builds a pure-Ruby auditor that goes looking — on a cron schedule, with an exit code your monitoring already understands.

Step through the build below:




user_account_audit.rb

Local user accounts drift quietly. Someone adds a service account by hand and forgets to lock the shell down. A migration script leaves a stale UID 0 entry from a “just to be safe” moment three years ago. A password reset never gets a hash written to /etc/shadow because the automation half-failed.

None of these show up in a dashboard. They show up in an incident report, months later, when someone asks “wait, how did that account get root?” This script answers that question before the incident, by parsing /etc/passwd and /etc/shadow directly and flagging six specific misconfigurations with a severity and an exit code cron already understands.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# user_account_audit.rb
#
# Audits local Linux user accounts for common security misconfigurations by
# parsing /etc/passwd (and, when readable, /etc/shadow) without shelling out
# to external tools. Designed to run standalone on any box with a stock Ruby
# install -- no gems required.
#
# Checks performed:
#   1. Duplicate UID 0 accounts (any account besides "root" with UID 0 is a
#      classic backdoor / privilege-escalation red flag).
#   2. Duplicate UIDs across different usernames (breaks accountability --
#      two "different" users are actually the same account to the kernel).
#   3. Accounts with a login shell but a missing/non-existent home directory.
#   4. Accounts with no password hash set at all in /etc/shadow (empty field,
#      not "!" or "*") -- these can be logged into with an empty password if
#      PAM allows it.
#   5. Accounts whose password field shows "never expires" (empty max-age)
#      combined with a real login shell -- flagged as informational, since
#      it is common but worth knowing about on a hardened box.
#   6. System accounts (UID < 1000 by convention) that have been given an
#      interactive login shell instead of nologin/false.
#
# Usage:
#   ruby user_account_audit.rb                       # audit the live system
#   ruby user_account_audit.rb --passwd FILE          # audit a passwd fixture
#   ruby user_account_audit.rb --passwd FILE --shadow FILE
#   ruby user_account_audit.rb --json                 # machine-readable output
#   ruby user_account_audit.rb --min-uid 1000          # override system/human UID cutoff
#
# Exit codes (cron/CI friendly):
#   0 - no findings
#   1 - WARN-level findings only
#   2 - CRIT-level findings present
require 'optparse'
require 'json'
require 'etc'
require 'time'
# ---------------------------------------------------------------------------
# Data object for a single finding so text and JSON output stay in sync.
# ---------------------------------------------------------------------------
Finding = Struct.new(:severity, :user, :check, :detail) do
  def to_h
    { severity: severity.to_s, user: user, check: check, detail: detail }
  end
end
class UserAccountAuditor
  SEVERITY_RANK = { info: 0, warn: 1, crit: 2 }.freeze
  def initialize(passwd_path:, shadow_path:, min_uid:)
    @passwd_path = passwd_path
    @shadow_path = shadow_path
    @min_uid = min_uid
    @findings = []
  end
  def run
    users = parse_passwd(@passwd_path)
    shadow = @shadow_path && File.readable?(@shadow_path) ? parse_shadow(@shadow_path) : nil
    check_duplicate_root_uid(users)
    check_duplicate_uids(users)
    check_missing_home_dirs(users)
    check_system_accounts_with_shell(users)
    check_shadow_findings(users, shadow) if shadow
    @findings
  end
  private
  # /etc/passwd fields: username:x:uid:gid:gecos:home:shell
  def parse_passwd(path)
    users = []
    File.foreach(path) do |line|
      line = line.strip
      next if line.empty? || line.start_with?('#')
      fields = line.split(':', -1)
      next unless fields.size >= 7
      users << {
        name: fields[0],
        uid: fields[2].to_i,
        gid: fields[3].to_i,
        gecos: fields[4],
        home: fields[5],
        shell: fields[6]
      }
    end
    users
  end
  # /etc/shadow fields: username:password_hash:last_change:min:max:warn:inactive:expire:reserved
  def parse_shadow(path)
    shadow = {}
    File.foreach(path) do |line|
      line = line.strip
      next if line.empty? || line.start_with?('#')
      fields = line.split(':', -1)
      next unless fields.size >= 8
      shadow[fields[0]] = {
        hash: fields[1],
        max_age: fields[4]
      }
    end
    shadow
  end
  NOLOGIN_SHELLS = %w[/usr/sbin/nologin /sbin/nologin /bin/false /usr/bin/false].freeze
  def interactive_shell?(shell)
    return false if shell.nil? || shell.empty?
    return false if NOLOGIN_SHELLS.include?(shell)
    true
  end
  def check_duplicate_root_uid(users)
    zero_uid_users = users.select { |u| u[:uid].zero? }
    extras = zero_uid_users.reject { |u| u[:name] == 'root' }
    extras.each do |u|
      add(:crit, u[:name], 'duplicate-root-uid',
          "UID 0 shared with account '#{u[:name]}' -- this account has full root " \
          'privileges. Verify it is expected; if not, this is likely a backdoor.')
    end
  end
  def check_duplicate_uids(users)
    users.group_by { |u| u[:uid] }.each do |uid, group|
      next if group.size < 2
      next if uid.zero? # already covered by check_duplicate_root_uid with clearer messaging
      names = group.map { |u| u[:name] }.join(', ')
      group.each do |u|
        add(:warn, u[:name], 'duplicate-uid',
            "UID #{uid} is shared by multiple accounts (#{names}). These accounts " \
            'are indistinguishable at the filesystem/permission level.')
      end
    end
  end
  def check_missing_home_dirs(users)
    users.each do |u|
      next unless interactive_shell?(u[:shell])
      next if u[:home].nil? || u[:home].empty?
      unless Dir.exist?(u[:home])
        add(:warn, u[:name], 'missing-home-dir',
            "Home directory '#{u[:home]}' does not exist, but the account has " \
            "login shell '#{u[:shell]}'.")
      end
    end
  end
  def check_system_accounts_with_shell(users)
    users.each do |u|
      next unless u[:uid] < @min_uid
      next if u[:name] == 'root'
      next unless interactive_shell?(u[:shell])
      add(:warn, u[:name], 'system-account-interactive-shell',
          "System account (UID #{u[:uid]}) has an interactive shell " \
          "'#{u[:shell]}' instead of nologin/false.")
    end
  end
  def check_shadow_findings(users, shadow)
    users.each do |u|
      entry = shadow[u[:name]]
      next unless entry
      next unless interactive_shell?(u[:shell])
      hash = entry[:hash]
      if hash == ''
        add(:crit, u[:name], 'empty-password-hash',
            'Password hash field in /etc/shadow is empty -- account may be ' \
            'loggable-in with a blank password depending on PAM config.')
      elsif !hash.start_with?('!', '*')
        if entry[:max_age].nil? || entry[:max_age].empty?
          add(:info, u[:name], 'password-never-expires',
              'Account has a set password with no maximum password age configured.')
        end
      end
    end
  end
  def add(severity, user, check, detail)
    @findings << Finding.new(severity, user, check, detail)
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_options(argv)
  opts = { passwd: '/etc/passwd', shadow: '/etc/shadow', json: false, min_uid: 1000 }
  parser = OptionParser.new do |o|
    o.banner = 'Usage: ruby user_account_audit.rb [options]'
    o.on('--passwd FILE', 'Path to passwd-format file (default: /etc/passwd)') { |v| opts[:passwd] = v }
    o.on('--shadow FILE', 'Path to shadow-format file (default: /etc/shadow, skipped if unreadable)') { |v| opts[:shadow] = v }
    o.on('--min-uid N', Integer, 'UID cutoff between system and human accounts (default: 1000)') { |v| opts[:min_uid] = v }
    o.on('--json', 'Emit machine-readable JSON instead of text') { opts[:json] = true }
    o.on('-h', '--help', 'Show this help') do
      puts o
      exit 0
    end
  end
  parser.parse!(argv)
  opts
end
def print_text_report(findings, users_scanned)
  puts "user_account_audit: scanned #{users_scanned} accounts, #{findings.size} finding(s)"
  puts '-' * 72
  if findings.empty?
    puts 'No issues found.'
    return
  end
  %i[crit warn info].each do |sev|
    group = findings.select { |f| f.severity == sev }
    next if group.empty?
    puts "\n[#{sev.to_s.upcase}] (#{group.size})"
    group.each do |f|
      puts "  - #{f.user}: #{f.check}"
      puts "      #{f.detail}"
    end
  end
end
if __FILE__ == $PROGRAM_NAME
  options = parse_options(ARGV)
  unless File.readable?(options[:passwd])
    warn "Cannot read passwd file: #{options[:passwd]}"
    exit 3
  end
  auditor = UserAccountAuditor.new(
    passwd_path: options[:passwd],
    shadow_path: options[:shadow],
    min_uid: options[:min_uid]
  )
  findings = auditor.run
  users_scanned = File.readlines(options[:passwd]).reject { |l| l.strip.empty? || l.start_with?('#') }.size
  if options[:json]
    puts JSON.pretty_generate(
      scanned_at: Time.now.utc.iso8601,
      users_scanned: users_scanned,
      finding_count: findings.size,
      findings: findings.map(&:to_h)
    )
  else
    print_text_report(findings, users_scanned)
  end
  worst = findings.map { |f| UserAccountAuditor::SEVERITY_RANK[f.severity] }.max || -1
  exit(worst >= UserAccountAuditor::SEVERITY_RANK[:crit] ? 2 : worst >= UserAccountAuditor::SEVERITY_RANK[:warn] ? 1 : 0)
end

Two design decisions matter more than the individual checks. First, parse_passwd and parse_shadow split on : with a -1 limit, not the default. Ruby’s String#split drops trailing empty fields unless you pass a negative limit — and an empty trailing field is exactly what an unset shadow password hash looks like. Using the default limit would silently swallow the one field this script most needs to catch.

Second, shadow-based checks degrade gracefully instead of erroring. /etc/shadow is 0600 root:shadow on every mainstream distro, so a non-root run legitimately can’t read it. Rather than requiring root, the script checks File.readable? and simply skips those three checks — you still get the passwd-based findings (duplicate UIDs, missing home directories, system accounts with a shell) from an unprivileged account, and the full picture as root.

Every finding is a small Struct with a severity, not a bare string. That is what lets the exit code and the JSON output stay in sync with the text report from one source of truth — add a seventh check and the exit-code logic, JSON schema, and text grouping all pick it up for free.

user_account_audit: scanned 7 accounts, 12 finding(s)
------------------------------------------------------------------------
[CRIT] (2)
  - backdoor: duplicate-root-uid
      UID 0 shared with account 'backdoor' -- this account has full root privileges. Verify it is expected; if not, this is likely a backdoor.
  - ghost: empty-password-hash
      Password hash field in /etc/shadow is empty -- account may be loggable-in with a blank password depending on PAM config.
[WARN] (9)
  - alice: duplicate-uid
      UID 1001 is shared by multiple accounts (alice, bob). These accounts are indistinguishable at the filesystem/permission level.
  - bob: duplicate-uid
      UID 1001 is shared by multiple accounts (alice, bob). These accounts are indistinguishable at the filesystem/permission level.
  - backdoor: missing-home-dir
      Home directory '/home/backdoor' does not exist, but the account has login shell '/bin/bash'.
  - alice: missing-home-dir
      Home directory '/home/alice' does not exist, but the account has login shell '/bin/bash'.
  - bob: missing-home-dir
      Home directory '/home/bob' does not exist, but the account has login shell '/bin/bash'.
  - ghost: missing-home-dir
      Home directory '/home/ghost' does not exist, but the account has login shell '/bin/bash'.
  - svc_backup: missing-home-dir
      Home directory '/var/lib/backup' does not exist, but the account has login shell '/bin/bash'.
  - backdoor: system-account-interactive-shell
      System account (UID 0) has an interactive shell '/bin/bash' instead of nologin/false.
  - svc_backup: system-account-interactive-shell
      System account (UID 998) has an interactive shell '/bin/bash' instead of nologin/false.
[INFO] (1)
  - alice: password-never-expires
      Account has a set password with no maximum password age configured.

Get the code

Full script + README + fixtures on GitHub: ruby-devops-toolkit/user-account-audit

prerequisites

Prerequisites

  • Ruby ≥ 2.7 (tested on 3.0.2) — uses only optparse, json, etc, and time from the standard library, no gems.
  • Read access to /etc/passwd; read access to /etc/shadow requires root, and the script degrades gracefully without it.
user-account-audit data flow diagram

From /etc/passwd + /etc/shadow to a pass/fail exit code
what it catches

Six Checks, Three Severities

  • duplicate-root-uid (CRIT) — any account besides root with UID 0 has full root privileges. The classic backdoor signature.
  • duplicate-uid (WARN) — two usernames sharing one UID are indistinguishable to the kernel and to every ACL on the box.
  • missing-home-dir (WARN) — an interactive shell pointed at a home directory that doesn’t exist.
  • system-account-interactive-shell (WARN) — a UID-below-1000 system account with a real shell instead of nologin/false.
  • empty-password-hash (CRIT) — an empty hash field in /etc/shadow, which can mean a loggable-in blank password depending on PAM.
  • password-never-expires (INFO) — a set password with no maximum age configured.
severity model diagram

Every check maps to CRIT, WARN, or INFO — the worst one wins the exit code
the full script

Full Script (for reference)

user_account_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# user_account_audit.rb
#
# Audits local Linux user accounts for common security misconfigurations by
# parsing /etc/passwd (and, when readable, /etc/shadow) without shelling out
# to external tools. Designed to run standalone on any box with a stock Ruby
# install -- no gems required.
#
# Checks performed:
#   1. Duplicate UID 0 accounts (any account besides "root" with UID 0 is a
#      classic backdoor / privilege-escalation red flag).
#   2. Duplicate UIDs across different usernames (breaks accountability --
#      two "different" users are actually the same account to the kernel).
#   3. Accounts with a login shell but a missing/non-existent home directory.
#   4. Accounts with no password hash set at all in /etc/shadow (empty field,
#      not "!" or "*") -- these can be logged into with an empty password if
#      PAM allows it.
#   5. Accounts whose password field shows "never expires" (empty max-age)
#      combined with a real login shell -- flagged as informational, since
#      it is common but worth knowing about on a hardened box.
#   6. System accounts (UID < 1000 by convention) that have been given an
#      interactive login shell instead of nologin/false.
#
# Usage:
#   ruby user_account_audit.rb                       # audit the live system
#   ruby user_account_audit.rb --passwd FILE          # audit a passwd fixture
#   ruby user_account_audit.rb --passwd FILE --shadow FILE
#   ruby user_account_audit.rb --json                 # machine-readable output
#   ruby user_account_audit.rb --min-uid 1000          # override system/human UID cutoff
#
# Exit codes (cron/CI friendly):
#   0 - no findings
#   1 - WARN-level findings only
#   2 - CRIT-level findings present
require 'optparse'
require 'json'
require 'etc'
require 'time'
# ---------------------------------------------------------------------------
# Data object for a single finding so text and JSON output stay in sync.
# ---------------------------------------------------------------------------
Finding = Struct.new(:severity, :user, :check, :detail) do
  def to_h
    { severity: severity.to_s, user: user, check: check, detail: detail }
  end
end
class UserAccountAuditor
  SEVERITY_RANK = { info: 0, warn: 1, crit: 2 }.freeze
  def initialize(passwd_path:, shadow_path:, min_uid:)
    @passwd_path = passwd_path
    @shadow_path = shadow_path
    @min_uid = min_uid
    @findings = []
  end
  def run
    users = parse_passwd(@passwd_path)
    shadow = @shadow_path && File.readable?(@shadow_path) ? parse_shadow(@shadow_path) : nil
    check_duplicate_root_uid(users)
    check_duplicate_uids(users)
    check_missing_home_dirs(users)
    check_system_accounts_with_shell(users)
    check_shadow_findings(users, shadow) if shadow
    @findings
  end
  private
  # /etc/passwd fields: username:x:uid:gid:gecos:home:shell
  def parse_passwd(path)
    users = []
    File.foreach(path) do |line|
      line = line.strip
      next if line.empty? || line.start_with?('#')
      fields = line.split(':', -1)
      next unless fields.size >= 7
      users << {
        name: fields[0],
        uid: fields[2].to_i,
        gid: fields[3].to_i,
        gecos: fields[4],
        home: fields[5],
        shell: fields[6]
      }
    end
    users
  end
  # /etc/shadow fields: username:password_hash:last_change:min:max:warn:inactive:expire:reserved
  def parse_shadow(path)
    shadow = {}
    File.foreach(path) do |line|
      line = line.strip
      next if line.empty? || line.start_with?('#')
      fields = line.split(':', -1)
      next unless fields.size >= 8
      shadow[fields[0]] = {
        hash: fields[1],
        max_age: fields[4]
      }
    end
    shadow
  end
  NOLOGIN_SHELLS = %w[/usr/sbin/nologin /sbin/nologin /bin/false /usr/bin/false].freeze
  def interactive_shell?(shell)
    return false if shell.nil? || shell.empty?
    return false if NOLOGIN_SHELLS.include?(shell)
    true
  end
  def check_duplicate_root_uid(users)
    zero_uid_users = users.select { |u| u[:uid].zero? }
    extras = zero_uid_users.reject { |u| u[:name] == 'root' }
    extras.each do |u|
      add(:crit, u[:name], 'duplicate-root-uid',
          "UID 0 shared with account '#{u[:name]}' -- this account has full root " \
          'privileges. Verify it is expected; if not, this is likely a backdoor.')
    end
  end
  def check_duplicate_uids(users)
    users.group_by { |u| u[:uid] }.each do |uid, group|
      next if group.size < 2
      next if uid.zero? # already covered by check_duplicate_root_uid with clearer messaging
      names = group.map { |u| u[:name] }.join(', ')
      group.each do |u|
        add(:warn, u[:name], 'duplicate-uid',
            "UID #{uid} is shared by multiple accounts (#{names}). These accounts " \
            'are indistinguishable at the filesystem/permission level.')
      end
    end
  end
  def check_missing_home_dirs(users)
    users.each do |u|
      next unless interactive_shell?(u[:shell])
      next if u[:home].nil? || u[:home].empty?
      unless Dir.exist?(u[:home])
        add(:warn, u[:name], 'missing-home-dir',
            "Home directory '#{u[:home]}' does not exist, but the account has " \
            "login shell '#{u[:shell]}'.")
      end
    end
  end
  def check_system_accounts_with_shell(users)
    users.each do |u|
      next unless u[:uid] < @min_uid
      next if u[:name] == 'root'
      next unless interactive_shell?(u[:shell])
      add(:warn, u[:name], 'system-account-interactive-shell',
          "System account (UID #{u[:uid]}) has an interactive shell " \
          "'#{u[:shell]}' instead of nologin/false.")
    end
  end
  def check_shadow_findings(users, shadow)
    users.each do |u|
      entry = shadow[u[:name]]
      next unless entry
      next unless interactive_shell?(u[:shell])
      hash = entry[:hash]
      if hash == ''
        add(:crit, u[:name], 'empty-password-hash',
            'Password hash field in /etc/shadow is empty -- account may be ' \
            'loggable-in with a blank password depending on PAM config.')
      elsif !hash.start_with?('!', '*')
        if entry[:max_age].nil? || entry[:max_age].empty?
          add(:info, u[:name], 'password-never-expires',
              'Account has a set password with no maximum password age configured.')
        end
      end
    end
  end
  def add(severity, user, check, detail)
    @findings << Finding.new(severity, user, check, detail)
  end
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
def parse_options(argv)
  opts = { passwd: '/etc/passwd', shadow: '/etc/shadow', json: false, min_uid: 1000 }
  parser = OptionParser.new do |o|
    o.banner = 'Usage: ruby user_account_audit.rb [options]'
    o.on('--passwd FILE', 'Path to passwd-format file (default: /etc/passwd)') { |v| opts[:passwd] = v }
    o.on('--shadow FILE', 'Path to shadow-format file (default: /etc/shadow, skipped if unreadable)') { |v| opts[:shadow] = v }
    o.on('--min-uid N', Integer, 'UID cutoff between system and human accounts (default: 1000)') { |v| opts[:min_uid] = v }
    o.on('--json', 'Emit machine-readable JSON instead of text') { opts[:json] = true }
    o.on('-h', '--help', 'Show this help') do
      puts o
      exit 0
    end
  end
  parser.parse!(argv)
  opts
end
def print_text_report(findings, users_scanned)
  puts "user_account_audit: scanned #{users_scanned} accounts, #{findings.size} finding(s)"
  puts '-' * 72
  if findings.empty?
    puts 'No issues found.'
    return
  end
  %i[crit warn info].each do |sev|
    group = findings.select { |f| f.severity == sev }
    next if group.empty?
    puts "\n[#{sev.to_s.upcase}] (#{group.size})"
    group.each do |f|
      puts "  - #{f.user}: #{f.check}"
      puts "      #{f.detail}"
    end
  end
end
if __FILE__ == $PROGRAM_NAME
  options = parse_options(ARGV)
  unless File.readable?(options[:passwd])
    warn "Cannot read passwd file: #{options[:passwd]}"
    exit 3
  end
  auditor = UserAccountAuditor.new(
    passwd_path: options[:passwd],
    shadow_path: options[:shadow],
    min_uid: options[:min_uid]
  )
  findings = auditor.run
  users_scanned = File.readlines(options[:passwd]).reject { |l| l.strip.empty? || l.start_with?('#') }.size
  if options[:json]
    puts JSON.pretty_generate(
      scanned_at: Time.now.utc.iso8601,
      users_scanned: users_scanned,
      finding_count: findings.size,
      findings: findings.map(&:to_h)
    )
  else
    print_text_report(findings, users_scanned)
  end
  worst = findings.map { |f| UserAccountAuditor::SEVERITY_RANK[f.severity] }.max || -1
  exit(worst >= UserAccountAuditor::SEVERITY_RANK[:crit] ? 2 : worst >= UserAccountAuditor::SEVERITY_RANK[:warn] ? 1 : 0)
end
how it works

Step-by-Step Walkthrough

The script is intentionally split into small, independently testable pieces:

  • parse_passwd / parse_shadow turn each colon-delimited line into a plain
    Hash — no Etc module iteration, because Etc doesn’t expose the shadow
    file at all, and passwd/shadow entries need to line up by username explicitly for the shadow-based
    checks to work.
  • UserAccountAuditor runs six check methods over the parsed users, each pushing
    Finding structs (severity, user, check,
    detail) onto a shared array. Every check is a short, single-purpose private method
    check_duplicate_root_uid, check_duplicate_uids, and so on — so
    adding a seventh check later means adding one method and one call, not touching the reporting or exit
    code logic.
  • The CLI layer (parse_options, print_text_report) is deliberately dumb:
    it either prints a grouped CRIT/WARN/INFO text report or hands the findings to
    JSON.pretty_generate. Neither path recomputes severity — they both just read
    Finding#severity, which is why the two output formats can never drift out of sync with
    each other.
  • The exit code is derived once, at the very end, from the worst severity seen across all findings
    — 2 for any CRIT, 1 for WARN-only, 0 for clean. That single line is what makes this drop straight
    into cron (MAILTO= on nonzero exit), a CI pipeline, or a Nagios-style check without any
    extra wrapper script.
example output

Example Output

user_account_audit.rb –passwd fixtures/passwd.fixture –shadow fixtures/shadow.fixture
user_account_audit: scanned 7 accounts, 12 finding(s)
————————————————————————
[CRIT] (2)
– backdoor: duplicate-root-uid
UID 0 shared with account 'backdoor' — this account has full root privileges. Verify it is expected; if not, this is likely a backdoor.
– ghost: empty-password-hash
Password hash field in /etc/shadow is empty — account may be loggable-in with a blank password depending on PAM config.

Full output — including all 9 WARN and 1 INFO finding — is in the output tab of the widget above, and in the script’s own test fixtures on GitHub.

troubleshooting

Troubleshooting

  • “Cannot read passwd file” — the --passwd path is wrong or
    unreadable. Double-check the path and permissions before assuming the script is broken.
  • Shadow checks never fire — you’re not running as root.
    /etc/shadow is 0600 root:shadow on virtually every distro; the script
    detects this with File.readable? and quietly skips those three checks rather than
    erroring, so a non-root run will only ever show passwd-based findings.
  • False positive on system-account-interactive-shell — some distributions
    intentionally give certain service accounts a real shell for su - serviceaccount
    workflows. Treat a hit here as “go verify,” not an automatic confirmed problem.
  • Different distros, different shadow layout — the script assumes the
    standard glibc /etc/shadow field order
    (user:hash:lastchange:min:max:warn:inactive:expire), which holds across every mainstream
    Linux distribution.
extending it

Extending It

  • Parse the shadow max_age field numerically and flag accounts whose rotation window
    is unreasonably long, instead of just flagging “no max age at all.”
  • Cross-reference /etc/group to flag accounts unexpectedly present in
    wheel/sudo.
  • Add a --lastlog flag that shells out to lastlog -b <n> and flags
    accounts that haven’t logged in within N days as stale.
  • Feed the findings into this repo’s prometheus-exporter/ script for a continuously
    scraped, always-current account-hygiene metric instead of a point-in-time report.