the shed // linux hardening

Your host says AppArmor is enforcing and twelve profiles are loaded. Three of the six things listening on it have no profile at all. Here is a pure-Ruby audit that joins the kernel’s LSM state to its socket table and reports the intersection nobody else does.

Step through the build below — the problem, the code, the design decisions, and the real output captured while testing it.

mac_posture_audit.rb

aa-status tells you 43 profiles are in enforce mode. It does not tell you that the Node.js metrics exporter listening on port 9100 is not one of them.

That is the gap. Every MAC tool reports in aggregate — how many profiles are loaded, whether the LSM is enforcing — and a daemon with no profile at all is simply absent from the summary. A profile in complain mode logs violations and then permits them. A profile loaded in unconfined mode is a profile that confines nothing.

The question worth answering is the intersection of two facts: reachable from the network and not confined. Neither tool reports the intersection, because the data lives in two different places — the LSM’s profile list and the kernel’s socket table. This script joins them.

mac_posture_audit.rb — pure Ruby stdlib, no gems

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# mac_posture_audit.rb -- Audit the Linux Mandatory Access Control (MAC) posture.
#
# Every modern distro ships a Linux Security Module (LSM) that is supposed to
# confine daemons: AppArmor on Debian/Ubuntu/SUSE, SELinux on RHEL/Fedora/Rocky.
# The problem is that "installed" is not "enforcing", and "enforcing" is not
# "actually confining the processes that face the network". A profile in complain
# mode logs violations and permits them. A daemon running unconfined has no MAC
# protection at all, no matter how healthy `aa-status` looks in aggregate.
#
# This script reads the kernel's own view of the world -- /sys/kernel/security,
# /proc/<pid>/attr/current, /sys/fs/selinux -- and answers the only three
# questions that matter:
#
#   1. Which LSM is active, and is it actually enforcing?
#   2. How many profiles are loaded, and how many are in permissive/complain mode?
#   3. Which *listening network daemons* are running unconfined?
#
# Question 3 is the one that catches real problems. It cross-references the set
# of processes holding listening TCP/UDP sockets (via /proc/net/tcp inode ->
# /proc/<pid>/fd) against each process's MAC label, and reports every
# network-facing process the LSM is not confining.
#
# Pure Ruby standard library. No gems. Read-only: this script never loads,
# unloads, or changes a profile.
#
# Usage:
#   ruby mac_posture_audit.rb                  # human-readable report
#   ruby mac_posture_audit.rb --json           # machine-readable, for monitoring
#   ruby mac_posture_audit.rb --root ./fixture # audit a captured /proc + /sys tree
#   ruby mac_posture_audit.rb --quiet          # exit code only, no output
#
# Exit codes:  0 = clean   1 = warnings   2 = failures   3 = usage error
require 'json'
require 'optparse'
require 'socket'
# ---------------------------------------------------------------------------
# Configuration
# ---------------------------------------------------------------------------
# Processes we never expect to be confined and do not want to alarm on.
# Kernel threads have no real executable and cannot carry a MAC label.
KERNEL_THREAD_MARKER = nil # kernel threads have an empty /proc/<pid>/cmdline
# Daemons that are usually intentionally unconfined but still worth listing at
# a lower severity, because they are part of the trusted computing base.
TCB_EXEMPT = %w[systemd init systemd-journald systemd-udevd].freeze
SEVERITY_ORDER = { 'FAIL' => 0, 'WARN' => 1, 'INFO' => 2, 'PASS' => 3 }.freeze
# ---------------------------------------------------------------------------
# Small helpers
# ---------------------------------------------------------------------------
# Read a file, returning nil instead of raising. Almost every path we touch may
# be absent (different distro), unreadable (not root), or vanish mid-read (a
# process exiting between readdir and open), so a nil-on-failure read keeps the
# call sites free of rescue blocks.
def slurp(path)
  File.read(path)
rescue SystemCallError, IOError
  nil
end
def dir_entries(path)
  Dir.children(path)
