A good /etc/login.defs means nothing if accounts override it. This script checks the policy and then the real /etc/shadow entries, and hands you the chage command for each offender.
Step through the build below:
The problem: login.defs only sets defaults for new accounts. Existing users keep whatever was written into /etc/shadow at creation, so an account with max-age 99999 sits happily outside a 90-day policy. Auditors check both, and so should you.
#!/usr/bin/env ruby
# frozen_string_literal: true
# login_defs_audit.rb - audit /etc/login.defs password-aging policy and check
# whether /etc/shadow accounts actually comply with it. Stdlib only.
# ruby login_defs_audit.rb [--login-defs F] [--shadow F] [--json] [--today YYYY-MM-DD]
# Exit: 0 clean, 1 WARN, 2 FAIL
require 'optparse'
require 'json'
require 'date'
# Parse "KEY value" lines; comments/blank ignored; last value wins (like shadow-utils).
def parse_login_defs(text)
text.each_line.with_object({}) do |l, h|
l = l.sub(/#.*/, '').strip
next if l.empty?
k, v = l.split(/\s+/, 2)
h[k] = v.to_s.strip
end
end
def policy_findings(d)
f = []
max = d['PASS_MAX_DAYS']&.to_i
f << ['FAIL', 'PASS_MAX_DAYS', "#{max.inspect} - passwords effectively never expire", 'PASS_MAX_DAYS 365 (CIS: <= 365)'] if max.nil? || max > 365 || max < 0
f << ['WARN', 'PASS_MAX_DAYS', "#{max} days is long", 'use 90-365'] if max && max.between?(181, 365)
min = d['PASS_MIN_DAYS']&.to_i
f << ['WARN', 'PASS_MIN_DAYS', "#{min.inspect} - users can change passwords repeatedly to cycle history", 'PASS_MIN_DAYS 1'] if min.nil? || min < 1
warn_age = d['PASS_WARN_AGE']&.to_i
f << ['WARN', 'PASS_WARN_AGE', "#{warn_age.inspect} - too little notice", 'PASS_WARN_AGE 7'] if warn_age.nil? || warn_age < 7
enc = d['ENCRYPT_METHOD'].to_s.upcase
f << ['FAIL', 'ENCRYPT_METHOD', "#{enc.empty? ? 'unset' : enc} - weak or default hash", 'ENCRYPT_METHOD SHA512 (or YESCRYPT)'] unless %w[SHA512 YESCRYPT].include?(enc)
umask = d['UMASK']
f << ['WARN', 'UMASK', "#{umask || 'unset'} - new files group/world readable", 'UMASK 027'] if umask.nil? || (umask.to_i(8) & 0o027) != 0o027
f
end
# shadow fields: name:hash:lastchg:min:max:warn:inactive:expire
def shadow_findings(text, defs, today)
global_max = defs['PASS_MAX_DAYS'].to_i
text.each_line.flat_map do |l|
n, hash, lastchg, _min, max, = l.chomp.split(':', -1)
next [] if n.nil? || n.empty?
next [] if hash.start_with?('!', '*') # locked / no password
out = []
out << ['FAIL', n, 'empty password hash', 'passwd -l ' + n] if hash.empty?
eff = max.to_s.empty? ? global_max : max.to_i
out << ['FAIL', n, "account max=#{max} never expires (overrides login.defs)", "chage -M #{[global_max, 365].min} #{n}"] if max.to_i >= 99_999
if !lastchg.to_s.empty? && lastchg.to_i.positive? && eff.positive? && eff < 99_999
age = (today - Date.new(1970, 1, 1)).to_i - lastchg.to_i
out << ['WARN', n, "password #{age} days old, policy max #{eff}", "chage -d 0 #{n}"] if age > eff
end
out
end
end
if $PROGRAM_NAME == __FILE__
o = { ld: '/etc/login.defs', sh: '/etc/shadow', today: Date.today }
OptionParser.new do |op|
op.on('--login-defs F') { |v| o[:ld] = v }
op.on('--shadow F') { |v| o[:sh] = v }
op.on('--today D') { |v| o[:today] = Date.parse(v) }
op.on('--json') { o[:json] = true }
end.parse!
defs = parse_login_defs(File.read(o[:ld]))
findings = policy_findings(defs).map { |s, w, m, fx| { sev: s, scope: 'policy', item: w, msg: m, fix: fx } }
if File.readable?(o[:sh])
findings += shadow_findings(File.read(o[:sh]), defs, o[:today]).map { |s, w, m, fx| { sev: s, scope: 'account', item: w, msg: m, fix: fx } }
else
warn "note: #{o[:sh]} not readable (run as root) - skipping per-account checks"
end
if o[:json]
puts JSON.pretty_generate(findings)
else
findings.each { |x| puts format('%-4s %-8s %-15s %s -> %s', x[:sev], x[:scope], x[:item], x[:msg], x[:fix]) }
puts findings.empty? ? 'RESULT: clean' : "RESULT: #{findings.count { |x| x[:sev] == 'FAIL' }} FAIL, #{findings.count { |x| x[:sev] == 'WARN' }} WARN"
end
exit(findings.any? { |x| x[:sev] == 'FAIL' } ? 2 : (findings.empty? ? 0 : 1))
end
1. Parse login.defs (comments stripped, last value wins). 2. Check policy: PASS_MAX/MIN/WARN, ENCRYPT_METHOD, UMASK. 3. Parse each shadow line; skip locked (!/*) accounts; flag empty hashes, never-expiring max, and stale passwords older than the effective max. 4. Print FAIL/WARN with a fix, exit 0/1/2. Reading shadow needs root; without it per-account checks are skipped with a note.
FAIL policy PASS_MAX_DAYS 99999 - passwords effectively never expire -> PASS_MAX_DAYS 365 (CIS: <= 365) WARN policy PASS_MIN_DAYS 0 - users can change passwords repeatedly to cycle history -> PASS_MIN_DAYS 1 FAIL policy ENCRYPT_METHOD MD5 - weak or default hash -> ENCRYPT_METHOD SHA512 (or YESCRYPT) WARN policy UMASK 022 - new files group/world readable -> UMASK 027 FAIL account root account max=99999 never expires (overrides login.defs) -> chage -M 365 root WARN account alice password 1727 days old, policy max 90 -> chage -d 0 alice FAIL account bob empty password hash -> passwd -l bob FAIL account bob account max=99999 never expires (overrides login.defs) -> chage -M 365 bob RESULT: 5 FAIL, 3 WARN
Full script + README on GitHub: ruby-devops-toolkit/login-defs-audit
What you need
- Ruby 3.0+ (tested on 3.3.6) on Linux
- No gems:
optparse,json,date - Root to read
/etc/shadow;--shadowaccepts a copy
Full code
#!/usr/bin/env ruby
# frozen_string_literal: true
# login_defs_audit.rb - audit /etc/login.defs password-aging policy and check
# whether /etc/shadow accounts actually comply with it. Stdlib only.
# ruby login_defs_audit.rb [--login-defs F] [--shadow F] [--json] [--today YYYY-MM-DD]
# Exit: 0 clean, 1 WARN, 2 FAIL
require 'optparse'
require 'json'
require 'date'
# Parse "KEY value" lines; comments/blank ignored; last value wins (like shadow-utils).
def parse_login_defs(text)
text.each_line.with_object({}) do |l, h|
l = l.sub(/#.*/, '').strip
next if l.empty?
k, v = l.split(/\s+/, 2)
h[k] = v.to_s.strip
end
end
def policy_findings(d)
f = []
max = d['PASS_MAX_DAYS']&.to_i
f << ['FAIL', 'PASS_MAX_DAYS', "#{max.inspect} - passwords effectively never expire", 'PASS_MAX_DAYS 365 (CIS: <= 365)'] if max.nil? || max > 365 || max < 0
f << ['WARN', 'PASS_MAX_DAYS', "#{max} days is long", 'use 90-365'] if max && max.between?(181, 365)
min = d['PASS_MIN_DAYS']&.to_i
f << ['WARN', 'PASS_MIN_DAYS', "#{min.inspect} - users can change passwords repeatedly to cycle history", 'PASS_MIN_DAYS 1'] if min.nil? || min < 1
warn_age = d['PASS_WARN_AGE']&.to_i
f << ['WARN', 'PASS_WARN_AGE', "#{warn_age.inspect} - too little notice", 'PASS_WARN_AGE 7'] if warn_age.nil? || warn_age < 7
enc = d['ENCRYPT_METHOD'].to_s.upcase
f << ['FAIL', 'ENCRYPT_METHOD', "#{enc.empty? ? 'unset' : enc} - weak or default hash", 'ENCRYPT_METHOD SHA512 (or YESCRYPT)'] unless %w[SHA512 YESCRYPT].include?(enc)
umask = d['UMASK']
f << ['WARN', 'UMASK', "#{umask || 'unset'} - new files group/world readable", 'UMASK 027'] if umask.nil? || (umask.to_i(8) & 0o027) != 0o027
f
end
# shadow fields: name:hash:lastchg:min:max:warn:inactive:expire
def shadow_findings(text, defs, today)
global_max = defs['PASS_MAX_DAYS'].to_i
text.each_line.flat_map do |l|
n, hash, lastchg, _min, max, = l.chomp.split(':', -1)
next [] if n.nil? || n.empty?
next [] if hash.start_with?('!', '*') # locked / no password
out = []
out << ['FAIL', n, 'empty password hash', 'passwd -l ' + n] if hash.empty?
eff = max.to_s.empty? ? global_max : max.to_i
out << ['FAIL', n, "account max=#{max} never expires (overrides login.defs)", "chage -M #{[global_max, 365].min} #{n}"] if max.to_i >= 99_999
if !lastchg.to_s.empty? && lastchg.to_i.positive? && eff.positive? && eff < 99_999
age = (today - Date.new(1970, 1, 1)).to_i - lastchg.to_i
out << ['WARN', n, "password #{age} days old, policy max #{eff}", "chage -d 0 #{n}"] if age > eff
end
out
end
end
if $PROGRAM_NAME == __FILE__
o = { ld: '/etc/login.defs', sh: '/etc/shadow', today: Date.today }
OptionParser.new do |op|
op.on('--login-defs F') { |v| o[:ld] = v }
op.on('--shadow F') { |v| o[:sh] = v }
op.on('--today D') { |v| o[:today] = Date.parse(v) }
op.on('--json') { o[:json] = true }
end.parse!
defs = parse_login_defs(File.read(o[:ld]))
findings = policy_findings(defs).map { |s, w, m, fx| { sev: s, scope: 'policy', item: w, msg: m, fix: fx } }
if File.readable?(o[:sh])
findings += shadow_findings(File.read(o[:sh]), defs, o[:today]).map { |s, w, m, fx| { sev: s, scope: 'account', item: w, msg: m, fix: fx } }
else
warn "note: #{o[:sh]} not readable (run as root) - skipping per-account checks"
end
if o[:json]
puts JSON.pretty_generate(findings)
else
findings.each { |x| puts format('%-4s %-8s %-15s %s -> %s', x[:sev], x[:scope], x[:item], x[:msg], x[:fix]) }
puts findings.empty? ? 'RESULT: clean' : "RESULT: #{findings.count { |x| x[:sev] == 'FAIL' }} FAIL, #{findings.count { |x| x[:sev] == 'WARN' }} WARN"
end
exit(findings.any? { |x| x[:sev] == 'FAIL' } ? 2 : (findings.empty? ? 0 : 1))
end
How it works, step by step
Parsing. parse_login_defs strips # comments and splits on first whitespace, mirroring shadow-utils where later lines win.
Policy. policy_findings returns tuples of severity, key, message and fix. A max over 365 or negative is a FAIL; the UMASK check masks the octal value against 027.
Accounts. Shadow has colon-separated fields: name, hash, last-change (days since epoch), min, max, warn. An empty max field inherits the global value. Age is today minus last-change in days, which is why --today exists: deterministic tests.
Remediation. Every finding carries its fix: chage -M, chage -d 0 to force a change, or passwd -l.
Running it
When things go sideways
- “not readable”: run with sudo or pass
--shadow. - YESCRYPT unknown: accepted alongside SHA512; add others to the list as your distro requires.
- PAM may override: some distros set aging via PAM modules; this tool reads login.defs only.
- Testing note: tested in the sandbox against hand-built login.defs and shadow fixtures with deliberate violations.
Where to take it next
- Check
INACTIVEand account expiry fields - Compare UID_MIN/MAX and system accounts with shells
- Emit JSON into your alerting pipeline (already supported via
--json)