the shed // linux // accounts

When an account is deleted its files keep the uid, and the next useradd gives that uid to a stranger — along with the old setuid helper and the world-writable drop box. A gem-free Ruby script that finds unowned and ungrouped files (CIS 6.1.11/6.1.12), ranks them by blast radius, and writes the chown for you.

Step through the build below:

orphan_file_audit.rb

The kernel does not know who “bob” is. It knows 1003. When bob leaves and someone runs userdel bob, every file he owned keeps its uid. ls -l starts printing a number instead of a name, and nothing else changes: his crontab still runs, his setuid helper in /usr/local/bin still works, his 0777 drop box is still writable by everyone.

Four months later useradd carol hands out the next free uid — 1003 — and carol owns all of it. She did not ask for it, she will not notice, and anyone who compromises her account has a root-run binary to edit. CIS calls this unowned / ungrouped files (6.1.11, 6.1.12) and tells you to run find -nouser. It does not tell you which of the 4,000 results matter.

orphan_file_audit.rb finds them, ranks them (world-writable, setuid, in /etc = CRIT), and prints the exact chown that re-homes each batch without deleting anything.

$ cat orphan_file_audit.rb # stdlib only

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# orphan_file_audit.rb — find files and directories owned by users or groups
# that no longer exist (CIS Linux Benchmark 6.1.11 / 6.1.12), rank them by
# how dangerous they are, and print the exact chown commands to fix them.
#
# Why it matters: when an account is deleted its UID is freed. The next
# "useradd" hands that UID to someone new, who silently inherits every file
# the old account left behind — home directories, cron spool, SUID helpers,
# world-writable drop boxes. Orphaned files are a privilege-escalation path
# and an audit finding; both are easy to close once you can see them.
#
# Usage:
#   sudo ruby orphan_file_audit.rb                        # walk / (one filesystem)
#   sudo ruby orphan_file_audit.rb /srv /home /var        # specific roots
#   ruby orphan_file_audit.rb --from-find files.txt       # analyse captured find output:
#       find / -xdev \( -nouser -o -nogroup \) -printf '%U %G %m %y %s %p\n' > files.txt
#   ruby orphan_file_audit.rb --passwd ./passwd --group ./group --from-find files.txt
#   ruby orphan_file_audit.rb --json
#
# Exit codes: 0 nothing orphaned, 1 orphans found, 2 orphans that are
# world-writable, SUID/SGID, or sit under a sensitive path.
#
# Stdlib only. The walker uses File.lstat so it never follows symlinks, and
# stays on one filesystem per root (like find -xdev) unless --cross-fs.
require 'optparse'
require 'json'
require 'find'
SENSITIVE_PREFIXES = %w[/etc /usr /bin /sbin /lib /lib64 /boot /var/spool/cron /var/lib /root /opt].freeze
# ---------------------------------------------------------------------------
# Account databases — parsed directly so the audit works on captured files
# from another host (fleet mode) and on any OS for testing.
# ---------------------------------------------------------------------------
def read_ids(path, name_col: 0, id_col: 2)
  File.readlines(path).each_with_object({}) do |line, h|
    next if line.strip.empty? || line.start_with?('#')
    f = line.chomp.split(':')
    next if f.size <= id_col
    h[f[id_col].to_i] = f[name_col]
  end
end
# ---------------------------------------------------------------------------
# One record per filesystem object. mode is the permission bits as an Integer,
# type is a single char like find -printf %y: f d l s p c b
# ---------------------------------------------------------------------------
Entry = Struct.new(:uid, :gid, :mode, :type, :size, :path, keyword_init: true)
def walk(roots, cross_fs: false)
  roots.flat_map do |root|
    dev = File.lstat(root).dev
    out = []
    Find.find(root) do |p|
      st = File.lstat(p)
      if st.directory? && !cross_fs && st.dev != dev
        Find.prune # like find -xdev: do not descend into other filesystems
        next
      end
      out << Entry.new(uid: st.uid, gid: st.gid, mode: st.mode & 0o7777, type: type_char(st), size: st.size, path: p)
    rescue Errno::EACCES, Errno::ENOENT, Errno::ELOOP
      next # unreadable or vanished mid-walk
    end
    out
  end
end
def type_char(st)
  return 'l' if st.symlink?
  return 'd' if st.directory?
  return 's' if st.socket?
  return 'p' if st.pipe?
  return 'c' if st.chardev?
  return 'b' if st.blockdev?
  'f'
