Lint Your Network Before It Exists: IaC Policy Scanning for VPCs

Security
Table of Contents

Most network security failures aren’t exotic. They’re a security group left open to the whole internet on port 22, a subnet that auto-assigns public IPs to everything you launch into it, or a virtual network with flow logs never turned on so you have no record of what happened. The surprise isn’t that these slip through — it’s that you can catch every one of them before a single resource exists, by linting the infrastructure code the same way you lint application code.

The Setup

When you describe a virtual private network in infrastructure-as-code — the network itself, its subnets, security groups, route tables, flow logs — you’ve written a complete, machine-readable description of a network that hasn’t been built yet. That’s the whole opening. A static analysis tool can parse those files, walk the resource graph, and apply a library of rules against it, exactly like a type checker walks an AST. The findings come back in seconds, in CI, with nothing deployed and nothing billable spun up.

The tool I reach for here ships with on the order of a thousand built-in policies, each one a named check with a stable ID. A meaningful slice of those target the network layer specifically, and they encode the misconfigurations that show up in real incident write-ups over and over: ingress from 0.0.0.0/0 on the SSH (22) and RDP (3389) ports, subnets that flip map_public_ip_on_launch to true, the default security group that permits all traffic instead of denying it, and the network that was provisioned without flow logging. None of these are clever. All of them are common. Every one is mechanically detectable from the code.

The Tool

What makes this approach worth adopting over a manual review checklist is that the policies are identified, deterministic, and suppressible — three properties that turn “be careful” into an enforceable gate. Each check has a stable ID, so a finding isn’t a vague “this looks risky,” it’s a specific rule with a name you can look up, document, and reason about. Run the scanner against your code directory and it returns pass/fail per resource per policy, the same result on every machine, with no human judgment in the loop to drift over time.

The suppression mechanism is the part teams underestimate. When a check fires on something you’ve genuinely accepted — a bastion host’s security group that does need port 22 reachable, gated by a separate network ACL — you don’t disable the check globally or argue with the tool. You annotate that one resource inline with the specific check ID and a written justification, right there in the code:

resource "aws_security_group" "bastion" {
  #checkov:skip=CKV_AWS_24:Bastion SG; ingress restricted by NACL + SSO
  ...
}

That one line does three things a Slack thread can’t. It scopes the exception to exactly one resource and one rule, so every other security group is still held to the standard. It forces a written reason, which becomes the audit trail when someone asks six months later why this is open. And it lives in version control next to the thing it exempts, so the justification travels with the code through every review. The exception becomes a documented decision instead of an invisible gap.

The natural home for this is a CI step that runs before the deploy plan is applied — the network-layer equivalent of running a reviewer over a diff before it merges. Same shift-left instinct: catch the cheap, mechanical problems automatically and early, so human review time is spent on the judgment calls a scanner can’t make. A scanner can tell you port 22 is open to the world. It cannot tell you whether this service should exist at all. Spend the scanner’s seconds on the former and your reviewers’ attention on the latter.

What I Got Wrong

The trap I walked into first was treating a failed check as a blocker to be silenced rather than a question to be answered. The fast move when a build goes red is to skip the check and move on. But a default-deny security group rule firing on your network isn’t noise — it’s the tool telling you the default configuration is the insecure one, and that the secure posture is something you have to opt into explicitly. I learned to read each finding as “is this an accepted risk with a documented reason, or did I just inherit an insecure default I didn’t notice?” Most of the time it was the second one. The scanner wasn’t being pedantic; it was catching the gap between what I assumed the defaults were and what they actually were.

Takeaways

  • An infrastructure-as-code definition of a network is a complete, machine-readable spec — you can audit it for misconfigurations before anything is deployed, in CI, at zero infra cost.
  • The high-value network checks are unglamorous and recurring: open SSH/RDP to 0.0.0.0/0, subnets auto-assigning public IPs, permissive default security groups, and missing flow logs.
  • A good policy scanner gives you stable check IDs, deterministic results, and scoped inline suppressions with written justifications — that’s what turns “be careful” into an enforceable gate with an audit trail.
  • Treat a failed check as a question, not a blocker to silence. Most network findings are insecure defaults you inherited without noticing — not the tool being pedantic.