Skip to main content
RunBook Academy

← All labs in OPNsense

Lab · advanced · ~60 min

Lab: Audit the ruleset — find shadowed, redundant, and dead rules

B · Nested virtualisationC · Simulation

Objectives

  • Read the compiled rule table in PF evaluation order
  • Identify shadowed rules (an earlier quick rule matches first)
  • Identify redundant rules (two rules match the same traffic and produce the same action)
  • Identify dead rules (no traffic has matched in months)
  • Document each finding with a remediation plan
  • Apply the safe rule change procedure to remove the offenders

Prerequisites

This lab reads the compiled ruleset the way a senior operator reads it during a quarterly audit: looking for shadowed rules, redundant rules, and dead rules. The patterns are not secret — they are the same patterns that the GUI hides. The discipline is to actually run the audit, document the findings, and remove the offenders with a peer-reviewed change.

By the end you will have a feel for what a “good” ruleset looks like, what “bad” rules look like, and how to find the differences. The audit report you write in this lab is the kind of artefact that goes into a change ticket.

Objective

By the end of this lab, you can:

  • Read the compiled rule table in PF evaluation order.
  • Identify shadowed rules (an earlier quick rule matches first).
  • Identify redundant rules (two rules match the same traffic and produce the same action).
  • Identify dead rules (no traffic has matched in months).
  • Document each finding with a remediation plan.
  • Apply the safe rule change procedure to remove the offenders.

Requirements

  • An OPNsense firewall with a non-trivial ruleset (at least 20 rules across multiple interfaces). The lab is most useful when the ruleset has been built up over time.
  • SSH access to the firewall for pfctl and configctl.
  • A peer reviewer who can confirm the findings before the cleanup is applied.
  • A configuration backup taken before the audit begins.

Tasks

Task 1: Snapshot the current state

The audit begins with a snapshot of the ruleset, the state table, and the configuration file. The diffs between the “before” snapshots and the post-cleanup snapshots are the audit report’s evidence.

