From 5f00af6729ee756911d2961cbecfdec319ae3e81 Mon Sep 17 00:00:00 2001 From: Ezaz Khan Date: Tue, 6 Oct 2026 10:16:37 -0500 Subject: [PATCH] fix: keep references in partly-known collections; expand for_each over locals with apply-time values 1. A list or object literal holding one apply-time value (a resource attribute, a module output) was serialized with that element as null, which reads as an explicit null and loses the reference. Each such element now carries the same {"__attribute__": ...} marker a top-level reference gets; known siblings are unchanged. 2. A for_each over a local whose keys are known but whose values hold apply-time attributes was never expanded. Terraform expands it, since the keys are known. Fixed in the trivy fork (cloud-custodian/trivy PR, pinned here from ezaz-khan-apex/trivy until it lands). Co-Authored-By: Claude Opus 5.5 (1M context) --- gotfparse/go.mod | 2 +- gotfparse/go.sum | 4 +- gotfparse/pkg/converter/converter.go | 88 +++++++++++++++++++ .../for-each-local-unknown-values/main.tf | 17 ++++ .../node/main.tf | 1 + .../partial-collection-references/main.tf | 17 ++++ .../policy/main.tf | 2 + tests/test_tfparse.py | 25 ++++++ 8 files changed, 153 insertions(+), 3 deletions(-) create mode 100644 tests/terraform/for-each-local-unknown-values/main.tf create mode 100644 tests/terraform/for-each-local-unknown-values/node/main.tf create mode 100644 tests/terraform/partial-collection-references/main.tf create mode 100644 tests/terraform/partial-collection-references/policy/main.tf diff --git a/gotfparse/go.mod b/gotfparse/go.mod index 19227a7..702ca5d 100644 --- a/gotfparse/go.mod +++ b/gotfparse/go.mod @@ -116,4 +116,4 @@ require ( k8s.io/utils v0.0.0-20260319190234-28399d86e0b5 // indirect ) -replace github.com/aquasecurity/trivy => github.com/cloud-custodian/trivy v0.0.0-20260918214643-0ed990bcb292 +replace github.com/aquasecurity/trivy => github.com/ezaz-khan-apex/trivy v0.0.0-20261006151129-a72c9d7e6e17 diff --git a/gotfparse/go.sum b/gotfparse/go.sum index 63374d0..7eb41b0 100644 --- a/gotfparse/go.sum +++ b/gotfparse/go.sum @@ -100,8 +100,6 @@ github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK3 github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= -github.com/cloud-custodian/trivy v0.0.0-20260918214643-0ed990bcb292 h1:DkxCy8Z5gyqtU8AY4BIpDCdFGPdg5ZyMhDxvJCNw/lQ= -github.com/cloud-custodian/trivy v0.0.0-20260918214643-0ed990bcb292/go.mod h1:Wf+eykRx2CxQsmo0e0gy8t7byAVbj6FpevuipHcodCc= github.com/cloudflare/circl v1.6.4 h1:pOXuDTCEYyzydgUpQ0CQz3LsinKjiSk6nNP5Lt5K64U= github.com/cloudflare/circl v1.6.4/go.mod h1:YxarevkLlbaHuWsxG6vmYNWBEsSp4pnp7j+4VljMavY= github.com/cncf/xds/go v0.0.0-20260202195803-dba9d589def2 h1:aBangftG7EVZoUb69Os8IaYg++6uMOdKK83QtkkvJik= @@ -143,6 +141,8 @@ github.com/envoyproxy/go-control-plane/ratelimit v0.1.0 h1:/G9QYbddjL25KvtKTv3an github.com/envoyproxy/go-control-plane/ratelimit v0.1.0/go.mod h1:Wk+tMFAFbCXaJPzVVHnPgRKdUdwW/KdbRt94AzgRee4= github.com/envoyproxy/protoc-gen-validate v1.3.3 h1:MVQghNeW+LZcmXe7SY1V36Z+WFMDjpqGAGacLe2T0ds= github.com/envoyproxy/protoc-gen-validate v1.3.3/go.mod h1:TsndJ/ngyIdQRhMcVVGDDHINPLWB7C82oDArY51KfB0= +github.com/ezaz-khan-apex/trivy v0.0.0-20261006151129-a72c9d7e6e17 h1:x6O1hb8qUnqNFqNjm+aabuIKgI3tpCxWOlqGQE3kbPI= +github.com/ezaz-khan-apex/trivy v0.0.0-20261006151129-a72c9d7e6e17/go.mod h1:Wf+eykRx2CxQsmo0e0gy8t7byAVbj6FpevuipHcodCc= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= diff --git a/gotfparse/pkg/converter/converter.go b/gotfparse/pkg/converter/converter.go index 3470316..a83b2af 100644 --- a/gotfparse/pkg/converter/converter.go +++ b/gotfparse/pkg/converter/converter.go @@ -318,6 +318,10 @@ func (t *terraformConverter) getAttributeValue(a *terraform.Attribute) any { if funcExpr, isFuncCall := hclAttr.Expr.(*hclsyntax.FunctionCallExpr); isFuncCall { return t.handleFunctionCall(funcExpr) } + + if partial, ok := t.handlePartialCollection(hclAttr.Expr, val); ok { + return partial + } } // Try to convert the value to a native type @@ -444,6 +448,90 @@ func (t *terraformConverter) handleScopeTraversal(traversalExpr *hclsyntax.Scope } // handleFunctionCall processes function call expressions +// handlePartialCollection converts a list or object literal whose value is known +// but not wholly known element by element, so an element that is a reference to +// an unknown value keeps that reference ({"__attribute__": ...}) instead of +// collapsing to null alongside its known siblings. ok is false when the +// expression is not such a literal, leaving the caller's conversion in place. +func (t *terraformConverter) handlePartialCollection(expr hcl.Expression, val cty.Value) (any, bool) { + if !val.IsKnown() || val.IsNull() || val.IsMarked() { + return nil, false + } + switch e := expr.(type) { + case *hclsyntax.TupleConsExpr: + if !(val.Type().IsTupleType() || val.Type().IsListType()) || val.LengthInt() != len(e.Exprs) { + return nil, false + } + out := make([]any, len(e.Exprs)) + for i, item := range e.Exprs { + out[i] = t.partialElement(item, val.Index(cty.NumberIntVal(int64(i)))) + } + return out, true + case *hclsyntax.ObjectConsExpr: + if !(val.Type().IsObjectType() || val.Type().IsMapType()) { + return nil, false + } + out := make(map[string]any, len(e.Items)) + for _, item := range e.Items { + key, literal := literalObjectKey(item.KeyExpr) + if !literal || !val.Type().IsObjectType() && !val.HasIndex(cty.StringVal(key)).True() || + val.Type().IsObjectType() && !val.Type().HasAttribute(key) { + return nil, false + } + var elem cty.Value + if val.Type().IsObjectType() { + elem = val.GetAttr(key) + } else { + elem = val.Index(cty.StringVal(key)) + } + out[key] = t.partialElement(item.ValueExpr, elem) + } + return out, true + } + return nil, false +} + +// partialElement converts one element of a partially known collection literal. +func (t *terraformConverter) partialElement(expr hcl.Expression, val cty.Value) any { + if val.IsWhollyKnown() { + if raw, ok := convertCtyToNativeValue(val); ok { + return raw + } + return nil + } + if traversalExpr, ok := expr.(*hclsyntax.ScopeTraversalExpr); ok { + return t.handleScopeTraversal(traversalExpr, val) + } + if partial, ok := t.handlePartialCollection(expr, val); ok { + return partial + } + if raw, ok := convertCtyToNativeValue(val); ok { + return raw + } + return nil +} + +// literalObjectKey returns an object constructor key written as a literal name +// or string; a computed key such as (var.x) is not literal. +func literalObjectKey(keyExpr hclsyntax.Expression) (string, bool) { + if wrapped, ok := keyExpr.(*hclsyntax.ObjectConsKeyExpr); ok { + if wrapped.ForceNonLiteral { + return "", false + } + if traversal, ok := wrapped.Wrapped.(*hclsyntax.ScopeTraversalExpr); ok && len(traversal.Traversal) == 1 { + return traversal.Traversal.RootName(), true + } + keyExpr = wrapped.Wrapped + } + if template, ok := keyExpr.(*hclsyntax.TemplateExpr); ok && len(template.Parts) == 1 { + keyExpr = template.Parts[0] + } + if lit, ok := keyExpr.(*hclsyntax.LiteralValueExpr); ok && lit.Val.Type() == cty.String { + return lit.Val.AsString(), true + } + return "", false +} + func (t *terraformConverter) handleFunctionCall(funcExpr *hclsyntax.FunctionCallExpr) any { logger.Debug("Function call detected", "name", funcExpr.Name, "argCount", len(funcExpr.Args)) diff --git a/tests/terraform/for-each-local-unknown-values/main.tf b/tests/terraform/for-each-local-unknown-values/main.tf new file mode 100644 index 0000000..280382b --- /dev/null +++ b/tests/terraform/for-each-local-unknown-values/main.tf @@ -0,0 +1,17 @@ +resource "google_compute_subnetwork" "s" { + name = "s" +} + +locals { + # The keys are known now; every value holds an attribute known only after apply. + nodes = { + a = { subnet = google_compute_subnetwork.s.self_link } + b = { subnet = google_compute_subnetwork.s.self_link } + } +} + +module "node" { + for_each = local.nodes + source = "./node" + hostname = each.key +} diff --git a/tests/terraform/for-each-local-unknown-values/node/main.tf b/tests/terraform/for-each-local-unknown-values/node/main.tf new file mode 100644 index 0000000..d3de75f --- /dev/null +++ b/tests/terraform/for-each-local-unknown-values/node/main.tf @@ -0,0 +1 @@ +variable "hostname" {} diff --git a/tests/terraform/partial-collection-references/main.tf b/tests/terraform/partial-collection-references/main.tf new file mode 100644 index 0000000..93b9235 --- /dev/null +++ b/tests/terraform/partial-collection-references/main.tf @@ -0,0 +1,17 @@ +resource "aws_iam_role" "app" { + name = "app" +} + +locals { + group = "arn:aws:iam::123456789012:group/admins" +} + +module "policy" { + source = "./policy" + # One element is known now, the other only after apply. + principals = [local.group, aws_iam_role.app.unique_id] + grants = { + reader = aws_iam_role.app.unique_id + owner = local.group + } +} diff --git a/tests/terraform/partial-collection-references/policy/main.tf b/tests/terraform/partial-collection-references/policy/main.tf new file mode 100644 index 0000000..e66ff0e --- /dev/null +++ b/tests/terraform/partial-collection-references/policy/main.tf @@ -0,0 +1,2 @@ +variable "principals" {} +variable "grants" {} diff --git a/tests/test_tfparse.py b/tests/test_tfparse.py index c1d2907..46e0884 100644 --- a/tests/test_tfparse.py +++ b/tests/test_tfparse.py @@ -843,3 +843,28 @@ def test_module_output_json_string(tmp_path): assert ( "module.container.json_map_encoded_list" in parsed_nested[0]["__unresolved__"] ), "Should preserve the reference path" + + +def test_partial_collection_keeps_unknown_references(tmp_path): + """A list or object literal with one apply-time element keeps its known + siblings and reports the unknown element as a reference, not null.""" + mod_path = init_module("partial-collection-references", tmp_path, run_init=False) + parsed = load_from_path(mod_path) + policy = next(m for m in parsed["module"] if m["__tfmeta"]["label"] == "policy") + + group = "arn:aws:iam::123456789012:group/admins" + assert policy["principals"][0] == group + assert policy["principals"][1]["__attribute__"] == "aws_iam_role.app.unique_id" + assert policy["grants"]["owner"] == group + assert policy["grants"]["reader"]["__attribute__"] == "aws_iam_role.app.unique_id" + + +def test_for_each_over_local_with_unknown_values(tmp_path): + """A for_each whose keys are known expands even when every value holds an + apply-time attribute, as Terraform does; the deferral used to leave it + unexpanded with each.key unbound.""" + mod_path = init_module("for-each-local-unknown-values", tmp_path, run_init=False) + parsed = load_from_path(mod_path) + nodes = [m for m in parsed["module"] if m["__tfmeta"]["label"].startswith("node")] + assert sorted(m["__tfmeta"]["label"] for m in nodes) == ['node["a"]', 'node["b"]'] + assert sorted(m["hostname"] for m in nodes) == ["a", "b"]