end
# find / -xdev \( -nouser -o -nogroup \) -printf '%U %G %m %y %s %p\n'
def read_find_output(path)
  File.readlines(path).filter_map do |line|
    uid, gid, mode, type, size, p = line.chomp.split(' ', 6)
    next unless p
    Entry.new(uid: uid.to_i, gid: gid.to_i, mode: mode.to_i(8), type: type, size: size.to_i, path: p)
  end
end
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
Orphan = Struct.new(:entry, :reasons, :severity, keyword_init: true)
def classify(entries, users, groups)
  entries.filter_map do |e|
    reasons = []
    reasons << "uid #{e.uid} has no passwd entry" unless users.key?(e.uid)
    reasons << "gid #{e.gid} has no group entry" unless groups.key?(e.gid)
    next if reasons.empty?
    risk = []
    risk << 'world-writable'    if e.mode & 0o002 != 0 && e.type != 'l'
    risk << 'setuid'            if e.mode & 0o4000 != 0
    risk << 'setgid'            if e.mode & 0o2000 != 0
    risk << 'sensitive-path'    if SENSITIVE_PREFIXES.any? { |pre| e.path == pre || e.path.start_with?(pre + '/') }
    risk << 'executable'        if e.type == 'f' && e.mode & 0o111 != 0
    sev = risk.any? { |r| %w[world-writable setuid setgid sensitive-path].include?(r) } ? 'CRIT' : 'WARN'
    Orphan.new(entry: e, reasons: reasons + risk, severity: sev)
  end
end
# Group orphans by (uid, gid) so the fix is one chown per former account,
# and suggest a target owner: the parent directory's owner if it is valid,
# else root.
def remediation(orphans, users, groups, entries_by_path)
  orphans.group_by { |o| [o.entry.uid, o.entry.gid] }.map do |(uid, gid), list|
    sample = list.first.entry
    parent = entries_by_path[File.dirname(sample.path)]
    # keep whichever half is still valid; replace the orphaned half with the
    # parent directory's owner/group when that is valid, else root
    new_uid = users.key?(uid)  ? uid : (parent && users.key?(parent.uid)  ? parent.uid : 0)
    new_gid = groups.key?(gid) ? gid : (parent && groups.key?(parent.gid) ? parent.gid : 0)
    {
      uid: uid, gid: gid, count: list.size, bytes: list.sum { |o| o.entry.size },
      suggested_owner: "#{users[new_uid] || new_uid}:#{groups[new_gid] || new_gid}",
      command: "find #{common_root(list.map { |o| o.entry.path })} -xdev #{users.key?(uid) ? '' : "-uid #{uid} "}#{groups.key?(gid) ? '' : "-gid #{gid} "}-exec chown -h #{new_uid}:#{new_gid} {} +".squeeze(' ')
    }
  end
end
def common_root(paths)
  parts = paths.map { |p| p.split('/') }
  common = parts.first
  parts.each { |p| common = common.zip(p).take_while { |a, b| a == b }.map(&:first) }
  root = common.join('/')
  root.empty? ? '/' : root
end
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
opts = { passwd: '/etc/passwd', group: '/etc/group', from_find: nil, json: false, cross_fs: false, limit: 40 }
OptionParser.new do |o|
  o.banner = 'Usage: orphan_file_audit.rb [options] [ROOT ...]'
  o.on('--passwd FILE', 'passwd file (default /etc/passwd)') { |v| opts[:passwd] = v }
  o.on('--group FILE', 'group file (default /etc/group)') { |v| opts[:group] = v }
  o.on('--from-find FILE', 'analyse captured find -printf output instead of walking') { |v| opts[:from_find] = v }
  o.on('--cross-fs', 'descend into other filesystems (default: stay on one, like -xdev)') { opts[:cross_fs] = true }
  o.on('--limit N', Integer, 'max rows to print in text mode (default 40)') { |v| opts[:limit] = v }
  o.on('--json', 'JSON output') { opts[:json] = true }
end.parse!
users  = read_ids(opts[:passwd])
groups = read_ids(opts[:group])
roots  = ARGV.empty? ? ['/'] : ARGV
entries = opts[:from_find] ? read_find_output(opts[:from_find]) : walk(roots, cross_fs: opts[:cross_fs])
by_path = entries.each_with_object({}) { |e, h| h[e.path] = e }
orphans = classify(entries, users, groups)
crit = orphans.count { |o| o.severity == 'CRIT' }
fixes = remediation(orphans, users, groups, by_path)
status = crit.positive? ? 'CRIT' : (orphans.empty? ? 'OK' : 'WARN')
if opts[:json]
  puts JSON.pretty_generate(status: status, scanned: entries.size, orphaned: orphans.size, critical: crit,
                            orphans: orphans.map { |o| o.entry.to_h.merge(severity: o.severity, reasons: o.reasons) },
                            remediation: fixes)