pfctl -s rules > /tmp/rules-pre-audit.txt
pfctl -s nat > /tmp/nat-pre-audit.txt
pfctl -s state > /tmp/state-pre-audit.txt
cp /conf/config.xml /tmp/config-pre-audit.xml
ls -lh /tmp/*-pre-audit.*

Count the rules:

grep -c '^@' /tmp/rules-pre-audit.txt

The number is the size of the ruleset. A 500-rule estate is not unusual for a production firewall; a 50-rule home-lab firewall is normal. Either way, the audit walks each rule.

Task 2: Read the ruleset in PF evaluation order

The compiled ruleset is the source of truth. The GUI shows the rules in a different order, with different metadata, and may hide the cable that connects two rules. The pfctl -s rules output is what the kernel is actually evaluating.

pfctl -s rules | less

Walk the rules. For each rule, ask:

  • What action does this rule take?
  • What traffic does it match?
  • Is there an earlier rule (lower @N) that matches the same traffic with quick?

A rule that has an earlier quick rule with overlapping criteria is a candidate shadowed rule. A rule that has another rule with identical action and criteria is a candidate redundant rule.

Task 3: Find shadowed rules

For this task, you are looking for shadowed rules. The pattern is:

@N pass in quick on <iface> inet proto tcp from <src> to <dst> port = <port>
@M block in quick on <iface> inet proto tcp from <src> to <dst> port = <port>

If @N < @M and both have quick, @M is shadowed — the pass rule at @N matches first and the block rule at @M is never reached.

A more subtle pattern: a pass rule at a lower position with broader criteria that includes the rule at a higher position:

@N pass in quick on <iface> inet proto tcp from <net> to any port = <port>
@M pass in quick on <iface> inet proto tcp from <net/24> to <dst> port = <port>

If the broader rule at @N matches the same traffic as the narrower rule at @M and the action is the same, @M is shadowed.

Walk the ruleset and write down each shadowed rule:

SHADOWED RULES
==============
@M block in quick on <iface> - shadowed by @N pass quick
@M+1 pass in quick on <iface> - shadowed by @N+5 pass quick broader criteria

Task 4: Find redundant rules

For this task, you are looking for two rules that match the same traffic on every dimension and produce the same effect.

Group rules by interface, direction, protocol, source, destination, port, and action. Two rules that match in all seven dimensions are redundant.

# Pull the rule lines into a more parseable form
grep '^@' /tmp/rules-pre-audit.txt | awk '{
  printf "%s %s %s %s %s %s %s\n", $1, $3, $4, $5, $6, $7, $8
}' | sort | uniq -c | sort -rn | head -10

The output groups rules by their match criteria. A row with count > 1 is a candidate redundant pair.

The verification step: read both rules’ full text and confirm they really do match the same traffic. The grouping is heuristic — two rules that look identical in the heuristic might differ on a field the heuristic did not capture (e.g. a set-priority rule on one of them).

Write down the redundant rules:

REDUNDANT RULES
===============
@N and @M both: pass in quick on <iface> inet from <src> to <dst> port = <port>
  Reason: identical match criteria
  Recommended action: remove @M (the later one)

Task 5: Find dead rules

A dead rule is one that has not matched in months. The heuristic is to look at the firewall log for the rule’s identifier. OPNsense tags each rule with a label that includes the @N; the log can be searched by label.

# Get the rule labels (the bracketed text after each rule)
grep -A 1 '^@' /tmp/rules-pre-audit.txt | grep -E '^\s*\[' | head -20

A rule with a label can be searched by grep <label> /var/log/filter/latest.log.

For unlabeled rules, the question is whether the rule has matched any state in the state table. The state table is short-lived (entries expire), so this is a snapshot, not a long-term view. For a long-term view, enable per-rule statistics:

# Check whether per-rule stats are enabled
sysctl net.inet.pf.states_hashsize
# Per-rule stats require the rule to have a label

Walk the ruleset and look for candidates:

  • Rules with descriptions that mention services not in use (e.g. “Allow FTP from DMZ to internal FTP server” when there is no FTP server).
  • Rules whose source or destination aliases have not been updated in months.
  • Rules added more than a year ago that have never been touched since.
# Grep the GUI rule descriptions for old services
configctl filter show rule | grep -i 'ftp\|telnet\|smtp' | head -10

Write down the dead rules:

DEAD RULES
==========
@N pass in quick on <iface> - "Allow FTP from DMZ" added 2024-03-15
  Reason: no FTP server in production since 2024-06-01
  Verification: last match was 2024-05-30; no matches in 14 months
  Recommended action: remove

Task 6: Cross-check by traffic

For the rules you suspect are dead, generate the traffic they were supposed to match and confirm the rule does not match.

If a rule is “Allow HTTPS from LAN to internal server” and you have no internal server at the named IP, the rule is dead. If you have an internal server but the traffic is not flowing, the rule is not dead — the rule is matching but the IP is wrong.

The discipline: confirm the rule is dead by checking the rule’s effect, not just its date or description.

Task 7: Write the audit report

FIREWALL RULESET AUDIT REPORT
=============================
Date: 2026-08-14
Auditor: <your name>
Reviewer: <peer name>
Firewall: <firewall hostname>

Total rules in compiled ruleset: <count>
Interfaces audited: <list>

SHADOWED RULES
- @M: <description>, shadowed by @N
  Remediation: remove @M
- @M+1: <description>, shadowed by @N+5
  Remediation: confirm with peer, remove @M+1

REDUNDANT RULES
- @N and @M: <descriptions>
  Remediation: remove @M (the later duplicate)
- @P and @Q: <descriptions>
  Remediation: confirm with peer, remove @Q

DEAD RULES
- @N: <description>, added <date>, last match <date>
  Remediation: remove
- @M: <description>, source alias deprecated <date>
  Remediation: remove

NO FINDINGS
- Shadowed: <count>
- Redundant: <count>
- Dead: <count>

Attach the snapshot files and the report.

Task 8: Peer review

Give the report to a peer. The peer reads each finding and confirms:

  • The rule is shadowed / redundant / dead (not just by the operator’s heuristic but by the actual rule text).
  • The removal will not affect production traffic.
  • The recovery path is documented (console access, configuration backup, GUI access).

The peer reviewer signs the report. The signature is the audit record.

Task 9: Plan the removal

For each rule identified for removal, plan the change:

  • Take a backup of the configuration immediately before the change.
  • Identify the recovery path: console access, configuration restore, GUI access.
  • Apply the change from one session.
  • Test from a second session: generate the traffic that the rule was supposed to handle and confirm the firewall still permits it (or, for shadowed rules, confirm that no traffic depended on the shadowed rule).
  • Document the removal: ticket, rationale, before/after evidence.

The plan goes into the change ticket. The ticket is the audit trail.

Task 10: Apply the removals

For each rule:

# Fill these in from the change ticket, one rule per pass.
RULE_ID=lan-legacy-smtp-relay     # short slug, used only in file names
IFACE=LAN                         # the interface tab the rule lives on
TEST_HOST=mail.example.internal   # a host the rule was supposed to serve
TEST_PORT=443

# 1. Take a backup and snapshot the running ruleset
cp /conf/config.xml "/tmp/config-pre-REMOVE-$RULE_ID.xml"
pfctl -s rules > "/tmp/rules-pre-$RULE_ID.txt"

# 2. Remove the rule in the GUI
#    Firewall -> Rules -> $IFACE -> delete the rule
#    Apply the change

# 3. Verify exactly one rule left the ruleset, and it is the right one
pfctl -s rules > "/tmp/rules-post-$RULE_ID.txt"
diff "/tmp/rules-pre-$RULE_ID.txt" "/tmp/rules-post-$RULE_ID.txt"
# expected: one removed line, matching the rule in the ticket

# 4. Generate the traffic the rule was supposed to handle
#    (for shadowed rules, this is the test that no traffic
#    depended on the shadowed rule)
curl --max-time 5 "https://$TEST_HOST:$TEST_PORT/"
# expected: still works (for shadowed rules, the rule that
# was matching the traffic is still in place)

# 5. Document the removal

Apply each removal one at a time. If a removal breaks something, the change is small and the recovery is fast.

Task 11: Snapshot the post-cleanup state

pfctl -s rules > /tmp/rules-post-audit.txt
pfctl -s nat > /tmp/nat-post-audit.txt
cp /conf/config.xml /tmp/config-post-audit.xml

# Compare against the pre-audit snapshots
diff /tmp/rules-pre-audit.txt /tmp/rules-post-audit.txt
diff /tmp/nat-pre-audit.txt /tmp/nat-post-audit.txt

The diffs should show only the removed rules. If any other rule or NAT entry changed, the cleanup touched something it should not have.

Task 12: Verify the firewall is still functional

End-to-end test:

# Substitute your own values before running:
WAN_IP=203.0.113.10   # the firewall's WAN address
FWD_PORT=8443         # a port you forward inbound, from the NAT snapshot

# Outbound traffic works
curl --max-time 5 https://example.com/ > /dev/null && echo "outbound OK"

# Inbound traffic works (for port forwards)
curl -k --max-time 5 "https://$WAN_IP:$FWD_PORT/" > /dev/null && echo "inbound OK"

# State table is populating
pfctl -s state | wc -l

The firewall should be doing exactly what it was doing before the audit, with fewer rules. The reduction is the evidence the audit found what it claimed to find.

Task 13: Commit the audit report

# REMOVED is the number of rules you deleted in Task 10.
REMOVED=7

mkdir -p ~/audit-reports
cp /tmp/rules-pre-audit.txt /tmp/rules-post-audit.txt \
   /tmp/audit-report.txt \
   ~/audit-reports/2026-08-14-ruleset-audit/

# Written on the lab bench, committed to the audit repository
cd ~/audit-reports
git init
git add .
git commit -m "audit: 2026-08-14 OPNsense ruleset audit, $REMOVED rules removed"

The audit report is in version control. The next audit can diff against this one.

Validation

  • The pre-audit snapshot files (/tmp/*-pre-audit.*) exist.
  • Every shadowed rule was identified and documented with the earlier shadowing rule.
  • Every redundant rule was identified with the matching rule and the recommended action.
  • Every dead rule was identified with the date it was last matched and the evidence.
  • The audit report was peer-reviewed and signed.
  • Each removal was applied with a backup, a test, and a documentation step.
  • The post-cleanup snapshot is consistent with the pre-audit snapshot minus the removed rules.
  • The firewall is functionally identical before and after the audit.

Cleanup

The lab is largely self-cleaning — the audit’s output is the cleanup. The remaining items are the audit report, the snapshots, and the change ticket.

# Move the audit artefacts to a permanent location
mkdir -p ~/firewall-audit-archives/2026-08-14
cp /tmp/*-pre-audit.* /tmp/*-post-audit.* /tmp/audit-report.txt \
   ~/firewall-audit-archives/2026-08-14/

# Close the change ticket with the audit report attached
# (the ticket is the audit trail)

The snapshots can be deleted after the audit report is committed to the audit repository.

What you learned

  • The compiled ruleset is the source of truth. The GUI is a view; the kernel is the truth.
  • A shadowed rule is invisible to PF. The next operator who reads the ruleset assumes it is in effect and is wrong.
  • A redundant rule is in effect but the operator’s mental model is wrong because two rules do the same thing. The next incident finds the wrong rule was the one the operator modified.
  • A dead rule is for a service that no longer runs. The service might come back; the operator must check the dormant use case before removing.
  • The audit is not a one-person task. The peer review is the audit. The auditor’s heuristic is good but not perfect.
  • The audit’s output is a change ticket with a backup, a test, and a documentation step. The ticket is the audit trail.

Deliverables

  • · A snapshot of the compiled ruleset (pfctl -s rules) before the audit
  • · A written audit report listing every shadowed, redundant, and dead rule found
  • · A peer-reviewed removal plan for each finding
  • · A post-cleanup snapshot proving the firewall still functions correctly

Verification status

Last reviewed
2026-08-14
Executed end to end
not yet run on hardware

The commands and configuration here have been reviewed against the verified software versions, but nobody has run this lab start to finish on a system meeting its prerequisites. Treat the Expected Outcome as the intended result rather than an observed one, and keep the Cleanup section to hand.