A five-minute debugging session opens MySQL to the world and nobody remembers to close it. This script parses iptables-save output and flags exactly that kind of drift against a JSON security baseline — default policies gone permissive, ports opened wider than approved, expected ports gone missing.
Step through the build below:
Firewall rules drift, and nobody notices until an audit or an incident forces the question. Someone opens MySQL to the world for a five-minute debugging session and forgets to close it. A default policy silently gets flipped from DROP to ACCEPT during a “quick fix.” The Task Scheduler GUI equivalent for firewalls — iptables -L — shows you the current state, but not whether that state still matches what security signed off on.
This script parses iptables-save‘s plain-text output into chains, default policies, and rules, then diffs that against a JSON baseline: has a default policy gone from DROP to ACCEPT, is a port open wider than the baseline allows, has an expected port quietly disappeared. All stdlib, no root privileges of its own — only whatever produced the snapshot needed root.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# firewall_drift_audit.rb -- parse an iptables-save (or nft, via the same
# legacy-compatible text format) ruleset and flag drift against a JSON
# security baseline: chains whose default policy went from DROP to ACCEPT,
# ports opened wider than the baseline allows, and baseline ports that
# quietly disappeared.
#
# Usage:
# sudo iptables-save | ruby firewall_drift_audit.rb --baseline baseline.json
# ruby firewall_drift_audit.rb --input snapshot.txt --baseline baseline.json --json
#
# Exit status:
# 0 no CRIT findings
# 1 at least one CRIT finding
# 2 usage / input error
#
# Requires: Ruby 3.x, stdlib only (json, optparse). Reads iptables-save's
# plain-text output -- no `iptables` gem, no native extension, and no root
# privileges of its own (only whatever produced the snapshot needed root).
require 'json'
require 'optparse'
Rule = Struct.new(:chain, :protocol, :dport, :source, :jump, :raw, keyword_init: true)
Finding = Struct.new(:severity, :reason, keyword_init: true)
# ---------------------------------------------------------------------------
# Parses the *filter table block of an iptables-save snapshot into chain
# policies and individual rules. Deliberately tolerant: unrecognized lines
# (custom chains, other tables, comments) are ignored rather than raising,
# since a real fleet's rulesets are messier than any single fixture.
# ---------------------------------------------------------------------------
def parse_iptables_save(text)
policies = {}
rules = []
in_filter = false
text.each_line do |line|
line = line.strip
next if line.empty? || line.start_with?('#')
in_filter = true if line == '*filter'
in_filter = false if line == 'COMMIT' || (line.start_with?('*') && line != '*filter')
next unless in_filter
if line.start_with?(':')
# ":INPUT DROP [0:0]"
name, policy, = line[1..].split(/\s+/)
policies[name] = policy
elsif line.start_with?('-A ')
chain = line.split(/\s+/)[1]
proto = line[/-p\s+(\S+)/, 1]
dport = line[/--dport\s+(\d+)/, 1]&.to_i
source = line[/-s\s+(\S+)/, 1]
jump = line[/-j\s+(\S+)/, 1]
rules << Rule.new(chain: chain, protocol: proto, dport: dport, source: source, jump: jump, raw: line)
end
end
{ policies: policies, rules: rules }
end
# ---------------------------------------------------------------------------
# Pure classification against a baseline hash (already-parsed JSON). No
# file or process I/O in here -- makes it trivial to unit test with
# hand-built policies/rules instead of a live parse.
# ---------------------------------------------------------------------------
def classify(parsed, baseline)
findings = []
policies = parsed[:policies]
rules = parsed[:rules]
(baseline['default_policies'] || {}).each do |chain, expected|
actual = policies[chain]
next if actual.nil? # chain not present in this snapshot at all; not this script's concern here
next if actual == expected
if expected == 'DROP' && actual == 'ACCEPT'
findings << Finding.new(severity: :crit, reason: "chain #{chain} default policy is ACCEPT, baseline requires DROP")
else
findings << Finding.new(severity: :warn, reason: "chain #{chain} default policy is #{actual}, baseline expects #{expected}")
end
end
allowed = { 'tcp' => (baseline['allowed_tcp_ports'] || []), 'udp' => (baseline['allowed_udp_ports'] || []) }
open_ports = Hash.new { |h, k| h[k] = [] } # proto => [ports actually open]
rules.each do |r|
next unless r.jump == 'ACCEPT' && r.dport && %w[tcp udp].include?(r.protocol)
open_ports[r.protocol] << r.dport
next if allowed[r.protocol].include?(r.dport)
wide_open = r.source.nil? || r.source == '0.0.0.0/0'
if wide_open
findings << Finding.new(severity: :crit, reason: "#{r.protocol}/#{r.dport} accepts from anywhere but is not in the baseline allowlist (#{r.raw})")
else
findings << Finding.new(severity: :warn, reason: "#{r.protocol}/#{r.dport} is open to #{r.source} (not in baseline) -- verify this is intentional")
end
end
allowed.each do |proto, ports|
ports.each do |port|
next if open_ports[proto].include?(port)
findings << Finding.new(severity: :warn, reason: "baseline expects #{proto}/#{port} to be open, but no matching ACCEPT rule was found")
end
end
overall = if findings.any? { |f| f.severity == :crit }
:crit
elsif findings.any? { |f| f.severity == :warn }
:warn
else
:ok
end
{ severity: overall, findings: findings, open_ports: open_ports }
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
options = { baseline_path: nil, input_path: nil, json: false }
parser = OptionParser.new do |opts|
opts.banner = "Usage: firewall_drift_audit.rb --baseline baseline.json [--input snapshot.txt | < iptables-save output]"
opts.on('--baseline FILE', 'JSON security baseline (required)') { |v| options[:baseline_path] = v }
opts.on('--input FILE', 'Read an iptables-save snapshot from a file instead of stdin') { |v| options[:input_path] = v }
opts.on('--json', 'Emit a JSON report instead of text') { options[:json] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!(ARGV)
unless options[:baseline_path]
warn 'error: --baseline FILE is required'
warn parser
exit 2
end
begin
baseline = JSON.parse(File.read(options[:baseline_path]))
rescue StandardError => e
warn "error: could not read/parse baseline: #{e.class}: #{e.message}"
exit 2
end
raw_text =
if options[:input_path]
File.read(options[:input_path])
elsif !$stdin.tty?
$stdin.read
else
warn 'error: no input -- pass --input FILE or pipe `iptables-save` output on stdin (requires root on a live host)'
exit 2
end
parsed = parse_iptables_save(raw_text)
result = classify(parsed, baseline)
if options[:json]
puts JSON.pretty_generate(
severity: result[:severity],
open_ports: result[:open_ports],
findings: result[:findings].map { |f| { severity: f.severity, reason: f.reason } }
)
else
tag = { crit: '[CRIT]', warn: '[WARN]', ok: '[ ok ]' }[result[:severity]]
puts "#{tag} overall firewall drift status: #{result[:severity]}"
result[:findings].each do |f|
line_tag = { crit: '[CRIT]', warn: '[WARN]', info: '[info]' }[f.severity]
puts " #{line_tag} #{f.reason}"
end
puts '---'
puts "#{result[:findings].count { |f| f.severity == :crit }} CRIT, #{result[:findings].count { |f| f.severity == :warn }} WARN"
end
exit(result[:severity] == :crit ? 1 : 0)
The script is split into a tolerant text parser and a pure classifier, the same shape as every drift-detection tool in this series. parse_iptables_save walks the *filter table block line by line, picking out chain policies (:INPUT DROP [0:0]) and rules (-A INPUT -p tcp --dport 22 -j ACCEPT) with regexes, and silently ignores anything it doesn’t recognize — a real fleet’s rulesets have custom chains and modules this script was never asked to understand.
classify takes the parsed policies/rules plus the baseline hash and never touches a file or a process. That is what let this get tested against two hand-built iptables-save snapshots — one compliant, one deliberately drifted — entirely offline, with --input standing in for what would otherwise require root on a live host.
Severity follows exposure: a newly-opened port with no -s clause (open to 0.0.0.0/0) is CRIT, the same port opened to a specific subnet is only WARN — worth a human’s attention, but not the same blast radius as being open to the entire internet.
$ ruby firewall_drift_audit.rb --baseline baseline.json --input snapshot_compliant.txt
[ ok ] overall firewall drift status: ok
---
0 CRIT, 0 WARN
$ echo $?
0
$ ruby firewall_drift_audit.rb --baseline baseline.json --input snapshot_drifted.txt
[CRIT] overall firewall drift status: crit
[CRIT] chain INPUT default policy is ACCEPT, baseline requires DROP
[CRIT] tcp/3306 accepts from anywhere but is not in the baseline allowlist (-A INPUT -p tcp -m tcp --dport 3306 -j ACCEPT)
[WARN] tcp/8080 is open to 10.0.5.0/24 (not in baseline) -- verify this is intentional
[WARN] baseline expects tcp/443 to be open, but no matching ACCEPT rule was found
[WARN] baseline expects udp/53 to be open, but no matching ACCEPT rule was found
---
2 CRIT, 3 WARN
$ echo $?
1
$ ruby firewall_drift_audit.rb --baseline baseline.json --input snapshot_drifted.txt --json
{
"severity": "crit",
"open_ports": { "tcp": [22, 80, 3306, 8080], "udp": [] },
"findings": [
{ "severity": "crit", "reason": "chain INPUT default policy is ACCEPT, baseline requires DROP" },
{ "severity": "crit", "reason": "tcp/3306 accepts from anywhere but is not in the baseline allowlist (...)" },
{ "severity": "warn", "reason": "tcp/8080 is open to 10.0.5.0/24 (not in baseline) -- verify this is intentional" },
{ "severity": "warn", "reason": "baseline expects tcp/443 to be open, but no matching ACCEPT rule was found" },
{ "severity": "warn", "reason": "baseline expects udp/53 to be open, but no matching ACCEPT rule was found" }
]
}
# both runs above used hand-built iptables-save fixtures (a compliant snapshot and a
# deliberately drifted one -- default policy flipped to ACCEPT, MySQL opened to the world,
# an unlisted port opened to a specific subnet, and two baseline ports quietly missing)
# fed through --input, exercising the real parser and classifier end to end.
Full script + README on GitHub: ruby-devops-toolkit/firewall-drift-audit
iptables -L shows current state, not compliance
iptables -L or iptables-save will happily show you every rule on a box right now — what they won’t do is tell you whether that ruleset still matches what was approved. A default policy quietly flipped from DROP to ACCEPT during an emergency change, or a debug port opened “just for a minute” and never closed, both look completely normal in a rule listing. You only find out the hard way, in an audit or an incident.
This script treats a firewall ruleset the same way this series treats a Windows Registry or a Task Scheduler library: parse the live (or snapshotted) state into plain data, diff it against a declared-good baseline, and report drift with a severity a human or a CI gate can act on.
What you need before running this
- Ruby 3.x, stdlib only —
jsonandoptparse, nothing to install. - A JSON baseline file describing expected default policies and allowed TCP/UDP ports (see the example below) — this is the “declared good” state everything gets diffed against.
- An iptables-save snapshot to audit: either pipe
sudo iptables-savedirectly on a live Linux host (root is required foriptables-saveitself, not for this script), or pass--input snapshot.txtto analyze a file captured elsewhere — handy for reviewing a snapshot from a box you don’t have root on, or for CI.
firewall_drift_audit.rb
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# firewall_drift_audit.rb -- parse an iptables-save (or nft, via the same
# legacy-compatible text format) ruleset and flag drift against a JSON
# security baseline: chains whose default policy went from DROP to ACCEPT,
# ports opened wider than the baseline allows, and baseline ports that
# quietly disappeared.
#
# Usage:
# sudo iptables-save | ruby firewall_drift_audit.rb --baseline baseline.json
# ruby firewall_drift_audit.rb --input snapshot.txt --baseline baseline.json --json
#
# Exit status:
# 0 no CRIT findings
# 1 at least one CRIT finding
# 2 usage / input error
#
# Requires: Ruby 3.x, stdlib only (json, optparse). Reads iptables-save's
# plain-text output -- no `iptables` gem, no native extension, and no root
# privileges of its own (only whatever produced the snapshot needed root).
require 'json'
require 'optparse'
Rule = Struct.new(:chain, :protocol, :dport, :source, :jump, :raw, keyword_init: true)
Finding = Struct.new(:severity, :reason, keyword_init: true)
# ---------------------------------------------------------------------------
# Parses the *filter table block of an iptables-save snapshot into chain
# policies and individual rules. Deliberately tolerant: unrecognized lines
# (custom chains, other tables, comments) are ignored rather than raising,
# since a real fleet's rulesets are messier than any single fixture.
# ---------------------------------------------------------------------------
def parse_iptables_save(text)
policies = {}
rules = []
in_filter = false
text.each_line do |line|
line = line.strip
next if line.empty? || line.start_with?('#')
in_filter = true if line == '*filter'
in_filter = false if line == 'COMMIT' || (line.start_with?('*') && line != '*filter')
next unless in_filter
if line.start_with?(':')
# ":INPUT DROP [0:0]"
name, policy, = line[1..].split(/\s+/)
policies[name] = policy
elsif line.start_with?('-A ')
chain = line.split(/\s+/)[1]
proto = line[/-p\s+(\S+)/, 1]
dport = line[/--dport\s+(\d+)/, 1]&.to_i
source = line[/-s\s+(\S+)/, 1]
jump = line[/-j\s+(\S+)/, 1]
rules << Rule.new(chain: chain, protocol: proto, dport: dport, source: source, jump: jump, raw: line)
end
end
{ policies: policies, rules: rules }
end
# ---------------------------------------------------------------------------
# Pure classification against a baseline hash (already-parsed JSON). No
# file or process I/O in here -- makes it trivial to unit test with
# hand-built policies/rules instead of a live parse.
# ---------------------------------------------------------------------------
def classify(parsed, baseline)
findings = []
policies = parsed[:policies]
rules = parsed[:rules]
(baseline['default_policies'] || {}).each do |chain, expected|
actual = policies[chain]
next if actual.nil? # chain not present in this snapshot at all; not this script's concern here
next if actual == expected
if expected == 'DROP' && actual == 'ACCEPT'
findings << Finding.new(severity: :crit, reason: "chain #{chain} default policy is ACCEPT, baseline requires DROP")
else
findings << Finding.new(severity: :warn, reason: "chain #{chain} default policy is #{actual}, baseline expects #{expected}")
end
end
allowed = { 'tcp' => (baseline['allowed_tcp_ports'] || []), 'udp' => (baseline['allowed_udp_ports'] || []) }
open_ports = Hash.new { |h, k| h[k] = [] } # proto => [ports actually open]
rules.each do |r|
next unless r.jump == 'ACCEPT' && r.dport && %w[tcp udp].include?(r.protocol)
open_ports[r.protocol] << r.dport
next if allowed[r.protocol].include?(r.dport)
wide_open = r.source.nil? || r.source == '0.0.0.0/0'
if wide_open
findings << Finding.new(severity: :crit, reason: "#{r.protocol}/#{r.dport} accepts from anywhere but is not in the baseline allowlist (#{r.raw})")
else
findings << Finding.new(severity: :warn, reason: "#{r.protocol}/#{r.dport} is open to #{r.source} (not in baseline) -- verify this is intentional")
end
end
allowed.each do |proto, ports|
ports.each do |port|
next if open_ports[proto].include?(port)
findings << Finding.new(severity: :warn, reason: "baseline expects #{proto}/#{port} to be open, but no matching ACCEPT rule was found")
end
end
overall = if findings.any? { |f| f.severity == :crit }
:crit
elsif findings.any? { |f| f.severity == :warn }
:warn
else
:ok
end
{ severity: overall, findings: findings, open_ports: open_ports }
end
# ---------------------------------------------------------------------------
# CLI
# ---------------------------------------------------------------------------
options = { baseline_path: nil, input_path: nil, json: false }
parser = OptionParser.new do |opts|
opts.banner = "Usage: firewall_drift_audit.rb --baseline baseline.json [--input snapshot.txt | < iptables-save output]"
opts.on('--baseline FILE', 'JSON security baseline (required)') { |v| options[:baseline_path] = v }
opts.on('--input FILE', 'Read an iptables-save snapshot from a file instead of stdin') { |v| options[:input_path] = v }
opts.on('--json', 'Emit a JSON report instead of text') { options[:json] = true }
opts.on('-h', '--help', 'Show this help') { puts opts; exit 0 }
end
parser.parse!(ARGV)
unless options[:baseline_path]
warn 'error: --baseline FILE is required'
warn parser
exit 2
end
begin
baseline = JSON.parse(File.read(options[:baseline_path]))
rescue StandardError => e
warn "error: could not read/parse baseline: #{e.class}: #{e.message}"
exit 2
end
raw_text =
if options[:input_path]
File.read(options[:input_path])
elsif !$stdin.tty?
$stdin.read
else
warn 'error: no input -- pass --input FILE or pipe `iptables-save` output on stdin (requires root on a live host)'
exit 2
end
parsed = parse_iptables_save(raw_text)
result = classify(parsed, baseline)
if options[:json]
puts JSON.pretty_generate(
severity: result[:severity],
open_ports: result[:open_ports],
findings: result[:findings].map { |f| { severity: f.severity, reason: f.reason } }
)
else
tag = { crit: '[CRIT]', warn: '[WARN]', ok: '[ ok ]' }[result[:severity]]
puts "#{tag} overall firewall drift status: #{result[:severity]}"
result[:findings].each do |f|
line_tag = { crit: '[CRIT]', warn: '[WARN]', info: '[info]' }[f.severity]
puts " #{line_tag} #{f.reason}"
end
puts '---'
puts "#{result[:findings].count { |f| f.severity == :crit }} CRIT, #{result[:findings].count { |f| f.severity == :warn }} WARN"
end
exit(result[:severity] == :crit ? 1 : 0)
Example baseline.json
{
"default_policies": { "INPUT": "DROP", "FORWARD": "DROP", "OUTPUT": "ACCEPT" },
"allowed_tcp_ports": [22, 80, 443],
"allowed_udp_ports": [53]
}
How it actually works
A tolerant, single-pass text parser
parse_iptables_save only cares about the *filter table block, tracked with an in_filter flag that flips on at *filter and off at COMMIT or the start of another table. Chain-policy lines (:CHAIN POLICY [pkts:bytes]) and rule lines (-A CHAIN ...) are matched with small regexes for protocol, destination port, source, and jump target — everything else on a line is simply not extracted, not rejected, so custom modules and match extensions this script has never seen don’t blow up the parse.
Severity tracks exposure, not just presence
An ACCEPT rule for a port not in the baseline is only CRIT if it’s reachable from anywhere — no -s clause, or an explicit 0.0.0.0/0. The identical port opened to a specific -s 10.0.5.0/24 is WARN: worth a human confirming it’s intentional, but nowhere near the blast radius of the same misconfiguration exposed to the whole internet. Baseline ports that disappeared entirely — maybe a rule got deleted by accident — are also WARN, since a missing ACCEPT rule is a service-outage risk rather than a compliance one.
Why --input exists (and why it’s not a compromise)
iptables-save genuinely requires root — this script’s own logic does not, and --input makes that split explicit. It also means the exact same code path that would run against a live host’s piped output is what got exercised while writing this tutorial: two realistic fixture snapshots, one clean and one deliberately drifted in four different ways, both fed straight through the real parser and classifier.
What a real run looks like
When it doesn't work
- “no input” error — the script refuses to silently read an interactive terminal as if it were piped data; pass
--input FILEor actually pipeiptables-saveoutput on stdin. - Every rule shows a nil protocol/port — this parser only extracts
-p,--dport,-s, and-j; rules that specify a port range (--dport 8000:8010) or use-m multiport --dportswon’t match the single-port regex and are effectively invisible to this version — see “extending it” below. - nftables hosts — this script only understands the legacy iptables-save text format. On a host using nftables natively, run
iptables-nft-save(part of the nftables compatibility layer) to get equivalent output, or extend the parser fornft -j list ruleset‘s JSON format instead. - False positives on a box with custom chains — this script only walks the built-in INPUT/FORWARD/OUTPUT chains referenced directly in baseline’s
default_policies; rules that jump to a custom chain for further processing are still captured as individual rules but that custom chain’s own default policy isn’t a concept iptables has (only built-in chains have policies) so there’s nothing to compare there.
Where to take this next
- Port ranges and multiport: extend the rule regex to handle
--dport 8000:8010and-m multiport --dports 80,443,8080instead of only single--dport Nmatches. - nftables-native parsing: add a second parser for
nft -j list ruleset‘s JSON output so hosts that never had iptables-nft installed are covered too. - Fleet-wide sweep: pair this with the concurrency pattern from this series’ SSH-based tools to pull
iptables-savefrom every host in a fleet and report drift in one pass. - Baseline generation mode: add a
--freezeflag that writes the *current* ruleset out as a new baseline, for onboarding an existing fleet that’s never had one.