Terraform graph: no edge to a module output when the module is expanded by count or for_each
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:
# 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
}- Conditional module with
count, security group attached to the load balancer:
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- The same configuration with
countremoved passes, which is the behaviour I'd expect from the first one too:
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: 0count = 2with one consumer per instance. Both instances are attached, both fail:
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.thisfor_eachwith 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:
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.thisCKV2_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_verticesincheckov/terraform/graph_builder/local_graph.pystrips 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_eachstring key survives reference tokenisation intact, so the lookup can find something, but_connect_moduletakes 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
countindex doesn't survive tokenisation at all.module.security_group[1].security_group_idreaches edge building as['security_group', 'security_group_id'], so binding to a specific instance isn't possible at that layer without changes further up inget_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.
Source: bridgecrewio/checkov