else
  puts "orphan_file_audit  scanned #{entries.size} entries (#{opts[:from_find] ? "from #{opts[:from_find]}" : roots.join(' ')})  " \
       "passwd=#{users.size} users  group=#{groups.size} groups"
  puts '-' * 96
  if orphans.empty?
    puts 'no files owned by unknown users or groups'
  else
    puts format('%-4s %-6s %-6s %-5s %-2s %9s  %-40s %s', 'SEV', 'UID', 'GID', 'MODE', 'T', 'SIZE', 'PATH', 'WHY')
    orphans.sort_by { |o| [o.severity == 'CRIT' ? 0 : 1, o.entry.path] }.first(opts[:limit]).each do |o|
      e = o.entry
      puts format('%-4s %-6d %-6d %-5s %-2s %9d  %-40s %s', o.severity, e.uid, e.gid, e.mode.to_s(8).rjust(4, '0'), e.type, e.size, e.path[0, 40], o.reasons.join(', '))
    end
    puts "... #{orphans.size - opts[:limit]} more (raise --limit or use --json)" if orphans.size > opts[:limit]
    puts
    puts 'Remediation (review before running):'
    fixes.each do |fx|
      puts format('  uid %-6d gid %-6d %5d files %10d bytes  -> chown to %s', fx[:uid], fx[:gid], fx[:count], fx[:bytes], fx[:suggested_owner])
      puts "    #{fx[:command]}"
    end
  end
  puts
  puts "#{status}: #{orphans.size} orphaned entries, #{crit} critical"
end
exit(crit.positive? ? 2 : (orphans.empty? ? 0 : 1))

Parse passwd and group yourself. Etc.getpwuid would work on the host, but reading /etc/passwd and /etc/group as files means the audit also works on captured copies from another box and in a test harness on any OS. NSS-only accounts (LDAP, SSSD) are the trade-off — see troubleshooting.

Two ways to get the file list. walk() is a pure-Ruby Find.find using File.lstat (never follows symlinks) that prunes other filesystems like find -xdev. --from-find consumes find -printf '%U %G %m %y %s %p\n' output instead, which is faster on a 20-million-inode box and is how you audit a fleet from one place.

Severity is about blast radius, not existence. classify() marks an orphan CRIT if it is world-writable, setuid/setgid, or lives under a sensitive prefix (/etc, /usr, /var/spool/cron, /var/lib…). Everything else is WARN. Executables get a flag so you look at them next.

Remediation keeps the valid half. A file with a valid owner but an orphaned group gets chown -h 33:0, not 0:0. Orphans are grouped by (uid, gid) and the printed find ... -exec chown -h is scoped to their common root directory.

$ ruby orphan_file_audit.rb –passwd fixtures/passwd –group fixtures/group –from-find fixtures/files.txt # exit 2

orphan_file_audit  scanned 15 entries (from /fx/orph/files.txt)  passwd=6 users  group=7 groups
------------------------------------------------------------------------------------------------
SEV  UID    GID    MODE  T       SIZE  PATH                                     WHY
CRIT 1003   1003   0777  d       4096  /home/bob/dropbox                        uid 1003 has no passwd entry, gid 1003 has no group entry, world-writable
CRIT 1003   1001   4755  f      22016  /usr/local/bin/oldsudo                   uid 1003 has no passwd entry, setuid, sensitive-path, executable
CRIT 1003   1003   0644  f        118  /var/spool/cron/crontabs/bob             uid 1003 has no passwd entry, gid 1003 has no group entry, sensitive-path
WARN 1003   1003   0755  d       4096  /home/bob                                uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1003   1003   0644  f       3771  /home/bob/.bashrc                        uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1003   1003   0600  f       1823  /home/bob/.ssh/id_ed25519                uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1003   1003   0755  f      18432  /home/bob/bin/deploy-helper              uid 1003 has no passwd entry, gid 1003 has no group entry, executable
WARN 1003   1003   0777  l         11  /home/bob/latest                         uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1004   998    0775  d       4096  /srv/app/shared                          uid 1004 has no passwd entry
WARN 1004   998    0664  f       5120  /srv/app/shared/config.yml               uid 1004 has no passwd entry
WARN 33     1005   0644  f       1200  /var/www/html/index.html                 gid 1005 has no group entry
Remediation (review before running):
  uid 1003   gid 1003       7 files      32347 bytes  -> chown to root:root
    find / -xdev -uid 1003 -gid 1003 -exec chown -h 0:0 {} +
  uid 1003   gid 1001       1 files      22016 bytes  -> chown to root:deploy
    find /usr/local/bin/oldsudo -xdev -uid 1003 -exec chown -h 0:1001 {} +
  uid 1004   gid 998        2 files       9216 bytes  -> chown to root:docker
    find /srv/app/shared -xdev -uid 1004 -exec chown -h 0:998 {} +
  uid 33     gid 1005       1 files       1200 bytes  -> chown to www-data:root
    find /var/www/html/index.html -xdev -gid 1005 -exec chown -h 33:0 {} +
