Skip to content

Latest commit

Β 

History

History
516 lines (393 loc) Β· 25.5 KB

File metadata and controls

516 lines (393 loc) Β· 25.5 KB

Architecture

System Overview

The tool reads a raw ruleset file (iptables-save or nft list ruleset output), converts it into an internal representation, and then does one of several things with it depending on which subcommand you ran. Here is the full picture:

                         β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                         β”‚  Raw Ruleset β”‚
                         β”‚    (file)    β”‚
                         β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                         β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
                         β”‚ detect_formatβ”‚
                         β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                 β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
                 β”‚                             β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”              β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
          β”‚parse_iptablesβ”‚              β”‚parse_nftablesβ”‚
          β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜              β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                 β”‚                             β”‚
                 β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
                         β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”
                         β”‚   Ruleset    β”‚
                         β”‚ (internal)   β”‚
                         β””β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”˜
                                β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚         β”‚           β”‚           β”‚          β”‚
   β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β” β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”€β”
   β”‚ display β”‚ β”‚analyze β”‚ β”‚optimizeβ”‚ β”‚ export β”‚ β”‚  diff  β”‚
   β”‚  table  β”‚ β”‚conflictβ”‚ β”‚suggest β”‚ β”‚ruleset β”‚ β”‚compare β”‚
   β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”€β”¬β”€β”€β”€β”€β”˜
                   β”‚           β”‚           β”‚         β”‚
            β”Œβ”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β–Όβ”€β”€β”   β”Œβ”€β”€β”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β”€β–Όβ”€β”€β”€β”€β”€β”
            β”‚   print_findings    β”‚   β”‚ string β”‚ β”‚print_  β”‚
            β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜   β”‚ output β”‚ β”‚ diff   β”‚
                                      β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Each CLI subcommand maps to a function in main.v that calls into one or more modules. No subcommand touches more than two or three modules. The harden command is the exception: it skips the parser entirely and goes straight to the generator.

Command Modules used
load parser, display
analyze parser, analyzer, display
optimize parser, analyzer, display
harden generator, display (banner only)
export parser, generator
diff parser, display

Module Layout

In V, every directory under src/ is a module. The module name matches the directory name. Files inside a module share the same namespace automatically, so common.v, iptables.v, and nftables.v all belong to module parser and can call each other's functions directly.

src/
β”œβ”€β”€ main.v          (module main)
β”œβ”€β”€ config/
β”‚   └── config.v    (module config)
β”œβ”€β”€ models/
β”‚   └── models.v    (module models)
β”œβ”€β”€ parser/
β”‚   β”œβ”€β”€ common.v    (module parser)
β”‚   β”œβ”€β”€ iptables.v  (module parser)
β”‚   └── nftables.v  (module parser)
β”œβ”€β”€ analyzer/
β”‚   β”œβ”€β”€ conflict.v  (module analyzer)
β”‚   └── optimizer.v (module analyzer)
β”œβ”€β”€ generator/
β”‚   └── generator.v (module generator)
└── display/
    └── display.v   (module display)

The dependency graph:

                        β”Œβ”€β”€β”€β”€β”€β”€β”
                        β”‚ main β”‚
                        β””β”€β”€β”¬β”€β”€β”€β”˜
                           β”‚
          β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
          β”‚        β”‚       β”‚        β”‚          β”‚
      β”Œβ”€β”€β”€β–Όβ”€β”€β” β”Œβ”€β”€β–Όβ”€β”€β”€β” β”Œβ”€β–Όβ”€β”€β” β”Œβ”€β”€β–Όβ”€β”€β”€β”€β” β”Œβ”€β”€β”€β–Όβ”€β”€β”€β”
      β”‚parserβ”‚ β”‚analyz.β”‚ β”‚gen.β”‚ β”‚displayβ”‚ β”‚config β”‚
      β””β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”€β”¬β”€β”€β”€β”˜ β””β”€β”¬β”€β”€β”˜ β””β”€β”€β”¬β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”˜
         β”‚        β”‚       β”‚       β”‚
         β”‚     β”Œβ”€β”€β–Όβ”€β”€β”€β”   β”‚    β”Œβ”€β”€β–Όβ”€β”€β”€β”
         β”œβ”€β”€β”€β”€β–Ίβ”‚models β”‚β—„β”€β”€β”˜   β”‚modelsβ”‚
         β”‚     β””β”€β”€β”¬β”€β”€β”€β”˜        β””β”€β”€β”¬β”€β”€β”€β”˜
         β”‚        β”‚               β”‚
         β–Ό        β–Ό               β–Ό
      β”Œβ”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”       β”Œβ”€β”€β”€β”€β”€β”€β”
      β”‚configβ”‚ β”‚configβ”‚       β”‚configβ”‚
      β””β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”˜       β””β”€β”€β”€β”€β”€β”€β”˜

Three things to notice:

  • config and models are leaf modules. They import nothing from the project.
  • parser, analyzer, generator, and display never import each other. Zero cross-dependencies.
  • main is the only module that imports everything. It is the composition root.

This means you can rewrite the entire nftables parser without touching the analyzer, or overhaul the display layer without the generator knowing. V enforces no circular imports at compile time, so this structure cannot accidentally degrade.

Data Flow

load command

fwrule load testdata/iptables_basic.rules

  os.read_file(path)
       β”‚
       β–Ό
  detect_format(content)  ──►  RuleSource.iptables
       β”‚
       β–Ό
  parse_iptables(content)
       β”‚
       β”œβ”€β”€ iterate lines
       β”œβ”€β”€ "*filter"         β†’ set current_table = .filter
       β”œβ”€β”€ ":INPUT DROP"     β†’ policies["INPUT"] = .drop
       β”œβ”€β”€ "-A INPUT ..."    β†’ tokenize β†’ parse flags β†’ Rule
       β”‚
       β–Ό
  Ruleset { rules: [...], policies: {...}, source: .iptables }
       β”‚
       β”œβ”€β”€ display.print_banner()
       β”œβ”€β”€ display.print_summary(rs)
       └── display.print_rule_table(rs)

load_ruleset in main.v reads the file, auto-detects the format, and dispatches to the right parser. The resulting Ruleset goes to the display module for rendering.

analyze command

fwrule analyze testdata/iptables_conflicts.rules

  load_ruleset(path)  ──►  Ruleset
       β”‚
       β”œβ”€β”€ analyzer.analyze_conflicts(rs)
       β”‚       β”‚
       β”‚       β”œβ”€β”€ rs.rules_by_chain()  β†’  map[string][]int
       β”‚       β”‚
       β”‚       └── for each chain:
       β”‚              find_duplicates(rules, indices)
       β”‚              find_shadowed_rules(rules, indices)
       β”‚              find_contradictions(rules, indices)
       β”‚              find_redundant_rules(rules, indices)
       β”‚       β”‚
       β”‚       └──►  []Finding
       β”‚
       β”œβ”€β”€ analyzer.suggest_optimizations(rs)
       β”‚       β”‚
       β”‚       └── for each chain:
       β”‚              find_mergeable_ports(rules, indices)
       β”‚              suggest_reordering(rules, indices)
       β”‚              find_missing_rate_limits(rules, indices)
       β”‚              find_missing_conntrack(rules, indices)
       β”‚       β”‚
       β”‚       └── find_missing_logging(rs)  (whole-ruleset check)
       β”‚       β”‚
       β”‚       └──►  []Finding
       β”‚
       └── display.print_findings(conflicts)
           display.print_findings(optimizations)

Two passes: conflict detection (things that are broken) then optimization analysis (things that could be better). Both return []Finding that the display module renders with severity coloring.

harden command

fwrule harden -s ssh,http,https -f nftables

  flag.new_flag_parser(args)
       β”‚
       β”œβ”€β”€ services = ["ssh", "http", "https"]
       β”œβ”€β”€ iface = "eth0", format = nftables
       β”‚
       β–Ό
  generator.generate_hardened(services, iface, .nftables)
       β”‚
       β”œβ”€β”€ default-deny policy β†’ loopback accept β†’ conntrack
       β”œβ”€β”€ anti-spoofing (RFC 1918 on public iface)
       β”œβ”€β”€ ICMP rate-limited
       β”œβ”€β”€ per-service rules from config.service_ports
       β”œβ”€β”€ drop logging β†’ final drop
       β”‚
       └──►  string (printed to stdout)

This is the only command that does not parse a file. It builds a ruleset from scratch using templates and the service-to-port mapping from config.v.

export command

fwrule export testdata/iptables_basic.rules -f nftables

  load_ruleset(path)  ──►  Ruleset (source: .iptables)
       β”‚
       β–Ό
  generator.export_ruleset(rs, .nftables)
       β”‚
       β”œβ”€β”€ group rules by table
       β”œβ”€β”€ for each chain: header + policy, then rule_to_nftables per rule
       β”‚
       └──►  string (printed to stdout)

