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:
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 -
Full script, baseline files and README on GitHub: ruby-devops-toolkit/listening-port-audit
What you need
- Ruby ≥ 2.7 — uses
filter_mapand endless methods. Tested on 3.0.2. - No gems. Stdlib only:
yaml,json,optparse,set. - Linux, with either
iproute2(forss) or a readable/proc/net. The script picks whichever it finds. - Unprivileged is fine — but
ssonly reveals the owning process for sockets your uid owns, so run it undersudoif you want process names in the report.
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.
#!/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
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.
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:
# 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:
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.
Four verdicts, one exit code
- 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: truewhose 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.
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.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 -
When it does not behave
- Every process shows
-. Either you are on the /proc backend, or you are unprivileged.ssonly reveals the process for sockets your uid owns. - A running service reports MISSING. Check the protocol. A
proto: tcprule is not satisfied by a UDP listener. Run--discoveron 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. ssexists 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
nilon an unexpected length rather than guessing, so a weird socket is skipped rather than misreported.
Where to take it next
- Fleet mode. Wrap it in
net-ssh, run--jsoneverywhere, merge into one report keyed by hostname. - Baseline inheritance. Split into
common.ymlplus a role file and deep-merge, sowebserverinherits the base rules. - Binary fingerprinting. Extend the DRIFT rule to checksum
/proc/<pid>/exeinstead of trusting the process name. - Prometheus. Emit the counts through the textfile collector and alert on
port_audit_unexpected > 0. - Unix sockets.
ss -xlpnlists them; a fifth finding class for world-writable socket files is a natural addition.