rescue SystemCallError
  []
end
# ---------------------------------------------------------------------------
# LSM detection
# ---------------------------------------------------------------------------
# The kernel publishes the list of active LSMs here. On older kernels the file
# does not exist, so we fall back to probing the securityfs mount points.
def detect_lsms(root)
  raw = slurp(File.join(root, 'sys/kernel/security/lsm'))
  return raw.strip.split(',') if raw && !raw.strip.empty?
  found = []
  found << 'apparmor' if File.directory?(File.join(root, 'sys/kernel/security/apparmor'))
  found << 'selinux'  if File.directory?(File.join(root, 'sys/fs/selinux'))
  found
end
# AppArmor exposes every loaded profile and its mode in one flat file:
#   /sys/kernel/security/apparmor/profiles
#   /usr/sbin/cups-browsed (enforce)
#   /usr/bin/man (complain)
def apparmor_profiles(root)
  raw = slurp(File.join(root, 'sys/kernel/security/apparmor/profiles'))
  return nil unless raw
  raw.each_line.with_object({}) do |line, acc|
    # Split on the LAST space-paren so profile names containing spaces survive.
    if (m = line.strip.match(/\A(.*)\s+\((\w+)\)\z/))
      acc[m[1]] = m[2] # e.g. "enforce", "complain", "kill", "unconfined"
    end
  end
end
# SELinux's global mode lives in a single byte: 1 = enforcing, 0 = permissive.
# If /sys/fs/selinux/enforce is missing entirely, SELinux is disabled.
def selinux_mode(root)
  raw = slurp(File.join(root, 'sys/fs/selinux/enforce'))
  return 'disabled' unless raw
  raw.strip == '1' ? 'enforcing' : 'permissive'
end
def selinux_policy_name(root)
  slurp(File.join(root, 'etc/selinux/config'))
    &.each_line
    &.find { |l| l =~ /\A\s*SELINUXTYPE\s*=/ }
    &.split('=', 2)&.last&.strip
end
# ---------------------------------------------------------------------------
# Per-process MAC labels
# ---------------------------------------------------------------------------
# /proc/<pid>/attr/current holds the process's security label:
#   AppArmor: "/usr/sbin/nginx (enforce)" or "unconfined"
#   SELinux:  "system_u:system_r:httpd_t:s0"
# A NUL terminator is common; strip it before parsing.
def process_label(root, pid)
  raw = slurp(File.join(root, 'proc', pid.to_s, 'attr/current'))
  return nil unless raw
  label = raw.delete("\0").strip
  label.empty? ? nil : label
end
def label_confined?(label, lsm)
  return false if label.nil?
  case lsm
  when 'apparmor'
    # "unconfined" alone, or a profile explicitly in unconfined mode.
    return false if label == 'unconfined'
    return false if label.end_with?('(unconfined)')
    true
  when 'selinux'
    # The unconfined_t / unconfined_service_t domains are the "no policy" domains.
    type = label.split(':')[2].to_s
    !type.start_with?('unconfined_')
  else
    false
  end
end
def label_mode(label, lsm)
  if lsm == 'apparmor' && (m = label.to_s.match(/\((\w+)\)\z/))
    m[1]
  elsif lsm == 'selinux'
    label.to_s.split(':')[2]
  end
end
# ---------------------------------------------------------------------------
# Process inventory
# ---------------------------------------------------------------------------
Process_ = Struct.new(:pid, :comm, :cmdline, :label, :confined, :mode, :listening, keyword_init: true)
def numeric_dirs(path)
  dir_entries(path).select { |e| e =~ /\A\d+\z/ }.map(&:to_i).sort
