#7680·checkov

Terraform: `tags` built with `merge()` are not resolved for a resource inside a `for_each` module (sometimes nondeterministically), causing false failures in tag checks

Author: brgsstmCreated Sep 9, 2026Updated Sep 9, 2026
Labelsgraph

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 rendered tags is 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 tags nested inside the for_each map and passed via each.value.tags — consistent. The locals value renders correctly (visible in LOG_LEVEL=DEBUG), but the resource's tags attribute 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 in ghcr.io/bridgecrewio/checkov:3.3.16 via bridgecrewio/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

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/:

bash
checkov -d repro-a --external-checks-dir policies --check CKV2_CUSTOM_REPRO --compact

Reproduction A — merge() inside the for_each-invoked child module (intermittent)

repro-a/
├── main.tf
└── mod/
    └── main.tf

repro-a/main.tf

hcl
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

hcl
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.vm

With 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.tf

repro-b/main.tf

hcl
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

hcl
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:

python
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 JSONPath tags[*].os with jsonpath_exists) and Python BaseResourceChecks all fail together on the same input — they consume the same rendered resource config, so the problem is upstream of check evaluation.
  • Removing for_each from the module block makes both reproductions pass reliably.
  • Keeping for_each but passing tags straight through with no merge() anywhere (tags = local.common_tagstags = var.tags) also passes reliably, even with each.value.<scalar> on sibling arguments and with many tag keys.
  • terraform plan on 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():

hcl
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-on for this reason.
  • Happy to provide the full LOG_LEVEL=DEBUG output for either reproduction, or test a patch/branch.