visudo only checks that your sudoers file parses — it says nothing about whether the policy it describes is a good idea. This tutorial builds a Ruby auditor that catches the privilege grants that show up over and over in real local-escalation writeups: passwordless root, wildcard commands, and world-writable policy files.
Full script, tests, and README on GitHub: ruby-devops-toolkit/sudoers-audit
Step through the build below:
sudo misconfigurations are one of the most common local-privilege-escalation vectors on Linux, and they accumulate the same way firewall rules do: someone adds NOPASSWD: ALL to unblock a deploy script at 2am, or a wildcard cmnd spec seemed fine when it was written and nobody revisits it once the binary it points at changes behavior. visudo only validates syntax — this script is the second check visudo doesn’t do.#!/usr/bin/env ruby
# frozen_string_literal: true
#
# sudoers_audit.rb
#
# Parses /etc/sudoers (plus any #include / #includedir files it pulls
# in, exactly like real sudo does) and flags the handful of privilege
# grants that repeatedly show up in real-world escalation writeups:
# NOPASSWD root shells, wildcard command paths (which let a "restricted"
# sudo rule run arbitrary binaries), and sudoers files that are
# world-writable. It also shells out to `visudo -c` to catch outright
# syntax errors before they lock someone out of sudo entirely.
#
# No gems required -- `optparse`, `json`, and `open3` are stdlib.
#
# Usage:
# sudo ruby sudoers_audit.rb # audit /etc/sudoers (needs root to read it)
# ruby sudoers_audit.rb --file ./my_sudoers # audit any sudoers-format file (testing/CI)
# ruby sudoers_audit.rb --file ./my_sudoers --json
# ruby sudoers_audit.rb --file ./my_sudoers --skip-visudo # if visudo isn't installed
#
# Exit codes (cron/CI friendly):
# 0 = no risky entries found
# 1 = WARN-level findings
# 2 = CRIT-level findings, a visudo syntax error, or the file couldn't be read
require 'optparse'
require 'json'
require 'open3'
# ---------------------------------------------------------------------------
# Parsing
#
# This is a lightweight, line-oriented parser -- NOT a full sudoers
# grammar implementation. It does not resolve User_Alias/Cmnd_Alias
# definitions, and it splits multiple cmndspecs on the top-level commas
# sudoers uses between them. That's enough to catch the risk patterns
# this script looks for; anything using heavy aliasing should also be
# read by a human, not just this script. See README Troubleshooting.
# ---------------------------------------------------------------------------
SudoersEntry = Struct.new(:file, :line_no, :raw, :who, :host, :runas, :tags_and_cmnds, keyword_init: true)
# Reads one sudoers-format file, following #include/#includedir
# directives it finds (the same mechanism real sudo uses to pull in
# /etc/sudoers.d/*), and returns [entries, included_files, errors].
def parse_sudoers(path, seen = [])
entries = []
included = []
errors = []
unless File.readable?(path)
errors << "cannot read #{path} (permission denied?)"
return [entries, included, errors]
end
return [entries, included, errors] if seen.include?(File.expand_path(path))
seen << File.expand_path(path)
buffer = nil
File.readlines(path).each_with_index do |raw_line, idx|
line_no = idx + 1
line = raw_line.chomp
# Line continuations: a trailing backslash joins with the next line.
if buffer
line = buffer + line.sub(/^\s*/, ' ')
buffer = nil
end
if line.end_with?('\\')
buffer = line.sub(/\\\z/, '')
next
end
stripped = line.strip
next if stripped.empty?
if stripped.start_with?('#include ')
inc_path = stripped.sub('#include ', '').strip
included << inc_path
sub_entries, sub_included, sub_errors = parse_sudoers(inc_path, seen)
entries.concat(sub_entries)
included.concat(sub_included)
errors.concat(sub_errors)
next
end
if stripped.start_with?('#includedir ')
dir = stripped.sub('#includedir ', '').strip
if Dir.exist?(dir)
Dir.children(dir).sort.each do |fname|
next if fname.start_with?('.') || fname.end_with?('~') || fname.include?('.rpmsave') || fname.include?('.rpmnew')
sub_path = File.join(dir, fname)
included << sub_path
sub_entries, sub_included, sub_errors = parse_sudoers(sub_path, seen)
entries.concat(sub_entries)
included.concat(sub_included)
errors.concat(sub_errors)
end
else
errors << "#includedir target #{dir} does not exist"
end
next
end
next if stripped.start_with?('#') # plain comment
next if stripped.match?(/^(Defaults|User_Alias|Host_Alias|Cmnd_Alias|Runas_Alias)\b/)
m = stripped.match(/\A(\S+)\s+(\S+)\s*=\s*(?:\(([^)]*)\)\s*)?(.+)\z/)
next unless m # not a user-spec line we recognize; ignore rather than false-flag
entries << SudoersEntry.new(
file: path, line_no: line_no, raw: stripped,
who: m[1], host: m[2], runas: m[3], tags_and_cmnds: m[4]
)
end
[entries, included, errors]
end
# ---------------------------------------------------------------------------
# Risk logic -- pure function over parsed entries, no file I/O.
# ---------------------------------------------------------------------------
WILDCARD_CMND = /[*?]/.freeze
def classify_entry(entry)
findings = []
cmndspecs = entry.tags_and_cmnds.split(/,(?![^(]*\))/).map(&:strip)
cmndspecs.each do |spec|
nopasswd = spec.match?(/\bNOPASSWD\s*:/)
cmnd = spec.sub(/\A(?:NOPASSWD|PASSWD|NOEXEC|EXEC|SETENV|NOSETENV|LOG_INPUT|NOLOG_INPUT|LOG_OUTPUT|NOLOG_OUTPUT)\s*:\s*/i, '').strip
is_all_cmnd = cmnd == 'ALL'
has_wildcard = cmnd.match?(WILDCARD_CMND)
broad_who = %w[ALL].include?(entry.who) || entry.who.start_with?('%')
if entry.who == 'ALL' && (is_all_cmnd || nopasswd)
findings << { severity: 'CRIT', reason: "who=ALL (every local account) granted '#{cmnd}'#{nopasswd ? ' with NOPASSWD' : ''}" }
elsif nopasswd && is_all_cmnd
findings << { severity: 'CRIT', reason: "NOPASSWD: ALL -- passwordless full-root grant for '#{entry.who}'" }
elsif nopasswd && has_wildcard
findings << { severity: 'CRIT', reason: "NOPASSWD with a wildcard command ('#{cmnd}') -- wildcards can usually be abused to run arbitrary binaries" }
elsif nopasswd
findings << { severity: 'WARN', reason: "NOPASSWD grant for '#{entry.who}' on '#{cmnd}' -- passwordless, review if still needed" }
elsif has_wildcard
findings << { severity: 'WARN', reason: "wildcard command spec '#{cmnd}' -- verify it can't be pointed at an unintended binary" }
elsif is_all_cmnd && broad_who
findings << { severity: 'WARN', reason: "'#{entry.who}' can run ALL commands (password required) -- confirm this group is meant to be full sudoers" }
end
end
findings
end
def check_file_permissions(path)
return [] unless File.exist?(path)
stat = File.stat(path)
findings = []
findings << { severity: 'CRIT', reason: "#{path} is world-writable (mode #{format('%o', stat.mode & 0o777)}) -- any local user could edit sudo policy" } if stat.mode & 0o002 != 0
findings << { severity: 'WARN', reason: "#{path} is group-writable (mode #{format('%o', stat.mode & 0o777)}) -- confirm the group is trusted" } if stat.mode & 0o020 != 0
findings
end
def run_visudo(path)
out, status = Open3.capture2e('visudo', '-c', '-f', path)
{ ok: status.success?, output: out.strip }
rescue Errno::ENOENT
{ ok: nil, output: 'visudo not found on PATH' }
end
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { file: '/etc/sudoers', json: false, skip_visudo: false }
parser = OptionParser.new do |opts|
opts.banner = 'Usage: sudoers_audit.rb [--file PATH] [options]'
opts.on('--file PATH', 'Sudoers file to audit (default: /etc/sudoers)') { |v| options[:file] = v }
opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
opts.on('--skip-visudo', 'Skip the visudo -c syntax check') { options[:skip_visudo] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!
entries, included_files, parse_errors = parse_sudoers(options[:file])
if entries.empty? && parse_errors.any?
warn "sudoers_audit: #{parse_errors.join('; ')}"
exit 2
end
findings = []
findings.concat(check_file_permissions(options[:file]).map { |f| f.merge(file: options[:file], line: nil) })
included_files.each { |f| findings.concat(check_file_permissions(f).map { |x| x.merge(file: f, line: nil) }) }
entries.each do |entry|
classify_entry(entry).each { |f| findings << f.merge(file: entry.file, line: entry.line_no, raw: entry.raw) }
end
visudo_result = options[:skip_visudo] ? nil : run_visudo(options[:file])
if visudo_result && visudo_result[:ok] == false
findings << { severity: 'CRIT', reason: "visudo -c reported a syntax error: #{visudo_result[:output]}", file: options[:file], line: nil }
end
if options[:json]
puts JSON.pretty_generate(
file: options[:file],
included_files: included_files,
entries_checked: entries.size,
visudo: visudo_result,
findings: findings
)
else
if findings.empty?
puts "sudoers_audit: #{entries.size} entries across #{1 + included_files.size} file(s) checked, no risky grants found"
else
findings.sort_by { |f| f[:severity] == 'CRIT' ? 0 : 1 }.each do |f|
loc = f[:line] ? "#{f[:file]}:#{f[:line]}" : f[:file]
puts "[#{f[:severity]}] #{loc}"
puts " #{f[:reason]}"
puts " > #{f[:raw]}" if f[:raw]
end
crit = findings.count { |f| f[:severity] == 'CRIT' }
warn_n = findings.count { |f| f[:severity] == 'WARN' }
puts "\n#{entries.size} entries checked, #{crit} CRIT, #{warn_n} WARN"
end
if visudo_result
status_label = visudo_result[:ok].nil? ? 'SKIPPED (visudo not found)' : (visudo_result[:ok] ? 'PASSED' : 'FAILED')
puts "visudo -c: #{status_label}"
end
end
exit_code =
if findings.any? { |f| f[:severity] == 'CRIT' }
2
elsif findings.any? { |f| f[:severity] == 'WARN' }
1
else
0
end
exit exit_code
end
#include/#includedir directives exactly like real sudo does, with a seen guard against include cycles, and turns unreadable files into collected errors instead of raised exceptions.
classify_entry is a pure function. It splits an entry’s command specs on their top-level commas and checks each one against a handful of real-world risk patterns — who=ALL, NOPASSWD: ALL, NOPASSWD plus a wildcard — while leaving a plain root ALL=(ALL:ALL) ALL completely unflagged.
Three independent checks converge on one findings list: entry classification, file-permission checks (world/group-writable), and a real visudo -c shell-out via Open3.
$ ruby sudoers_audit.rb --file ./example_sudoers
[CRIT] ./sudoers.d/deploy
./sudoers.d/deploy is world-writable (mode 666) -- any local user could edit sudo policy
[CRIT] ./example_sudoers:7
NOPASSWD: ALL -- passwordless full-root grant for 'alice'
> alice ALL=(ALL) NOPASSWD: ALL
[CRIT] ./example_sudoers:9
NOPASSWD with a wildcard command ('/usr/bin/vim *') -- wildcards can usually be abused to run arbitrary binaries
> carol ALL=(root) NOPASSWD: /usr/bin/vim *
[CRIT] ./example_sudoers:10
who=ALL (every local account) granted 'ALL'
> ALL ALL=(ALL) ALL
[WARN] ./sudoers.d/deploy
./sudoers.d/deploy is group-writable (mode 666) -- confirm the group is trusted
[WARN] ./example_sudoers:6
'%sudo' can run ALL commands (password required) -- confirm this group is meant to be full sudoers
> %sudo ALL=(ALL:ALL) ALL
[WARN] ./sudoers.d/deploy:1
NOPASSWD grant for 'deploy' on '/usr/local/bin/deploy.sh' -- passwordless, review if still needed
> deploy ALL=(www-data) NOPASSWD: /usr/local/bin/deploy.sh
7 entries checked, 4 CRIT, 3 WARN
visudo -c: PASSED
$ echo "exit=$?"
exit=2
$ ruby sudoers_audit_test.rb
classify_entry: root with full access -> no findings
ok - severities
classify_entry: NOPASSWD ALL for a named user -> CRIT
ok - severities
classify_entry: who=ALL granted ALL -> CRIT
ok - severities
classify_entry: NOPASSWD + wildcard command -> CRIT
ok - severities
classify_entry: NOPASSWD on a specific, non-wildcard command -> WARN
ok - severities
classify_entry: password-required, specific command, no wildcard -> no findings
ok - severities
classify_entry: broad group, password required, ALL commands -> WARN
ok - severities
check_file_permissions: world-writable file -> CRIT + WARN (group AND world bits)
ok - severities
check_file_permissions: mode 0440 (normal) -> no findings
ok - severities
parse_sudoers: end-to-end against real files, including #includedir
ok - parse errors
ok - included files found
ok - entries parsed (root, alice, deploy)
parse_sudoers: unreadable file reports an error instead of raising
ok - entries
ok - an error was recorded
14 checks, 0 failures
What you need
- Ruby >= 2.7 (tested on 3.0.2)
- Read access to the sudoers file you’re auditing — for the real
/etc/sudoersthat means running as root (it’s0440 root:rooton a normal system); for any other file, ordinary permissions visudoonPATHfor the syntax check (skip with--skip-visudoif it’s not installed)- No gems —
optparse,json, andopen3are all Ruby standard library
Running it
# Audit the real system sudoers (needs root to read the file)
sudo ruby sudoers_audit.rb
# Audit any sudoers-format file -- CI, a staged change, a container image
ruby sudoers_audit.rb --file ./staged_sudoers
# Machine-readable output
ruby sudoers_audit.rb --file ./staged_sudoers --json
# Skip the visudo syntax check (e.g. visudo isn't installed here)
ruby sudoers_audit.rb --file ./staged_sudoers --skip-visudo
0— no risky grants found1— WARN-level findings only2— CRIT-level findings, avisudo -csyntax error, or the file couldn’t be read
How it works
Four pieces, each with a single job:
parse_sudoers(path)reads the file line by line, joins backslash-continued lines, skips comments/Defaults/*_Aliaslines, and recursively follows#include FILE/#includedir DIRdirectives — the same mechanism realsudouses to pull in/etc/sudoers.d/*. Each recognized user-spec line becomes aSudoersEntrystruct; unreadable files and missing include targets are collected as errors rather than raising, so one bad file doesn’t kill the whole audit.classify_entry(entry)is a pure function — no file I/O — that splits an entry’s command specs on their top-level commas and checks each one:who == 'ALL'granted anything is CRIT (every local account, not a specific administrator);NOPASSWD: ALLis CRIT (passwordless full root);NOPASSWDcombined with a wildcard (*/?) is CRIT;NOPASSWDalone on a specific command is WARN; a bare wildcard command is WARN; a broad group grantedALLcommands is WARN. A plainroot ALL=(ALL:ALL) ALLproduces no findings at all.check_file_permissions(path)flags world-writable (CRIT) and group-writable (WARN) sudoers files viaFile.stat(path).mode, checked for both the main file and every included file.run_visudo(path)shells out tovisudo -c -f pathviaOpen3.capture2eand reports pass/fail/skipped, withErrno::ENOENThandled cleanly whenvisudoisn’t installed at all.
sudoers_audit.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# sudoers_audit.rb
#
# Parses /etc/sudoers (plus any #include / #includedir files it pulls
# in, exactly like real sudo does) and flags the handful of privilege
# grants that repeatedly show up in real-world escalation writeups:
# NOPASSWD root shells, wildcard command paths (which let a "restricted"
# sudo rule run arbitrary binaries), and sudoers files that are
# world-writable. It also shells out to `visudo -c` to catch outright
# syntax errors before they lock someone out of sudo entirely.
#
# No gems required -- `optparse`, `json`, and `open3` are stdlib.
#
# Usage:
# sudo ruby sudoers_audit.rb # audit /etc/sudoers (needs root to read it)
# ruby sudoers_audit.rb --file ./my_sudoers # audit any sudoers-format file (testing/CI)
# ruby sudoers_audit.rb --file ./my_sudoers --json
# ruby sudoers_audit.rb --file ./my_sudoers --skip-visudo # if visudo isn't installed
#
# Exit codes (cron/CI friendly):
# 0 = no risky entries found
# 1 = WARN-level findings
# 2 = CRIT-level findings, a visudo syntax error, or the file couldn't be read
require 'optparse'
require 'json'
require 'open3'
# ---------------------------------------------------------------------------
# Parsing
#
# This is a lightweight, line-oriented parser -- NOT a full sudoers
# grammar implementation. It does not resolve User_Alias/Cmnd_Alias
# definitions, and it splits multiple cmndspecs on the top-level commas
# sudoers uses between them. That's enough to catch the risk patterns
# this script looks for; anything using heavy aliasing should also be
# read by a human, not just this script. See README Troubleshooting.
# ---------------------------------------------------------------------------
SudoersEntry = Struct.new(:file, :line_no, :raw, :who, :host, :runas, :tags_and_cmnds, keyword_init: true)
# Reads one sudoers-format file, following #include/#includedir
# directives it finds (the same mechanism real sudo uses to pull in
# /etc/sudoers.d/*), and returns [entries, included_files, errors].
def parse_sudoers(path, seen = [])
entries = []
included = []
errors = []
unless File.readable?(path)
errors << "cannot read #{path} (permission denied?)"
return [entries, included, errors]
end
return [entries, included, errors] if seen.include?(File.expand_path(path))
seen << File.expand_path(path)
buffer = nil
File.readlines(path).each_with_index do |raw_line, idx|
line_no = idx + 1
line = raw_line.chomp
# Line continuations: a trailing backslash joins with the next line.
if buffer
line = buffer + line.sub(/^\s*/, ' ')
buffer = nil
end
if line.end_with?('\\')
buffer = line.sub(/\\\z/, '')
next
end
stripped = line.strip
next if stripped.empty?
if stripped.start_with?('#include ')
inc_path = stripped.sub('#include ', '').strip
included << inc_path
sub_entries, sub_included, sub_errors = parse_sudoers(inc_path, seen)
entries.concat(sub_entries)
included.concat(sub_included)
errors.concat(sub_errors)
next
end
if stripped.start_with?('#includedir ')
dir = stripped.sub('#includedir ', '').strip
if Dir.exist?(dir)
Dir.children(dir).sort.each do |fname|
next if fname.start_with?('.') || fname.end_with?('~') || fname.include?('.rpmsave') || fname.include?('.rpmnew')
sub_path = File.join(dir, fname)
included << sub_path
sub_entries, sub_included, sub_errors = parse_sudoers(sub_path, seen)
entries.concat(sub_entries)
included.concat(sub_included)
errors.concat(sub_errors)
end
else
errors << "#includedir target #{dir} does not exist"
end
next
end
next if stripped.start_with?('#') # plain comment
next if stripped.match?(/^(Defaults|User_Alias|Host_Alias|Cmnd_Alias|Runas_Alias)\b/)
m = stripped.match(/\A(\S+)\s+(\S+)\s*=\s*(?:\(([^)]*)\)\s*)?(.+)\z/)
next unless m # not a user-spec line we recognize; ignore rather than false-flag
entries << SudoersEntry.new(
file: path, line_no: line_no, raw: stripped,
who: m[1], host: m[2], runas: m[3], tags_and_cmnds: m[4]
)
end
[entries, included, errors]
end
# ---------------------------------------------------------------------------
# Risk logic -- pure function over parsed entries, no file I/O.
# ---------------------------------------------------------------------------
WILDCARD_CMND = /[*?]/.freeze
def classify_entry(entry)
findings = []
cmndspecs = entry.tags_and_cmnds.split(/,(?![^(]*\))/).map(&:strip)
cmndspecs.each do |spec|
nopasswd = spec.match?(/\bNOPASSWD\s*:/)
cmnd = spec.sub(/\A(?:NOPASSWD|PASSWD|NOEXEC|EXEC|SETENV|NOSETENV|LOG_INPUT|NOLOG_INPUT|LOG_OUTPUT|NOLOG_OUTPUT)\s*:\s*/i, '').strip
is_all_cmnd = cmnd == 'ALL'
has_wildcard = cmnd.match?(WILDCARD_CMND)
broad_who = %w[ALL].include?(entry.who) || entry.who.start_with?('%')
if entry.who == 'ALL' && (is_all_cmnd || nopasswd)
findings << { severity: 'CRIT', reason: "who=ALL (every local account) granted '#{cmnd}'#{nopasswd ? ' with NOPASSWD' : ''}" }
elsif nopasswd && is_all_cmnd
findings << { severity: 'CRIT', reason: "NOPASSWD: ALL -- passwordless full-root grant for '#{entry.who}'" }
elsif nopasswd && has_wildcard
findings << { severity: 'CRIT', reason: "NOPASSWD with a wildcard command ('#{cmnd}') -- wildcards can usually be abused to run arbitrary binaries" }
elsif nopasswd
findings << { severity: 'WARN', reason: "NOPASSWD grant for '#{entry.who}' on '#{cmnd}' -- passwordless, review if still needed" }
elsif has_wildcard
findings << { severity: 'WARN', reason: "wildcard command spec '#{cmnd}' -- verify it can't be pointed at an unintended binary" }
elsif is_all_cmnd && broad_who
findings << { severity: 'WARN', reason: "'#{entry.who}' can run ALL commands (password required) -- confirm this group is meant to be full sudoers" }
end
end
findings
end
def check_file_permissions(path)
return [] unless File.exist?(path)
stat = File.stat(path)
findings = []
findings << { severity: 'CRIT', reason: "#{path} is world-writable (mode #{format('%o', stat.mode & 0o777)}) -- any local user could edit sudo policy" } if stat.mode & 0o002 != 0
findings << { severity: 'WARN', reason: "#{path} is group-writable (mode #{format('%o', stat.mode & 0o777)}) -- confirm the group is trusted" } if stat.mode & 0o020 != 0
findings
end
def run_visudo(path)
out, status = Open3.capture2e('visudo', '-c', '-f', path)
{ ok: status.success?, output: out.strip }
rescue Errno::ENOENT
{ ok: nil, output: 'visudo not found on PATH' }
end
# ---------------------------------------------------------------------------
# Run
# ---------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
options = { file: '/etc/sudoers', json: false, skip_visudo: false }
parser = OptionParser.new do |opts|
opts.banner = 'Usage: sudoers_audit.rb [--file PATH] [options]'
opts.on('--file PATH', 'Sudoers file to audit (default: /etc/sudoers)') { |v| options[:file] = v }
opts.on('--json', 'Emit machine-readable JSON instead of text') { options[:json] = true }
opts.on('--skip-visudo', 'Skip the visudo -c syntax check') { options[:skip_visudo] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!
entries, included_files, parse_errors = parse_sudoers(options[:file])
if entries.empty? && parse_errors.any?
warn "sudoers_audit: #{parse_errors.join('; ')}"
exit 2
end
findings = []
findings.concat(check_file_permissions(options[:file]).map { |f| f.merge(file: options[:file], line: nil) })
included_files.each { |f| findings.concat(check_file_permissions(f).map { |x| x.merge(file: f, line: nil) }) }
entries.each do |entry|
classify_entry(entry).each { |f| findings << f.merge(file: entry.file, line: entry.line_no, raw: entry.raw) }
end
visudo_result = options[:skip_visudo] ? nil : run_visudo(options[:file])
if visudo_result && visudo_result[:ok] == false
findings << { severity: 'CRIT', reason: "visudo -c reported a syntax error: #{visudo_result[:output]}", file: options[:file], line: nil }
end
if options[:json]
puts JSON.pretty_generate(
file: options[:file],
included_files: included_files,
entries_checked: entries.size,
visudo: visudo_result,
findings: findings
)
else
if findings.empty?
puts "sudoers_audit: #{entries.size} entries across #{1 + included_files.size} file(s) checked, no risky grants found"
else
findings.sort_by { |f| f[:severity] == 'CRIT' ? 0 : 1 }.each do |f|
loc = f[:line] ? "#{f[:file]}:#{f[:line]}" : f[:file]
puts "[#{f[:severity]}] #{loc}"
puts " #{f[:reason]}"
puts " > #{f[:raw]}" if f[:raw]
end
crit = findings.count { |f| f[:severity] == 'CRIT' }
warn_n = findings.count { |f| f[:severity] == 'WARN' }
puts "\n#{entries.size} entries checked, #{crit} CRIT, #{warn_n} WARN"
end
if visudo_result
status_label = visudo_result[:ok].nil? ? 'SKIPPED (visudo not found)' : (visudo_result[:ok] ? 'PASSED' : 'FAILED')
puts "visudo -c: #{status_label}"
end
end
exit_code =
if findings.any? { |f| f[:severity] == 'CRIT' }
2
elsif findings.any? { |f| f[:severity] == 'WARN' }
1
else
0
end
exit exit_code
end
When it doesn't behave
cannot read /etc/sudoers (permission denied?)— expected on a normal system unless you run as root;/etc/sudoersis0440 root:rootby design. Run withsudo, or point--fileat a copy for review without elevated access.- A rule you know is risky isn’t flagged — this is a lightweight, line-oriented parser, not a full sudoers grammar implementation. It does not resolve
User_Alias/Cmnd_Alias/Runas_Aliasdefinitions, so a command hidden behind aCmnd_Aliaswon’t be traced back to the wildcard. Expand aliases by hand for any file that leans on them heavily. visudo -cfails on a file that “looks fine” —visudoalso checks ownership/permissions of included files; you’ll see warnings likeowned by uid N, should be 0when testing as a non-root user against scratch files, which is expected and separate from this script’s own findings.#includedirtarget reported as missing — sudoers on some distros includes an empty or not-yet-created/etc/sudoers.dby default; that’s reported as a parse error here rather than silently ignored, intentionally.- False positive on a legitimate wildcard — some wildcard command specs really are safe. The WARN/CRIT distinction exists specifically so these get a human look rather than a hard failure — treat WARN as “review,” not “wrong.”
classify_entry and check_file_permissions (pure functions) were unit-tested directly, covering a normal unrestricted root grant (no findings), NOPASSWD: ALL (CRIT), who=ALL (CRIT), NOPASSWD plus a wildcard (CRIT), a tightly-scoped non-wildcard command (no findings, confirming the checker doesn’t cry wolf), and both world-writable and normal-mode file permissions. parse_sudoers was then tested end-to-end against real scratch files on disk, including a real #includedir pulling in a second file (14/14 checks passing, sudoers_audit_test.rb). The full CLI was also run end-to-end against a hand-built sudoers file plus a deliberately world-writable included file, with visudo -c genuinely invoked and its real output captured (see the output tab above). The real /etc/sudoers in this sandbox is 0440 and unreadable to a non-root user, which the script handles as documented rather than crashing — verified directly.
Where to take it next
- Alias resolution — parse
User_Alias/Cmnd_Alias/Runas_Aliasinto a lookup table and expand them before classification. - Baseline diffing — snapshot
--jsonoutput and diff consecutive runs (same pattern as this toolkit’sregistry-driftscript) to alert on newly added risky grants specifically. - Environment-variable exposure — flag entries with
SETENVand missingDefaults env_reset/secure_path. - Per-organization allowlisting — add a
--baseline known_good.jsonso only new or changed risky grants surface as findings. - Group membership cross-check — resolve
%groupnameagainst/etc/groupand report how many real accounts a broad group grant actually covers.