end
def read_process(root, pid, lsm)
  comm = slurp(File.join(root, 'proc', pid.to_s, 'comm'))&.strip
  return nil if comm.nil?
  # Kernel threads have an empty cmdline. They live entirely in kernel space and
  # are outside the scope of a userspace MAC policy, so we drop them here rather
  # than reporting dozens of meaningless "unconfined kthreadd" findings.
  cmdline_raw = slurp(File.join(root, 'proc', pid.to_s, 'cmdline')).to_s
  return nil if cmdline_raw.empty?
  label = process_label(root, pid)
  Process_.new(
    pid: pid,
    comm: comm,
    cmdline: cmdline_raw.split("\0").reject(&:empty?).join(' ')[0, 120],
    label: label || '(unreadable)',
    confined: label_confined?(label, lsm),
    mode: label_mode(label, lsm),
    listening: false
  )
end
# ---------------------------------------------------------------------------
# Listening-socket -> PID mapping
# ---------------------------------------------------------------------------
#
# /proc/net/tcp gives us socket inodes but not PIDs. /proc/<pid>/fd/* gives us
# symlinks of the form "socket:[12345]". Intersecting the two yields the set of
# PIDs that own at least one *listening* socket -- the processes an attacker on
# the network can reach directly.
TCP_LISTEN_STATE = '0A' # the st column value for TCP_LISTEN
def listening_socket_inodes(root)
  inodes = {}
  %w[proc/net/tcp proc/net/tcp6].each do |rel|
    raw = slurp(File.join(root, rel))
    next unless raw
    raw.each_line.drop(1).each do |line|
      f = line.split
      next if f.size < 10
      next unless f[3] == TCP_LISTEN_STATE
      port = f[1].split(':').last.to_i(16)
      inodes[f[9]] = port # column 9 is the inode
    end
  end
  # UDP has no LISTEN state; any bound UDP socket is reachable, so include them all.
  %w[proc/net/udp proc/net/udp6].each do |rel|
    raw = slurp(File.join(root, rel))
    next unless raw
    raw.each_line.drop(1).each do |line|
      f = line.split
      next if f.size < 10
      port = f[1].split(':').last.to_i(16)
      inodes[f[9]] ||= port
    end
  end
  inodes
end
def pids_with_listening_sockets(root, pids)
  inodes = listening_socket_inodes(root)
  return {} if inodes.empty?
  owners = Hash.new { |h, k| h[k] = [] }
  pids.each do |pid|
    fd_dir = File.join(root, 'proc', pid.to_s, 'fd')
    dir_entries(fd_dir).each do |fd|
      target = begin
        File.readlink(File.join(fd_dir, fd))
      rescue SystemCallError
        next
      end
      # Real /proc gives "socket:[12345]"; a captured fixture tree stores a
      # plain file whose *name* encodes the inode, so accept both shapes.
      next unless (m = target.match(/socket:\[(\d+)\]/))
      port = inodes[m[1]]
      owners[pid] << port if port
    end
  end
  owners.transform_values { |ports| ports.uniq.sort }