CRIT: 11 orphaned entries, 3 critical
$ echo $?
2
Get the code

Full script + README on GitHub: ruby-devops-toolkit/orphan-file-audit

01 // the problem

UID reuse is a feature until it is an incident

Linux ownership is numeric. /etc/passwd is just a lookup table that turns 1003 into “bob” for display, and nothing in the kernel consults it when deciding whether a process may write a file. So when the table entry disappears the permissions do not — they wait. Offboarding checklists say “delete the account”; they rarely say “and re-own the 800 files in /srv/app that the deploy pipeline still reads”. The result is a growing population of files with a bare number for an owner, and eventually a new employee whose uid happens to match.

UID reuse timeline: a deleted user's setuid file is inherited by the next account that gets the same UID

The kernel only knows numbers. When a number is reassigned, everything with that number comes with it.

The benchmark fix — find / -xdev \( -nouser -o -nogroup \) — is correct and useless at scale, because it produces a flat list with no sense of priority and no suggested action. Most of the entries are harmless (.bashrc, cache files). A handful are not: a 4755 binary in /usr/local/bin, a 0777 directory, a crontab that still fires nightly as whoever holds the uid now. The audit’s job is to put those at the top and hand you the command that closes them.

02 // prerequisites

What you need

Requirements
  • Ruby 3.0+, standard library only (optparse, json, find). Tested on 3.4.
  • Root (or at least read access to every directory you want covered) when using the built-in walker. --from-find input can be produced by sudo find and analysed unprivileged.
  • Local account files: /etc/passwd and /etc/group by default; --passwd/--group accept captured copies for fleet audits.
  • Any OS for the --from-find mode — it is pure text processing, which is also how the script is tested.
03 // the code

orphan_file_audit.rb

The full script: an account-file reader, two ways to build the entry list, a classifier, a remediation planner, and the printer.

orphan_file_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# orphan_file_audit.rb — find files and directories owned by users or groups
# that no longer exist (CIS Linux Benchmark 6.1.11 / 6.1.12), rank them by
# how dangerous they are, and print the exact chown commands to fix them.
#
# Why it matters: when an account is deleted its UID is freed. The next
# "useradd" hands that UID to someone new, who silently inherits every file
# the old account left behind — home directories, cron spool, SUID helpers,
# world-writable drop boxes. Orphaned files are a privilege-escalation path
# and an audit finding; both are easy to close once you can see them.
#
# Usage:
#   sudo ruby orphan_file_audit.rb                        # walk / (one filesystem)
#   sudo ruby orphan_file_audit.rb /srv /home /var        # specific roots
#   ruby orphan_file_audit.rb --from-find files.txt       # analyse captured find output:
#       find / -xdev \( -nouser -o -nogroup \) -printf '%U %G %m %y %s %p\n' > files.txt
#   ruby orphan_file_audit.rb --passwd ./passwd --group ./group --from-find files.txt
#   ruby orphan_file_audit.rb --json
#
# Exit codes: 0 nothing orphaned, 1 orphans found, 2 orphans that are
# world-writable, SUID/SGID, or sit under a sensitive path.
#
# Stdlib only. The walker uses File.lstat so it never follows symlinks, and
# stays on one filesystem per root (like find -xdev) unless --cross-fs.
require 'optparse'
require 'json'
require 'find'
SENSITIVE_PREFIXES = %w[/etc /usr /bin /sbin /lib /lib64 /boot /var/spool/cron /var/lib /root /opt].freeze
# ---------------------------------------------------------------------------
# Account databases — parsed directly so the audit works on captured files
# from another host (fleet mode) and on any OS for testing.
# ---------------------------------------------------------------------------
def read_ids(path, name_col: 0, id_col: 2)
  File.readlines(path).each_with_object({}) do |line, h|
    next if line.strip.empty? || line.start_with?('#')
    f = line.chomp.split(':')
    next if f.size <= id_col
    h[f[id_col].to_i] = f[name_col]
  end
