Documentation · v0.1.1

VLANVerify documentation

Everything you need to write a policy, run a scan, and read the report. For installation see Download & install.

Concepts

VLANVerify runs from a single Linux device connected to a trunk port on your switch. For every VLAN listed in your policy, it creates a tagged 802.1Q sub-interface on that trunk NIC (ip link add link eth0 name eth0.10 type vlan id 10), so the one physical device can originate and receive traffic as if it were a host on each VLAN in turn, without ever being moved between ports.

TermMeaning
PolicyA YAML file describing which VLANs exist and which pairwise reachability rules should hold between them. The single source of truth for a scan.
Sub-interfaceThe tagged 802.1Q interface VLANVerify creates per VLAN (e.g. eth0.10), torn down again after the scan unless --keep-interfaces is given.
RuleOne pairwise expectation: traffic from one VLAN to another (or the special target internet) should be allowed or denyed.
VerdictWhat a rule was actually measured to do, compared against what the policy expected: HELD, VIOLATED or INCONCLUSIVE.
ProbeOne ICMP ping or TCP connect attempt from a source VLAN's sub-interface to a target IP/port.

Each sub-interface tries DHCP first by default (dhcp: true, the default), falling back to a configured static address if DHCP is disabled for that VLAN or times out (defaults.dhcp_timeout, 15 seconds by default, overridable per VLAN). A VLAN that ends up with no address by either method is reported plainly, with the reason, rather than treated as a crash; any rule depending on it becomes INCONCLUSIVE.

Policy file schema

device:
  trunk_interface: eth0        # optional; can also be passed as --interface

vlans:
  - id: 10                     # 802.1Q VLAN tag, 1-4094
    name: corp                 # used to refer to this VLAN in rules — must be unique
    dhcp: true                 # default true; try DHCP on this sub-interface first
    static:                    # fallback (or override, if dhcp: false) static config
      address: 10.0.10.5/24
      gateway: 10.0.10.1
    target_ip: 10.0.10.50      # known-good host to test against; if omitted,
                                # VLANVerify ping-sweeps the subnet to find one
    dhcp_timeout: 15           # optional per-VLAN override of defaults.dhcp_timeout

rules:
  - from: guest                # must be a VLAN name (not "internet")
    to: corp                   # a VLAN name, or the special value "internet"
    expect: deny                # allow | deny
    ports: [22, 443]           # optional; defaults to defaults.ports

  - from: guest
    to: internet                # special target: reachability to defaults.internet_target
    expect: allow

defaults:
  ports: [22, 80, 443, 445, 3389]   # used when a rule has no ports: of its own
  dhcp_timeout: 15                   # seconds to wait for DHCP before falling back
  internet_target: 1.1.1.1
  internet_port: 443
  ping: true                         # also ICMP ping alongside the TCP port checks

Run vlanverify validate --policy your.yaml to check a file against this schema: it reports every problem found, not just the first, and never touches the network.

$ vlanverify validate --policy policy.yaml
✓ policy.yaml is valid
  3 VLAN(s): corp(10), guest(20), iot(30)
  5 rule(s)
    guest -> corp: expect deny (ports: 22, 80, 443, 445, 3389)
    guest -> iot: expect deny (ports: 22, 80, 443, 445, 3389)
    iot -> corp: expect deny (ports: 22, 80, 443, 445, 3389)
    guest -> internet: expect allow (ports: 443)
    corp -> iot: expect allow (ports: 22, 443)

Commands

CommandPurpose
vlanverify validate --policy FILECheck a policy file is well-formed against the schema. Reports every problem found, not just the first. Never touches the network.
vlanverify scan --policy FILE --output FILEBring up the VLAN sub-interfaces, run every rule's tests, and write the HTML report. Live by default; add --dry-run to simulate the whole pipeline instead.

scan options

OptionMeaning
--policy PATHRequired. Path to the policy YAML file.
--interfaceTrunk NIC to create VLAN sub-interfaces on. Overrides device.trunk_interface in the policy; one of the two must be set.
--output PATHWhere to write the HTML report. Default: report.html.
--dry-runDon't touch real interfaces or send real traffic — log what would happen and use simulated (worst-case: everything reachable) probe results. Works on any OS.
--keep-interfacesDon't delete the VLAN sub-interfaces after the scan. Useful for debugging on real hardware.
-v, --verboseVerbose (debug-level) logging.

Usage

# Check a policy file is well-formed, no network access required
vlanverify validate --policy policy.yaml

# Try the full pipeline without touching real interfaces or hardware
vlanverify scan --policy policy.yaml --interface eth0 \
    --output report.html --dry-run

# On the actual probe device, plugged into a trunk port, as root:
sudo vlanverify scan --policy policy.yaml --interface eth0 \
    --output report.html

# Keep the sub-interfaces up afterwards, with verbose logging
sudo vlanverify scan --policy policy.yaml --interface eth0 \
    --output report.html --keep-interfaces -v

# Fail a CI job if segmentation doesn't hold
vlanverify scan --policy policy.yaml --interface eth0 \
    --output report.html || echo "segmentation regression — see report.html"

Live scanning requires Linux. scan without --dry-run shells out to ip (iproute2) and a DHCP client, and uses SO_BINDTODEVICE to bind each probe to the correct VLAN sub-interface, none of which exist on macOS or Windows. Running it there without --dry-run exits immediately with a clear error. --dry-run mode has no such requirement and works anywhere Python 3.10+ runs.

Verdicts & exit codes

Every rule gets exactly one verdict, computed by comparing what actually happened (did any ping or TCP connect get through?) against what the rule's expect says should happen:

VerdictWhen it's assigned
HELDActual behaviour matched what the policy expected. An allow rule was reachable, or a deny rule was genuinely blocked.
VIOLATEDActual behaviour contradicted the policy. A deny rule that was actually reachable is a security finding: the VLAN boundary isn't enforced. An allow rule that was actually blocked is flagged too, but as a likely over-restrictive firewall rule rather than a segmentation failure.
INCONCLUSIVECouldn't be properly tested, for example no live host was found on the target VLAN, or the source VLAN never got an address at all. Reported plainly rather than guessed at.

A VIOLATED verdict on a deny rule is treated as a security violation, the critical, headline finding for an audit: a VLAN boundary that isn't actually enforced. A VIOLATED verdict on an allow rule is still reported and still shown as a violation in the summary counts, but is not counted as a security violation for exit-code purposes, since it usually means an over-restrictive firewall rule rather than a segmentation failure.

Exit codeMeaning
0Scan completed, no security findings (a violated allow rule or an INCONCLUSIVE result alone will not raise this).
1The tool itself failed: an invalid policy, a missing or unreachable trunk interface, or a similar setup problem, before any test ran.
2The scan completed, and at least one deny rule was violated. See the report for which.

This makes it straightforward to wire a scan into a script, cron job or CI pipeline as a segmentation regression check.

The HTML report

vlanverify scan writes a single, self-contained report.html, no external CSS, JavaScript, fonts or images, safe to email or attach directly to an audit evidence pack. It contains:

  • A plain-English summary: when the scan ran, which policy file (and its hash, so a report can be tied back to the exact policy it tested), whether it was a live or dry-run scan, and the Held/Violated/Inconclusive counts.
  • A prominent banner for any security findings (a violated deny rule), separated from the milder, non-security violations (a blocked allow rule).
  • A results table listing every rule with its verdict.
  • An expandable technical details section per rule with the raw ping and TCP-connect results (outcome and detail per probe) behind that verdict.

The console output of vlanverify scan itself is intentionally brief, interface setup status per VLAN, then the summary counts, an optional violation warning, and the output path, since the full per-rule detail lives in the report:

$ sudo vlanverify scan --policy policy.yaml --interface eth0 --output report.html
VLANVerify scan [LIVE] — trunk interface eth0
  corp (eth0.10): dhcp -> 10.0.10.5
  guest (eth0.20): dhcp -> 10.0.20.14
  iot (eth0.30): static -> 10.0.30.250

Held: 4  Violated: 1  Inconclusive: 0
⚠ 1 VLAN boundary violation(s) found — see report.html
Report written to report.html

Dry-run mode

--dry-run runs the exact same pipeline, policy loading, schema validation, interface orchestration, testing and reporting, but swaps in a simulated backend at every layer that would otherwise touch real hardware or the network: interfaces report a plausible DHCP/static outcome without calling ip, and probes default to reachable (the pessimistic, worst-case assumption: an unsegmented network), which also happens to be a good way to see the report's VIOLATED highlighting without needing a misconfigured switch to demonstrate it.

This is possible without duplicating logic because every layer that touches the outside world, interface orchestration, host discovery and probing, is split into an abstract base plus a Real and a Dry/Mock implementation; the CLI simply picks which pair to use based on --dry-run. It's useful for developing against VLANVerify, demoing the report, or testing a policy file's shape before you have hardware to run it on.

Project layout

vlanverify/
  models.py       # shared dataclasses/enums (no other module dependencies)
  schema.py       # policy YAML parsing + validation (pydantic)
  interfaces.py   # VLAN sub-interface orchestration: Real + DryRun backends
  discovery.py    # live host discovery (ping sweep / ARP): Real + DryRun backends
  testengine.py   # ping/TCP probing + rule verdict logic: Real + Mock backends
  report.py       # ScanReport -> self-contained HTML
  templates/
    report.html.j2
  cli.py          # `vlanverify validate` / `vlanverify scan`
policies/
  example.yaml
tests/

Every layer that touches the outside world lives behind an abstract base class with a real and a dry-run/mock implementation, which is also the seam the roadmap items below are designed to slot into without a rewrite.

Third-party dependencies

VLANVerify does not vendor third-party source code. It depends on a small set of MIT-licensed Python libraries, all used as ordinary, unmodified dependencies.

PackageLicenceNotes
clickBSD-3-ClauseCLI framework
pydanticMITpolicy schema validation
PyYAMLMITpolicy file parsing
Jinja2BSD-3-ClauseHTML report templating
pytestMITdev only

A live scan additionally shells out to the system ip (iproute2) command and a DHCP client (dhclient or dhcpcd), and uses the standard-library socket module with SO_BINDTODEVICE; none of these are Python package dependencies.

Scope & non-goals

VLANVerify's current status is an MVP: point-in-time, run-on-demand scans only. The architecture leaves room for the following without a rewrite, but none of them are built yet:

  • Config-only audit mode — read firewall/switch ACL exports instead of live-testing traffic. This would be a new backend for the same verdict logic in testengine.py, not a new tool.
  • Multi-agent mode — one small device per VLAN instead of 802.1Q trunk tagging from a single device. Interface orchestration already abstracts "how does the probe get an address on VLAN X" from everything downstream, so this is a third implementation of that abstraction, not a redesign.
  • Continuous or scheduled scanning: this is point-in-time, run-on-demand only for now.
  • Firewall/switch config ingestion and diffing against live results.
  • Any GUI beyond the generated HTML report.
  • Cloud-native segmentation (AWS/Azure security groups, Kubernetes network policies).

See Contributing for how to propose changes, and Security & trust for how to report vulnerabilities.