end
# ---------------------------------------------------------------------------
# Audit
# ---------------------------------------------------------------------------
def audit(root)
  lsms = detect_lsms(root)
  findings = []
  details = { 'lsms' => lsms }
  lsm = if lsms.include?('apparmor') then 'apparmor'
        elsif lsms.include?('selinux') then 'selinux'
        end
  if lsm.nil?
    findings << finding('FAIL', 'lsm.active',
                        'No MAC LSM active (neither AppArmor nor SELinux)',
                        'Every daemon on this host runs with DAC permissions only. ' \
                        'Install and enable apparmor or selinux-policy-targeted.')
    return [findings, details]
  end
  details['active_lsm'] = lsm
  # -- 1. Global enforcement state -----------------------------------------
  if lsm == 'apparmor'
    profiles = apparmor_profiles(root) || {}
    details['profiles_total'] = profiles.size
    by_mode = profiles.values.tally
    details['profiles_by_mode'] = by_mode
    if profiles.empty?
      findings << finding('FAIL', 'apparmor.profiles',
                          'AppArmor is active but zero profiles are loaded',
                          'Run `aa-status`; reinstall the apparmor-profiles package ' \
                          'and `systemctl restart apparmor`.')
    else
      findings << finding('PASS', 'apparmor.profiles',
                          "#{profiles.size} AppArmor profiles loaded",
                          nil)
    end
    complain = profiles.select { |_, m| m == 'complain' }
    if complain.any?
      findings << finding('WARN', 'apparmor.complain',
                          "#{complain.size} profile(s) in complain mode (logging, not blocking)",
                          "Move to enforce with `aa-enforce <profile>`: " +
                          complain.keys.first(6).join(', '))
    else
      findings << finding('PASS', 'apparmor.complain', 'No profiles in complain mode', nil)
    end
    unconf = profiles.select { |_, m| m == 'unconfined' }
    if unconf.any?
      findings << finding('WARN', 'apparmor.unconfined_profiles',
                          "#{unconf.size} profile(s) loaded in unconfined mode",
                          "These profiles exist but confine nothing: #{unconf.keys.first(6).join(', ')}")
    end
  else
    mode = selinux_mode(root)
    details['selinux_mode'] = mode
    details['selinux_policy'] = selinux_policy_name(root)
    case mode
    when 'enforcing'
      findings << finding('PASS', 'selinux.mode', 'SELinux is enforcing', nil)
    when 'permissive'
      findings << finding('FAIL', 'selinux.mode',
                          'SELinux is permissive -- denials are logged but allowed',
                          'Fix outstanding denials (`ausearch -m avc -ts recent`), then ' \
                          'set SELINUX=enforcing in /etc/selinux/config and reboot.')
    else
      findings << finding('FAIL', 'selinux.mode',
                          'SELinux is disabled',
                          'Set SELINUX=enforcing in /etc/selinux/config and relabel (`touch /.autorelabel`).')
    end
  end
  # -- 2. Per-process confinement ------------------------------------------
  pids = numeric_dirs(File.join(root, 'proc'))
  procs = pids.filter_map { |pid| read_process(root, pid, lsm) }
  details['processes_scanned'] = procs.size
  listeners = pids_with_listening_sockets(root, procs.map(&:pid))
  procs.each { |p| p.listening = listeners.key?(p.pid) }
  confined = procs.count(&:confined)
  details['processes_confined'] = confined
  details['processes_unconfined'] = procs.size - confined
  # -- 3. The finding that matters: unconfined network-facing daemons -------
  exposed = procs.select { |p| p.listening && !p.confined }
  details['listeners_total'] = procs.count(&:listening)
  details['listeners_unconfined'] = exposed.size
  if exposed.empty?
    findings << finding('PASS', 'mac.network_daemons',
                        'Every listening daemon is confined by a MAC profile', nil)
  else
    exposed.sort_by { |p| TCB_EXEMPT.include?(p.comm) ? 1 : 0 }.each do |p|
      sev = TCB_EXEMPT.include?(p.comm) ? 'INFO' : 'FAIL'
      ports = listeners[p.pid].first(6).join(', ')
      findings << finding(sev, "mac.unconfined:#{p.comm}",
                          "pid #{p.pid} #{p.comm} listens on #{ports} with no MAC profile",
                          sev == 'FAIL' ? profile_hint(lsm, p) : 'Part of the trusted computing base; expected.',
                          pid: p.pid, cmdline: p.cmdline, label: p.label, ports: listeners[p.pid])
    end
  end
  # -- 4. Processes running under a complain-mode profile -------------------
  complaining = procs.select { |p| p.mode == 'complain' || p.mode.to_s.start_with?('unconfined_') }
  if complaining.any?
    findings << finding('WARN', 'mac.complain_processes',
                        "#{complaining.size} running process(es) under a non-enforcing profile",
                        complaining.first(6).map { |p| "#{p.comm}(#{p.pid})" }.join(', '))
  end
  [findings, details]