end
# ---------------------------------------------------------------------------
# One record per filesystem object. mode is the permission bits as an Integer,
# type is a single char like find -printf %y: f d l s p c b
# ---------------------------------------------------------------------------
Entry = Struct.new(:uid, :gid, :mode, :type, :size, :path, keyword_init: true)
def walk(roots, cross_fs: false)
  roots.flat_map do |root|
    dev = File.lstat(root).dev
    out = []
    Find.find(root) do |p|
      st = File.lstat(p)
      if st.directory? && !cross_fs && st.dev != dev
        Find.prune # like find -xdev: do not descend into other filesystems
        next
      end
      out << Entry.new(uid: st.uid, gid: st.gid, mode: st.mode & 0o7777, type: type_char(st), size: st.size, path: p)
    rescue Errno::EACCES, Errno::ENOENT, Errno::ELOOP
      next # unreadable or vanished mid-walk
    end
    out
  end
end
def type_char(st)
  return 'l' if st.symlink?
  return 'd' if st.directory?
  return 's' if st.socket?
  return 'p' if st.pipe?
  return 'c' if st.chardev?
  return 'b' if st.blockdev?
  'f'
end
# find / -xdev \( -nouser -o -nogroup \) -printf '%U %G %m %y %s %p\n'
def read_find_output(path)
  File.readlines(path).filter_map do |line|
    uid, gid, mode, type, size, p = line.chomp.split(' ', 6)
    next unless p
    Entry.new(uid: uid.to_i, gid: gid.to_i, mode: mode.to_i(8), type: type, size: size.to_i, path: p)
  end
end
# ---------------------------------------------------------------------------
# Classification
# ---------------------------------------------------------------------------
Orphan = Struct.new(:entry, :reasons, :severity, keyword_init: true)
def classify(entries, users, groups)
  entries.filter_map do |e|
    reasons = []
    reasons << "uid #{e.uid} has no passwd entry" unless users.key?(e.uid)
    reasons << "gid #{e.gid} has no group entry" unless groups.key?(e.gid)
    next if reasons.empty?
    risk = []
    risk << 'world-writable'    if e.mode & 0o002 != 0 && e.type != 'l'
    risk << 'setuid'            if e.mode & 0o4000 != 0
    risk << 'setgid'            if e.mode & 0o2000 != 0
    risk << 'sensitive-path'    if SENSITIVE_PREFIXES.any? { |pre| e.path == pre || e.path.start_with?(pre + '/') }
    risk << 'executable'        if e.type == 'f' && e.mode & 0o111 != 0
    sev = risk.any? { |r| %w[world-writable setuid setgid sensitive-path].include?(r) } ? 'CRIT' : 'WARN'
    Orphan.new(entry: e, reasons: reasons + risk, severity: sev)
  end
end
# Group orphans by (uid, gid) so the fix is one chown per former account,
# and suggest a target owner: the parent directory's owner if it is valid,
# else root.
def remediation(orphans, users, groups, entries_by_path)
  orphans.group_by { |o| [o.entry.uid, o.entry.gid] }.map do |(uid, gid), list|
    sample = list.first.entry
    parent = entries_by_path[File.dirname(sample.path)]
    # keep whichever half is still valid; replace the orphaned half with the
    # parent directory's owner/group when that is valid, else root
    new_uid = users.key?(uid)  ? uid : (parent && users.key?(parent.uid)  ? parent.uid : 0)
    new_gid = groups.key?(gid) ? gid : (parent && groups.key?(parent.gid) ? parent.gid : 0)
    {
      uid: uid, gid: gid, count: list.size, bytes: list.sum { |o| o.entry.size },
      suggested_owner: "#{users[new_uid] || new_uid}:#{groups[new_gid] || new_gid}",
      command: "find #{common_root(list.map { |o| o.entry.path })} -xdev #{users.key?(uid) ? '' : "-uid #{uid} "}#{groups.key?(gid) ? '' : "-gid #{gid} "}-exec chown -h #{new_uid}:#{new_gid} {} +".squeeze(' ')
    }
  end
