#7674·checkov

Terraform graph: no edge to a module output when the module is expanded by count or for_each

Author: AlexKantor87Created Sep 8, 2026Updated Sep 8, 2026

Describe the issue

A module block expanded with count or for_each gets graph vertices named security_group[0] or security_group["alpha"], but a reference to that module's output never matches those vertices. The referencing resource ends up with no edge to the module output, so any graph check that depends on that edge fails however the Terraform is written. Plain module calls, including nested ones, are unaffected, which I suspect is why it hasn't surfaced before now: it only bites codebases that use count as their conditional-module idiom, and for those it bites on every graph check at once.

There are really two behaviours here, both reproduced below on 3.3.16:

  • With count, no edge is created at all and every instance is orphaned.
  • With for_each, a single instance happens to work, but with more than one instance the reference binds to whichever output vertex is reached first, so which instance fails changes between runs on identical input.

Expected behaviour is the same as the plain module call. A reference to module.security_group[0].security_group_id should create an edge to the output of the instance it names, and CKV2_AWS_5 should pass.

Examples

The module is the same in every case:

hcl
# module/main.tf
resource "aws_security_group" "this" {
  name   = var.name
  vpc_id = "vpc-12345678"
}

# module/outputs.tf
output "security_group_id" {
  value = aws_security_group.this.id
}

# module/variables.tf
variable "name" {
  type = string
}
  1. Conditional module with count, security group attached to the load balancer:
hcl
variable "create_sg" {
  default = true
}

module "security_group" {
  count  = var.create_sg ? 1 : 0
  source = "./module"
  name   = "alb-sg"
}

resource "aws_lb" "alb" {
  name            = "example"
  security_groups = [module.security_group[0].security_group_id]
  subnets         = ["subnet-1", "subnet-2"]
}
$ checkov -d . --check CKV2_AWS_5 --compact --quiet
terraform scan results:

Passed checks: 0, Failed checks: 1, Skipped checks: 0

Check: CKV2_AWS_5: "Ensure that Security Groups are attached to another resource"
	FAILED for resource: module.security_group[0].aws_security_group.this
	File: /module/main.tf:1-4
  1. The same configuration with count removed passes, which is the behaviour I'd expect from the first one too:
hcl
module "security_group" {
  source = "./module"
  name   = "alb-sg"
}

resource "aws_lb" "alb" {
  name            = "example"
  security_groups = [module.security_group.security_group_id]
  subnets         = ["subnet-1", "subnet-2"]
}
$ checkov -d . --check CKV2_AWS_5 --compact --quiet
terraform scan results:

Passed checks: 1, Failed checks: 0, Skipped checks: 0
  1. count = 2 with one consumer per instance. Both instances are attached, both fail:
hcl
module "security_group" {
  count  = 2
  source = "./module"
  name   = "sg-${count.index}"
}

resource "aws_lb" "first" {
  name            = "first"
  security_groups = [module.security_group[0].security_group_id]
  subnets         = ["subnet-1", "subnet-2"]
}

resource "aws_lb" "second" {
  name            = "second"
  security_groups = [module.security_group[1].security_group_id]
  subnets         = ["subnet-1", "subnet-2"]
}
Passed checks: 0, Failed checks: 2, Skipped checks: 0

	FAILED for resource: module.security_group[0].aws_security_group.this
	FAILED for resource: module.security_group[1].aws_security_group.this
  1. for_each with two instances, again one consumer each. One instance passes and one fails, and which one fails moves around. Three consecutive runs, nothing changed in between:
hcl
module "security_group" {
  for_each = toset(["alpha", "beta"])
  source   = "./module"
  name     = each.key
}

resource "aws_lb" "alpha" {
  name            = "alpha"
  security_groups = [module.security_group["alpha"].security_group_id]
  subnets         = ["subnet-1", "subnet-2"]
}

resource "aws_lb" "beta" {
  name            = "beta"
  security_groups = [module.security_group["beta"].security_group_id]
  subnets         = ["subnet-1", "subnet-2"]
}
run 1: Passed checks: 1, Failed checks: 1  FAILED for resource: module.security_group["beta"].aws_security_group.this
run 2: Passed checks: 1, Failed checks: 1  FAILED for resource: module.security_group["alpha"].aws_security_group.this
run 3: Passed checks: 1, Failed checks: 1  FAILED for resource: module.security_group["beta"].aws_security_group.this

CKV2_AWS_5 is just a convenient example. Anything that traverses an edge into a module output behaves the same way, so in a repo that wraps its security groups, IAM or logging config in conditional modules the effect is a wall of false positives that can't be fixed in the Terraform.

Desktop (please complete the following information):

  • OS: macOS 26.6
  • Checkov Version: 3.3.16 (first hit it on 3.3.6, and it's still there on main)

Additional context

Where it goes wrong, as far as I can tell from digging into it:

  • _get_possible_vertices in checkov/terraform/graph_builder/local_graph.py strips the index off the reference before it looks the vertex up, while the expanded vertices keep their [0] or ["alpha"] suffix, so nothing matches and no edge is built.
  • A for_each string key survives reference tokenisation intact, so the lookup can find something, but _connect_module takes the first admissible output vertex and stops rather than matching the instance. That's where the run-to-run flip in example 4 comes from.
  • A numeric count index doesn't survive tokenisation at all. module.security_group[1].security_group_id reaches edge building as ['security_group', 'security_group_id'], so binding to a specific instance isn't possible at that layer without changes further up in get_referenced_vertices_in_value.

There are three related cases I ran into that sit in tokenisation rather than edge building, and I've left them alone for now: splat (module.sg[*].attr arrives as ['sg'], losing both the marker and the attribute), a dynamic index (module.sg[var.i].attr, same), and a for_each key containing a dot (module.sg["a.b"].attr gets split inside the quotes). Happy to raise those separately with reproductions if they're useful to you.

#6113 was the same class of problem for the terraform_plan framework and was fixed by #6145. This one is the terraform HCL framework.

I opened #7663 with a fix and tests before writing any of this up, which was the wrong way round, so this issue is the missing half of it. The tests in there cover the three shapes above and the plain and nested module cases to make sure they don't move. Very happy to rework the fix however suits you, or to leave it with the team if you'd rather handle the tokenisation layer at the same time.