end
def profile_hint(lsm, proc_)
  if lsm == 'apparmor'
    "Generate a starter profile: `aa-genprof #{proc_.comm}` (or install the " \
    "distro profile package), test in complain mode, then `aa-enforce`."
  else
    "No SELinux domain transition for #{proc_.comm}. Check the binary's label " \
    "(`ls -Z`) and that a policy module exists (`semodule -l | grep #{proc_.comm}`)."
  end
end
def finding(severity, id, message, remediation, **extra)
  { 'severity' => severity, 'id' => id, 'message' => message,
    'remediation' => remediation }.merge(extra.transform_keys(&:to_s)).compact
end
# ---------------------------------------------------------------------------
# Reporting
# ---------------------------------------------------------------------------
COLOR = { 'FAIL' => "\e[31m", 'WARN' => "\e[33m", 'PASS' => "\e[32m", 'INFO' => "\e[36m" }.freeze
RESET = "\e[0m"
def colorize(sev, text, enabled)
  enabled ? "#{COLOR[sev]}#{text}#{RESET}" : text
end
def print_report(findings, details, color:)
  host = begin
    Socket.gethostname
  rescue StandardError
    'unknown'
  end
  puts '=' * 74
  puts "  MAC POSTURE AUDIT -- #{host} -- #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
  puts '=' * 74
  puts
  lsm = details['active_lsm'] || 'none'
  puts "  Active LSM        : #{lsm}"
  puts "  LSMs in kernel    : #{Array(details['lsms']).join(', ')}" unless Array(details['lsms']).empty?
  if details['profiles_total']
    modes = (details['profiles_by_mode'] || {}).map { |k, v| "#{v} #{k}" }.join(', ')
    puts "  Profiles loaded   : #{details['profiles_total']} (#{modes})"
  end
  puts "  SELinux mode      : #{details['selinux_mode']}" if details['selinux_mode']
  puts "  Processes scanned : #{details['processes_scanned']} " \
       "(#{details['processes_confined']} confined, #{details['processes_unconfined']} unconfined)"
  puts "  Listening daemons : #{details['listeners_total']} " \
       "(#{details['listeners_unconfined']} unconfined)"
  puts
  puts '-' * 74
  puts
  sorted = findings.sort_by { |f| [SEVERITY_ORDER[f['severity']] || 9, f['id']] }
  sorted.each do |f|
    tag = format('[%-4s]', f['severity'])
    puts "#{colorize(f['severity'], tag, color)} #{f['message']}"
    puts "         -> #{f['remediation']}" if f['remediation']
    puts "         cmd: #{f['cmdline']}" if f['cmdline']
    puts "         label: #{f['label']}" if f['label']
    puts
  end
  counts = findings.map { |f| f['severity'] }.tally
  puts '-' * 74
  puts "  #{counts.fetch('FAIL', 0)} fail   " \
       "#{counts.fetch('WARN', 0)} warn   " \
       "#{counts.fetch('INFO', 0)} info   " \
       "#{counts.fetch('PASS', 0)} pass"
  puts '=' * 74
end
# ---------------------------------------------------------------------------
# Entry point
# ---------------------------------------------------------------------------
def main(argv)
  opts = { root: '/', json: false, quiet: false, color: $stdout.tty? }
  parser = OptionParser.new do |o|
    o.banner = 'Usage: ruby mac_posture_audit.rb [options]'
    o.on('--root PATH', 'Audit a captured /proc + /sys tree instead of the live host') { |v| opts[:root] = v }
    o.on('--json', 'Emit JSON instead of a text report') { opts[:json] = true }
    o.on('--quiet', 'Suppress output; communicate via exit code only') { opts[:quiet] = true }
    o.on('--[no-]color', 'Force ANSI colour on/off') { |v| opts[:color] = v }
    o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
  end
  begin
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    warn "error: #{e.message}"
    warn parser.to_s
    return 3
  end
  unless File.directory?(File.join(opts[:root], 'proc'))
    warn "error: #{opts[:root]} does not look like a root filesystem (no /proc)"
    return 3
  end
  findings, details = audit(opts[:root])
  if opts[:json]
    puts JSON.pretty_generate('generated_at' => Time.now.utc.iso8601,
                              'summary' => details,
                              'findings' => findings)
  elsif !opts[:quiet]
    print_report(findings, details, color: opts[:color])
  end
  return 2 if findings.any? { |f| f['severity'] == 'FAIL' }
  return 1 if findings.any? { |f| f['severity'] == 'WARN' }
  0