end
def common_root(paths)
  parts = paths.map { |p| p.split('/') }
  common = parts.first
  parts.each { |p| common = common.zip(p).take_while { |a, b| a == b }.map(&:first) }
  root = common.join('/')
  root.empty? ? '/' : root
end
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
opts = { passwd: '/etc/passwd', group: '/etc/group', from_find: nil, json: false, cross_fs: false, limit: 40 }
OptionParser.new do |o|
  o.banner = 'Usage: orphan_file_audit.rb [options] [ROOT ...]'
  o.on('--passwd FILE', 'passwd file (default /etc/passwd)') { |v| opts[:passwd] = v }
  o.on('--group FILE', 'group file (default /etc/group)') { |v| opts[:group] = v }
  o.on('--from-find FILE', 'analyse captured find -printf output instead of walking') { |v| opts[:from_find] = v }
  o.on('--cross-fs', 'descend into other filesystems (default: stay on one, like -xdev)') { opts[:cross_fs] = true }
  o.on('--limit N', Integer, 'max rows to print in text mode (default 40)') { |v| opts[:limit] = v }
  o.on('--json', 'JSON output') { opts[:json] = true }
end.parse!
users  = read_ids(opts[:passwd])
groups = read_ids(opts[:group])
roots  = ARGV.empty? ? ['/'] : ARGV
entries = opts[:from_find] ? read_find_output(opts[:from_find]) : walk(roots, cross_fs: opts[:cross_fs])
by_path = entries.each_with_object({}) { |e, h| h[e.path] = e }
orphans = classify(entries, users, groups)
crit = orphans.count { |o| o.severity == 'CRIT' }
fixes = remediation(orphans, users, groups, by_path)
status = crit.positive? ? 'CRIT' : (orphans.empty? ? 'OK' : 'WARN')
if opts[:json]
  puts JSON.pretty_generate(status: status, scanned: entries.size, orphaned: orphans.size, critical: crit,
                            orphans: orphans.map { |o| o.entry.to_h.merge(severity: o.severity, reasons: o.reasons) },
                            remediation: fixes)
else
  puts "orphan_file_audit  scanned #{entries.size} entries (#{opts[:from_find] ? "from #{opts[:from_find]}" : roots.join(' ')})  " \
       "passwd=#{users.size} users  group=#{groups.size} groups"
  puts '-' * 96
  if orphans.empty?
    puts 'no files owned by unknown users or groups'
  else
    puts format('%-4s %-6s %-6s %-5s %-2s %9s  %-40s %s', 'SEV', 'UID', 'GID', 'MODE', 'T', 'SIZE', 'PATH', 'WHY')
    orphans.sort_by { |o| [o.severity == 'CRIT' ? 0 : 1, o.entry.path] }.first(opts[:limit]).each do |o|
      e = o.entry
      puts format('%-4s %-6d %-6d %-5s %-2s %9d  %-40s %s', o.severity, e.uid, e.gid, e.mode.to_s(8).rjust(4, '0'), e.type, e.size, e.path[0, 40], o.reasons.join(', '))
    end
    puts "... #{orphans.size - opts[:limit]} more (raise --limit or use --json)" if orphans.size > opts[:limit]
    puts
    puts 'Remediation (review before running):'
    fixes.each do |fx|
      puts format('  uid %-6d gid %-6d %5d files %10d bytes  -> chown to %s', fx[:uid], fx[:gid], fx[:count], fx[:bytes], fx[:suggested_owner])
      puts "    #{fx[:command]}"
    end
  end
  puts
  puts "#{status}: #{orphans.size} orphaned entries, #{crit} critical"
end
exit(crit.positive? ? 2 : (orphans.empty? ? 0 : 1))
orphan_file_audit.rb architecture

Two inputs, one Entry shape, one classifier, one remediation planner.
04 // walkthrough

How it works, step by step

1. read_ids — passwd and group as plain files

Both files are colon-separated with the numeric id in column 2, so one function with a column argument reads either. The result is a Hash from id to name. Reading the files directly, rather than calling Etc.getpwuid per file, has two benefits: it is O(1) per lookup with no libc call, and it works on a copy of the files taken from another host — which is how you audit a fleet from one place. The cost is that accounts served only by NSS (LDAP, SSSD, Active Directory) are not in the file; see troubleshooting for the getent workaround.

