the shed // linux // hardening

Your fstab says /tmp is noexec. The kernel may disagree. This script reads /proc/self/mountinfo and reports what is actually enforced right now.

Step through the build below:

mount_option_audit.rb

Hardening guides (CIS, STIG) demand that world-writable locations like /tmp, /var/tmp and /dev/shm be mounted nosuid,nodev,noexec, so an attacker who drops a payload there cannot run it or abuse a SUID binary. Teams usually verify this by grepping /etc/fstab, but fstab is only intent: a remount, a systemd tmp.mount unit, or a container runtime can leave the live table different. /proc/self/mountinfo is the kernel’s own answer.

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# mount_option_audit.rb - Audit LIVE mount options of sensitive Linux mount points.
#
# Reads /proc/self/mountinfo (what the kernel actually enforces right now, not
# what /etc/fstab says) and checks that world-writable locations such as /tmp,
# /var/tmp and /dev/shm are mounted with nosuid, nodev and noexec.
#
# Usage:  ruby mount_option_audit.rb [--json] [--mountinfo FILE]
# Exit:   0 = all good, 1 = findings, 2 = usage error
require 'json'
require 'optparse'
# mount point => options that SHOULD be present, plus severity if missing
POLICY = {
  '/tmp'     => { need: %w[nosuid nodev noexec], sev: 'HIGH' },
  '/var/tmp' => { need: %w[nosuid nodev noexec], sev: 'HIGH' },
  '/dev/shm' => { need: %w[nosuid nodev noexec], sev: 'HIGH' },
  '/home'    => { need: %w[nosuid nodev],        sev: 'MEDIUM' },
  '/boot'    => { need: %w[nosuid nodev],        sev: 'MEDIUM' }
}.freeze
Mount = Struct.new(:mount_point, :fstype, :source, :options)
# mountinfo line format (man 5 proc):
# 36 35 98:0 /root /mnt1 rw,noatime master:1 - ext3 /dev/root rw,errors=continue
# Fields before " - " are variable; the mount point is field 5 and per-mount
# options field 6. After the "-" separator: fstype, source, super options.
def parse_mountinfo(text)
  text.each_line.filter_map do |line|
    pre, post = line.chomp.split(' - ', 2)
    next unless post
    pf = pre.split(' ')
    qf = post.split(' ')
    # Octal escapes (\040 = space) are used in paths
    mp = pf[4].to_s.gsub(/\\([0-7]{3})/) { Regexp.last_match(1).to_i(8).chr }
    Mount.new(mp, qf[0], qf[1], pf[5].to_s.split(','))
  end
end
def audit(mounts)
  by_mp = {}
  mounts.each { |m| by_mp[m.mount_point] = m } # later entries shadow earlier ones
  POLICY.filter_map do |mp, rule|
    m = by_mp[mp]
    next({ mount: mp, status: 'NOT_SEPARATE', severity: 'INFO', missing: [],
           note: 'not a separate mount; inherits options of parent filesystem' }) unless m
    missing = rule[:need] - m.options
    { mount: mp, status: missing.empty? ? 'OK' : 'FAIL',
      severity: missing.empty? ? 'OK' : rule[:sev], missing: missing,
      fstype: m.fstype, options: m.options }
  end
end
opts = { json: false, file: '/proc/self/mountinfo' }
OptionParser.new do |o|
  o.banner = 'Usage: mount_option_audit.rb [options]'
  o.on('--json') { opts[:json] = true }
  o.on('--mountinfo FILE', 'read a saved mountinfo file (testing)') { |f| opts[:file] = f }
end.parse!
begin
  results = audit(parse_mountinfo(File.read(opts[:file])))
rescue Errno::ENOENT, Errno::EACCES => e
  warn "cannot read mountinfo: #{e.message}"
  exit 2
end
if opts[:json]
  puts JSON.pretty_generate(results)
else
  puts format('%-10s %-13s %-8s %s', 'MOUNT', 'STATUS', 'SEVERITY', 'DETAIL')
  results.each do |r|
    detail = r[:missing].empty? ? (r[:note] || r[:options].join(',')) : "missing: #{r[:missing].join(', ')}"
    puts format('%-10s %-13s %-8s %s', r[:mount], r[:status], r[:severity], detail)
  end
end
exit(results.any? { |r| r[:status] == 'FAIL' } ? 1 : 0)

1. Parse mountinfo safely. Each line has a variable number of optional fields before a literal - separator, so the parser splits on that first. Field 5 is the mount point and field 6 the per-mount options. Paths encode spaces as octal escapes like \040, which we decode with gsub.

2. Encode policy as data. A POLICY hash maps each mount point to the options it must carry and a severity. Adding /var/log is one new line, not new logic.

3. Compare with set difference. need - present yields exactly the missing options. Mounts that are not separate filesystems are reported as NOT_SEPARATE (informational) because they inherit the parent’s options — itself a finding worth knowing about.

4. Report and exit code. Text table or --json; exit 1 if anything failed, so cron, CI or a monitoring agent can alert on it.

$ ruby mount_option_audit.rb
MOUNT      STATUS        SEVERITY DETAIL
/tmp       OK            OK       rw,nosuid,nodev,noexec,relatime
/var/tmp   NOT_SEPARATE  INFO     not a separate mount; inherits options of parent filesystem
/dev/shm   FAIL          HIGH     missing: noexec
/home      OK            OK       rw,nosuid,nodev,relatime
/boot      FAIL          MEDIUM   missing: nosuid, nodev
Get the code

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

Audit Live Mount Options on Linux with Ruby: Is /tmp Really noexec?

How the pieces fit together
prerequisites