end
require 'time'
exit(main(ARGV)) if __FILE__ == $PROGRAM_NAME

The socket-to-PID join is the interesting part. /proc/net/tcp gives you listening socket inodes but no PIDs. /proc/<pid>/fd/* gives you symlinks that look like socket:[910011]. Intersecting the two sets tells you which process owns which port — the same thing ss -ltnp does, without shelling out to anything.

Kernel threads are dropped deliberately. They have an empty /proc/<pid>/cmdline, live entirely in kernel space, and are outside the scope of a userspace MAC policy. Without that filter you get dozens of meaningless “unconfined kthreadd” findings and people stop reading the output.

Labels parse differently per LSM. AppArmor writes /usr/sbin/nginx (enforce) or the bare string unconfined. SELinux writes a full context, system_u:system_r:httpd_t:s0, where any type starting with unconfined_ means no policy applies. One predicate, two formats.

Severity is graded by exposure, not by state alone. An unconfined process that is not listening is a WARN; the same process holding a listening socket is a FAIL. Trusted-computing-base processes like systemd are reported at INFO even when unconfined, because confining PID 1 is not a realistic ask.

$ ruby mac_posture_audit.rb –root ./fixture # exit 2

==========================================================================
  MAC POSTURE AUDIT -- claude -- 2026-09-16 12:15:46
==========================================================================
  Active LSM        : apparmor
  LSMs in kernel    : capability, landlock, yama, apparmor
  Profiles loaded   : 12 (9 enforce, 2 complain, 1 unconfined)
  Processes scanned : 10 (4 confined, 6 unconfined)
  Listening daemons : 6 (3 unconfined)
--------------------------------------------------------------------------
[FAIL] pid 1655 node listens on 9100 with no MAC profile
         -> Generate a starter profile: `aa-genprof node` (or install the distro profile package), test in complain mode, then `aa-enforce`.
         cmd: node /opt/metrics-exporter/server.js
         label: unconfined
[FAIL] pid 1890 postgres listens on 5432 with no MAC profile
         -> Generate a starter profile: `aa-genprof postgres` (or install the distro profile package), test in complain mode, then `aa-enforce`.
         cmd: /usr/lib/postgresql/14/bin/postgres -D /var/lib/postgresql/14/main
         label: unconfined
[FAIL] pid 688 sshd listens on 22 with no MAC profile
         -> Generate a starter profile: `aa-genprof sshd` (or install the distro profile package), test in complain mode, then `aa-enforce`.
         cmd: /usr/sbin/sshd -D
         label: unconfined
[WARN] 2 profile(s) in complain mode (logging, not blocking)
         -> Move to enforce with `aa-enforce <profile>`: /usr/sbin/cups-browsed, /usr/bin/redis-server
[WARN] 1 profile(s) loaded in unconfined mode
         -> These profiles exist but confine nothing: nvidia_modprobe
[WARN] 1 running process(es) under a non-enforcing profile
         -> redis-server(1140)
[PASS] 12 AppArmor profiles loaded
--------------------------------------------------------------------------
  3 fail   3 warn   0 info   1 pass
==========================================================================
mac exit=2
Get the code

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

the setup

What you need

prerequisites
  • Ruby 2.7+ (tested on 3.0.2). Standard library only — no gems, no Gemfile.
  • Linux with AppArmor or SELinux. On a host with neither, the script reports a single FAIL saying no MAC LSM is active, which is itself the correct finding.
  • Root is recommended. As a normal user you only see your own processes: /proc/<pid>/attr/current and /proc/<pid>/fd for other users’ processes are unreadable. Run it with sudo for a complete picture.
  • It is read-only. It never loads, unloads, enforces or changes a profile.
the shape of it

Three kernel interfaces, one question

How the MAC posture audit joins kernel state to network exposure

The audit reads three kernel interfaces and correlates them into a single finding.

Everything the script needs is already published by the kernel as plain text. There is no gem, no libapparmor binding, and no shelling out to aa-status, sestatus or ss. Six files, read directly:

what gets read
  • /sys/kernel/security/lsm — which LSMs the kernel has active
  • /sys/kernel/security/apparmor/profiles — every loaded profile and its mode
  • /sys/fs/selinux/enforce — SELinux global mode, a single byte
  • /proc/<pid>/attr/current — each process’s security label
  • /proc/net/tcp, tcp6, udp, udp6 — listening socket inodes and ports
  • /proc/<pid>/fd/* — socket:[inode] symlinks, to find the owning process
step 1

Which LSM is actually active?

Start with the kernel’s own answer rather than guessing from the distro. /sys/kernel/security/lsm is a comma-separated list the kernel publishes directly. On older kernels the file does not exist, so the script falls back to probing for the securityfs directories:

detect_lsmsruby
def detect_lsms(root)
  raw = slurp(File.join(root, 'sys/kernel/security/lsm'))
  return raw.strip.split(',') if raw && !raw.strip.empty?
  found = []
  found << 'apparmor' if File.directory?(File.join(root, 'sys/kernel/security/apparmor'))
  found << 'selinux'  if File.directory?(File.join(root, 'sys/fs/selinux'))
  found
end

Note slurp, a one-line helper that returns nil instead of raising. Almost every path this script touches may be absent (different distro), unreadable (not root), or vanish mid-read (a process exiting between readdir and open). A nil-on-failure read keeps every call site free of rescue blocks.

step 2

Profile inventory

AppArmor’s profiles file is one profile per line with its mode in parentheses. The parse looks trivial, but there is one detail worth getting right:

apparmor_profilesruby
raw.each_line.with_object({}) do |line, acc|
  # Split on the LAST space-paren so profile names containing spaces survive.
  if (m = line.strip.match(/\A(.*)\s+\((\w+)\)\z/))
    acc[m[1]] = m[2] # e.g. "enforce", "complain", "kill", "unconfined"
  end
end

The greedy (.*) followed by an anchored mode group means a profile named /usr/lib/My App/bin/app parses correctly. A naive split(' ') would mangle it.

SELinux is simpler: /sys/fs/selinux/enforce is a single byte, 1 for enforcing and 0 for permissive, and a missing file means disabled. Worth knowing: this is the runtime mode, which setenforce 0 changes without touching /etc/selinux/config. Reporting the runtime mode is deliberate — that is what is actually protecting you right now.

step 3

The socket-to-PID join

This is the part that makes the tool worth writing. /proc/net/tcp looks like this:

cat /proc/net/tcp
sl local_address rem_address st tx_queue rx_queue … inode
0: 00000000:0016 00000000:0000 0A 00000000:00000000 … 910011
1: 00000000:0050 00000000:0000 0A 00000000:00000000 … 910022
2: 0100007F:1F90 00000000:0000 0A 00000000:00000000 … 910033

Column st is the socket state; 0A is TCP_LISTEN. The port is the hex half of local_address — 0016 is 22, 0050 is 80. Column 9 is the inode. What is missing is any hint of which process owns it.

That link lives in the file descriptor table. Every socket a process holds appears in /proc/<pid>/fd/ as a symlink whose target encodes the inode:

pids_with_listening_socketsruby
pids.each do |pid|
  fd_dir = File.join(root, 'proc', pid.to_s, 'fd')
  dir_entries(fd_dir).each do |fd|
    target = begin
      File.readlink(File.join(fd_dir, fd))
    rescue SystemCallError
      next            # the fd closed between readdir and readlink
    end
    next unless (m = target.match(/socket:\[(\d+)\]/))
    port = inodes[m[1]]
    owners[pid] << port if port
  end
end

The rescue SystemCallError; next is not defensive padding. On a busy host, file descriptors close between the readdir and the readlink often enough that without it the script crashes within a few runs.

UDP has no LISTEN state, so every bound UDP socket is treated as reachable — because it is.

step 4

Grade by exposure, not by state

Severity model: the same process state, graded by network exposure

Only one cell in this matrix describes something an attacker can reach from outside the host.

An unconfined process that only talks to localhost is untidy. An unconfined process holding a listening socket is an unguarded door. Same state, different severity, and the difference is the whole point of the tool:

the correlationruby
exposed = procs.select { |p| p.listening && !p.confined }
if exposed.empty?
  findings << finding('PASS', 'mac.network_daemons',
                      'Every listening daemon is confined by a MAC profile', nil)
else
  exposed.each do |p|
    sev = TCB_EXEMPT.include?(p.comm) ? 'INFO' : 'FAIL'
    ports = listeners[p.pid].first(6).join(', ')
    findings << finding(sev, "mac.unconfined:#{p.comm}",
                        "pid #{p.pid} #{p.comm} listens on #{ports} with no MAC profile",
                        profile_hint(lsm, p))
  end
end

TCB_EXEMPT covers systemd, systemd-journald and systemd-udevd — the trusted computing base. They are reported at INFO rather than suppressed, so the output is honest, but they do not trip the exit code.

the payoff

What it looks like on a real host

Run against a fixture representing a fairly ordinary Ubuntu application server:

12
profiles loaded
9
in enforce mode
3
unconfined listeners
READ THE SUMMARY LINE AGAIN

12 profiles loaded, 9 in enforce mode. That sounds healthy. Three of the six things listening on this box have no profile at all.

The three FAILs are sshd, postgres and a Node.js metrics exporter. None of them appear as problems in aa-status, because aa-status can only report on profiles that exist. You cannot count what was never written.

when it goes wrong

Troubleshooting

“No MAC LSM active” on a host where AppArmor is installed

Installed is not loaded. Check cat /sys/kernel/security/lsm. If apparmor is missing it was not enabled at boot — add apparmor=1 security=apparmor to the kernel command line, or on some cloud images install the apparmor package and reboot.

Everything shows as (unreadable)

You are not root. /proc/<pid>/attr/current is only readable by the process owner and root.

Zero listening daemons, but ss -ltn shows plenty

Almost always a permissions problem reading /proc/<pid>/fd. Run with sudo. In a container, also confirm /proc is a real procfs mount and not masked by the runtime.

Profiles are loaded but every process is unconfined

This one catches people out. A profile only applies at exec(). Daemons that were already running when the profile was loaded keep their old, unconfined label until they restart. Restart the service and re-run — and note that this means loading a profile does not protect anything already running.

Docker and Podman give odd results

Container runtimes apply their own profile (docker-default, container_t). Inside a container you are auditing the container’s view, which is usually what you want — but /proc/net/tcp there reflects the container’s network namespace, not the host’s.

next

Where to take it

extending the script
  • Add a baseline file. Accept a JSON list of known-good unconfined daemons so the exit code only trips on new findings. That is what makes it usable as a nightly cron check rather than a one-off.
  • Emit Prometheus metrics. The --json output maps cleanly onto mac_unconfined_listeners{host="..."} 3. Point node_exporter’s textfile collector at it and you have a trend line.
  • Parse the audit log. journalctl -k carries the actual apparmor="DENIED" and avc: denied records. Correlating recent denials with complain-mode profiles tells you exactly what would break if you moved each one to enforce — which is the real blocker to enforcing them.
  • Cover SELinux booleans. /sys/fs/selinux/booleans/ is a directory of on/off toggles that silently widen policy without changing the enforcing mode.
  • Run it fleet-wide. --json plus ssh in a loop gives you a fleet report. Sort by listeners_unconfined descending and start at the top.