the shed // linux // password policy

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:

login_defs_audit.rb

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
Get the code

Full script + README on GitHub: ruby-devops-toolkit/login-defs-audit

Flow diagram: login.defs and shadow compared into findings

policy versus reality
prerequisites

What you need

  • Ruby 3.0+ (tested on 3.3.6) on Linux
  • No gems: optparse, json, date
  • Root to read /etc/shadow; --shadow accepts a copy
the script

Full code

login_defs_audit.rbruby
#!/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
walkthrough

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.

example output

Running it

login_defs_audit.rb
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
troubleshooting

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

Where to take it next

  • Check INACTIVE and account expiry fields
  • Compare UID_MIN/MAX and system accounts with shells
  • Emit JSON into your alerting pipeline (already supported via --json)

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating