DannyRuizB/firewallscope

GitHub: DannyRuizB/firewallscope

一款纯客户端的 Linux 防火墙规则可视化分析工具,将 iptables/nftables/ufw 等规则文本转化为结构化图形、差异对比和包追踪视图。

Stars: 0 | Forks: 0

# FirewallScope [![lint](https://static.pigsec.cn/wp-content/uploads/repos/cas/73/73ccbf4580efb36d6c888b7f4803d5e82c229071ca2fd1a481dac1a7a20547dc.svg)](https://github.com/DannyRuizB/firewallscope/actions/workflows/lint.yml) [![Live demo](https://img.shields.io/badge/demo-dannyruizb.github.io%2Ffirewallscope-3b82f6)](https://dannyruizb.github.io/firewallscope/) ![No backend](https://img.shields.io/badge/backend-none%20·%20runs%20in%20your%20browser-f59e0b) ![Formats](https://img.shields.io/badge/parsers-iptables%20·%20ip6tables%20·%20nftables%20·%20ufw-6366f1) [![License: MIT](https://img.shields.io/badge/license-MIT-blue)](LICENSE) ### [Try the live demo →](https://dannyruizb.github.io/firewallscope/) ![FirewallScope on the port-forward sample: the input pane shows an iptables-save dump with a filter table (INPUT, FORWARD, OUTPUT) and a nat table publishing a web app (DNAT to 10.0.0.10:80/:443) and an SSH jumpbox (DNAT 2222 to 10.0.0.20:22) plus an outbound MASQUERADE. The graph view on the right renders both tables as compound boxes with chains colour-coded by policy (INPUT / FORWARD red for DROP, OUTPUT and nat chains green for ACCEPT).](https://static.pigsec.cn/wp-content/uploads/repos/cas/58/585a5dd0bc453e769650d5077de3490590c466f3cffb8cee51c665e7fcda2233.png) 🟢 **v1.0.0** — released. Four parsers (`iptables-save`, `ip6tables-save`, `nft list ruleset`, `ufw status verbose`) with auto-detect. Structural graph with chain tooltips (action distribution + comments) and PNG / SVG export. **Diff** view (added / removed / reordered / policy-change). **Linter** with 33 smells (permissive-accept, exposed-admin-port, wide-open-port-range, overbroad-source-trust, missing-input-drop, shadowed-rule, fallthrough-accept, rule-after-policy-drop, loopback-not-allowed, exposed-via-dnat, forward-no-default-deny, missing-established-accept, masquerade-any-source, drop-without-log, missing-invalid-drop, icmpv6-blocked, unused-chain, unlimited-log, duplicate-rule, unlimited-icmp-echo, unrestricted-egress, mac-based-trust, admin-port-no-rate-limit, log-tcp-sequence, missing-loopback-spoof-drop, ipv6-unfiltered, dnat-forward-blocked, bogon-source-accept, log-without-prefix, dnat-unscoped, dnat-no-hairpin, rate-limit-not-per-source, rate-limit-drop-inverted) and JSON / Markdown export. **Trace a packet** across the full pipeline: `INPUT` / `FORWARD` / `OUTPUT` directions, interface matches (`-i` / `-o` / `iifname` / `oifname`) modeled, and the complete **NAT model** — `nat/PREROUTING` and `nat/OUTPUT` DNAT / REDIRECT rewrite the packet *before* filter, `nat/POSTROUTING` SNAT / MASQUERADE runs *after* filter accepts. The verdict line surfaces every rewrite, the trace log shows step-by-step matches, and the Graph view highlights visited chains live. ## Why Reading a firewall ruleset is reading text — `iptables-save` is hundreds of `-A INPUT -p tcp …` lines, `nft list ruleset` is nested braces, `ufw status verbose` is a column table. FirewallScope flips that: paste the dump, see the structure. The intended audience is **homelab admins, sysadmins learning iptables/nftables, and security students** who want to understand a ruleset without grepping for chain names. It is intentionally **not** a security scanner — no exploit detection, no rule auditing, no CVE matching. The goal is *visibility*. Everything runs **client-side**. No upload, no daemon, no server. ## What this demonstrates A project built on in-depth Linux networking and security knowledge. Skills on display: - **Linux firewalling in depth** — `iptables`, `ip6tables`, `nftables` and `ufw`: tables, chains, policies and rule semantics across all four syntaxes. - **Packet flow & NAT** — full `PREROUTING` / `FORWARD` / `POSTROUTING` model, `DNAT` / `SNAT` / `MASQUERADE` rewrites, and packet tracing across the pipeline. - **Security-review thinking** — a linter for common ruleset smells (exposed admin ports, wide-open port ranges, permissive accepts, shadowed rules, missing input drop…). - **Frontend engineering** — a zero-backend, fully client-side parser and graph renderer, deployed on GitHub Pages. ## Use it Live demo: **https://dannyruizb.github.io/firewallscope/** — runs 100% in your browser. Run locally — any static server works: git clone https://github.com/DannyRuizB/firewallscope.git cd firewallscope python3 -m http.server 8080 # open http://localhost:8080 ## Supported formats FirewallScope auto-detects the format from the input. You can also force one from the dropdown if the heuristic fails. | Format | Generate with | Notes | |--------|---------------|-------| | **iptables-save** (IPv4) | `sudo iptables-save` | The classic dump. `*filter`, `*nat`, `*mangle`, `*raw`, `*security` all supported. | | **ip6tables-save** (IPv6) | `sudo ip6tables-save` | Same grammar as IPv4, detected by the `# Generated by ip6tables-save` header or by the presence of IPv6 addresses without IPv4 ones. | | **nftables** | `sudo nft list ruleset` | Native nftables output. Tables (`ip` / `ip6` / `inet` / `bridge` / `arp` / `netdev`) and their family are preserved. | | **UFW** | `sudo ufw status verbose` | The human-readable column output, including default policies (`Default: deny (incoming)`) and per-rule actions (`ALLOW IN`, `DENY IN`, `REJECT IN`, `LIMIT IN`, with `OUT` and `FWD` variants). | The samples in `samples/` show one realistic input per format — pick one from the *Load sample* dropdown in the UI. Each format also ships an `*-after.txt` companion sample for trying out the **diff view** (see *What you see* below). ## What you see FirewallScope parses every format into the same intermediate model — `{ tables: [{ chains: [{ rules: [...] }] }] }` — and renders two views on top of it: - **Graph** (default) — tables as dashed compound boxes, chains as nodes inside them. Built-in chains are coloured by their default policy (ACCEPT green, DROP red, REJECT orange). User-defined chains carry a dashed red border. **Jumps between chains** are drawn as arrows; when multiple rules in a chain jump to the same target, the arrow carries a multiplier (`2×`). Click a chain to jump to its rules in the Table view. - **Table** — every chain rendered as a small block with: a policy pill, the rule list with index, the match condition (`-p tcp --dport 22`), the action as a pill (green `ACCEPT`, red `DROP`, orange `REJECT`, dashed `jump ` for chain references, neutral for `LOG`, `RETURN`, `MASQUERADE`, `REDIRECT`, `DNAT`, `SNAT` with their detail kept), and any `--comment "…"` extracted into its own column. - **Lint** — a built-in static analysis pass over the parsed ruleset, reporting every smell class listed in the Roadmap below (with how each is detected). The core ones: `missing-input-drop` (error, INPUT-like chain has no default-deny), `exposed-admin-port` (error, sensitive admin service accessible from any source), `permissive-accept` (warning, ACCEPT from any source with no port restriction), and `shadowed-rule` (warning, a terminal rule whose packet space is fully covered by an earlier terminal rule in the same chain — checked by predicate-subset over protocol, source / destination CIDR, sport / dport intervals, with family v4/v6 and ct-state context taken into account). Findings are shown as a list with severity badges; clicking one jumps to the offending rule in the Table view. Rules and chains with findings also carry an inline `⚠` pill / badge in the Table and a `⚠ N` lint line in the Graph chain label. The linter is disabled in diff mode. - **Trace** — fill in a packet (protocol, source / destination IP, dport / sport, ct-state) and FirewallScope simulates how it flows through the `filter` table's `INPUT` chain rule by rule, recursing into user-defined chains on jumps. The result is a verdict (`ACCEPT` / `DROP` / `REJECT`) and a step-by-step log of which rules matched, which were skipped (because they use a match the simulator doesn't model — `-i`, `-m limit`, `-m recent`, `-m mac`, etc.), where the trace jumped, and which chain policy fired if nothing matched. The path is also highlighted live in the Graph view: visited chains turn blue, the deciding chain gets a thicker blue ring, and the jump edges taken thicken too. Trace is disabled in diff mode. All four views are derived from the same model, so swapping is instant. **Diff mode**: click *Compare* in the input header to open a second pane, paste a second ruleset of the **same format**, and hit *Analyze*. FirewallScope merges both into one annotated model and highlights: - **Added** rules (green, `+` prefix) and **removed** rules (red, struck-through, `−` prefix) inside each common chain. - **Moved** rules (blue, `~` prefix, `moved ↑N` / `moved ↓N` badge) — rules that exist on both sides but at a different position inside the same chain. Detected by taking the longest common subsequence of rule signatures as the unchanged anchor. - **Added** and **removed** chains as a whole (green / dashed-red border in the graph; `+ added` / `− removed` badge in the table). - **Policy changes** on a common chain as `policy DROP → ACCEPT` next to the chain name. - A summary banner at the top of the output pane with rule and chain deltas. Cross-format diffs (e.g. iptables vs nftables) are rejected with a clear error — both inputs must come from the same tool. ## How it works - **Parsers**: one per format, all under `src/parsers/`. Each one consumes raw text and produces the same intermediate representation. The dispatcher in `src/parser.js` detects the format and routes to the right parser. - **Renderers**: `src/graph.js` handles both the Cytoscape graph (with the [dagre](https://github.com/dagrejs/dagre) LR layout) and the DOM table. - **Stack**: vanilla HTML / CSS / JS, no build step. Cytoscape 3.30, dagre 0.8 and the cytoscape-dagre plugin are pulled via CDN. ## Tests The parsing, linting, diff and trace logic is covered by a [`node:test`](https://nodejs.org/api/test.html) suite — no extra dependencies. Each module is a browser IIFE that hangs its API off `window`, so the tests load them into a Node `vm` sandbox whose global doubles as `window` (no browser, no bundler) and assert against the realistic fixtures in `samples/`: every format auto-detected, every linter smell raised by at least one sample (the suite pins this), packet traces (verdicts + error paths) and same-format / cross-format diffs. npm test # node --test over test/*.test.js — needs only Node, no install CI runs ESLint **and** this suite on every push and pull request. ## Roadmap FirewallScope's direction: cover the common firewall surfaces and gradually add views that turn the same model into different lenses (structure, flow, diff). - [x] **v0.1** — Four parsers (iptables / ip6tables / nftables / ufw), format auto-detection with manual override, structural graph view, table view, paste / upload / drag & drop input, four realistic samples. - [x] **v0.2** — Hover a chain in the graph to get an enriched tooltip with the action distribution (X ACCEPT / Y DROP / Z jump / other) and the `--comment` values found in its rules (truncated to 5). Export the graph as **PNG** (2× scale) or **SVG** (vector, editable in Inkscape / Figma) from the ⤓ button in the graph header. - [x] **v0.3** — Diff view: paste two rulesets of the same format, see what rules and chains were added / removed and which chains had their policy changed, colour-coded in both the graph and the table. - [x] **v0.3.1** — Diff now detects **reordering**: per chain, the longest common subsequence of rules forms the unchanged anchor and the rules outside it that exist on both sides are flagged as *moved*, with a `moved ↑N` / `moved ↓N` badge showing how many positions they shifted. - [x] **v0.4** — Linter pass with three smells: **permissive-accept** (warning — ACCEPT from any source with no port restriction, excluding loopback and conntrack-established), **exposed-admin-port** (error — ssh / telnet / mysql / rdp / postgres / redis / mongodb open from any source), and **missing-input-drop** (error — built-in INPUT chain of the filter table with policy ACCEPT and no catch-all DROP). A new **Lint** tab lists the findings with severity icons and a click jumps to the offending rule in the Table view; affected rules carry an inline `⚠` pill and chains with findings get a `⚠ N` badge in both the Table and the Graph. - [x] **v0.4.1** — `shadowed-rule` smell: detects a terminal rule (`ACCEPT` / `DROP` / `REJECT` / `RETURN`) whose packet space is fully contained in an earlier terminal rule of the same chain. The subset check models protocol, source / destination CIDR, sport / dport (single / list / nft-set / range intervals), and filters out rules with unmodeled matches (`-i`, `-o`, `-m limit`, `-m recent`, `-m mark`, `-m mac`, `-m string`, `meter`) to stay conservative. Family (v4 / v6) and ct-state context are taken into account so cross-family or state-divergent rules are not falsely paired. - [x] **v0.5** — *Trace a packet* — describe a packet (protocol, src / dst IP, sport / dport, ct-state) and FirewallScope walks the `filter` `INPUT` chain rule by rule, recursing into user-defined chains on jumps. The Trace tab shows the verdict (`ACCEPT` / `DROP` / `REJECT`) as a coloured pill, the deciding rule, and a step-by-step log; the Graph tab highlights the visited chains in blue, the deciding chain with a thicker ring, and the jump edges taken. Rules using matches the simulator doesn't model (`-i`, `-m limit`, `-m recent`, `-m mark`, `-m mac`, `-m string`, nft `meter`) are recorded as `skip` with an explicit reason rather than guessed at. - [x] **v0.5.1** — Trace gains a Direction dropdown (`input` / `output` / `forward`). The engine now resolves the built-in entry chain by direction — for nft it follows the chain `hook`, for the other formats it matches the chain name (`INPUT` / `OUTPUT` / `FORWARD`). The packet summary line under the verdict labels the direction (`OUTPUT packet:` / `FORWARD packet:` / `INPUT packet:`) so the trace context is unambiguous. - [x] **v0.6.0** — Smell `fallthrough-accept` wires the trace engine back into the linter. For every built-in `INPUT` / `FORWARD` chain of the `filter` table the linter runs a probe trace (`tcp / 22`, state `NEW`, generic external source) and emits a warning if the verdict is `ACCEPT` *and* came from chain-policy fall-through (no rule matched). Clicking the finding switches to the Trace tab, fills the form with the probe packet, and re-runs the trace so the operator can see the exact step path. - [x] **v0.6.1** — `fallthrough-accept` becomes multi-probe: it iterates the seven `ADMIN_PORTS` (ssh / telnet / mysql / rdp / postgres / redis / mongodb) and emits **one finding per chain** listing the services that fell through, instead of one finding per port. A guard skips `ip6tables` and nft `ip6`-family tables to avoid false positives from the trace's limited IPv6 modeling. - [x] **v0.7.0** — Export the lint report. The Lint tab gets two buttons next to the summary: **Export JSON** (machine-friendly: `{ generator, generatedAt, source.format, counts, findings }`, includes `probePacket` when relevant) and **Export Markdown** (`## Errors` / `## Warnings`, one section per finding with the rule in a code fence). Filename `firewallscope-lint-YYYY-MM-DD.`. Hidden when there are no findings and in diff mode. - [x] **v0.7.1** — Two new smells. **`rule-after-policy-drop`** (warning, per rule): finds the first catch-all DROP / REJECT in a chain and flags every rule after it as dead. Overlaps with `shadowed-rule` on terminal actions but also catches jumps / LOG / counter rules that shadow detection deliberately skips. **`loopback-not-allowed`** (warning, per chain, INPUT-only): if a built-in INPUT chain has a deny posture (policy DROP / REJECT or a catch-all-drop tail) and no `-i lo -j ACCEPT` (or nft `iifname "lo" accept`) rule, emit a finding — local services bound to 127.0.0.1 (Postgres, X11 sockets, systemd-resolved) get silently dropped. Linter goes from 5 to 7 smells. - [x] **v0.8.0** — Interface matches (`-i` / `-o` / `iifname` / `oifname` / `iif` / `oif`) modeled in the trace engine. Pre-v0.8 these were treated as unmodeled and `matchesPacket` would skip the rule, which rendered `FORWARD` traces nearly useless (every rule that filtered on iface skipped). A new `extractIfaceFromRaw` helper recognises iptables / ufw `-i eth0` and nft `iifname "eth0"` / `iifname eth0` / `iif eth0` forms. The linter's `hasUnmodeledMatch` copy stays conservative on purpose — modeling interfaces in `shadowed-rule` would risk new false positives, and the trace / linter split is acceptable for now. - [x] **v0.9.0** — Trace engine now walks `nat/PREROUTING` before the `filter` chain for `INPUT` and `FORWARD` directions. The first matching `DNAT` or `REDIRECT` rule rewrites the packet's destination IP and / or `dport`, and the rewritten packet is what the filter chain evaluates. The step log shows a dedicated `DNAT · 10.0.0.1:8080 → 192.168.1.10:8006` entry so the rewrite is visible. iptables (`--to-destination`, `--to-ports`) and nft (`dnat to`, `redirect to`) syntaxes are recognised; ranges, sets and load-balanced pools fall back to a plain match step. `nat/OUTPUT` is not modeled — only the inbound side, which covers the typical port-forward case. - [x] **v0.9.1** — Trace verdict line now surfaces the NAT rewrite next to the decision: `INPUT packet: tcp :8080 · rewritten by nat/PREROUTING → 127.0.0.1:8006 · decided by filter/INPUT rule #9`. Reuses `report.natPacket` produced by the v0.9.0 walk — no engine changes. - [x] **v0.10.0** — NAT model rounded out on the outbound side. The trace now walks `nat/OUTPUT` (DNAT, before `filter/OUTPUT`) for the `output` direction, and `nat/POSTROUTING` (SNAT / MASQUERADE, after the filter chain accepts) for both `forward` and `output`. A new `SNAT` step entry shows the source rewrite (`SNAT · 172.17.0.5 → 10.0.0.1` or `MASQUERADE · 172.17.0.5 → ` when the rewrite is to the outgoing-interface IP that runtime resolves). Recognises iptables (`SNAT --to-source`, `MASQUERADE`) and nft (`snat to`, `masquerade`) syntaxes. The verdict line gains a `· SNAT by nat/POSTROUTING → …` segment after the decision. - [x] **v0.11.0** — Linter smell `exposed-via-dnat` (warning): flags any `DNAT` / `REDIRECT` rule in `nat/PREROUTING` whose **rewritten** destination port is an admin service (`ssh`, `mysql`, `rdp`, `postgres`, `redis`, `mongodb`) and that has no source restriction. The DNAT silently bypasses any default-deny intuition the operator may have for the filter chain — a port-forward of `--dport 2222` to `10.0.0.5:22` exposes ssh just as effectively as a `filter/INPUT --dport 22 -j ACCEPT` would. The detector reuses the trace engine's `extractDnatRewrite` (newly exposed on `window.FirewallScope`) so the externally-visible dport and the internal target are both parsed without duplicating the regex set across modules. Linter now has 8 smells. - [x] **v0.11.1** — Demo material + trace form UX. New `iptables (port-forward / DNAT)` sample in the Load-sample menu showing a realistic edge-router config: web app published at `:80` / `:443` via DNAT, an SSH jumpbox foolishly published at `:2222 → :22` (which the linter flags), and an outbound `MASQUERADE`. Trace form interface inputs relabeled to `Incoming iface (-i)` / `Outgoing iface (-o)` so the iptables flag the field maps to is unambiguous. - [x] **v1.0.1** — Broader `exposed-admin-port` / `fallthrough-accept` coverage: the `ADMIN_PORTS` table grows from 7 to 14, adding the data/admin services that are classic accidental-exposure incidents — `docker-api` (2375, Docker daemon without TLS = remote root), `elasticsearch` (9200) and `memcached` (11211) (both authless by default), `smb` (445), `mssql` (1433), `vnc` (5900) and `ftp` (21). New `iptables (exposed services)` sample in the Load-sample menu exercises them, with a source-restricted VNC rule that correctly stays *un*flagged. - [x] **v1.1.0** — New linter smell **`wide-open-port-range`** (warning): an `ACCEPT` from any source whose dport opens more than 1024 ports at once (e.g. `--dport 1024:65535`) — a common mistake that either slips past the linter or gets reported misleadingly as a single admin-port hit. Checked before `exposed-admin-port` so a huge range is reported as a range, not as whichever admin port happens to fall inside it; ordinary app ranges (a few hundred ports) stay unflagged. Linter goes from 8 to 9 smells. - [x] **v1.2.0** — Real IPv6 subset arithmetic in `shadowed-rule`. Until now the CIDR subset check only did full 128-bit math for IPv4; for IPv6 it fell back to a literal text compare, so `2001:db8:0:1::5/128` sitting inside an earlier `2001:db8::/32` DROP went undetected. A proper IPv6 parser (128-bit BigInt, `::` zero-compression, `%zone` suffix, IPv4-mapped tails) now feeds the same prefix comparison used for v4, so v6 shadowed rules are caught with the same precision — and addresses outside the earlier prefix are correctly left alone. - [x] **v1.3.0** — Two chain-posture smells, closing the default-deny story beyond INPUT. **`forward-no-default-deny`** (warning): a built-in `FORWARD` chain of the filter table with policy ACCEPT and no catch-all DROP / REJECT — the moment `ip_forward` is on, the host routes traffic between any networks it can reach (the classic quietly-open Docker / VPN router). Warning rather than error because the ruleset alone can't tell whether forwarding is enabled. **`missing-established-accept`** (warning): a deny-posture `INPUT` (policy DROP / REJECT or a catch-all-drop tail) with no `ESTABLISHED,RELATED` accept rule — replies to the host's own outbound connections (DNS answers, package downloads) get dropped. Skipped for ufw, whose backend inserts the conntrack rule invisibly. New **iptables (sloppy router)** sample trips both, and the test suite now pins that the sample set exercises every smell. Linter goes from 9 to 11 smells. - [x] **v1.4.0** — Two NAT-and-visibility smells. **`masquerade-any-source`** (warning, per rule): a `nat/POSTROUTING` `MASQUERADE` / `SNAT` with no source restriction rewrites *everything* the host forwards, not just the LAN / VPN subnet it was meant for — combined with a permissive FORWARD chain the box becomes an anonymizing relay. Scoping the rule with `-s` (nft `ip saddr`) is nearly free and self-documents intent; source-scoped rules stay unflagged. **`drop-without-log`** (info, per chain): a deny-posture INPUT / FORWARD with no `LOG` / `NFLOG` (or nft `log`) rule drops packets without leaving any forensic trail — port scans and brute-force attempts simply vanish. Info severity (first smell to use it): the ruleset isn't less safe, just blind. Skipped for ufw, whose backend logs invisibly. The sloppy-router sample now trips both. Linter goes from 11 to 13 smells. - [x] **v1.5.0** — New linter smell **`icmpv6-blocked`** (error, per chain): an IPv6-capable default-deny `INPUT` (`ip6tables`, or an nft table of family `ip6` / `inet`) that never accepts ICMPv6. This is the classic "hardened until it breaks" mistake — Neighbor Discovery (the IPv6 replacement for ARP) and Path MTU Discovery both run over ICMPv6, so blocking it kills address resolution and black-holes every packet bigger than the path MTU (IPv6 routers never fragment). The detector follows jumps into user chains, recognizes every spelling (`-p ipv6-icmp` / `-p icmpv6`, nft `ip6 nexthdr icmpv6` / `meta l4proto ipv6-icmp` / `icmpv6 type`), and never fires on IPv4-only rulesets (plain `iptables`, nft family `ip`) or on ufw, whose `before6.rules` accepts ICMPv6 invisibly. New **ip6tables (ICMPv6 blocked)** sample in the Load-sample menu trips it. - [x] **v1.6.0** — New linter smell **`unused-chain`** (warning with rules / info when empty, per chain): a user-defined chain that no packet can ever reach. Reachability is a BFS from the built-in chains following jump/goto within the table, so a chain referenced only by *another* dead chain is flagged too — and the severity split matters: an unreferenced chain *with rules* means someone wrote an ACCEPT/DROP that reads as active but never runs (the jump is missing), while an empty one is just clutter. Skipped for ufw (no user chains in `ufw status`) and for tables with no built-in chain (partial pastes). The sloppy-router sample gains an unwired `LAN_TRUST` chain that trips it, and the flagship iptables sample gets the real-world `-A PREROUTING -m addrtype --dst-type LOCAL -j DOCKER` line its nat `DOCKER` chain always implied — all shipped samples stay clean except the ones meant to fail. - [x] **v1.7.0** — New linter smell **`unlimited-log`** (warning, per rule): a `LOG` / `NFLOG` (or nft `log`) rule with no rate limit. Logging without `-m limit` (or nft `limit rate`) turns observability into an attack surface: every matching packet writes a syslog line, so a port scan or plain packet flood becomes a disk-filling, log-drowning primitive. Recognizes `-m limit`, `-m hashlimit` and nft `limit rate`; complements `drop-without-log` (v1.4.0) — that one wants a LOG rule to exist, this one wants it throttled. Skipped for ufw (its LOG rules live in the backend, invisible to `ufw status`). The known-good samples now model the best practice (`-m limit --limit 5/min` / `limit rate 5/minute` on every LOG), while the port-forward and no-ICMPv6 samples keep their unthrottled LOGs and trip the smell. - [x] **v1.8.0** — New linter smell **`duplicate-rule`** (warning, per rule): a rule that is a byte-for-byte copy (whitespace-insensitive) of an earlier rule in the same chain — the signature of a non-idempotent provisioning script, an `iptables -A` in rc.local or a deploy hook that appends on every run. A duplicated terminal rule never fires, but a duplicated *side-effect* rule runs twice: a LOG writes two lines per packet, a jump traverses its subchain again — exactly the rules `shadowed-rule` deliberately skips. Division of labor is explicit: exact textual copies are `duplicate-rule`'s domain (shadow detection now excludes them), semantic subsets remain `shadowed-rule`'s. Findings carry a `duplicateOf` index pointing at the original. Skipped for ufw (its CLI refuses to add an existing rule). The sloppy-router sample now appends its MASQUERADE twice, as its rc.local would. - [x] **v1.9.0** — New linter smell **`missing-invalid-drop`** (info, per chain): a deny-posture `INPUT` / `FORWARD` that accepts ports but never drops `ctstate INVALID`. The default-deny only catches traffic that matches *no* accept — malformed / out-of-window packets match a bare `--dport 80 -j ACCEPT` just like a legitimate SYN, so standard hardening drops INVALID right after loopback, before any port accept. Raised only when the chain has an accept a crafted packet could actually ride (non-loopback, non-conntrack); an INVALID drop placed *below* the accepts is flagged too, pointing at the misplaced rule instead of the chain. Info severity, the second smell to use it after `drop-without-log`: a hardening gap, not an open door. Skipped for ufw, whose backend drops INVALID in `ufw-before-input` without ever showing it. Completes the conntrack-posture trio with `missing-established-accept` (replies get in) and `missing-input-drop` (everything else stays out). - [x] **v1.10.0** — New linter smell **`overbroad-source-trust`** (warning, per rule): an ACCEPT whose source is a huge *public* range — `/8` or shorter for IPv4, `/16` or shorter for IPv6. `0.0.0.0/1` is half the internet and `2000::/3` is *all* global unicast, yet neither matches the any-source test, so an admin port "restricted" to `128.0.0.0/2` sails past `exposed-admin-port` and past most human reviews ("it has a `-s`, someone thought about it"). Private space is exempt (`10.0.0.0/8`, loopback, ULA `fc00::/7`, link-local — a site-wide trust is a normal rule), DROP/REJECT rules are exempt (broad *caution* is fine, broad *trust* is the smell), and true any-source accepts stay with the existing smells. Reuses the BigInt CIDR machinery from `shadowed-rule` (`parseCidr` + `cidrSubsetOrAny`) — no new parsing. The exposed-services sample gains a `-s 0.0.0.0/1 --dport 8443` "restricted admin panel" that trips it. - [x] **v1.11.0** — New linter smell **`unlimited-icmp-echo`** (info, per rule): an ACCEPT that answers ping (ICMP echo-request) from any source with no rate limit. Every echo-request costs the host an echo-reply, so an unthrottled accept is a free reflection primitive — a spoofed-source flood turns the box into an amplifier, a direct one burns its CPU and bandwidth. For IPv4 a blanket `-p icmp -j ACCEPT` counts (echo-request is included in "all types"); an explicit non-echo `--icmp-type` never answers ping and is spared. For IPv6 only an *explicit* echo-request (type 128) match is flagged — blanket ICMPv6 accepts are required hygiene (Neighbor Discovery, PMTUD) that `icmpv6-blocked` exists to demand, and punishing them here would send users in circles. Recognizes the nft spellings (`ip protocol icmp`, `icmp type echo-request`, `icmpv6 type 128`) and the same throttles as `unlimited-log` (`-m limit`, `-m hashlimit`, nft `limit rate`). Skipped for ufw (its ICMP policy lives in `before.rules`, invisible to `ufw status`). The known-good samples now model the fix (`-m limit --limit 10/sec` / `limit rate 10/second` on their ICMP accepts); the sloppy-router and port-forward samples keep their unthrottled `-p icmp -j ACCEPT` and trip it. - [x] **v1.12.0** — New linter smell **`unrestricted-egress`** (info, per chain): a locked-down `INPUT` next to a wide-open `OUTPUT`. Ingress filtering never sees what post-compromise tooling does — reverse shells, data exfiltration and C2 beacons all dial *out*, riding an `OUTPUT` policy `ACCEPT` with no catch-all deny. Flagged **only when some INPUT-like chain in the same table has a deny posture** (policy `DROP`/`REJECT` or a final catch-all deny): on a firewall open in both directions `missing-input-drop` already says the important thing, and lecturing about egress on top would be noise. Info severity on purpose — unrestricted egress is the default posture almost everywhere, so this is defense-in-depth advice (allow loopback, ESTABLISHED, DNS/NTP and the destinations the host actually needs, then default-deny), not an open door. Reads all four formats, including the default ufw posture (`deny (incoming), allow (outgoing)` — the fix there is `ufw default deny outgoing` plus `allow out` rules); an nft ruleset with a deny-posture `output` hook chain stays clean. - [x] **v1.13.0** — New linter smell **`mac-based-trust`** (warning, per rule): an ACCEPT keyed on the sender's MAC address (`-m mac --mac-source`, nft `ether saddr`). A MAC is identification, not authentication — it is broadcast to the whole local segment (ARP/NDP) and forged with one `ip link set address` command, so a MAC-"restricted" accept hands every LAN neighbor a skeleton key while *looking* scoped. The finding also reminds that the IP-level smells still judge such a rule as unrestricted: a MAC match is **not** a source restriction, so an ssh accept guarded only by MAC correctly draws `exposed-admin-port` *and* this smell at once (pinned by a test). Blocking by MAC stays unflagged — broad caution is fine, borrowed trust is the smell, the same philosophy as `overbroad-source-trust`'s DROP exemption. Skipped for ufw (its rule syntax has no MAC match). The sloppy-router sample gains a "management UI for my laptop's MAC" rule that trips it. - [x] **v1.14.0** — New linter smell **`admin-port-no-rate-limit`** (info, per rule): an ACCEPT to an admin port (SSH, RDP, database consoles — the existing `ADMIN_PORTS` set) with no per-source rate limit. Brute-force login attempts then arrive at full speed; a netfilter throttle (`-m recent` / `-m hashlimit` / `-m connlimit`, plain `-m limit`, or nft `limit rate` / `ct count`) caps attempts per source *before* they reach the service, complementing Fail2Ban (which reacts after the fact, from the log). Reads the iptables and nft spellings; **composes** with `exposed-admin-port` on different axes — that one is about *who* can reach the port, this one about *how fast* they can hammer it, so an unthrottled any-source SSH draws both (pinned by a test). Fires regardless of source scope: even a subnet-restricted admin port is worth throttling. Skipped for ufw (its own `limit` verb lives in the backend view). The sloppy-router sample's `--dport 22` accept trips it. - [x] **v1.15.0** — New linter smell **`log-tcp-sequence`** (warning, per rule): a LOG rule carrying `--log-tcp-sequence` (nft: `log flags tcp sequence`, also included in the `log flags all` shorthand) writes TCP sequence numbers into syslog — the iptables man page calls the flag "a security risk if the log is readable by users", sequence numbers are the raw material for off-path injection / connection hijacking, and log lines routinely travel further than root (adm-group readable `/var/log`, log shippers, centralised collectors). Completes the LOG trio: `drop-without-log` asks that a deny-posture chain log at all, `unlimited-log` that logging be bounded, this one that it not leak. `--log-tcp-options` / `--log-ip-options` are deliberately spared (header options, not hijacking material), and the smell is independent of the rate limit — a throttled LOG can still leak (pinned by a test). Skipped for ufw, whose CLI cannot express LOG flags. The port-forward sample's INPUT LOG rule now trips it. - [x] **v1.16.0** — New linter smell **`missing-loopback-spoof-drop`** (info, per chain): the other half of CIS "loopback traffic is configured" — `loopback-not-allowed` demands the `-i lo ACCEPT`, this one demands its companion: **drop anything claiming a `127.0.0.0/8` (or `::1`) source that arrives on a real interface**. A spoofed loopback source rides a plain `--dport 80 ACCEPT` like any packet, and services that trust "it came from localhost" believe it. The kernel normally drops these as martians, but `route_localnet=1` re-opens the door — and container tooling flips it (kube-proxy did, **CVE-2020-8558**), so the firewall rule is the belt to the kernel's braces. Only raised when the chain *has* the lo accept (the broken-loopback case stays `loopback-not-allowed`'s job) and has an accept a spoofed packet could ride; a drop placed *below* the accepts is flagged as misplaced, pointing at the rule. Reads the iptables, ip6tables and nft spellings; skipped for ufw (before.rules never show in `ufw status`). The good samples now model the pair (`-s 127.0.0.0/8 -j DROP` / `-s ::1/128 -j DROP` / `ip saddr 127.0.0.0/8 drop` right under the lo accept); the port-forward sample trips it. - [x] **v1.17.0** — New linter smell **`ipv6-unfiltered`** (warning, per ruleset — the first detector that pairs address *families*): a deny-postured `table ip` input hook with **no `ip6` or `inet` table hooking input at all**. nftables families are independent pipelines, so a carefully locked-down IPv4 firewall filters *only* IPv4 — every dual-stack service is still reachable over the address router advertisements handed each host, and attackers scan v6 too. Fires only for nftables pastes (an `iptables-save` dump can't show the other family — `ip6tables` may well be fine; ufw manages both stacks itself) and only when the v4 posture proves filtering was intended. If a v6/inet input hook *exists* but is wide open, `missing-input-drop` already says the important thing — this smell only speaks where no other can. New **nft (IPv4-only — v6 open)** sample. Linter goes from 25 to 26 smells. - [x] **v1.18.0** — New linter smell **`dnat-forward-blocked`** (warning, cross-table): a `DNAT` in `nat/PREROUTING` rewrites the packet *before* the filter table sees it — but the rewritten packet still has to survive `FORWARD`. Publish `:2222 → 10.0.0.20:22` and forget the matching `FORWARD -d 10.0.0.20 --dport 22 ACCEPT`, and a deny-postured `FORWARD` silently drops it: the port-forward looks configured, the service is dark, and you debug the DNAT for an hour. The **exact opposite** failure of `exposed-via-dnat` (there the forward *works* and exposes an admin port) — they compose, so the new **iptables (dead port-forward)** sample trips both on different rules. Fires only when `FORWARD` is deny-postured and no `ACCEPT` covers the target; a conntrack `ESTABLISHED,RELATED` rule doesn't count (the first forwarded packet is `NEW`), and any accept whose destination and port cover the target suppresses it — including matches we don't fully model, so it never cries wolf. Fixed a latent bug on the way: `extractDnatRewrite` returned `null` for **nftables** `dnat to …` rules (the trace and `exposed-via-dnat` never saw them). Linter goes from 26 to 27 smells. - [x] **v1.19.0** — New linter smell **`bogon-source-accept`** (warning): an `ACCEPT` in a filter chain trusting a **non-routable / bogon source** — "this network" (`0.0.0.0/8`), link-local (`169.254.0.0/16`), the TEST-NET / documentation blocks (`192.0.2.0/24`, `198.51.100.0/24`, `203.0.113.0/24`), reserved future-use space (`240.0.0.0/4`), or the v6 documentation prefix (`2001:db8::/32`). A packet arriving with one of these as its source is spoofed — that range can't legitimately originate traffic reaching the host — so accepting it is either botched anti-spoofing or misplaced trust. Same *wide caution ok, wide trust not* rule as `overbroad-source-trust` and `mac-based-trust`: a `DROP`/`REJECT` of a bogon is correct anti-spoofing and stays unflagged; only an `ACCEPT` trips. Loopback (`127/8`, `::1`) is left to its own pair of smells, and CGNAT (`100.64/10`) / RFC1918 stay out (legitimate behind many networks). The sloppy-router sample gains an `ACCEPT` from `169.254.0.0/16`. Linter goes from 27 to 28 smells. - [x] **v1.20.0** — New linter smell **`log-without-prefix`** (info): a `LOG` / `NFLOG` / nft `log` rule with no `--log-prefix` (nft `log prefix "..."`, NFLOG `--nflog-prefix`). Anonymous log lines all look alike in syslog — you can't tell an INPUT drop from a FORWARD drop, grep for one chain, or route a jail's lines to their own file. The prefix is the label that makes the log usable after the fact. Completes the **LOG family**: `drop-without-log` (a log exists), `unlimited-log` (it's rate-limited), `log-tcp-sequence` (it doesn't leak sequence numbers), and now this (it's identifiable). Info severity — the log works, it's just harder to read; skipped for ufw, whose backend prefixes its own logs. The port-forward sample's `FORWARD` LOG loses its prefix to trip it. Linter goes from 28 to 29 smells. - [x] **v1.21.0** — New linter smell **`dnat-unscoped`** (warning): a `DNAT` in `nat/PREROUTING` that names **neither a destination address** (`-d` / nft `ip daddr`) **nor an input interface** (`-i` / nft `iifname`) matches that port on *every* packet entering the router — not just what arrives on the public side. A LAN client talking to *any* host on that port is silently hijacked into the forward too, and so is traffic between internal segments; a port-forward should say where it applies (the public address or the outside interface). Completes the DNAT family from the *other* side: `exposed-via-dnat` asks *who can reach the target*, `dnat-forward-blocked` asks *whether the forward even works*, this one asks *what else the rewrite swallows*. **`REDIRECT` is exempt on purpose** — it rewrites to the local host, and transparent-proxy REDIRECTs legitimately match any destination. The sloppy-router sample gains an 8080 forward added "in a hurry" that trips it. Linter goes from 29 to 30 smells. - [x] **v1.22.0** — New linter smell **`dnat-no-hairpin`** (info): the **fourth side of the DNAT family** — `exposed-via-dnat` asks *who can reach the target*, `dnat-forward-blocked` *whether the forward works at all*, `dnat-unscoped` *what else it swallows*, and this one asks *whether it works from the inside*. A LAN client connecting to the router's **public** address rides the same DNAT to the internal server — but the server sees the client's own on-link source and replies **directly**, bypassing the router; the client expected the reply from the public address and drops the connection. The forward works from the internet and silently fails from the LAN: the classic *"works from my phone on 4G, not from my desk"* mystery. The fix is the **hairpin (NAT-loopback) leg** — a `POSTROUTING` MASQUERADE scoped to the DNAT target as *destination* — or split-horizon DNS (hence info, not warning). Never cries wolf: fires only when POSTROUTING already NATs something (provably a router), only for RFC1918 targets a LAN packet can actually match (an `-i `-scoped DNAT never sees LAN traffic — no SNAT leg could help it), and any destination-covering SNAT/MASQUERADE — or one with neither destination nor out-interface — suppresses it; the classic `-o `-only masquerade does **not** (hairpinned replies leave through the LAN interface). The sloppy router's hurried 8080 forward now draws `dnat-unscoped` *and* this smell on the same rule, two complaints on different axes (pinned by a test). Linter goes from 30 to 31 smells. - [x] **v1.23.0** — New linter smell **`rate-limit-not-per-source`** (warning): the follow-up question to `admin-port-no-rate-limit` — that one asks whether a throttle *exists*, this one whether it is *keyed right*. Plain `-m limit`, `-m hashlimit` without a `srcip` mode, and a bare nft `limit rate` all keep **one token bucket that every client drains together**: an attacker holding the bucket empty with a trickle of SYNs makes the rule drop everyone *else's* connections too, so the "brute-force throttle" doubles as a **remote off-switch for the service** — the classic flaw of the tutorial SYN-flood recipe. The fix is a per-client bucket: `-m hashlimit --hashlimit-mode srcip`, `-m recent`, `-m connlimit` (nft: a `meter` / dynamic set keyed on `ip saddr`, or `ct count`). Scoped to **TCP accepts on purpose**: global caps are the right tool where *total* volume is the concern — ICMP echo (what `unlimited-icmp-echo` prescribes) and UDP amplification ceilings stay unflagged. Mutually exclusive with `admin-port-no-rate-limit` by construction (no throttle → that one; shared throttle → this one; pinned by a test). The sloppy router's second 8443 accept "throttles brute force" with plain `-m limit` and trips it. Linter goes from 31 to 32 smells. - [x] **v1.24.0** — New linter smell **`rate-limit-drop-inverted`** (warning): the DROP side of the rate-limit story — the bucket points the **wrong way**. `-m limit` (always), `-m hashlimit` without `--hashlimit-above`, and nft `limit rate` without `over` all match traffic while it is *under* the rate: the right direction for an ACCEPT ("let this much through"), exactly backwards on a DROP — the rule discards the first N packets of every interval (calm, legitimate traffic) and once the bucket runs dry **the flood sails past it** to the rules below. The tutorial SYN-flood recipe with `-j DROP` on the limit line degrades the service on a quiet day and protects nothing under attack. Judged for **every protocol** (dropping calm pings is as backwards as dropping calm SYNs) and per-source keying does not save it (a `meter { ip saddr limit rate 3/minute } drop` just inverts per client). **REJECT is exempt on purpose**: an under-limit REJECT is the classic reflector-avoidance recipe — cap how many polite rejections leave per second, let the excess fall through to a silent drop. Alongside it, **`rate-limit-not-per-source` now judges drop-the-excess rules too**: `limit rate over` / `--hashlimit-above` with one global bucket lets an attacker push everyone into the excess, so the flood protection drops legitimate packets alongside the flood — same off-switch, DROP form (still TCP-only; mutually exclusive with the inverted smell by construction: under-limit direction lands there, over-limit sharing lands here). The sloppy router gains the tutorial SYN-flood pair on port 80 and trips it. Linter goes from 32 to 33 smells. ## Stack - **Parsing**: vanilla JavaScript, one IIFE per parser, exposed on `window` to keep `file://` debugging cheap. - **Visualisation**: [Cytoscape.js](https://js.cytoscape.org/) 3.30 + [dagre](https://github.com/dagrejs/dagre) 0.8 via [cytoscape-dagre](https://github.com/cytoscape/cytoscape.js-dagre) 2.5. - **No backend**. Everything is one HTML file, three JS files for the renderer, and four parser files — served statically. ## About Built by **[Danny Ruiz](https://github.com/DannyRuizB)** — systems & network administrator (ASIR, *Administración de Sistemas Informáticos en Red*). [More projects →](https://github.com/DannyRuizB?tab=repositories) ## License MIT © Danny Ruiz Boluda
标签:iptables, MITM代理, nftables, ufw, 云安全监控, 前端可视化, 多模态安全, 数据可视化, 自定义脚本, 防火墙规则, 静态分析