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:
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.
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
Full script + README on GitHub: ruby-devops-toolkit/mount-option-audit
What you need
- Ruby 2.7+ (tested on 3.3.6), standard library only — no gems
- Linux with
/procmounted (any modern distro) - No root needed: mountinfo is world-readable
Complete code
#!/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)
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.
What you will see
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
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 FILEto test against a saved file, as shown in the output tab.
Extending it
- Add bind-mount checks for
/var/tmp->/tmp(a common way to satisfy the rule). - Cross-check against
/etc/fstaband flag drift between intent and reality. - Push exit codes into Prometheus via a textfile collector.