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:
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.
Full script + README + fixtures on GitHub: ruby-devops-toolkit/user-account-audit
Prerequisites
- Ruby ≥ 2.7 (tested on 3.0.2) — uses only
optparse,json,etc, andtimefrom the standard library, no gems. - Read access to
/etc/passwd; read access to/etc/shadowrequires root, and the script degrades gracefully without it.
Six Checks, Three Severities
- duplicate-root-uid (CRIT) — any account besides
rootwith 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.
Full Script (for reference)
#!/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
Step-by-Step Walkthrough
The script is intentionally split into small, independently testable pieces:
parse_passwd/parse_shadowturn each colon-delimited line into a plain
Hash — noEtcmodule iteration, becauseEtcdoesn’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.UserAccountAuditorruns six check methods over the parsed users, each pushing
Findingstructs (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
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
- “Cannot read passwd file” — the
--passwdpath 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/shadowis0600 root:shadowon virtually every distro; the script
detects this withFile.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 forsu - 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/shadowfield order
(user:hash:lastchange:min:max:warn:inactive:expire), which holds across every mainstream
Linux distribution.
Extending It
- Parse the shadow
max_agefield numerically and flag accounts whose rotation window
is unreasonably long, instead of just flagging “no max age at all.” - Cross-reference
/etc/groupto flag accounts unexpectedly present in
wheel/sudo. - Add a
--lastlogflag that shells out tolastlog -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.