Vylara
securityawsnetworking

We generate security groups from your code, and they're tighter than the ones you'd write by hand

Hand-written security groups rot the day they're written. Deriving them from your dependency manifests yields tighter rules that regenerate on every deploy.

Written byVylara Team
6 min read
Security groups, generated from your code

Security groups are the firewall almost nobody writes carefully. Not because engineers don’t care — because the workflow guarantees rot. You’re debugging a connection timeout at 6pm, you add 0.0.0.0/0on port 5432 “temporarily,” the timeout goes away, the ticket closes, and the rule outlives three reorgs. Or you copy main.tf from the last project and inherit a rule allowing 10.0.0.0/8into Redis from a VPC topology that doesn’t exist anymore. Run a scanner over any AWS account older than two years and you’ll find the sediment: SSH open to the office IP of an office the company left, an “ALB” security group attached to nothing, a database accepting traffic from a peering connection nobody remembers.

The root cause isn’t laziness. It’s that security groups are authoredas a separate artifact from the application, so they drift from it the moment they’re written. Hand-written rules encode what someone believed the network looked like on the day they wrote them.

While building deployment automation, we landed on a different framing: your code already declares the network graph.You don’t need to ask anyone what talks to what — the repository says it.

The application is the source of truth #

Take an ordinary web app. The dependency manifest alone tells you most of the topology:

  • pg or psycopg or prisma in the deps → something connects to Postgres on 5432.
  • ioredis or redis-py → something connects to Redis on 6379.
  • bullmq or celery→ there’s a background worker tier, and it shares the broker and usually the database with the app.
  • A next or djangodependency → there’s an HTTP listener that should sit behind a load balancer.

Our analyzer does this with zero LLM involvement — it’s pure static analysis over manifests. It parses package.json (including dev and peer deps), requirements.txt/pyproject.toml, go.mod, and maps dependency names through indicator tables: asyncpg → postgresql, mongoose → mongodb, bull → redis-backed queue, @aws-sdk/client-s3 or multer → object storage. When dependencies are ambiguous, env vars break the tie: a regex scan for process.env.X / os.environ["X"] / os.Getenv("X") plus .env.example parsing means a DATABASE_URL reference confirms the database even if the driver hides behind an ORM, and a MONGO_URL flips the inferred engine from Postgres to Mongo. docker-compose.yml services fill remaining gaps — an elasticsearch or rabbitmq container is a declaration of intent even when no dependency names it.

The output is a graph: frontend → API, API → Postgres, API → Redis, worker → Postgres, worker → Redis. And once you have the graph, least-privilege rules stop being something a human authors and become something you derive.

The generated chain #

Here’s the actual ruleset we emit for that ordinary web app, as Terraform:

terraform
# Public edge — HTTPS only
resource "aws_security_group" "alb" {
  ingress {
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

# App tier — only from the ALB, only on the app port
resource "aws_security_group" "app" {
  ingress {
    from_port       = var.app_port
    to_port         = var.app_port
    protocol        = "tcp"
    security_groups = [aws_security_group.alb.id]
  }
}

# Postgres — only from app and worker SGs, only 5432
resource "aws_security_group" "rds" {
  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app.id]
  }
  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.worker.id]
  }
}

Redis gets the same treatment on 6379. The structural decisions:

  • Exactly one rule references 0.0.0.0/0 for ingress, and it’s port 443 on the load balancer. Not 80 — TLS termination is mandatory, so there’s no listener to expose. The internet’s entire attack surface against the deployment is one port on one managed component.
  • Every other ingress rule references a security group, never a CIDR. This is the detail that makes the rules survive change. security_groups = [aws_security_group.alb.id]means “traffic from members of the ALB group” — membership-based, not address-based. Tasks scale out, get replaced, change IPs; the rule never needs touching. A CIDR rule is a bet about future IP allocation. An SG reference is a statement about identity.
  • The app accepts exactly one port from exactly one source.No SSH ingress exists on any tier — there’s no bastion rule to forget, because shell access isn’t part of the topology.
  • Data stores have no path from anywhere except the two compute tiers, and they live on private subnets with no public IPs, so even a fat-fingered SG edit later doesn’t directly expose them — there’s no route from the internet to begin with. Defense in two layers: routing and SG.

Each tier is its own security group rather than one shared “internal” group, which is the other classic rot vector — a shared group means every rule added for one service applies to all of them.

Why defaults beat dials #

We deliberately don’t offer a “just open this CIDR” knob in the generated baseline. The reasoning: every configurable security control becomes the workaround channel. If loosening a rule takes one console click, the 6pm-timeout incident ends with a permanent 0.0.0.0/0. If the topology is derived from code, the fix for “the worker can’t reach the new queue” is the code-level declaration that the worker uses the queue — and the rule appears with exactly the right source and port, and is removed when the dependency goes away. The security posture regenerates with every deploy instead of accreting.

This is the same argument as memory-safe languages: the win isn’t that the tool is smarter than a careful human, it’s that the careful human is no longer load-bearing.

Where inference honestly stops #

Static analysis can’t see everything, and pretending otherwise is how you ship a confidently wrong firewall.

  • Outbound to third-party APIs is invisible at the network level.The code calls Stripe and OpenAI over HTTPS to addresses we can’t enumerate, so the compute tiers keep open egress through NAT. Ingress is where derived least-privilege is strong; egress allowlisting needs different machinery (DNS-filtering proxies, VPC endpoints) and we don’t claim to derive it.
  • Dynamic dispatch defeats manifests.A port read from a config service at runtime, a connection string assembled from fragments, a plugin loaded by name — none of that shows up in a dependency table. The env-var scan catches a lot of it (SMTP_HOSTis a strong hint), but it’s heuristic.
  • Sidecars and agents have their own topology.A Datadog agent, a service mesh proxy, an OTEL collector — each brings ports the application code never mentions. Today that’s an explicit add, not an inference.
  • Inference errors need a human checkpoint. redisin the deps usually means cache; sometimes it means primary datastore; occasionally it means a transitive dependency nobody uses. The derived plan is reviewed and approved before anything is provisioned — derivation produces the proposal, not unaudited prod changes.

The honest claim isn’t “the machine writes perfect firewalls.” It’s narrower and more defensible: for the 90% of network topology that the code already declares, derived rules are tighter than hand-written ones on day one and — unlike hand-written ones — they’re still correct in year two, because they’re regenerated from the thing that actually changed.

I work on Vylara, a deployment platform where this derivation runs on every repo we analyze.

Try Vylara on your repo

Review your cloud plan in Vylara, merge delivery changes as Git PRs, and deploy into your own AWS or Azure account when you’re ready.

Start free

Related posts