2. walk — a find -xdev in Ruby

Find.find yields every path under each root. File.lstat (not stat) is used so symlinks are recorded as links and never followed — following them would both loop and misattribute the target’s owner. Before descending into a directory the script compares its dev to the root’s; a different device number means a different filesystem, and Find.prune skips it, exactly like -xdev. That keeps /proc, /sys, NFS mounts and container overlays out of a root-level scan. Permission errors and files that vanish mid-walk are rescued and skipped.

3. read_find_output — the fleet-friendly path

For large filesystems, GNU find is faster than any interpreted walker, and it already runs everywhere. -printf '%U %G %m %y %s %p\n' emits numeric uid, numeric gid, octal mode, type letter, size and path — the same six fields as the Entry struct. Splitting on the first five spaces (split(' ', 6)) preserves paths that contain spaces. You can capture with -nouser -o -nogroup to pre-filter, or without, to let the script see valid parents for its remediation suggestions.

4. classify — reasons and risk

An entry is an orphan if its uid is not in users or its gid is not in groups; both reasons are recorded because the fix differs. Risk flags are then computed from mode bits and path: 0o002 for world-writable (ignored on symlinks, whose mode is meaningless), 0o4000/0o2000 for setuid/setgid, a prefix match against SENSITIVE_PREFIXES, and an executable flag for regular files with any execute bit. CRIT is any of the first three; the executable flag is informational but sorts those entries up within their tier.

5. remediation — one chown per former account

Orphans are grouped by (uid, gid). For each group the script decides a target owner: keep whichever half is still valid, and replace the orphaned half with the parent directory’s owner if that is valid, else root. It then prints a scoped find <common-root> -xdev -uid N -exec chown -h U:G {} + — -h so symlinks themselves are re-owned rather than their targets, -xdev so the fix cannot cross into a mount, and the common root computed from the group’s paths so the command touches as little as possible. Nothing is executed; the output is for a human to review.

05 // example output

A captured find listing from a box that offboarded badly

The fixture describes a host where “bob” (uid 1003) and a service account (uid 1004) were deleted, a group (1005) was removed, and nothing was re-owned. Valid entries for deploy and alice are included so the parent-owner logic has something to work with.

fixtures/files.txttext
# find / -xdev -printf '%U %G %m %y %s %p\n'   (captured on app-07; valid + orphaned entries)
0 0 755 d 4096 /home
1001 1001 755 d 4096 /home/deploy
1001 1001 644 f 2210 /home/deploy/.bashrc
1003 1003 755 d 4096 /home/bob
1003 1003 644 f 3771 /home/bob/.bashrc
1003 1003 600 f 1823 /home/bob/.ssh/id_ed25519
1003 1003 777 d 4096 /home/bob/dropbox
1003 1003 755 f 18432 /home/bob/bin/deploy-helper
1003 1001 4755 f 22016 /usr/local/bin/oldsudo
1003 1003 644 f 118 /var/spool/cron/crontabs/bob
1004 998 664 f 5120 /srv/app/shared/config.yml
1004 998 775 d 4096 /srv/app/shared
1002 1002 644 f 88 /home/alice/notes.txt
33 1005 644 f 1200 /var/www/html/index.html
1003 1003 777 l 11 /home/bob/latest
ruby orphan_file_audit.rb –passwd fixtures/passwd –group fixtures/group –from-find fixtures/files.txt
orphan_file_audit scanned 15 entries (from /fx/orph/files.txt) passwd=6 users group=7 groups
————————————————————————————————
SEV UID GID MODE T SIZE PATH WHY
CRIT 1003 1003 0777 d 4096 /home/bob/dropbox uid 1003 has no passwd entry, gid 1003 has no group entry, world-writable
CRIT 1003 1001 4755 f 22016 /usr/local/bin/oldsudo uid 1003 has no passwd entry, setuid, sensitive-path, executable
CRIT 1003 1003 0644 f 118 /var/spool/cron/crontabs/bob uid 1003 has no passwd entry, gid 1003 has no group entry, sensitive-path
WARN 1003 1003 0755 d 4096 /home/bob uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1003 1003 0644 f 3771 /home/bob/.bashrc uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1003 1003 0600 f 1823 /home/bob/.ssh/id_ed25519 uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1003 1003 0755 f 18432 /home/bob/bin/deploy-helper uid 1003 has no passwd entry, gid 1003 has no group entry, executable
WARN 1003 1003 0777 l 11 /home/bob/latest uid 1003 has no passwd entry, gid 1003 has no group entry
WARN 1004 998 0775 d 4096 /srv/app/shared uid 1004 has no passwd entry
WARN 1004 998 0664 f 5120 /srv/app/shared/config.yml uid 1004 has no passwd entry
WARN 33 1005 0644 f 1200 /var/www/html/index.html gid 1005 has no group entry
Remediation (review before running):
uid 1003 gid 1003 7 files 32347 bytes -> chown to root:root
find / -xdev -uid 1003 -gid 1003 -exec chown -h 0:0 {} +
uid 1003 gid 1001 1 files 22016 bytes -> chown to root:deploy
find /usr/local/bin/oldsudo -xdev -uid 1003 -exec chown -h 0:1001 {} +
uid 1004 gid 998 2 files 9216 bytes -> chown to root:docker
find /srv/app/shared -xdev -uid 1004 -exec chown -h 0:998 {} +
uid 33 gid 1005 1 files 1200 bytes -> chown to www-data:root
find /var/www/html/index.html -xdev -gid 1005 -exec chown -h 33:0 {} +
CRIT: 11 orphaned entries, 3 critical

