For network engineers and engineers building LLM-powered networking tools.
The Assumption That Doesn’t Hold
If you’ve ever run iptables-save and stared at a wall of chains and rules, you’ve
probably wondered: can’t I just paste this into an LLM and get a plain-English explanation?
You can. And it will give you something back that looks plausible. The problem is that it can contain structural errors that are hard to spot without already knowing the ruleset.
Here’s what we found when we built an --explain feature on top of an iptables and nftables
parser: Gemini 2.0 Flash — without a carefully engineered system prompt — produced explanations
with systematic errors subtle enough to mislead a network engineer and dangerous enough to cause
a missed security finding or a false sense of assurance. Rules are silently collapsed. Negations are inverted.
Chain traversal stops before reaching the actual verdict. Unreachable rules are described
as if they enforce traffic.
This post is about how we fixed those errors — not with brittle specific rules, but by engineering a system prompt that encodes the general principles of how iptables and nftables actually work, and then iterating against real sample rulesets until the output was structurally correct.
The key lesson: an LLM that doesn’t deeply understand firewall evaluation semantics will confidently produce wrong explanations. The fix is principles, not patches.
What We Built
The --explain feature sits on top of a structured parser. The parser converts raw
iptables-save or nft --json list ruleset output into a typed JSON representation —
tables, chains, rules, match fields, verdicts, counters, diagnostics. That JSON is passed
to the LLM with a carefully engineered system prompt.
iptables-save output nftables JSON output
│ │
▼ ▼
[iptables-parser] [nftables-parser]
│ │
▼ ▼
Structured JSON Structured JSON
(tables, chains, (tables, chains,
rules, verdicts, rules, verdicts,
diagnostics) diagnostics)
│ │
└──────────┬─────────────────┘
▼
[LLM + system prompt]
│
▼
Markdown explanation
(Traffic Table, Notable Rules,
Warnings, Scope Limitations)
We tested against eleven iptables sample files and twelve nftables sample files, evaluated each LLM output against the ground truth JSON, and added principles to the system prompt whenever we found a structural error. Each fix was re-run to verify it held, then the next sample was tested.
What follows is a catalogue of every error class we encountered and the principle that addressed it.
Error 1: Rules Collapsed — The Summarisation Trap
What happened: The INPUT chain in one sample had three consecutive rules for port 80. The LLM collapsed the two LOG rules into one, treating them as the same rule and producing a description with only two entries. The third rule — an unreachable duplicate LOG — was never flagged.
INPUT chain (filter table):
rule 1: -p tcp --dport 80 -j LOG --log-prefix "HTTP-ACCESS: " ← non-terminal: logs and continues
rule 2: -p tcp --dport 80 -j ACCEPT ← terminal: all port-80 traffic accepted here
rule 3: -p tcp --dport 80 -j LOG --log-prefix "HTTP-ACCESS: " ← LLM MISSES this
(unreachable — rule 2 already
ACCEPTs all port-80 traffic
before rule 3 can fire)
Rules 1 and 3 look identical. The LLM collapsed them into a single “LOG for port-80” entry and never noticed that rule 3 is a dead rule — the ACCEPT on rule 2 catches everything first.
Why it happens: LLMs are trained to summarise. When rules look similar, summarisation instinct kicks in. The model collapses them into a pattern rather than treating each rule as an independent enforcement point at a specific position.
The principle we added:
Enumerate every rule individually. Never collapse or summarise multiple rules into one description because they appear similar. Two rules that match the same traffic class at different positions have different operational meaning — the second may be unreachable.
For every rule in a chain, verify reachability: check whether any preceding rule has a terminal target and matches a superset of this rule’s traffic. If so, the rule is unreachable and must be flagged in Warnings regardless of how intentional it may appear.
The key word is verify. The LLM must actively check the superset condition for every rule, not just read the chain top-to-bottom and describe what it sees.
Error 2: Negation Inversion — The Most Dangerous Mistake
What happened: The rule -A DOCKER ! -i docker0 -o docker0 -j DROP was described as
“drops traffic NOT coming from an interface other than docker0.” That double negative is
wrong. ! -i docker0 means NOT arriving on docker0 — i.e., external traffic. The LLM
inverted the negation and produced the opposite meaning.
Actual rule: ! -i docker0 -o docker0 -j DROP
↑
NOT on docker0 (= external traffic) → DROP
LLM said: "traffic NOT coming from an interface OTHER THAN docker0"
= traffic coming FROM docker0 → DROP
↑
WRONG — exactly backwards
Why it happens: Double negatives are hard. “Not from an interface other than X” and “not from X” are different, and the LLM was doing casual language translation rather than precise logical evaluation.
The principle we added:
Negation changes the match condition to its opposite. When a match field carries
_negated: truein the JSON (or!prefix in the raw rule), the rule matches packets that do NOT satisfy that criterion. State the negation explicitly in every such rule description. Silently dropping the negation inverts the entire operational meaning of the rule.
This forced the LLM to treat _negated: true as a flag requiring explicit language — not
an optional qualifier that could be absorbed into a paraphrase.
Error 3: Chain Traversal Stops at the Jump
What happened: The Traffic Table had rows like:
| Inbound | TCP | 22 (SSH) | Any | CHAIN | Jumps to f2b-sshd |
A jump to a user-defined chain is not a traffic outcome. It is a transfer of evaluation. The actual outcome — REJECT for banned IPs, ACCEPT for everyone else — was missing entirely.
INPUT chain:
rule 1: -p tcp --dport 22 -j f2b-sshd ← LLM stops HERE
← Should continue:
f2b-sshd chain:
rule 1: -s 1.2.3.4/32 -j REJECT ← banned IP → REJECT
rule 2: -j RETURN ← others → back to INPUT, resumes at next rule → default ACCEPT
Why it happens: The LLM treated jump as a terminal verdict. It isn’t. A jump
transfers evaluation into another chain and must be traced to its outcome — terminal
verdict or RETURN back to the caller.
The principle we added:
Resolve every user-defined chain traversal to a terminal outcome for every traffic path. When describing a jump, do not stop at “traffic goes to chain X.” For each class of traffic that enters that chain, trace what happens: which rule provides a terminal verdict, or whether control returns via RETURN or chain exhaustion — and if so, what happens next in the calling chain.
The Traffic Table Action column must show ONLY terminal verdicts: ACCEPT, DROP, REJECT, MASQUERADE, SNAT, or DNAT. “CHAIN”, “JUMP”, “See Notes” are PROHIBITED in the Action column.
We also added explicit WRONG/RIGHT example rows to the output format directive:
WRONG: | Inbound | TCP | 22 | Any | CHAIN | Jumps to f2b-sshd |
RIGHT: | Inbound | TCP | 22 | 1.2.3.4 | REJECT | f2b-sshd rule 1: banned IP |
RIGHT: | Inbound | TCP | 22 | Any | ACCEPT | f2b-sshd RETURN → INPUT resumes → default ACCEPT |
Error 4: Counter Semantics — null Is Not Zero
What happened: The Counters section appeared in outputs where no --counters flag had
been used and every counter value was null in the JSON. The LLM emitted a section headed
“Counters” with the text “No packet count data available in this capture.”
That section should not exist at all. But more subtly: the LLM was conflating null with
0. They mean different things.
packet_count: null → file captured WITHOUT --counters
No hit data was recorded. The rule may have been hit zero
times or a billion times — you simply don't know.
packet_count: 0 → file captured WITH --counters (iptables-save --counters)
Counter data exists. This rule was hit zero times since
counters were last reset.
packet_count: 847 → Counter data exists. This rule has been hit 847 times.
Non-zero on a DROP rule = active blocking confirmed.
The principle we added:
DO NOT include this section if all
packet_countvalues are null. Null means the file was captured without--counters— no hit data was recorded at all. Do not emit the heading, do not write any explanatory text. The section must not exist.CRITICAL:
null≠0. A rule withpacket_count: 0has counter data (just zero hits). A rule withpacket_count: nullhas no counter data.
We had to add “do not emit the heading” because the initial instruction — “omit this section entirely” — was being interpreted as “write the section, note that counters are absent.”
Error 5: Prescriptive Suggestions — The Analysis vs Advice Boundary
What happened: The f2b-sshd chain explanation ended with: “If fail2ban intends to drop
all SSH traffic after blocking an IP, it should consider adding -j DROP before the
RETURN in f2b-sshd.”
This is wrong on two levels. First, fail2ban’s design is intentional: ban specific IPs, let everyone else through. Adding a DROP before RETURN would block all non-banned SSH traffic. Second, the LLM’s job is to explain what the rules do, not to advise on what they should do.
Why it happens: LLMs are trained to be helpful. Explaining behaviour and suggesting improvements are both helpful in general contexts. For firewall analysis, the suggestion mode is a liability — a wrong suggestion can undermine the entire explanation.
The principle we added:
Describe what the rules do. Do not prescribe what they should do. Do not suggest adding, removing, or reordering rules. Do not characterise current behaviour as a deficiency requiring fixing unless it is structurally broken (unreachable rule, unresolved chain reference). Reporting that a design is intentional is correct; suggesting the operator change it is out of scope.
We later had to extend this with an explicit prohibited-phrases list as the model found variations: “should be reviewed”, “a review is warranted”, “it is crucial to verify”, “careful review of these changes is warranted.” Each new variant required the phrase (or its pattern) to be explicitly banned.
Error 6: IPv6 Misidentification
What happened: An ip6tables file produced a snapshot JSON where the family field
said "ipv4" — a known parser limitation. The LLM faithfully reported “This is an IPv4
ruleset” and used the wrong sysctl (net.ipv4.ip_forward instead of
net.ipv6.conf.all.forwarding). ICMPv6 type 128 was described as “Router Solicitation”
rather than “Echo Request (ping).”
iptables JSON family field: "ipv4" ← unreliable for ip6tables captures
ip6tables rule raw content: "-p ipv6-icmp --icmpv6-type 128"
↑
This is the evidence to trust
The principle we added:
Detect IPv6 by examining rule content, not the
familyfield. If anyraw_rulecontains-p ipv6-icmp,--icmpv6-type,::1/128,fe80::, or any IPv6 address notation, this is an IPv6 (ip6tables) ruleset. Thefamilyfield is unreliable — always prefer content evidence.ICMPv6 type numbers differ from ICMPv4: Type 128 = Echo Request (ping), Type 129 = Echo Reply, Type 133 = Router Solicitation, Type 134 = Router Advertisement.
nftables: A New Class of Error
nftables introduced errors specific to its structural model. The most serious one required a fundamentally different fix.
Error 7: Orphaned Chain Hallucination
What happened: fx-11-log-return-reject-goto.json defines a regular chain called
check-flags. No rule anywhere in the file has jump_target: "check-flags" or
goto_target: "check-flags". The chain is never called. It is completely unreachable.
The LLM described traffic going to check-flags as if it were called from the input
chain. It inferred the call from the chain’s content (check-flags handles ports 80 and
443) rather than from actual jump_target/goto_target field values in the JSON.
input chain:
handle 5: tcp dport 22 → log + accept ← no jump to check-flags
handle 6: tcp dport 23 → reject
check-flags chain (NEVER CALLED — is_base_chain: false, no inbound references):
handle 7: ct state established → return
handle 8: tcp dport 80 → goto allowed
handle 9: tcp dport 443 → goto missing_chain
LLM said: "Port 80 traffic goes to check-flags, then goto allowed → accept"
Reality: Port 80 traffic hits input's default policy (accept) directly.
check-flags is never evaluated.
Why it’s especially dangerous: The explanation looked plausible. check-flags is a reasonable name for a chain that handles web traffic. The LLM made a coherent story out of structure that was actually disconnected.
The fix — a mandatory pre-analysis step:
We added a Chain Reachability Map as the first required output section, with a step-by-step procedure:
STEP 1: List every chain and its is_base_chain value.
STEP 2: For each regular chain, search EVERY rule in ALL chains
for jump_target or goto_target equal to this chain's name.
STEP 3: If found → REACHABLE — called from: <table>/<chain> handle <N>
STEP 4: If NOT found → ORPHAN — no rule points to this chain
Only chains marked REACHABLE may contribute rows to the Traffic Table.
ORPHAN chains must not contribute rows and must be flagged in Warnings.
This was accompanied by an explicit principle:
THE MOST COMMON MISTAKE: Do not assume a regular chain is called just because it contains rules that handle certain ports. The ONLY way a regular chain is reachable is if another reachable rule has
jump_targetorgoto_targetequal to that chain’s name. A regular chain containing rules with jump/goto to other places does NOT prove it is itself called.
Error 8: Counter Absence ≠ Zero
nftables inline counters are expression objects inside rules, not separate tracking. A rule
either has a {"counter": {"packets": N, "bytes": N}} object in its raw_expressions, or
it doesn’t. Absence means no counter is attached — not that the counter is zero.
The LLM was treating missing counter objects as zero-hit evidence and writing things like “this rule has not been triggered,” which is unfounded. We added:
Counter absence ≠ zero hits. A rule with no inline counter expression has NO counter data. Do not state “this rule has not been triggered” for rules without counters.
The Meta-Lesson: Principles Over Patches
Every fix above is a principle, not a patch for the specific sample that triggered it.
The negation principle doesn’t say “when describing the DOCKER chain, be careful with
! -i docker0.” It says: when any field carries _negated: true, state the negation
explicitly. The chain-resolution principle doesn’t name fail2ban. It specifies the general
requirement that every jump must be traced to a terminal verdict.
This distinction matters. A patch teaches the LLM to handle one case correctly. A principle teaches it to handle every case — including ones you haven’t seen yet. Curve-fitting to passing samples is how you end up with a system prompt that explains your test data well and fails on production rulesets.
Patch approach (what not to do):
Bug observed: "! -i docker0 described wrong in DOCKER chain"
Fix added: "when describing the DOCKER chain, note that ! -i docker0 means
traffic NOT from docker0"
Result: fixed for DOCKER. Fails on every other chain with negation.
Principle approach (what we did):
Bug observed: "negation misread"
Root cause: LLM drops _negated: true and inverts meaning
Fix added: "when any field carries _negated: true, state the negation explicitly"
Result: fixed for DOCKER, f2b-sshd, any future chain with negation
This also keeps the system prompt from growing into a list of known-bad examples that breaks the moment you add a new sample.
The Iterative Process
The workflow we built for this:
┌─────────────────────────────────────┐
│ Run --explain on sample file │
└──────────────────┬──────────────────┘
│
▼
┌─────────────────────────────────────┐
│ Read raw sample + JSON snapshot │◄── ground truth
│ Evaluate LLM output vs. truth │
└──────────────────┬──────────────────┘
│
┌────────┴────────┐
│ │
errors no errors
│ │
▼ ▼
┌──────────────────┐ ┌───────────┐
│ Identify root │ │ NEXT │
│ cause (don't │ │ SAMPLE │
│ look at symptom) │ └───────────┘
└────────┬─────────┘
│
▼
┌──────────────────────────────────────┐
│ Encode general principle in prompt │
│ (NOT a patch for this specific case) │
└────────┬─────────────────────────────┘
│
▼
┌──────────────────┐
│ Re-run same │
│ sample, verify │
└────────┬─────────┘
│
┌───────┴──────┐
│ │
still bad fixed
│ │
▼ ▼
iterate NEXT SAMPLE
We ran this loop against:
- 11 iptables samples (state mode + 5 diff pairs)
- 12 nftables samples (state mode + 4 diff pairs)
The system prompt grew from a starting point that covered the basics (first-match semantics, table evaluation order, chain traversal mechanics) to one that encodes 20+ distinct principles covering the failure modes above. Each principle has a corresponding unit test that asserts the principle is present in the prompt — so any future edit that accidentally removes a principle will fail the test suite.
What the Final Output Looks Like
For a fail2ban + Docker ruleset, the LLM now produces a Traffic Table like:
| Direction | Protocol | Port | Source | Action | Notes |
|------------|----------|------|---------|--------|---------------------------------------------|
| Inbound | TCP | 22 | 1.2.3.4 | REJECT | f2b-sshd rule 1: matched ban entry |
| Inbound | TCP | 22 | Any | ACCEPT | f2b-sshd RETURN → INPUT resumes → default |
| Forwarded | TCP | 80 | External| ACCEPT | DOCKER: published port, -d 172.17.0.2/32 |
| | | | | | ! -i docker0 -o docker0 -j ACCEPT |
| Forwarded | TCP | Any | External| DROP | DOCKER: ! -i docker0 -o docker0 -j DROP |
| | | | | | (unpublished ports, catch-all) |
| Forwarded | Any | Any | Any | DROP | Default policy (FORWARD chain) |
Contrast that with an early run that showed CHAIN in the Action column and described
! -i docker0 as “traffic from the docker0 interface.”
Key Takeaways
For network engineers using LLMs to read firewalls:
-
Don’t trust the first answer. Test it against a rule you know — a DROP rule, a rule with a negation, a chain that jumps to another chain. If the LLM gets those wrong, the rest of the explanation may be unreliable.
-
Ask it to explain the Traffic Table verdict for a specific IP and port end-to-end. If it can’t trace the full chain path to a terminal verdict, it doesn’t actually understand the ruleset.
-
Zero counters and missing counters are different things. In iptables-save output, a rule with
[0:0](packets:bytes) has counter data — it was monitored and not triggered. A rule with no counters at all is just… unknown.
For engineers building LLM-powered analysis tools:
-
Structured JSON input outperforms raw text. Parsing the ruleset first means the LLM gets typed fields (
_negated: true,jump_target: "f2b-sshd") instead of raw strings it has to re-parse. -
Principles beat patches. Root-cause the error, encode the general principle. Re-run the original sample and then move on.
-
Unit-test your system prompt. If a principle is in the prompt, write a test that asserts it. System prompts drift — tests catch regressions.
-
Build a mandatory pre-analysis step for graph-structured data. For nftables regular chains, the Chain Reachability Map forced the model to build the call graph before writing any analysis. Without it, the model’s instinct to “tell a coherent story” overrode its need to “report what the JSON actually says.”
What’s Next
The --explain and --explain-diff features are now part of both parsers:
# iptables
python3 iptables_parser.py /etc/iptables/rules.v4 --explain
python3 iptables_parser.py before.txt --explain-diff after.txt
# nftables
python3 nftables_parser.py nft_ruleset.json --explain
python3 nftables_parser.py before.json --explain-diff after.json
The next step is integrating them into the Netfilter Inspector and making both available as standalone Claude skills — so a network engineer can paste a ruleset into a conversation and get a structurally correct explanation without any CLI setup.
The system prompt work described here is what makes the output structurally grounded — not just plausible-sounding.
Source code and sample files: github.com/ranga-sampath/agentic-network-tools/netfilter-inspector
