Writing Automated Scanners to Audit Network Topology Diagrams
scanning the exported diagram, not the live infrastructure
Architecture diagrams drift. Someone draws the network in draw.io or Packet Tracer during design review, everyone signs off, and six months later the Terraform has three more routes than the diagram shows and one fewer firewall rule. The diagram stops being a source of truth and becomes decoration. The fix isn't "remember to update the diagram": it's treating the diagram as data and running the same kind of static analysis against it that you'd run against code.
The diagram is already a graph, it's just serialized weirdly
A draw.io file is XML under the hood: an mxGraphModel with mxCell elements for nodes and edges. Export any diagram as .drawio (it's just XML, no special export step) and you can parse it directly:
import xml.etree.ElementTree as ET
import networkx as nx
def load_topology(path: str) -> nx.DiGraph:
tree = ET.parse(path)
root = tree.getroot()
graph = nx.DiGraph()
# vertices: mxCell elements with vertex="1" are nodes (hosts, zones)
for cell in root.iter("mxCell"):
if cell.get("vertex") == "1":
graph.add_node(cell.get("id"), label=cell.get("value", ""))
# edges: mxCell elements with edge="1" connect source -> target,
# and carry the connection's label (e.g. "443/TLS", "5432")
for cell in root.iter("mxCell"):
if cell.get("edge") == "1":
graph.add_edge(
cell.get("source"),
cell.get("target"),
label=cell.get("value", ""),
)
return graphOnce it's a networkx.DiGraph, every check below is 10-20 lines, and it's the same graph library you'd reach for to analyze any other network structure, which means the checks compose and you can add new ones without touching the parser.
Check 1: ports that shouldn't be there
Most teams maintain an informal allow-list in someone's head ("db only takes 5432 from the app tier"). Make it explicit and enforce it against every edge label:
ALLOWED_PORTS = {"443", "5432", "6379", "22-bastion-only"}
def find_disallowed_ports(graph: nx.DiGraph) -> list[tuple[str, str, str]]:
violations = []
for src, dst, data in graph.edges(data=True):
label = data.get("label", "")
port = label.split("/")[0].strip()
if port and port not in ALLOWED_PORTS:
violations.append((src, dst, port))
return violationsThis catches the drift case directly: someone opens 3389 for a debugging session, forgets to remove it, and updates the diagram (because they're being diligent) without checking it against policy. The scanner catches what code review of a Terraform diff might not, because RDP to a Windows jump box doesn't look obviously wrong in isolation. It only looks wrong against the allow-list.
Check 2: transit without encryption
Any edge crossing a trust-zone boundary needs a label indicating encryption (TLS, IPSEC, encrypted), or it fails the check. Internal, same-zone edges are exempt (you don't necessarily TLS-wrap loopback to a sidecar), so the check needs zone metadata, which you attach as a node attribute:
def find_unencrypted_transit(graph: nx.DiGraph, zones: dict[str, str]) -> list[tuple[str, str]]:
violations = []
for src, dst, data in graph.edges(data=True):
src_zone = zones.get(src)
dst_zone = zones.get(dst)
crosses_boundary = src_zone != dst_zone
label = data.get("label", "").lower()
is_encrypted = any(term in label for term in ("tls", "ipsec", "encrypted", "https"))
if crosses_boundary and not is_encrypted:
violations.append((src, dst))
return violationszones is a small dict you maintain by hand ({"cui-db": "cui", "app-server": "app", "alb": "public"}). It doesn't need to be derived automatically, and trying to infer it from node position or color in the diagram is more fragile than just declaring it.
Check 3: single points of failure
This is where treating the diagram as a real graph pays off: networkx already has the algorithm you need. An articulation point (cut vertex) is a node whose removal disconnects the graph. In a network topology, that's a literal single point of failure: if that box goes down, something on the other side loses connectivity entirely.
def find_spofs(graph: nx.DiGraph) -> list[str]:
undirected = graph.to_undirected()
return list(nx.articulation_points(undirected))Run against a real topology, this reliably flags the "one NAT gateway for three availability zones" pattern and the "single Transit Gateway attachment with no redundant path" pattern, both common because they're the default in a first draft and easy to forget to revisit before the diagram gets treated as final.
Wiring it into CI
None of this is useful sitting in a script someone runs manually before a design review. It belongs in the same CI pipeline as the Terraform plan, running against the diagram file whenever it changes:
# .github/workflows/topology-audit.yml
name: topology-audit
on:
pull_request:
paths: ["diagrams/*.drawio"]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- run: pip install networkx
- run: python scripts/audit_topology.py diagrams/production.drawio --fail-on-violation--fail-on-violation makes it a real gate, not advisory output nobody reads. The failure message should print the specific edge or node ID and the diagram's own label for it, so the person fixing it doesn't have to cross-reference an ID against the visual diagram by hand.
What this doesn't replace
A topology scanner catches drift and policy violations in the diagram. It does not verify the diagram matches the actual deployed infrastructure: that's a separate check, comparing the diagram's declared edges against Terraform state or a live VPC Flow Logs sample. Treat them as two different tests: "is the diagram internally consistent with policy" and "does the diagram match reality." Skipping the second one is how a diagram passes every automated check in this article and still describes a network that hasn't existed for two months.
Building something like this?
Tell us what you're working on, we'll scope it together.
More from the blog
Mapping CUI Data Flows in AWS Multi-VPC Environments
Boundary isolation, KMS envelope encryption, GuardDuty, and how the pieces line up against NIST SP 800-171 and CMMC.
Immutable CloudTrail Auditing via AWS QLDB & S3 Object Lock
A tamper-evident logging pipeline for financial and enterprise compliance, and an honest look at when you actually need the extra layer.
