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:
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.
#!/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.
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
Full script + README on GitHub: ruby-devops-toolkit/orphan-file-audit
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.
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.
What you need
- 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-findinput can be produced bysudo findand analysed unprivileged. - Local account files:
/etc/passwdand/etc/groupby default;--passwd/--groupaccept captured copies for fleet audits. - Any OS for the
--from-findmode — it is pure text processing, which is also how the script is tested.
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.
#!/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))
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.
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.
# 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
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:
{
"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,
...
]
}
When it does not behave
- Every LDAP/SSSD user’s files show as orphaned. Directory-backed accounts are not in
/etc/passwd. Generate a complete table withgetent passwd > /tmp/passwd; getent group > /tmp/groupand pass those via--passwd/--group.getentgoes through NSS, so it includes everything the host itself can resolve. - The walker is slow on a big volume. Use
findfor the listing and--from-findfor 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/dockeror/var/lib/containersare owned by uids from inside the container. The walker’s-xdevbehaviour 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::EACCESeverywhere. Run the walker as root, or capture withsudo findand analyse as yourself.- The suggested
chownroot 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
--jsonpath. 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-nouserfile before scheduling it.
Where to take it next
- Offboarding hook. Run the audit with the departing user’s uid pinned (
find / -xdev -uid N) beforeuserdel, 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
--jsonto one place and aggregate by uid: the same orphaned uid on twenty servers is one offboarding miss, not twenty findings. - Apply mode. An
--applyflag that runs the generatedchowncommands 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-...-1103with no account) show up in ACLs the same way;icaclsor thewin32-securitygem can surface them.