Each Rule struct carries enough information to be serialized into either format. The rule_to_iptables and rule_to_nftables functions read fields off the struct and reconstruct the target syntax.

diff command

fwrule diff old.rules new.rules

  load_ruleset(path1)  ──►  Ruleset (left)
  load_ruleset(path2)  ──►  Ruleset (right)
       β”‚
       β–Ό
  display.print_diff(left, right)
       β”‚
       β”œβ”€β”€ build_rule_set(rules)  β†’  map[string]bool  (both sides)
       β”œβ”€β”€ keys in left but not right β†’ "only in left"
       β”œβ”€β”€ keys in right but not left β†’ "only in right"
       └── no differences β†’ "Rulesets are equivalent"

The diff normalizes every rule to a canonical string via Rule.str() and compares sets. It compares semantic content, not raw text, so an iptables rule and an nftables rule expressing the same policy show as equivalent.

Core Types

All types live in src/models/models.v. The parser produces them, the analyzer inspects them, the generator and display modules consume them.

Ruleset

Ruleset {
    rules    []Rule              ordered list of all parsed rules
    policies map[string]Action   chain name β†’ default action ("INPUT" β†’ .drop)
    source   RuleSource          iptables or nftables
}

The top-level container. rules is ordered by position in the original file. policies maps chain names to their default actions. The rules_by_chain() method groups rule indices by chain name so the analyzer can restrict comparisons to within a single chain.

Rule

Rule {
    table       Table            filter, nat, mangle, raw, security
    chain       string           "INPUT", "FORWARD", or custom name
    chain_type  ChainType        parsed enum for known chains
    action      Action           accept, drop, reject, log, masquerade, ...
    criteria    MatchCriteria    all match conditions (see below)
    target_args string           extra args after -j (e.g., --log-prefix "...")
    line_number int              original line number in source file
    raw_text    string           unparsed original line
    source      RuleSource       which format this rule came from
}

Whether the input was iptables or nftables, every parsed rule becomes this same struct. chain_type defaults to .custom for user-defined chains. line_number and raw_text survive the parse so that findings can reference back to the original file.

MatchCriteria

MatchCriteria {
    protocol    Protocol         default: .all (matches everything)
    source      ?NetworkAddr     optional source CIDR
    destination ?NetworkAddr     optional destination CIDR
    src_ports   []PortSpec       source port ranges
    dst_ports   []PortSpec       destination port ranges
    in_iface    ?string          input interface
    out_iface   ?string          output interface
    states      ConnState        bitmask: new|established|related|invalid
    icmp_type   ?string          ICMP type string
    limit_rate  ?string          rate limit (e.g., "3/minute")
    limit_burst ?int             burst count
    comment     ?string          rule comment
}

This is where V's option types (?Type) pay off. source ?NetworkAddr means "this rule might or might not constrain the source address." When none, the rule matches any source. When set, it matches only that network. This distinction is critical for superset/subset logic: source = none is a superset of source = 10.0.0.0/8, because "match anything" contains "match this network." Without option types you could not distinguish "no constraint" from "explicitly matches 0.0.0.0/0."

Finding

Finding {
    severity     Severity        info, warning, critical
    title        string          short label ("Shadowed rule detected")
    description  string          full explanation with rule numbers
    rule_indices []int           zero-based indices into Ruleset.rules
    suggestion   string          actionable fix
}

The output of both conflict detection and optimization analysis. rule_indices contains zero-based indices into Ruleset.rules, so the display layer can say "Rules 7, 8" without needing to hold rule objects.

NetworkAddr and PortSpec

NetworkAddr {                PortSpec {
    address string               start   int
    cidr    int = 32             end     int = -1
    negated bool                 negated bool
}                            }

NetworkAddr stores an IP and prefix length. The cidr field defaults to 32 (a single host). The negated flag handles ! prefixes in both iptables (! -s 10.0.0.0/8) and nftables (ip saddr != 10.0.0.0/8).

PortSpec stores a port or port range. A single port like 22 has end = -1, and effective_end() returns start in that case so range math works uniformly. A range like 1024:65535 has start = 1024, end = 65535.

cidr_contains and port_range_contains are the two containment primitives that the analyzer's entire superset/subset logic is built on.

ConnState as a @[flag] enum