What you need

  • Ruby 2.7+ (tested on 3.3.6), standard library only — no gems
  • Linux with /proc mounted (any modern distro)
  • No root needed: mountinfo is world-readable
the full script

Complete code

mount_option_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# mount_option_audit.rb - Audit LIVE mount options of sensitive Linux mount points.
#
# Reads /proc/self/mountinfo (what the kernel actually enforces right now, not
# what /etc/fstab says) and checks that world-writable locations such as /tmp,
# /var/tmp and /dev/shm are mounted with nosuid, nodev and noexec.
#
# Usage:  ruby mount_option_audit.rb [--json] [--mountinfo FILE]
# Exit:   0 = all good, 1 = findings, 2 = usage error
require 'json'
require 'optparse'
# mount point => options that SHOULD be present, plus severity if missing
POLICY = {
  '/tmp'     => { need: %w[nosuid nodev noexec], sev: 'HIGH' },
  '/var/tmp' => { need: %w[nosuid nodev noexec], sev: 'HIGH' },
  '/dev/shm' => { need: %w[nosuid nodev noexec], sev: 'HIGH' },
  '/home'    => { need: %w[nosuid nodev],        sev: 'MEDIUM' },
  '/boot'    => { need: %w[nosuid nodev],        sev: 'MEDIUM' }
}.freeze
Mount = Struct.new(:mount_point, :fstype, :source, :options)
# mountinfo line format (man 5 proc):
# 36 35 98:0 /root /mnt1 rw,noatime master:1 - ext3 /dev/root rw,errors=continue
# Fields before " - " are variable; the mount point is field 5 and per-mount
# options field 6. After the "-" separator: fstype, source, super options.
def parse_mountinfo(text)
  text.each_line.filter_map do |line|
    pre, post = line.chomp.split(' - ', 2)
    next unless post
    pf = pre.split(' ')
    qf = post.split(' ')
    # Octal escapes (\040 = space) are used in paths
    mp = pf[4].to_s.gsub(/\\([0-7]{3})/) { Regexp.last_match(1).to_i(8).chr }
    Mount.new(mp, qf[0], qf[1], pf[5].to_s.split(','))
  end
end
def audit(mounts)
  by_mp = {}
  mounts.each { |m| by_mp[m.mount_point] = m } # later entries shadow earlier ones
  POLICY.filter_map do |mp, rule|
    m = by_mp[mp]
    next({ mount: mp, status: 'NOT_SEPARATE', severity: 'INFO', missing: [],
           note: 'not a separate mount; inherits options of parent filesystem' }) unless m
    missing = rule[:need] - m.options
    { mount: mp, status: missing.empty? ? 'OK' : 'FAIL',
      severity: missing.empty? ? 'OK' : rule[:sev], missing: missing,
      fstype: m.fstype, options: m.options }
  end
end
opts = { json: false, file: '/proc/self/mountinfo' }
OptionParser.new do |o|
  o.banner = 'Usage: mount_option_audit.rb [options]'
  o.on('--json') { opts[:json] = true }
  o.on('--mountinfo FILE', 'read a saved mountinfo file (testing)') { |f| opts[:file] = f }
end.parse!
begin
  results = audit(parse_mountinfo(File.read(opts[:file])))
rescue Errno::ENOENT, Errno::EACCES => e
  warn "cannot read mountinfo: #{e.message}"
  exit 2
end
if opts[:json]
  puts JSON.pretty_generate(results)
else
  puts format('%-10s %-13s %-8s %s', 'MOUNT', 'STATUS', 'SEVERITY', 'DETAIL')
  results.each do |r|
    detail = r[:missing].empty? ? (r[:note] || r[:options].join(',')) : "missing: #{r[:missing].join(', ')}"
    puts format('%-10s %-13s %-8s %s', r[:mount], r[:status], r[:severity], detail)
  end
end
exit(results.any? { |r| r[:status] == 'FAIL' } ? 1 : 0)
walkthrough

How it works

1. Parse mountinfo safely

Each line has a variable number of optional fields before a literal - separator, so the parser splits on that first. Field 5 is the mount point and field 6 the per-mount options. Paths encode spaces as octal escapes like \040, which we decode with gsub.

2. Encode policy as data

A POLICY hash maps each mount point to the options it must carry and a severity. Adding /var/log is one new line, not new logic.

3. Compare with set difference

need - present yields exactly the missing options. Mounts that are not separate filesystems are reported as NOT_SEPARATE (informational) because they inherit the parent’s options — itself a finding worth knowing about.

4. Report and exit code

Text table or --json; exit 1 if anything failed, so cron, CI or a monitoring agent can alert on it.

example output

What you will see

outputtext
MOUNT      STATUS        SEVERITY DETAIL
/tmp       OK            OK       rw,nosuid,nodev,noexec,relatime
/var/tmp   NOT_SEPARATE  INFO     not a separate mount; inherits options of parent filesystem
/dev/shm   FAIL          HIGH     missing: noexec
/home      OK            OK       rw,nosuid,nodev,relatime
/boot      FAIL          MEDIUM   missing: nosuid, nodev
troubleshooting

When it misbehaves

  • Every line reports NOT_SEPARATE: you are probably in a container where /tmp is part of the overlay root. Audit the host or the container’s mounts instead.
  • Later mounts shadow earlier ones; the script keeps the last entry per mount point, matching kernel behaviour.
  • Use --mountinfo FILE to test against a saved file, as shown in the output tab.
next steps

Extending it

  • Add bind-mount checks for /var/tmp -> /tmp (a common way to satisfy the rule).
  • Cross-check against /etc/fstab and flag drift between intent and reality.
  • Push exit codes into Prometheus via a textfile collector.

Tha-Shed

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

Start creating