the shed // ruby / devops

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.

Get the code

Full script, tests, and README on GitHub: ruby-devops-toolkit/sudoers-audit

Step through the build below:

sudoers_audit.rb
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
parse_sudoers is recursive. It follows #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
01 / prerequisites

What you need

  • Ruby >= 2.7 (tested on 3.0.2)
  • Read access to the sudoers file you’re auditing — for the real /etc/sudoers that means running as root (it’s 0440 root:root on a normal system); for any other file, ordinary permissions
  • visudo on PATH for the syntax check (skip with --skip-visudo if it’s not installed)
  • No gems — optparse, json, and open3 are all Ruby standard library
02 / usage

Running it

usagebash
# 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 found
  • 1 — WARN-level findings only
  • 2 — CRIT-level findings, a visudo -c syntax error, or the file couldn’t be read
03 / walkthrough

How it works

sudoers-audit architecture diagram: recursive parser feeding three independent checks into one findings list

One recursive parser, three independent checks, merged findings

Four pieces, each with a single job:

  • parse_sudoers(path) reads the file line by line, joins backslash-continued lines, skips comments/Defaults/*_Alias lines, and recursively follows #include FILE / #includedir DIR directives — the same mechanism real sudo uses to pull in /etc/sudoers.d/*. Each recognized user-spec line becomes a SudoersEntry struct; 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: ALL is CRIT (passwordless full root); NOPASSWD combined with a wildcard (*/?) is CRIT; NOPASSWD alone on a specific command is WARN; a bare wildcard command is WARN; a broad group granted ALL commands is WARN. A plain root ALL=(ALL:ALL) ALL produces no findings at all.
  • check_file_permissions(path) flags world-writable (CRIT) and group-writable (WARN) sudoers files via File.stat(path).mode, checked for both the main file and every included file.
  • run_visudo(path) shells out to visudo -c -f path via Open3.capture2e and reports pass/fail/skipped, with Errno::ENOENT handled cleanly when visudo isn’t installed at all.
04 / full source

sudoers_audit.rb

sudoers_audit.rbruby
#!/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
05 / troubleshooting

When it doesn't behave

  • cannot read /etc/sudoers (permission denied?) — expected on a normal system unless you run as root; /etc/sudoers is 0440 root:root by design. Run with sudo, or point --file at 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_Alias definitions, so a command hidden behind a Cmnd_Alias won’t be traced back to the wildcard. Expand aliases by hand for any file that leans on them heavily.
  • visudo -c fails on a file that “looks fine”visudo also checks ownership/permissions of included files; you’ll see warnings like owned by uid N, should be 0 when testing as a non-root user against scratch files, which is expected and separate from this script’s own findings.
  • #includedir target reported as missing — sudoers on some distros includes an empty or not-yet-created /etc/sudoers.d by 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.”
Tested how

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.

06 / extending

Where to take it next

  • Alias resolution — parse User_Alias/Cmnd_Alias/Runas_Alias into a lookup table and expand them before classification.
  • Baseline diffing — snapshot --json output and diff consecutive runs (same pattern as this toolkit’s registry-drift script) to alert on newly added risky grants specifically.
  • Environment-variable exposure — flag entries with SETENV and missing Defaults env_reset/secure_path.
  • Per-organization allowlisting — add a --baseline known_good.json so only new or changed risky grants surface as findings.
  • Group membership cross-check — resolve %groupname against /etc/group and report how many real accounts a broad group grant actually covers.