@[flag]
pub enum ConnState {
    new_conn       bit 0 β†’ value 1
    established    bit 1 β†’ value 2
    related        bit 2 β†’ value 4
    invalid        bit 3 β†’ value 8
    untracked      bit 4 β†’ value 16
}

The @[flag] attribute makes this a bitfield. Each variant is a power of two, and a single ConnState value can represent multiple states at once. ESTABLISHED,RELATED is two bits set in one integer. The set(), has(), all(), and is_empty() methods are generated automatically by V.

This mirrors how the kernel's conntrack system actually works: connection states are bitmask flags, not mutually exclusive values. A packet in state ESTABLISHED is not also NEW, but a rule can match both ESTABLISHED and RELATED simultaneously. The bitfield makes subset checks in the analyzer trivial: outer.states.all(inner.states) is a single bitwise AND.

Parser Design

The parser solves a two-format problem. iptables-save and nft list ruleset express the same firewall concepts but with completely different syntax.

iptables parser (iptables.v)

iptables-save output is line-oriented. Every rule is one line with flag-value pairs:

-A INPUT -p tcp -s 10.0.0.0/8 --dport 22 -m conntrack --ctstate NEW -j ACCEPT

The parser works in two stages. tokenize_iptables splits on whitespace while respecting quoted strings, then the token iterator consumes flag-value pairs:

["-A", "INPUT", "-p", "tcp", "-s", "10.0.0.0/8", "--dport", "22", "-j", "ACCEPT"]
  β”‚       β”‚       β”‚      β”‚     β”‚         β”‚            β”‚         β”‚     β”‚       β”‚
  └─chainβ”€β”˜       β””protoβ”€β”˜    └──sourceβ”€β”€β”˜            └──portβ”€β”€β”€β”˜    β””actionβ”€β”˜

The ! negation operator is handled by a next_negated flag that carries forward to the next address or port parsed. At the file level, parse_iptables iterates all lines: *filter sets the current table, :INPUT DROP [0:0] records chain policies, COMMIT is skipped, and lines starting with -A/-I get fed to the rule parser.

nftables parser (nftables.v)

nftables output is block-structured with braces:

table inet filter {
    chain input {
        type filter hook input priority 0; policy drop;
        ct state established,related accept
        tcp dport 22 accept
    }
}

The parser uses line-by-line iteration with three levels of nesting:

parse_nftables            scans for "table" lines
    β”‚
    └── parse_nft_table   extracts table name, scans for "chain" lines
            β”‚
            └── parse_nft_chain   extracts chain name + policy, scans for rule lines
                    β”‚
                    └── parse_nft_rule   tokenizes a single rule line

Each function takes the full lines []string array and a start index, returning the new index after consuming its block. A closing } returns control to the parent.

Inside each rule line, keyword tokens drive the parse: tcp/udp set protocol and trigger port parsing, ip saddr/daddr extract addresses, ct state extracts connection tracking, and terminal keywords (accept, drop, reject) set the action. parse_nft_port_match handles both single ports (dport 22) and brace-enclosed sets (dport { 80, 443 }).

Shared parsing layer (common.v)

Both parsers share functions from common.v: parse_network_addr (CIDR + negation), parse_port_spec (single ports, ranges, negation), parse_port_list (comma-separated), parse_protocol (names and numbers to enum), parse_action, parse_table, parse_chain_type, and parse_conn_states (comma-separated states to bitfield).

detect_format looks at the first non-empty, non-comment line. * or : or -A means iptables. table means nftables.

Analyzer Design

Pairwise comparison

The analyzer groups rules by chain via rs.rules_by_chain(), then compares every pair within each chain (N*(N-1)/2 comparisons per chain). Rules in different chains are never compared because the kernel evaluates each chain independently.

Four conflict types

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ Type           β”‚ How it is detected                                  β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Shadowed       β”‚ Rule A appears before rule B in the chain. A's     β”‚
β”‚                β”‚ criteria is a superset of B's. B can never fire     β”‚
β”‚                β”‚ because A catches all its traffic first.            β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Contradiction  β”‚ Rules A and B overlap in their match criteria but   β”‚
β”‚                β”‚ have opposing actions (one accepts, one drops or    β”‚
β”‚                β”‚ rejects). Not a full superset, or it would be       β”‚
β”‚                β”‚ classified as shadowing instead.                    β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Duplicate      β”‚ Two rules have identical criteria AND the same      β”‚
β”‚                β”‚ action. The second one is dead weight.              β”‚
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚ Redundant      β”‚ Rule A is a superset of rule B with the same       β”‚
β”‚                β”‚ action, but they are not exact duplicates. B is     β”‚
β”‚                β”‚ unnecessary but not harmful.                        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Superset/subset math

