One careless NOPASSWD line in /etc/sudoers.d is a root backdoor. This Ruby script walks every include, joins continuation lines, and flags the dangerous rules before an attacker finds them.
Step through the build below:
Sudo rules accumulate. A deploy user gets NOPASSWD: ALL for a Friday release, someone allows vim so an editor can touch one config, a wildcard sneaks into a path. Each of those is effectively unrestricted root. visudo -c only checks syntax, not safety. This script does static analysis: it follows @includedir, mimics sudo's file-name rules, and reports findings by severity with a CI-friendly exit code. It never runs sudo.
#!/usr/bin/env ruby
# frozen_string_literal: true
# sudoers_audit.rb - static audit of /etc/sudoers and its includes. Linux. Ruby 2.7+, stdlib only.
# Read-only: it never calls visudo or sudo. Run as root to read /etc/sudoers (mode 0440).
# Exit 0 = no findings above INFO, 1 = HIGH/MEDIUM findings.
require 'optparse'
require 'json'
Finding = Struct.new(:severity, :file, :line, :rule, :detail, keyword_init: true)
class SudoersAudit
attr_reader :findings
def initialize(root_file, admin_groups: %w[root %sudo %wheel %admin])
@root_file = root_file
@admins = admin_groups
@findings = []
@seen = {}
end
def run
walk(@root_file)
@findings
end
private
def add(sev, file, line, rule, detail)
@findings << Finding.new(severity: sev, file: file, line: line, rule: rule, detail: detail)
end
# Recursively follow #include / @include / #includedir / @includedir
def walk(path, depth = 0)
return add('MEDIUM', path, 0, 'include-depth', 'include nesting > 10') if depth > 10
return if @seen[path]
@seen[path] = true
check_file_perms(path)
logical_lines(path).each do |num, text|
case text
when /\A[#@]include(dir)?\s+(\S+)/
target = Regexp.last_match(2)
if Regexp.last_match(1)
Dir.glob(File.join(target, '*')).sort.each do |f|
base = File.basename(f)
next if base.include?('.') || base.end_with?('~') # sudo ignores these
walk(f, depth + 1)
end
else
walk(target, depth + 1)
end
when /\A#/, ''
next
else
check_rule(path, num, text)
end
end
end
# Join backslash-continued lines; strip trailing comments.
def logical_lines(path)
out = []
buf = +''
start = 1
File.foreach(path).with_index(1) do |raw, n|
line = raw.chomp
start = n if buf.empty?
if line.end_with?('\\')
buf << line.chomp('\\') << ' '
next
end
buf << line
out << [start, buf.sub(/(?<!^)\s+#.*\z/, '').strip]
buf = +''
end
out
end
def check_file_perms(path)
st = File.stat(path)
add('HIGH', path, 0, 'bad-owner', "owned by uid #{st.uid}, must be root") unless st.uid.zero?
add('HIGH', path, 0, 'world-writable', format('mode %04o', st.mode & 0o7777)) if (st.mode & 0o002) != 0
add('MEDIUM', path, 0, 'group-writable', format('mode %04o', st.mode & 0o7777)) if (st.mode & 0o020) != 0
rescue SystemCallError => e
add('INFO', path, 0, 'unreadable', e.message)
end
def check_rule(file, num, text)
return if text =~ /\A(User_Alias|Runas_Alias|Host_Alias|Cmnd_Alias)\b/
who = text.split(/\s+/, 2).first
if text =~ /\bDefaults.*!authenticate/
add('HIGH', file, num, 'no-authenticate', 'Defaults !authenticate disables passwords globally')
end
if text =~ /\bDefaults.*!(use_pty|requiretty)|\bDefaults.*env_keep.*(LD_|PYTHON|RUBY)/
add('MEDIUM', file, num, 'weak-defaults', text)
end
return unless text =~ /=/ && text !~ /\ADefaults/
nopasswd = text.include?('NOPASSWD')
all_cmds = text =~ /(?:\)|:|=|,)\s*(?:NOPASSWD:\s*)?ALL\s*\z/
is_admin = @admins.include?(who)
add('HIGH', file, num, 'nopasswd-all', "#{who}: passwordless ALL") if nopasswd && all_cmds
add('MEDIUM', file, num, 'nopasswd', "#{who}: NOPASSWD rule") if nopasswd && !all_cmds
add('MEDIUM', file, num, 'unexpected-all', "#{who}: full ALL access") if all_cmds && !is_admin && who != 'root'
add('HIGH', file, num, 'wildcard-cmd', "#{who}: wildcard in command (arg injection risk)") if text =~ /\/\S*\*/
if text =~ %r{/(vi|vim|nano|less|more|man|find|awk|python\d?|perl|ruby|bash|sh|zsh|tar|env)\b}
add('HIGH', file, num, 'shell-escape', "#{who}: #{Regexp.last_match(1)} allows shell escape to root")
end
end
end
if $PROGRAM_NAME == __FILE__
opts = { file: '/etc/sudoers', json: false }
OptionParser.new do |o|
o.on('-f', '--file PATH', 'root sudoers file (default /etc/sudoers)') { |v| opts[:file] = v }
o.on('-j', '--json') { opts[:json] = true }
end.parse!
abort "cannot read #{opts[:file]} (run as root?)" unless File.readable?(opts[:file])
order = { 'HIGH' => 0, 'MEDIUM' => 1, 'INFO' => 2 }
fs = SudoersAudit.new(opts[:file]).run.sort_by { |f| [order[f.severity], f.file, f.line] }
if opts[:json]
puts JSON.pretty_generate(fs.map(&:to_h))
elsif fs.empty?
puts 'No findings.'
else
fs.each { |f| puts format('%-6s %-16s %s:%d %s', f.severity, f.rule, f.file, f.line, f.detail) }
puts "\n#{fs.count { |f| f.severity == 'HIGH' }} high, #{fs.count { |f| f.severity == 'MEDIUM' }} medium"
end
exit(fs.any? { |f| %w[HIGH MEDIUM].include?(f.severity) } ? 1 : 0)
end
1. Walk includes recursively. walk follows #include, @include, #includedir and @includedir, tracks visited files, and caps depth at 10. Files in a drop-in directory whose names contain a dot or end in ~ are skipped, exactly like sudo does.
2. Join logical lines. Backslash continuations are joined and trailing comments stripped so a rule split across lines is analysed as one.
3. Check file ownership and mode. Sudoers files must be owned by root and not group/world writable. A 0666 drop-in is an instant HIGH finding.
4. Pattern-check each rule. NOPASSWD plus ALL is HIGH; NOPASSWD alone is MEDIUM; full ALL for a non-admin user is MEDIUM; wildcards and editors/interpreters (vim, find, python, ruby, bash, less) are HIGH because they allow a root shell escape.
5. Sort and exit. Findings sort HIGH first. Exit 1 if any HIGH/MEDIUM, so a nightly CI job can fail loudly.
$ test run (Linux sandbox)
HIGH nopasswd-all fixture/sudoers.d/10-deploy:1 deploy: passwordless ALL HIGH wildcard-cmd fixture/sudoers.d/20-webops:1 webops: wildcard in command (arg injection risk) HIGH shell-escape fixture/sudoers.d/20-webops:1 webops: vim allows shell escape to root HIGH world-writable fixture/sudoers.d/30-bob:0 mode 0666 MEDIUM weak-defaults fixture/sudoers:2 Defaults !use_pty MEDIUM unexpected-all fixture/sudoers.d/10-deploy:1 deploy: full ALL access MEDIUM nopasswd fixture/sudoers.d/20-webops:1 webops: NOPASSWD rule MEDIUM group-writable fixture/sudoers.d/30-bob:0 mode 0666 MEDIUM unexpected-all fixture/sudoers.d/30-bob:1 bob: full ALL access 4 high, 5 medium exit code: 1
Full script + README on GitHub: ruby-devops-toolkit/sudoers-audit
What you need
- Ruby 2.7+ (tested on 3.3.6), stdlib only.
- Linux with sudo installed. Run as root to read
/etc/sudoers(mode 0440). - Use
--fileto audit a copy of someone else's sudoers tree safely.
Full code
#!/usr/bin/env ruby
# frozen_string_literal: true
# sudoers_audit.rb - static audit of /etc/sudoers and its includes. Linux. Ruby 2.7+, stdlib only.
# Read-only: it never calls visudo or sudo. Run as root to read /etc/sudoers (mode 0440).
# Exit 0 = no findings above INFO, 1 = HIGH/MEDIUM findings.
require 'optparse'
require 'json'
Finding = Struct.new(:severity, :file, :line, :rule, :detail, keyword_init: true)
class SudoersAudit
attr_reader :findings
def initialize(root_file, admin_groups: %w[root %sudo %wheel %admin])
@root_file = root_file
@admins = admin_groups
@findings = []
@seen = {}
end
def run
walk(@root_file)
@findings
end
private
def add(sev, file, line, rule, detail)
@findings << Finding.new(severity: sev, file: file, line: line, rule: rule, detail: detail)
end
# Recursively follow #include / @include / #includedir / @includedir
def walk(path, depth = 0)
return add('MEDIUM', path, 0, 'include-depth', 'include nesting > 10') if depth > 10
return if @seen[path]
@seen[path] = true
check_file_perms(path)
logical_lines(path).each do |num, text|
case text
when /\A[#@]include(dir)?\s+(\S+)/
target = Regexp.last_match(2)
if Regexp.last_match(1)
Dir.glob(File.join(target, '*')).sort.each do |f|
base = File.basename(f)
next if base.include?('.') || base.end_with?('~') # sudo ignores these
walk(f, depth + 1)
end
else
walk(target, depth + 1)
end
when /\A#/, ''
next
else
check_rule(path, num, text)
end
end
end
# Join backslash-continued lines; strip trailing comments.
def logical_lines(path)
out = []
buf = +''
start = 1
File.foreach(path).with_index(1) do |raw, n|
line = raw.chomp
start = n if buf.empty?
if line.end_with?('\\')
buf << line.chomp('\\') << ' '
next
end
buf << line
out << [start, buf.sub(/(?<!^)\s+#.*\z/, '').strip]
buf = +''
end
out
end
def check_file_perms(path)
st = File.stat(path)
add('HIGH', path, 0, 'bad-owner', "owned by uid #{st.uid}, must be root") unless st.uid.zero?
add('HIGH', path, 0, 'world-writable', format('mode %04o', st.mode & 0o7777)) if (st.mode & 0o002) != 0
add('MEDIUM', path, 0, 'group-writable', format('mode %04o', st.mode & 0o7777)) if (st.mode & 0o020) != 0
rescue SystemCallError => e
add('INFO', path, 0, 'unreadable', e.message)
end
def check_rule(file, num, text)
return if text =~ /\A(User_Alias|Runas_Alias|Host_Alias|Cmnd_Alias)\b/
who = text.split(/\s+/, 2).first
if text =~ /\bDefaults.*!authenticate/
add('HIGH', file, num, 'no-authenticate', 'Defaults !authenticate disables passwords globally')
end
if text =~ /\bDefaults.*!(use_pty|requiretty)|\bDefaults.*env_keep.*(LD_|PYTHON|RUBY)/
add('MEDIUM', file, num, 'weak-defaults', text)
end
return unless text =~ /=/ && text !~ /\ADefaults/
nopasswd = text.include?('NOPASSWD')
all_cmds = text =~ /(?:\)|:|=|,)\s*(?:NOPASSWD:\s*)?ALL\s*\z/
is_admin = @admins.include?(who)
add('HIGH', file, num, 'nopasswd-all', "#{who}: passwordless ALL") if nopasswd && all_cmds
add('MEDIUM', file, num, 'nopasswd', "#{who}: NOPASSWD rule") if nopasswd && !all_cmds
add('MEDIUM', file, num, 'unexpected-all', "#{who}: full ALL access") if all_cmds && !is_admin && who != 'root'
add('HIGH', file, num, 'wildcard-cmd', "#{who}: wildcard in command (arg injection risk)") if text =~ /\/\S*\*/
if text =~ %r{/(vi|vim|nano|less|more|man|find|awk|python\d?|perl|ruby|bash|sh|zsh|tar|env)\b}
add('HIGH', file, num, 'shell-escape', "#{who}: #{Regexp.last_match(1)} allows shell escape to root")
end
end
end
if $PROGRAM_NAME == __FILE__
opts = { file: '/etc/sudoers', json: false }
OptionParser.new do |o|
o.on('-f', '--file PATH', 'root sudoers file (default /etc/sudoers)') { |v| opts[:file] = v }
o.on('-j', '--json') { opts[:json] = true }
end.parse!
abort "cannot read #{opts[:file]} (run as root?)" unless File.readable?(opts[:file])
order = { 'HIGH' => 0, 'MEDIUM' => 1, 'INFO' => 2 }
fs = SudoersAudit.new(opts[:file]).run.sort_by { |f| [order[f.severity], f.file, f.line] }
if opts[:json]
puts JSON.pretty_generate(fs.map(&:to_h))
elsif fs.empty?
puts 'No findings.'
else
fs.each { |f| puts format('%-6s %-16s %s:%d %s', f.severity, f.rule, f.file, f.line, f.detail) }
puts "\n#{fs.count { |f| f.severity == 'HIGH' }} high, #{fs.count { |f| f.severity == 'MEDIUM' }} medium"
end
exit(fs.any? { |f| %w[HIGH MEDIUM].include?(f.severity) } ? 1 : 0)
end
Step by step
Walk includes recursively
walk follows #include, @include, #includedir and @includedir, tracks visited files, and caps depth at 10. Files in a drop-in directory whose names contain a dot or end in ~ are skipped, exactly like sudo does.
Join logical lines
Backslash continuations are joined and trailing comments stripped so a rule split across lines is analysed as one.
Check file ownership and mode
Sudoers files must be owned by root and not group/world writable. A 0666 drop-in is an instant HIGH finding.
Pattern-check each rule
NOPASSWD plus ALL is HIGH; NOPASSWD alone is MEDIUM; full ALL for a non-admin user is MEDIUM; wildcards and editors/interpreters (vim, find, python, ruby, bash, less) are HIGH because they allow a root shell escape.
Sort and exit
Findings sort HIGH first. Exit 1 if any HIGH/MEDIUM, so a nightly CI job can fail loudly.
What a run looks like
When it misbehaves
- cannot read /etc/sudoers: run with sudo, or point
--fileat a copy. - False positives: the checks are heuristic regexes. A flagged
findrule is only exploitable if-execis allowed; review before removing. - Aliases are not expanded:
Cmnd_Aliasdefinitions are skipped, so a rule using an alias may hide a risky command (see Extending). - Testing note: verified against a fixture tree (root file plus three drop-ins, one chmod 0666) in the Linux sandbox.
Extending the script
- Expand
Cmnd_AliasandUser_Aliasbefore checking rules. - Compare findings to an approved baseline JSON and only alert on new ones.
- Check
sudo -l -U useroutput for effective rights. - Ship results to your SIEM as JSON with
--json.