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.
| Term | Meaning |
|---|---|
| Policy | A YAML file describing which VLANs exist and which pairwise reachability rules should hold between them. The single source of truth for a scan. |
| Sub-interface | The tagged 802.1Q interface VLANVerify creates per VLAN (e.g. eth0.10), torn down again after the scan unless --keep-interfaces is given. |
| Rule | One pairwise expectation: traffic from one VLAN to another (or the special target internet) should be allowed or denyed. |
| Verdict | What a rule was actually measured to do, compared against what the policy expected: HELD, VIOLATED or INCONCLUSIVE. |
| Probe | One 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
| Command | Purpose |
|---|---|
vlanverify validate --policy FILE | Check 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 FILE | Bring 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
| Option | Meaning |
|---|---|
--policy PATH | Required. Path to the policy YAML file. |
--interface | Trunk NIC to create VLAN sub-interfaces on. Overrides device.trunk_interface in the policy; one of the two must be set. |
--output PATH | Where to write the HTML report. Default: report.html. |
--dry-run | Don'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-interfaces | Don't delete the VLAN sub-interfaces after the scan. Useful for debugging on real hardware. |
-v, --verbose | Verbose (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:
| Verdict | When it's assigned |
|---|---|
| HELD | Actual behaviour matched what the policy expected. An allow rule was reachable, or a deny rule was genuinely blocked. |
| VIOLATED | Actual 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. |
| INCONCLUSIVE | Couldn'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 code | Meaning |
|---|---|
0 | Scan completed, no security findings (a violated allow rule or an INCONCLUSIVE result alone will not raise this). |
1 | The tool itself failed: an invalid policy, a missing or unreachable trunk interface, or a similar setup problem, before any test ran. |
2 | The 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
denyrule), separated from the milder, non-security violations (a blockedallowrule). - 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.
| Package | Licence | Notes |
|---|---|---|
click | BSD-3-Clause | CLI framework |
pydantic | MIT | policy schema validation |
PyYAML | MIT | policy file parsing |
Jinja2 | BSD-3-Clause | HTML report templating |
pytest | MIT | dev 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.