"Does rule A match every packet that rule B matches?" breaks down field by field.

CIDR containment converts IPs to 32-bit integers and compares prefixes via bit shifts:

outer = 10.0.0.0/8       inner = 10.1.2.0/24

ip_to_u32("10.0.0.0")  = 0x0A000000
ip_to_u32("10.1.2.0")  = 0x0A010200

shift = 32 - 8 = 24

0x0A000000 >> 24 = 0x0A
0x0A010200 >> 24 = 0x0A

Same prefix after shift β†’ 10.0.0.0/8 contains 10.1.2.0/24

Port range containment is a simple bounds check:

outer = 1024:65535     inner = 8080:8443

outer.start (1024) <= inner.start (8080)  βœ“
outer.end (65535)  >= inner.end (8443)    βœ“

β†’ outer contains inner

Protocol hierarchy: protocol .all is a superset of every specific protocol. If the outer rule matches .all and the inner matches .tcp, the outer covers everything the inner does.

Option type handling: none (no constraint) is a superset of any specific value. source = none covers source = 10.0.0.0/8 because "match anything" contains "match this network." If the outer has a specific address, the inner must also have one, and CIDR containment must hold.

Why findings carry rule indices

Every Finding includes rule_indices pointing back to specific positions in Ruleset.rules. The display layer uses these to print "Rules: 7, 12" next to each finding without needing the Rule objects themselves.

Generator Design

Template-based hardened rulesets

generate_hardened dispatches to either generate_iptables_hardened or generate_nftables_hardened. Both build a string array line by line following the same logical template: default-deny policies, loopback accept, conntrack, anti-spoofing (RFC 1918 on public interface), rate-limited ICMP, per-service rules from config.service_ports, drop logging, and a final explicit DROP. SSH gets rate limiting (3/minute burst 5). DNS gets both TCP and UDP. NTP gets UDP only. Everything else gets TCP.

Format export

export_ruleset iterates every rule and calls rule_to_iptables or rule_to_nftables to reconstruct the syntax:

Rule { protocol: .tcp, dst_ports: [PortSpec{22}], action: .accept }
    β”‚
    β”œβ”€β”€ rule_to_iptables β†’ "-A INPUT -p tcp --dport 22 -j ACCEPT"
    └── rule_to_nftables β†’ "tcp dport 22 accept"

The export functions also handle structural elements: table headers, chain declarations with policies, and format-specific markers like COMMIT for iptables.

Design Decisions

Why V

V compiles to a native binary with zero runtime dependencies. You run v . and get a single executable. No interpreter, no VM, no shared libraries beyond libc. For a security tool that might run on locked-down servers, this matters. The v.mod file shows dependencies: [].

The syntax is deliberately simple. If you can read C, Go, or Python, you can read V immediately. Option types (?Type) give you null safety without Rust's ceremony. The @[flag] enum attribute gives you bitfield operations for free, mapping perfectly to how conntrack states work in the kernel.

Why pairwise comparison instead of a decision tree or BDD

This is O(n^2), and there are faster approaches (decision trees, BDDs, interval trees). But at 100 rules per chain, pairwise does 4,950 checks of integer comparisons, finishing in under a millisecond. Even 1000 rules (extreme) yields roughly 500,000 comparisons, still milliseconds.

More importantly, pairwise comparison produces findings referencing exactly two rules. "Rule 7 shadows rule 12" is immediately actionable. A BDD-based approach would need extra work to trace back to the specific rules involved.

Why no external dependencies

The V standard library provides os (file I/O), flag (argument parsing), term (ANSI colors), and strings (manipulation). That covers everything needed. External dependencies in security tools create supply chain risk. A single static binary can be dropped onto any Linux system and run immediately with no package manager involved.

Why separate modules instead of a single file

You could put everything in one file. V would not care. But separate modules give you compiler-enforced boundaries (the parser cannot call display functions), independent test files (v test src/parser/ runs parser tests in isolation), and navigability (conflict detection bug means look at src/analyzer/conflict.v).

Adding a new parser (for ufw rules, say) would require a new file in src/parser/, a new RuleSource variant, a new case in detect_format, and a new case in load_ruleset. No changes needed in analyzer, generator, or display. They already operate on the Ruleset abstraction.