the shed // ruby // sudo

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:

sudoers_audit.rb

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
Get the code

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

Audit sudoers for Passwordless Root and Shell Escapes with Ruby workflow diagram

Workflow: from /etc/sudoers to severity-sorted findings.
prerequisites

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 --file to audit a copy of someone else's sudoers tree safely.
the script

Full code

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

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.

example output

What a run looks like

test run
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
troubleshooting

When it misbehaves

  • cannot read /etc/sudoers: run with sudo, or point --file at a copy.
  • False positives: the checks are heuristic regexes. A flagged find rule is only exploitable if -exec is allowed; review before removing.
  • Aliases are not expanded: Cmnd_Alias definitions 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.
next steps

Extending the script

  • Expand Cmnd_Alias and User_Alias before checking rules.
  • Compare findings to an approved baseline JSON and only alert on new ones.
  • Check sudo -l -U user output for effective rights.
  • Ship results to your SIEM as JSON with --json.

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating