Flow from the kernel socket table through a YAML baseline comparison to four verdict classes
the shed // listening port audit

Every host tells you what it is listening on. None of them tell you whether that is what you agreed to. A single-file Ruby script that compares the live socket table against a baseline you can commit to git — and fails the build when Redis quietly moves to 0.0.0.0.

Step through the build below:

port_audit.rb

ss -tulpn tells you what is listening right now. It does not tell you whether that is what is supposed to be listening. On a fleet of any size the interesting question is never “what ports are open” but “what changed since the last time someone looked”.

The classic incident is Redis. It ships with no authentication because the documented deployment model is loopback-only. Someone edits redis.conf to test something from another host, sets bind 0.0.0.0, and forgets. Nothing alerts, because Redis is up and healthy — it is just now up and healthy for the entire internet.

This script turns “what should be listening” into a file you commit next to your config management, and turns drift from that file into a non-zero exit code.

baseline.yml

# baseline.yml -- what this class of host is allowed to listen on.
# Commit this next to your config management so drift shows up in code review.

# Ports we never want to hear about (build agents, sidecar proxies, etc).
ignore_ports: [1080, 3128]

allowed:
  - port: 22
    proto: tcp
    scope: any
    process: sshd
    required: true
    note: fleet SSH access

  - port: 3000
    proto: tcp
    scope: any
    required: true
    note: application server behind the load balancer

  - port: 5432
    proto: tcp
    scope: loopback
    required: false
    note: postgres -- must never leave the box

  - port: 6379
    proto: tcp
    scope: loopback
    required: false
    note: redis -- no auth configured, loopback only

  - port: 9100
    proto: tcp
    scope: any
    required: false
    note: node_exporter scraped by prometheus

port_audit.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# port_audit.rb -- Listening-port baseline auditor for Linux
#
# Enumerates every TCP/UDP socket the host is listening on, compares the live
# picture against a declarative YAML baseline, and reports four classes of
# finding:
#
#   OK          listener matches the baseline (right port, right proto, right scope)
#   UNEXPECTED  something is listening that the baseline never authorised
#   EXPOSED     an authorised service is bound world-wide when the baseline
#               said it should be loopback-only
#   MISSING     the baseline expects a listener that is not currently up
#
# The socket enumeration has two backends. It prefers iproute2's `ss`, because
# that is the only source that reliably gives you the owning process name. If
# `ss` is absent (minimal containers, distroless images, locked-down appliances)
# it falls back to parsing /proc/net/{tcp,tcp6,udp,udp6} directly in pure Ruby
# with no external binaries at all.
#
# Exit codes are designed for cron / Nagios / systemd OnFailure use:
#   0  clean
#   1  drift found (UNEXPECTED / EXPOSED / MISSING)
#   2  the audit itself failed to run
#
# Usage:
#   ruby port_audit.rb --baseline baseline.yml
#   ruby port_audit.rb --baseline baseline.yml --json
#   ruby port_audit.rb --discover > baseline.yml   # bootstrap from a known-good host
#
# Ruby >= 2.7, stdlib only.

require 'yaml'
require 'json'
require 'optparse'
require 'set'

