Terraform: `tags` built with `merge()` are not resolved for a resource inside a `for_each` module (sometimes nondeterministically), causing false failures in tag checks
Describe the issue
When a module is invoked with for_each, and the module call also passes each.value.<attr> for one or more arguments, Checkov's variable renderer can fail to produce the resource's tags map when that map comes from a merge() call. The resource then has no usable tags value, so any check that inspects tags (custom YAML/graph checks, custom Python BaseResourceChecks, and built-in tag-related checks) reports the tags as missing even though they are present and correct.
Two manifestations, both reproduced below with minimal, fully static inputs (no variables, no data sources, no providers needed):
- A.
merge()inside the child module — intermittent. The renderedtagsis sometimes a correct map and sometimes the literal unresolved string${merge(...)}. Repeated runs of the same unchanged input flip between PASS and FAIL. - B. merged
tagsnested inside thefor_eachmap and passed viaeach.value.tags— consistent. Thelocalsvalue renders correctly (visible inLOG_LEVEL=DEBUG), but the resource'stagsattribute ends up empty ([{}]) on every run.
Real Terraform resolves both configurations with no issue: terraform plan shows every tag as a concrete value (nothing (known after apply)), so this is Checkov's evaluator rather than an ambiguity in the HCL.
The impact is that a correct, plan-clean Terraform module is failed (in A, randomly) by any tagging policy, and the failure cannot be avoided from the policy side — YAML attribute checks (tags.x), JSONPath checks (tags[*].x) and Python checks all read the same rendered value.
Version
- Checkov:
3.3.16(pip install checkov; also reproduces inghcr.io/bridgecrewio/checkov:3.3.16viabridgecrewio/checkov-action@v12, which is where it was first observed in CI) - Python:
3.13.11 - OS: Windows 11 (Git Bash / PowerShell) for the runs below; the CI observation was on the Linux Docker image
- Framework:
terraform
Examples
Both reproductions use this custom check (any tag check works; this is the smallest one):
policies/RequireOsTag.yaml
metadata:
id: "CKV2_CUSTOM_REPRO"
name: "repro: require os tag"
category: "GENERAL_SECURITY"
definition:
cond_type: "attribute"
resource_types:
- "azurerm_windows_virtual_machine"
attribute: "tags.os"
operator: "exists"Run from the parent of repro-a/ / repro-b/:
checkov -d repro-a --external-checks-dir policies --check CKV2_CUSTOM_REPRO --compactReproduction A — merge() inside the for_each-invoked child module (intermittent)
repro-a/
├── main.tf
└── mod/
└── main.tfrepro-a/main.tf
locals {
instances = {
vm01 = {
size = "Standard_B2ms"
}
}
common_tags = {
team = "platform"
os = "Windows"
}
}
module "vm" {
for_each = local.instances
source = "./mod"
name = each.key
size = each.value.size # <-- each.value.<attr> on a sibling argument
vm_role = "testing"
tags = local.common_tags
}repro-a/mod/main.tf
variable "name" { type = string }
variable "size" { type = string }
variable "vm_role" { type = string }
variable "tags" { type = map(string) }
resource "azurerm_windows_virtual_machine" "vm" {
name = var.name
size = var.size
tags = merge(
var.tags,
{
Role = var.vm_role
}
)
}Expected: tags.os resolves to "Windows" on every run; the check passes.
Actual: the result changes between runs with no change to the input. 8 consecutive runs:
FAILED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vm
PASSED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vm
PASSED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vm
FAILED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vm
FAILED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vm
FAILED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vm
PASSED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vm
FAILED for resource: module.vm["vm01"].azurerm_windows_virtual_machine.vmWith LOG_LEVEL=DEBUG the tags expression can be seen passing through several partially rendered forms. In a passing run the final form is a real map; in a failing run it is left as this literal string, which then becomes the resource's tags value:
${merge({'team': 'platform', 'os': 'Windows'},{'Role': testing})}Note {'Role': testing}: the substituted value of var.vm_role has lost its quotes. In passing runs the same step renders as {'Role': "testing"}. The unquoted form is not valid for the expression evaluator, so the outer merge() is never evaluated and the string is what the checks see.
The failure rate goes up with the number of keys in the merged map and with the number of custom checks loaded from --external-checks-dir (even checks for unrelated resource types), which suggests ordering/timing in the render-and-evaluate pipeline rather than a purely deterministic parse problem — but as shown above it also reproduces with a 2-key map and a single check.
Reproduction B — merged tags nested inside the for_each map, passed as each.value.tags (consistent)
Same policies/ as above.
repro-b/
├── main.tf
└── mod/
└── main.tfrepro-b/main.tf
locals {
common_tags = {
team = "platform"
env = "non-prod"
}
instances = {
vm01 = {
location = "uksouth"
size = "Standard_B2ms"
zone = "1"
tags = merge(local.common_tags,
{
Role = "testing"
os = "Windows"
}
)
}
}
}
module "vm" {
for_each = local.instances
source = "./mod"
name = each.key
region = each.value.location
size = each.value.size
availability_zone = each.value.zone
tags = each.value.tags # <-- nested map attribute of each.value
}repro-b/mod/main.tf
variable "name" { type = string }
variable "region" { type = string }
variable "size" { type = string }
variable "availability_zone" { type = string }
variable "tags" { type = map(string) }
resource "azurerm_windows_virtual_machine" "vm" {
name = var.name
location = var.region
size = var.size
zone = var.availability_zone
tags = var.tags # no merge() in the module at all
}Expected: tags.os resolves to "Windows"; the check passes.
Actual: fails on every run (19/19 in our testing, across this and a larger variant). LOG_LEVEL=DEBUG shows local.instances rendering correctly, including the merged tags:
{'vm01': {'location': 'uksouth', 'size': 'Standard_B2ms', 'tags': {'team': 'platform', 'env': 'non-prod', 'Role': 'testing', 'os': 'Windows'}, 'zone': '1'}}…but the value never reaches the resource. A Python BaseResourceCheck that prints what it receives confirms the resource's rendered config has an empty tags map:
def scan_resource_conf(self, conf):
print(repr(conf.get("tags"))) # -> [{}]Scalar fields extracted the same way (each.value.size, each.value.location, each.value.zone) do resolve correctly for the same module instance; it is specifically the nested map attribute (each.value.tags) that is lost.
Why this looks like the renderer rather than the check layer
- YAML graph checks (dotted
tags.os, and JSONPathtags[*].oswithjsonpath_exists) and PythonBaseResourceChecks all fail together on the same input — they consume the same rendered resource config, so the problem is upstream of check evaluation. - Removing
for_eachfrom the module block makes both reproductions pass reliably. - Keeping
for_eachbut passingtagsstraight through with nomerge()anywhere (tags = local.common_tags→tags = var.tags) also passes reliably, even witheach.value.<scalar>on sibling arguments and with many tag keys. terraform planon the same configurations shows fully concrete tag values.
Workaround
Do the merge() at the module call site, referencing only scalar each.value.<attr> fields, and have the child module assign tags = var.tags without any further merge():
locals {
common_tags = {
team = "platform"
env = "non-prod"
}
instances = {
vm01 = {
size = "Standard_B2ms"
role = "testing"
os = "Windows"
}
}
}
module "vm" {
for_each = local.instances
source = "./mod"
name = each.key
size = each.value.size
tags = merge(local.common_tags, {
Role = each.value.role
os = each.value.os
})
}Verified stable across 30+ consecutive runs with multiple custom checks loaded. It is, however, a Terraform style constraint imposed by the scanner: both original forms are valid and plan cleanly, so this shouldn't be required.
Additional context
- The intermittent behaviour in A means the same commit can pass CI on one run and fail on the next with no code change, which makes the affected checks unusable as blocking gates for repos that use this (common) module pattern. We are currently running our tag checks with
soft-fail-onfor this reason. - Happy to provide the full
LOG_LEVEL=DEBUGoutput for either reproduction, or test a patch/branch.
Source: bridgecrewio/checkov