Three CRITs float to the top: the world-writable directory, the setuid binary in /usr/local/bin (which also has a valid group, so the suggested fix is 0:1001, not 0:0), and the crontab. The remediation block is one command per former account. With --json the same data is structured for a ticketing system or a fleet dashboard:

orphan_file_audit.rb –json (excerpt)json
{
  "status": "CRIT",
  "scanned": 15,
  "orphaned": 11,
  "critical": 3,
  "orphans": [
    {
      "uid": 1003,
      "gid": 1003,
      "mode": 493,
      "type": "d",
      "size": 4096,
      "path": "/home/bob",
      "severity": "WARN",
      "reasons": [
        "uid 1003 has no passwd entry",
        "gid 1003 has no group entry"
      ]
    },
    {
      "uid": 1003,
      "gid": 1003,
    ...
  ]
}
2
ways to feed it
4
risk flags
0
files deleted
06 // troubleshooting

When it does not behave

Common issues
  • Every LDAP/SSSD user’s files show as orphaned. Directory-backed accounts are not in /etc/passwd. Generate a complete table with getent passwd > /tmp/passwd; getent group > /tmp/group and pass those via --passwd/--group. getent goes through NSS, so it includes everything the host itself can resolve.
  • The walker is slow on a big volume. Use find for the listing and --from-find for the analysis. On a multi-million-inode filesystem this is the difference between minutes and an hour.
  • Container image layers full of orphans. Files under /var/lib/docker or /var/lib/containers are owned by uids from inside the container. The walker’s -xdev behaviour keeps overlay mounts out of a root scan; if you scan those paths explicitly, expect noise, and do not chown them — you will break the images.
  • Errno::EACCES everywhere. Run the walker as root, or capture with sudo find and analyse as yourself.
  • The suggested chown root is /. That happens when one former account’s files are spread across unrelated trees. The command is still scoped by -uid/-gid, so it only touches that account’s files, but review the list first — or split it by directory.
  • Testing note. The classifier and remediation planner were verified against the fixture above (11 orphans, 3 CRIT, exit 2) and the --json path. The pure-Ruby walker was exercised against a small tree for its control flow (prune, lstat, rescue), but the test environment reports uid 0 for every file, so its orphan detection on a live filesystem was reviewed rather than observed. Run it once against a directory you know contains a -nouser file before scheduling it.
07 // extending

Where to take it next

Ideas
  • Offboarding hook. Run the audit with the departing user’s uid pinned (find / -xdev -uid N) before userdel, so the re-own happens while the name still resolves and the manager can be asked what to keep.
  • Reserve, don’t reuse. Add the freed uid to /etc/login.defs‘ exclusions or keep a tombstone entry (bob-retired:x:1003:1003::/nonexistent:/usr/sbin/nologin) so the number is never handed out; the audit then stops reporting those files, which is a choice you should make deliberately.
  • Fleet report. Ship each host’s --json to one place and aggregate by uid: the same orphaned uid on twenty servers is one offboarding miss, not twenty findings.
  • Apply mode. An --apply flag that runs the generated chown commands after a dry run is a small change; keep it off by default and log every path it touches.
  • Windows twin. Orphaned SIDs (S-1-5-21-...-1103 with no account) show up in ACLs the same way; icacls or the win32-security gem can surface them.