module PortAudit
  VERSION = '1.0.0'

  # ---------------------------------------------------------------------------
  # A single listening socket, normalised so both backends produce the same shape.
  # ---------------------------------------------------------------------------
  Listener = Struct.new(:proto, :addr, :port, :process, :pid, keyword_init: true) do
    # "Scope" is the security-relevant question: who can reach this socket?
    #   :loopback  127.0.0.0/8 or ::1        -- local processes only
    #   :any       0.0.0.0 or ::             -- every interface, incl. the internet
    #   :specific  a particular NIC address  -- one network only
    def scope
      case addr
      when '127.0.0.1', '::1' then :loopback
      when '0.0.0.0', '::', '*' then :any
      else
        addr.start_with?('127.') ? :loopback : :specific
      end
    end

    def key
      "#{proto}/#{port}"
    end

    def to_s
      "#{proto}/#{port} on #{addr} (#{process || 'unknown'})"
    end
  end

  # ---------------------------------------------------------------------------
  # Backend 1: iproute2 `ss`. Gives us the process name, which /proc/net cannot
  # without walking every /proc/*/fd symlink as root.
  # ---------------------------------------------------------------------------
  class SsBackend
    # -t tcp, -u udp, -l listening only, -p show process, -n numeric (no DNS),
    # -H suppress the header row so we do not have to skip it.
    COMMAND = 'ss -tulpnH 2>/dev/null'

    def self.available?
      system('command -v ss > /dev/null 2>&1')
    end

    def name = 'ss'

    def listeners
      out = `#{COMMAND}`
      return [] unless $?.success?

      out.each_line.filter_map { |line| parse_line(line) }
    end

    private

    # A typical line looks like:
    #   tcp   LISTEN 0  4096  127.0.0.1:5432  0.0.0.0:*  users:(("postgres",pid=812,fd=5))
    # UDP lines say UNCONN instead of LISTEN, which is normal for a bound UDP socket.
    def parse_line(line)
      f = line.split
      return nil if f.size < 5

      proto = f[0]
      state = f[1]
      return nil unless %w[LISTEN UNCONN].include?(state)

      local = f[4]
      addr, port = split_endpoint(local)
      return nil if port.nil?

      process, pid = parse_users(line)

      Listener.new(proto: proto.sub(/\d$/, ''), addr: addr, port: port.to_i,
                   process: process, pid: pid)
    end

    # IPv6 endpoints are written [::1]:8080, IPv4 as 127.0.0.1:8080, and a
    # wildcard v6 bind shows up as *:8080. Split on the LAST colon so the v6
    # address itself survives intact.
    def split_endpoint(endpoint)
      idx = endpoint.rindex(':')
      return [nil, nil] unless idx

      addr = endpoint[0...idx].delete('[]')
      port = endpoint[(idx + 1)..]
      addr = '::' if addr == '*'
      [addr, port]
    end

    def parse_users(line)
      m = line.match(/users:\(\("([^"]+)",pid=(\d+)/)
      m ? [m[1], m[2].to_i] : [nil, nil]
    end
  end

  # ---------------------------------------------------------------------------
  # Backend 2: pure-Ruby /proc/net parsing. No shelling out, works in a
  # scratch container. Addresses are little-endian hex, which is the only
  # genuinely fiddly part.
  # ---------------------------------------------------------------------------
  class ProcNetBackend
    # st == 0A is TCP_LISTEN. UDP sockets have no listen state; a bound UDP
    # socket sits in st 07 (TCP_CLOSE reused as "unconnected").
    TCP_LISTEN = '0A'
    UDP_UNCONN = '07'

    SOURCES = {
      '/proc/net/tcp'  => %w[tcp  4],
      '/proc/net/tcp6' => %w[tcp6 6],
      '/proc/net/udp'  => %w[udp  4],
      '/proc/net/udp6' => %w[udp6 6]
    }.freeze

    def self.available? = File.readable?('/proc/net/tcp')

    def name = '/proc/net'

    def listeners
      SOURCES.flat_map do |path, (proto, family)|
        next [] unless File.readable?(path)

        wanted = proto.start_with?('tcp') ? TCP_LISTEN : UDP_UNCONN
        parse_file(path, proto, family.to_i, wanted)
      end
    end

    private

    def parse_file(path, proto, family, wanted_state)
      File.readlines(path).drop(1).filter_map do |line|
        f = line.split
        next nil if f.size < 4
        next nil unless f[3].upcase == wanted_state

        addr_hex, port_hex = f[1].split(':')
        addr = family == 4 ? decode_v4(addr_hex) : decode_v6(addr_hex)
        next nil if addr.nil?

        Listener.new(proto: proto.sub(/6$/, ''), addr: addr,
                     port: port_hex.to_i(16), process: nil, pid: nil)
      end
    end

    # /proc/net/tcp writes IPv4 as a single little-endian 32-bit hex word, so
    # 0100007F is 127.0.0.1 -- read the byte pairs back to front.
    def decode_v4(hex)
      return nil unless hex&.length == 8

      hex.scan(/../).reverse.map { |b| b.to_i(16) }.join('.')
    end

    # IPv6 is four little-endian 32-bit words. Reverse the bytes inside each
    # word, then join, then compress the longest run of zero groups.
    def decode_v6(hex)
      return nil unless hex&.length == 32

      bytes = hex.scan(/.{8}/).flat_map { |word| word.scan(/../).reverse }
      groups = bytes.each_slice(2).map { |hi, lo| (hi + lo).sub(/\A0+(?=.)/, '') }
      compress_v6(groups)
    end

    def compress_v6(groups)
      joined = groups.join(':')
      return '::' if groups.all? { |g| g == '0' }
      return '::1' if groups[0..6].all? { |g| g == '0' } && groups[7] == '1'

      # Collapse the longest run of >=2 zero groups into "::" per RFC 5952.
      best = joined.scan(/(?:\A|:)0(?::0)+(?=:|\z)/).max_by(&:length)
      best ? joined.sub(best, '::').sub(/:::+/, '::') : joined
    end
  end

  # ---------------------------------------------------------------------------
  # The baseline: a declarative description of what SHOULD be listening.
  # ---------------------------------------------------------------------------
  #   allowed:
  #     - port: 22
  #       proto: tcp
  #       scope: any          # any | loopback | specific
  #       process: sshd       # optional; warns on mismatch
  #       required: true      # if absent from the host, report MISSING
  #       note: "fleet SSH"
  #   ignore_ports: [0]       # ports never reported (e.g. ephemeral test rigs)
  class Baseline
    Rule = Struct.new(:port, :proto, :scope, :process, :required, :note,
                      keyword_init: true)

    attr_reader :rules, :ignored

    def initialize(data)
      @ignored = Array(data['ignore_ports']).map(&:to_i).to_set
      @rules = Array(data['allowed']).map do |r|
        Rule.new(
          port: r['port'].to_i,
          proto: (r['proto'] || 'tcp').downcase,
          scope: (r['scope'] || 'any').downcase.to_sym,
          process: r['process'],
          required: r.fetch('required', false),
          note: r['note']
        )
      end
    end

    def self.load(path)
      raise ArgumentError, "baseline not found: #{path}" unless File.exist?(path)

      new(YAML.safe_load(File.read(path)) || {})
    end

    def rule_for(listener)
      @rules.find { |r| r.port == listener.port && r.proto == listener.proto }
    end

    def ignored?(listener) = @ignored.include?(listener.port)
  end

  # ---------------------------------------------------------------------------
  # The comparison engine.
  # ---------------------------------------------------------------------------
  Finding = Struct.new(:status, :severity, :proto, :port, :addr, :process,
                       :detail, keyword_init: true)

  class Auditor
    SEVERITY = { 'UNEXPECTED' => 'high', 'EXPOSED' => 'high',
                 'MISSING' => 'medium', 'DRIFT' => 'low', 'OK' => 'info' }.freeze

    def initialize(baseline) = @baseline = baseline

    def run(listeners)
      findings = listeners.reject { |l| @baseline.ignored?(l) }
                          .map { |l| classify(l) }
      findings + missing_findings(listeners)
    end

    private

    def classify(listener)
      rule = @baseline.rule_for(listener)
      return finding('UNEXPECTED', listener,
                     'no baseline rule authorises this listener') if rule.nil?

      if rule.scope == :loopback && listener.scope != :loopback
        return finding('EXPOSED', listener,
                       "baseline says loopback-only, bound to #{listener.addr}")
      end

      if rule.process && listener.process && rule.process != listener.process
        return finding('DRIFT', listener,
                       "expected process #{rule.process}, found #{listener.process}")
      end

      finding('OK', listener, rule.note || 'matches baseline')
    end

    # A required listener that never showed up in the live scan.
    def missing_findings(listeners)
      live = listeners.map(&:key).to_set
      @baseline.rules.select(&:required).reject { |r| live.include?("#{r.proto}/#{r.port}") }
               .map do |r|
        Finding.new(status: 'MISSING', severity: SEVERITY['MISSING'],
                    proto: r.proto, port: r.port, addr: '-', process: r.process,
                    detail: 'required listener is not running')
      end
    end

    def finding(status, listener, detail)
      Finding.new(status: status, severity: SEVERITY[status], proto: listener.proto,
                  port: listener.port, addr: listener.addr,
                  process: listener.process, detail: detail)
    end
  end

  # ---------------------------------------------------------------------------
  # Output
  # ---------------------------------------------------------------------------
  class Report
    MARK = { 'OK' => '[ OK ]', 'UNEXPECTED' => '[FAIL]', 'EXPOSED' => '[FAIL]',
             'MISSING' => '[WARN]', 'DRIFT' => '[WARN]' }.freeze

    def initialize(findings, source) = (@findings = findings; @source = source)

    def text
      lines = []
      lines << '=' * 74
      lines << "  LISTENING PORT AUDIT   source=#{@source}   #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
      lines << '=' * 74
      lines << format('  %-7s %-6s %-6s %-22s %s', 'STATUS', 'PROTO', 'PORT', 'ADDRESS', 'PROCESS')
      lines << '-' * 74

      sorted.each do |f|
        lines << format('  %-7s %-6s %-6s %-22s %s', MARK[f.status], f.proto,
                        f.port, f.addr.to_s[0, 22], f.process || '-')
        lines << "          -> #{f.detail}" unless f.status == 'OK'
      end

      lines << '-' * 74
      lines << "  #{summary_line}"
      lines << '=' * 74
      lines.join("\n")
    end

    def json
      JSON.pretty_generate(
        generated_at: Time.now.utc.iso8601_safe,
        source: @source,
        summary: counts,
        findings: @findings.map(&:to_h)
      )
    end

    def counts
      @findings.group_by(&:status).transform_values(&:size)
    end

    # Anything that is not OK is drift the operator must look at.
    def drift? = @findings.any? { |f| f.status != 'OK' }

    private

    ORDER = %w[UNEXPECTED EXPOSED MISSING DRIFT OK].freeze

    def sorted
      @findings.sort_by { |f| [ORDER.index(f.status) || 9, f.port] }
    end

    def summary_line
      c = counts
      "#{@findings.size} listeners checked | " +
        ORDER.map { |s| "#{s.downcase}=#{c.fetch(s, 0)}" }.join(' ')
    end
  end

  # Small shim so the script works without requiring 'time' on old rubies.
  module TimeShim
    def iso8601_safe = strftime('%Y-%m-%dT%H:%M:%SZ')
  end

  # ---------------------------------------------------------------------------
  # Discovery mode: dump the current state as a baseline you can commit to git.
  # ---------------------------------------------------------------------------
  def self.discover(listeners)
    seen = {}
    listeners.each do |l|
      key = "#{l.proto}/#{l.port}"
      # Prefer the widest scope we saw for a given port, since that is the
      # one that actually determines exposure.
      next if seen[key] && seen[key].scope == :any

      seen[key] = l
    end

    allowed = seen.values.sort_by { |l| [l.proto, l.port] }.map do |l|
      { 'port' => l.port, 'proto' => l.proto, 'scope' => l.scope.to_s,
        'process' => l.process, 'required' => false,
        'note' => 'discovered automatically -- review me' }.compact
    end

    { 'ignore_ports' => [], 'allowed' => allowed }.to_yaml
  end

  def self.backend
    if SsBackend.available?
      SsBackend.new
    elsif ProcNetBackend.available?
      ProcNetBackend.new
    else
      raise 'no usable socket source: neither ss nor /proc/net is available'
    end
  end
end

Time.include(PortAudit::TimeShim)

# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  opts = { baseline: 'baseline.yml', format: :text }

  OptionParser.new do |o|
    o.banner = 'Usage: port_audit.rb [options]'
    o.on('-b', '--baseline PATH', 'YAML baseline file')  { |v| opts[:baseline] = v }
    o.on('-j', '--json', 'emit JSON instead of a table') { opts[:format] = :json }
    o.on('-d', '--discover', 'print a baseline from current state') { opts[:discover] = true }
    o.on('--force-proc', 'skip ss, use the /proc/net parser')       { opts[:force_proc] = true }
    o.on('-v', '--version') { puts "port_audit #{PortAudit::VERSION}"; exit 0 }
    o.on('-h', '--help')    { puts o; exit 0 }
  end.parse!

  begin
    backend = opts[:force_proc] ? PortAudit::ProcNetBackend.new : PortAudit.backend
    listeners = backend.listeners

    if opts[:discover]
      puts PortAudit.discover(listeners)
      exit 0
    end

    baseline = PortAudit::Baseline.load(opts[:baseline])
    findings = PortAudit::Auditor.new(baseline).run(listeners)
    report   = PortAudit::Report.new(findings, backend.name)

    puts opts[:format] == :json ? report.json : report.text
    exit(report.drift? ? 1 : 0)
  rescue StandardError => e
    warn "port_audit: #{e.class}: #{e.message}"
    exit 2
  end
end

Two socket backends. SsBackend shells out to ss -tulpnH because it is the only source that gives you the owning process name without walking every /proc/*/fd symlink as root. ProcNetBackend parses /proc/net/{tcp,tcp6,udp,udp6} in pure Ruby, so the audit still runs in a scratch container where iproute2 was never installed.

Little-endian hex. /proc/net/tcp writes an IPv4 address as one little-endian 32-bit word, so 0100007F is 127.0.0.1 — you read the byte pairs back to front. IPv6 is four such words, reversed within each word, then compressed per RFC 5952.

Scope, not address. The security question is not the address string but who can reach the socket. Listener#scope collapses it to loopback, any, or specific — and the EXPOSED finding is exactly the case where the baseline said loopback and the live bind is anything else. That single rule is the reason to run this at all.

$ ruby port_audit.rb

$ ruby port_audit.rb --baseline baseline.yml
==========================================================================
  LISTENING PORT AUDIT   source=ss   2026-08-18 14:41:05
==========================================================================
  STATUS  PROTO  PORT   ADDRESS                PROCESS
--------------------------------------------------------------------------
  [FAIL]  tcp    8080   0.0.0.0                ruby
          -> no baseline rule authorises this listener
  [FAIL]  tcp    6379   0.0.0.0                ruby
          -> baseline says loopback-only, bound to 0.0.0.0
  [WARN]  tcp    22     -                      sshd
          -> required listener is not running
  [ OK ]  tcp    3000   0.0.0.0                ruby
  [ OK ]  tcp    5432   127.0.0.1              ruby
  [ OK ]  tcp    9100   0.0.0.0                ruby
--------------------------------------------------------------------------
  6 listeners checked | unexpected=1 exposed=1 missing=1 drift=0 ok=3
==========================================================================
$ echo $?   # 1 = drift found
1

$ # same host, dependency-free backend (no ss binary needed)
$ ruby port_audit.rb --baseline baseline.yml --force-proc | head -12
==========================================================================
  LISTENING PORT AUDIT   source=/proc/net   2026-08-18 14:41:05
==========================================================================
  STATUS  PROTO  PORT   ADDRESS                PROCESS
--------------------------------------------------------------------------
  [FAIL]  tcp    8080   0.0.0.0                -
          -> no baseline rule authorises this listener
  [FAIL]  tcp    6379   0.0.0.0                -
          -> baseline says loopback-only, bound to 0.0.0.0
  [WARN]  tcp    22     -                      sshd
          -> required listener is not running
  [ OK ]  tcp    3000   0.0.0.0                -
Get the code

Full script, baseline files and README on GitHub: ruby-devops-toolkit/listening-port-audit

prerequisites

What you need

requirements
  • Ruby ≥ 2.7 — uses filter_map and endless methods. Tested on 3.0.2.
  • No gems. Stdlib only: yaml, json, optparse, set.
  • Linux, with either iproute2 (for ss) or a readable /proc/net. The script picks whichever it finds.
  • Unprivileged is fine — but ss only reveals the owning process for sockets your uid owns, so run it under sudo if you want process names in the report.
Flow from the kernel socket table through the baseline comparison to four verdict classes

Two interchangeable socket backends feed one comparison engine, which emits four verdict classes.
the script

port_audit.rb

The whole thing is one file with no dependencies. Drop it wherever you keep operational scripts and point it at a baseline.

port_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# port_audit.rb -- Listening-port baseline auditor for Linux
#
# Enumerates every TCP/UDP socket the host is listening on, compares the live
# picture against a declarative YAML baseline, and reports four classes of
# finding:
#
#   OK          listener matches the baseline (right port, right proto, right scope)
#   UNEXPECTED  something is listening that the baseline never authorised
#   EXPOSED     an authorised service is bound world-wide when the baseline
#               said it should be loopback-only
#   MISSING     the baseline expects a listener that is not currently up
#
# The socket enumeration has two backends. It prefers iproute2's `ss`, because
# that is the only source that reliably gives you the owning process name. If
# `ss` is absent (minimal containers, distroless images, locked-down appliances)
# it falls back to parsing /proc/net/{tcp,tcp6,udp,udp6} directly in pure Ruby
# with no external binaries at all.
#
# Exit codes are designed for cron / Nagios / systemd OnFailure use:
#   0  clean
#   1  drift found (UNEXPECTED / EXPOSED / MISSING)
#   2  the audit itself failed to run
#
# Usage:
#   ruby port_audit.rb --baseline baseline.yml
#   ruby port_audit.rb --baseline baseline.yml --json
#   ruby port_audit.rb --discover > baseline.yml   # bootstrap from a known-good host
#
# Ruby >= 2.7, stdlib only.

require 'yaml'
require 'json'
require 'optparse'
require 'set'

module PortAudit
  VERSION = '1.0.0'

  # ---------------------------------------------------------------------------
  # A single listening socket, normalised so both backends produce the same shape.
  # ---------------------------------------------------------------------------
  Listener = Struct.new(:proto, :addr, :port, :process, :pid, keyword_init: true) do
    # "Scope" is the security-relevant question: who can reach this socket?
    #   :loopback  127.0.0.0/8 or ::1        -- local processes only
    #   :any       0.0.0.0 or ::             -- every interface, incl. the internet
    #   :specific  a particular NIC address  -- one network only
    def scope
      case addr
      when '127.0.0.1', '::1' then :loopback
      when '0.0.0.0', '::', '*' then :any
      else
        addr.start_with?('127.') ? :loopback : :specific
      end
    end

    def key
      "#{proto}/#{port}"
    end

    def to_s
      "#{proto}/#{port} on #{addr} (#{process || 'unknown'})"
    end
  end

  # ---------------------------------------------------------------------------
  # Backend 1: iproute2 `ss`. Gives us the process name, which /proc/net cannot
  # without walking every /proc/*/fd symlink as root.
  # ---------------------------------------------------------------------------
  class SsBackend
    # -t tcp, -u udp, -l listening only, -p show process, -n numeric (no DNS),
    # -H suppress the header row so we do not have to skip it.
    COMMAND = 'ss -tulpnH 2>/dev/null'

    def self.available?
      system('command -v ss > /dev/null 2>&1')
    end

    def name = 'ss'

    def listeners
      out = `#{COMMAND}`
      return [] unless $?.success?

      out.each_line.filter_map { |line| parse_line(line) }
    end

    private

    # A typical line looks like:
    #   tcp   LISTEN 0  4096  127.0.0.1:5432  0.0.0.0:*  users:(("postgres",pid=812,fd=5))
    # UDP lines say UNCONN instead of LISTEN, which is normal for a bound UDP socket.
    def parse_line(line)
      f = line.split
      return nil if f.size < 5

      proto = f[0]
      state = f[1]
      return nil unless %w[LISTEN UNCONN].include?(state)

      local = f[4]
      addr, port = split_endpoint(local)
      return nil if port.nil?

      process, pid = parse_users(line)

      Listener.new(proto: proto.sub(/\d$/, ''), addr: addr, port: port.to_i,
                   process: process, pid: pid)
    end

    # IPv6 endpoints are written [::1]:8080, IPv4 as 127.0.0.1:8080, and a
    # wildcard v6 bind shows up as *:8080. Split on the LAST colon so the v6
    # address itself survives intact.
    def split_endpoint(endpoint)
      idx = endpoint.rindex(':')
      return [nil, nil] unless idx

      addr = endpoint[0...idx].delete('[]')
      port = endpoint[(idx + 1)..]
      addr = '::' if addr == '*'
      [addr, port]
    end

    def parse_users(line)
      m = line.match(/users:\(\("([^"]+)",pid=(\d+)/)
      m ? [m[1], m[2].to_i] : [nil, nil]
    end
  end

  # ---------------------------------------------------------------------------
  # Backend 2: pure-Ruby /proc/net parsing. No shelling out, works in a
  # scratch container. Addresses are little-endian hex, which is the only
  # genuinely fiddly part.
  # ---------------------------------------------------------------------------
  class ProcNetBackend
    # st == 0A is TCP_LISTEN. UDP sockets have no listen state; a bound UDP
    # socket sits in st 07 (TCP_CLOSE reused as "unconnected").
    TCP_LISTEN = '0A'
    UDP_UNCONN = '07'

    SOURCES = {
      '/proc/net/tcp'  => %w[tcp  4],
      '/proc/net/tcp6' => %w[tcp6 6],
      '/proc/net/udp'  => %w[udp  4],
      '/proc/net/udp6' => %w[udp6 6]
    }.freeze

    def self.available? = File.readable?('/proc/net/tcp')

    def name = '/proc/net'

    def listeners
      SOURCES.flat_map do |path, (proto, family)|
        next [] unless File.readable?(path)

        wanted = proto.start_with?('tcp') ? TCP_LISTEN : UDP_UNCONN
        parse_file(path, proto, family.to_i, wanted)
      end
    end

    private

    def parse_file(path, proto, family, wanted_state)
      File.readlines(path).drop(1).filter_map do |line|
        f = line.split
        next nil if f.size < 4
        next nil unless f[3].upcase == wanted_state

        addr_hex, port_hex = f[1].split(':')
        addr = family == 4 ? decode_v4(addr_hex) : decode_v6(addr_hex)
        next nil if addr.nil?

        Listener.new(proto: proto.sub(/6$/, ''), addr: addr,
                     port: port_hex.to_i(16), process: nil, pid: nil)
      end
    end

    # /proc/net/tcp writes IPv4 as a single little-endian 32-bit hex word, so
    # 0100007F is 127.0.0.1 -- read the byte pairs back to front.
    def decode_v4(hex)
      return nil unless hex&.length == 8

      hex.scan(/../).reverse.map { |b| b.to_i(16) }.join('.')
    end

    # IPv6 is four little-endian 32-bit words. Reverse the bytes inside each
    # word, then join, then compress the longest run of zero groups.
    def decode_v6(hex)
      return nil unless hex&.length == 32

      bytes = hex.scan(/.{8}/).flat_map { |word| word.scan(/../).reverse }
      groups = bytes.each_slice(2).map { |hi, lo| (hi + lo).sub(/\A0+(?=.)/, '') }
      compress_v6(groups)
    end

    def compress_v6(groups)
      joined = groups.join(':')
      return '::' if groups.all? { |g| g == '0' }
      return '::1' if groups[0..6].all? { |g| g == '0' } && groups[7] == '1'

      # Collapse the longest run of >=2 zero groups into "::" per RFC 5952.
      best = joined.scan(/(?:\A|:)0(?::0)+(?=:|\z)/).max_by(&:length)
      best ? joined.sub(best, '::').sub(/:::+/, '::') : joined
    end
  end

  # ---------------------------------------------------------------------------
  # The baseline: a declarative description of what SHOULD be listening.
  # ---------------------------------------------------------------------------
  #   allowed:
  #     - port: 22
  #       proto: tcp
  #       scope: any          # any | loopback | specific
  #       process: sshd       # optional; warns on mismatch
  #       required: true      # if absent from the host, report MISSING
  #       note: "fleet SSH"
  #   ignore_ports: [0]       # ports never reported (e.g. ephemeral test rigs)
  class Baseline
    Rule = Struct.new(:port, :proto, :scope, :process, :required, :note,
                      keyword_init: true)

    attr_reader :rules, :ignored

    def initialize(data)
      @ignored = Array(data['ignore_ports']).map(&:to_i).to_set
      @rules = Array(data['allowed']).map do |r|
        Rule.new(
          port: r['port'].to_i,
          proto: (r['proto'] || 'tcp').downcase,
          scope: (r['scope'] || 'any').downcase.to_sym,
          process: r['process'],
          required: r.fetch('required', false),
          note: r['note']
        )
      end
    end

    def self.load(path)
      raise ArgumentError, "baseline not found: #{path}" unless File.exist?(path)

      new(YAML.safe_load(File.read(path)) || {})
    end

    def rule_for(listener)
      @rules.find { |r| r.port == listener.port && r.proto == listener.proto }
    end

    def ignored?(listener) = @ignored.include?(listener.port)
  end

  # ---------------------------------------------------------------------------
  # The comparison engine.
  # ---------------------------------------------------------------------------
  Finding = Struct.new(:status, :severity, :proto, :port, :addr, :process,
                       :detail, keyword_init: true)

  class Auditor
    SEVERITY = { 'UNEXPECTED' => 'high', 'EXPOSED' => 'high',
                 'MISSING' => 'medium', 'DRIFT' => 'low', 'OK' => 'info' }.freeze

    def initialize(baseline) = @baseline = baseline

    def run(listeners)
      findings = listeners.reject { |l| @baseline.ignored?(l) }
                          .map { |l| classify(l) }
      findings + missing_findings(listeners)
    end

    private

    def classify(listener)
      rule = @baseline.rule_for(listener)
      return finding('UNEXPECTED', listener,
                     'no baseline rule authorises this listener') if rule.nil?

      if rule.scope == :loopback && listener.scope != :loopback
        return finding('EXPOSED', listener,
                       "baseline says loopback-only, bound to #{listener.addr}")
      end

      if rule.process && listener.process && rule.process != listener.process
        return finding('DRIFT', listener,
                       "expected process #{rule.process}, found #{listener.process}")
      end

      finding('OK', listener, rule.note || 'matches baseline')
    end

    # A required listener that never showed up in the live scan.
    def missing_findings(listeners)
      live = listeners.map(&:key).to_set
      @baseline.rules.select(&:required).reject { |r| live.include?("#{r.proto}/#{r.port}") }
               .map do |r|
        Finding.new(status: 'MISSING', severity: SEVERITY['MISSING'],
                    proto: r.proto, port: r.port, addr: '-', process: r.process,
                    detail: 'required listener is not running')
      end
    end

    def finding(status, listener, detail)
      Finding.new(status: status, severity: SEVERITY[status], proto: listener.proto,
                  port: listener.port, addr: listener.addr,
                  process: listener.process, detail: detail)
    end
  end

  # ---------------------------------------------------------------------------
  # Output
  # ---------------------------------------------------------------------------
  class Report
    MARK = { 'OK' => '[ OK ]', 'UNEXPECTED' => '[FAIL]', 'EXPOSED' => '[FAIL]',
             'MISSING' => '[WARN]', 'DRIFT' => '[WARN]' }.freeze

    def initialize(findings, source) = (@findings = findings; @source = source)

    def text
      lines = []
      lines << '=' * 74
      lines << "  LISTENING PORT AUDIT   source=#{@source}   #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
      lines << '=' * 74
      lines << format('  %-7s %-6s %-6s %-22s %s', 'STATUS', 'PROTO', 'PORT', 'ADDRESS', 'PROCESS')
      lines << '-' * 74

      sorted.each do |f|
        lines << format('  %-7s %-6s %-6s %-22s %s', MARK[f.status], f.proto,
                        f.port, f.addr.to_s[0, 22], f.process || '-')
        lines << "          -> #{f.detail}" unless f.status == 'OK'
      end

      lines << '-' * 74
      lines << "  #{summary_line}"
      lines << '=' * 74
      lines.join("\n")
    end

    def json
      JSON.pretty_generate(
        generated_at: Time.now.utc.iso8601_safe,
        source: @source,
        summary: counts,
        findings: @findings.map(&:to_h)
      )
    end

    def counts
      @findings.group_by(&:status).transform_values(&:size)
    end

    # Anything that is not OK is drift the operator must look at.
    def drift? = @findings.any? { |f| f.status != 'OK' }

    private

    ORDER = %w[UNEXPECTED EXPOSED MISSING DRIFT OK].freeze

    def sorted
      @findings.sort_by { |f| [ORDER.index(f.status) || 9, f.port] }
    end

    def summary_line
      c = counts
      "#{@findings.size} listeners checked | " +
        ORDER.map { |s| "#{s.downcase}=#{c.fetch(s, 0)}" }.join(' ')
    end
  end

  # Small shim so the script works without requiring 'time' on old rubies.
  module TimeShim
    def iso8601_safe = strftime('%Y-%m-%dT%H:%M:%SZ')
  end

  # ---------------------------------------------------------------------------
  # Discovery mode: dump the current state as a baseline you can commit to git.
  # ---------------------------------------------------------------------------
  def self.discover(listeners)
    seen = {}
    listeners.each do |l|
      key = "#{l.proto}/#{l.port}"
      # Prefer the widest scope we saw for a given port, since that is the
      # one that actually determines exposure.
      next if seen[key] && seen[key].scope == :any

      seen[key] = l
    end

    allowed = seen.values.sort_by { |l| [l.proto, l.port] }.map do |l|
      { 'port' => l.port, 'proto' => l.proto, 'scope' => l.scope.to_s,
        'process' => l.process, 'required' => false,
        'note' => 'discovered automatically -- review me' }.compact
    end

    { 'ignore_ports' => [], 'allowed' => allowed }.to_yaml
  end

  def self.backend
    if SsBackend.available?
      SsBackend.new
    elsif ProcNetBackend.available?
      ProcNetBackend.new
    else
      raise 'no usable socket source: neither ss nor /proc/net is available'
    end
  end
end

Time.include(PortAudit::TimeShim)

# -----------------------------------------------------------------------------
# CLI
# -----------------------------------------------------------------------------
if __FILE__ == $PROGRAM_NAME
  opts = { baseline: 'baseline.yml', format: :text }

  OptionParser.new do |o|
    o.banner = 'Usage: port_audit.rb [options]'
    o.on('-b', '--baseline PATH', 'YAML baseline file')  { |v| opts[:baseline] = v }
    o.on('-j', '--json', 'emit JSON instead of a table') { opts[:format] = :json }
    o.on('-d', '--discover', 'print a baseline from current state') { opts[:discover] = true }
    o.on('--force-proc', 'skip ss, use the /proc/net parser')       { opts[:force_proc] = true }
    o.on('-v', '--version') { puts "port_audit #{PortAudit::VERSION}"; exit 0 }
    o.on('-h', '--help')    { puts o; exit 0 }
  end.parse!

  begin
    backend = opts[:force_proc] ? PortAudit::ProcNetBackend.new : PortAudit.backend
    listeners = backend.listeners

    if opts[:discover]
      puts PortAudit.discover(listeners)
      exit 0
    end

    baseline = PortAudit::Baseline.load(opts[:baseline])
    findings = PortAudit::Auditor.new(baseline).run(listeners)
    report   = PortAudit::Report.new(findings, backend.name)

    puts opts[:format] == :json ? report.json : report.text
    exit(report.drift? ? 1 : 0)
  rescue StandardError => e
    warn "port_audit: #{e.class}: #{e.message}"
    exit 2
  end
end
walkthrough

How it works

Picking a socket source

There are two ways to find out what a Linux box is listening on, and they have different trade-offs. ss from iproute2 is the ergonomic one: -t for TCP, -u for UDP, -l for listening only, -p for the owning process, -n to skip DNS, and -H to suppress the header row so there is no line to skip while parsing.

The catch is that ss is a package. Minimal containers, distroless images and locked-down appliances frequently do not have it. So there is a second backend that reads /proc/net directly — which is exactly what ss itself does, just without the convenience.

Which one am I on?

The report header prints source=ss or source=/proc/net. If process names are all -, you are on the /proc backend, because that interface simply does not carry them.

Decoding /proc/net addresses

This is the only genuinely fiddly part of the script. Addresses in /proc/net/tcp are little-endian hex words, so a naive left-to-right read gives you nonsense:

port_audit.rb &mdash; IPv4 decoderuby
# 0100007F -> 127.0.0.1 : read the byte pairs back to front
def decode_v4(hex)
  return nil unless hex&.length == 8

  hex.scan(/../).reverse.map { |b| b.to_i(16) }.join('.')
end

IPv6 is four little-endian 32-bit words. You reverse the bytes inside each word, join into eight groups, then collapse the longest run of zero groups so ::1 prints as ::1 rather than as seven zero groups and a one.

The state column matters too. 0A is TCP_LISTEN. UDP has no listen state, so a bound UDP socket sits in 07 — which is TCP_CLOSE, reused to mean “unconnected”. Filter on the wrong constant and you get either nothing at all or every established connection on the box.

Splitting endpoints without eating IPv6

ss writes IPv6 endpoints as [::1]:8080 and a wildcard v6 bind as *:8080. Split on the last colon, not the first, or the address itself disappears:

port_audit.rb &mdash; endpoint splitruby
idx  = endpoint.rindex(':')
addr = endpoint[0...idx].delete('[]')
port = endpoint[(idx + 1)..]
addr = '::' if addr == '*'

Scope is the whole point

A listener’s address string is not the interesting fact about it. Who can reach it is. Listener#scope collapses the address into three cases — :loopback, :any, :specific — and the baseline declares which one each service is allowed to have.

EXPOSED is the finding that pays for the script

A service that is allowed to exist, bound wider than the baseline permits. Redis on 0.0.0.0 with no auth is up, healthy, and monitored green.

Four verdicts, one exit code

what the auditor reports
  • UNEXPECTED — something is listening that no baseline rule authorises. New service, forgotten debug port, or something you should care about.
  • EXPOSED — an authorised service bound wider than its rule allows. The Redis case.
  • MISSING — a rule marked required: true whose listener is not up. Catches a service that failed to start after a reboot.
  • DRIFT — right port, wrong process name. Often benign, occasionally not.

Exit codes are chosen for automation: 0 clean, 1 drift found, 2 the audit itself failed. That distinction matters — a cron job that cannot tell “the host is fine” from “the check crashed” reports fine right up until it does not.

example output

Running it against a drifted host

Five listeners staged on a test box, one of them deliberately wrong, plus a required SSH rule with nothing behind it:

$ ruby port_audit.rb –baseline baseline.ymltext
$ ruby port_audit.rb --baseline baseline.yml
==========================================================================
  LISTENING PORT AUDIT   source=ss   2026-08-18 14:41:05
==========================================================================
  STATUS  PROTO  PORT   ADDRESS                PROCESS
--------------------------------------------------------------------------
  [FAIL]  tcp    8080   0.0.0.0                ruby
          -> no baseline rule authorises this listener
  [FAIL]  tcp    6379   0.0.0.0                ruby
          -> baseline says loopback-only, bound to 0.0.0.0
  [WARN]  tcp    22     -                      sshd
          -> required listener is not running
  [ OK ]  tcp    3000   0.0.0.0                ruby
  [ OK ]  tcp    5432   127.0.0.1              ruby
  [ OK ]  tcp    9100   0.0.0.0                ruby
--------------------------------------------------------------------------
  6 listeners checked | unexpected=1 exposed=1 missing=1 drift=0 ok=3
==========================================================================
$ echo $?   # 1 = drift found
1

$ # same host, dependency-free backend (no ss binary needed)
$ ruby port_audit.rb --baseline baseline.yml --force-proc | head -12
==========================================================================
  LISTENING PORT AUDIT   source=/proc/net   2026-08-18 14:41:05
==========================================================================
  STATUS  PROTO  PORT   ADDRESS                PROCESS
--------------------------------------------------------------------------
  [FAIL]  tcp    8080   0.0.0.0                -
          -> no baseline rule authorises this listener
  [FAIL]  tcp    6379   0.0.0.0                -
          -> baseline says loopback-only, bound to 0.0.0.0
  [WARN]  tcp    22     -                      sshd
          -> required listener is not running
  [ OK ]  tcp    3000   0.0.0.0                -
6
listeners
2
high findings
0
gems
troubleshooting

When it does not behave

common problems
  • Every process shows -. Either you are on the /proc backend, or you are unprivileged. ss only reveals the process for sockets your uid owns.
  • A running service reports MISSING. Check the protocol. A proto: tcp rule is not satisfied by a UDP listener. Run --discover on the live host to see what the script actually sees.
  • Everything is UNEXPECTED after a distro upgrade. Systemd socket activation moves listeners between units without changing ports. Re-run --discover, diff against the committed baseline, accept what you understand.
  • ss exists but returns nothing in a container. A container in its own network namespace genuinely has no listeners from the host’s point of view. Run the audit inside the namespace, not beside it.
  • An address looks malformed. Both decoders return nil on an unexpected length rather than guessing, so a weird socket is skipped rather than misreported.
extending

Where to take it next

ideas
  • Fleet mode. Wrap it in net-ssh, run --json everywhere, merge into one report keyed by hostname.
  • Baseline inheritance. Split into common.yml plus a role file and deep-merge, so webserver inherits the base rules.
  • Binary fingerprinting. Extend the DRIFT rule to checksum /proc/<pid>/exe instead of trusting the process name.
  • Prometheus. Emit the counts through the textfile collector and alert on port_audit_unexpected > 0.
  • Unix sockets. ss -xlpn lists them; a fifth finding class for world-writable socket files is a natural addition.