Skip to content

Commit 153318f

Browse files
authored
fix(terraform): for_each on a map returns a resource for every key (#9156)
1 parent e306e2d commit 153318f

2 files changed

Lines changed: 235 additions & 4 deletions

File tree

pkg/iac/adapters/terraform/aws/iam/roles_test.go

Lines changed: 169 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package iam
22

33
import (
44
"sort"
5+
"strings"
56
"testing"
67

78
"github.com/aquasecurity/iamgo"
@@ -377,3 +378,171 @@ data "aws_partition" "current" {}
377378
})
378379
}
379380
}
381+
382+
// validateLambdaEcsKeys validates that attachment references contain both lambda and ecs-tasks keys
383+
func validateLambdaEcsKeys(t *testing.T, attachmentRefs []string) {
384+
hasLambda := false
385+
hasEcs := false
386+
for _, ref := range attachmentRefs {
387+
if strings.Contains(ref, "lambda") {
388+
hasLambda = true
389+
}
390+
if strings.Contains(ref, "ecs-tasks") {
391+
hasEcs = true
392+
}
393+
}
394+
if !hasLambda || !hasEcs {
395+
t.Errorf("expected attachment refs to include both lambda and ecs-tasks keys, got %v", attachmentRefs)
396+
}
397+
}
398+
399+
func Test_forEachReferences(t *testing.T) {
400+
tests := []struct {
401+
name string
402+
terraform string
403+
expectedCount int
404+
}{
405+
{
406+
name: "computed local with for_each map",
407+
terraform: `
408+
locals {
409+
platform_role_principals = {
410+
lambda = "lambda.amazonaws.com"
411+
ecs-tasks = "ecs-tasks.amazonaws.com"
412+
}
413+
}
414+
415+
data "aws_iam_policy_document" "platform_role_assume_policy" {
416+
for_each = local.platform_role_principals
417+
418+
statement {
419+
actions = ["sts:AssumeRole"]
420+
principals {
421+
type = "Service"
422+
identifiers = [each.value]
423+
}
424+
}
425+
}
426+
427+
resource "aws_iam_role" "platform_role" {
428+
for_each = local.platform_role_principals
429+
name = "platform-${each.key}"
430+
assume_role_policy = data.aws_iam_policy_document.platform_role_assume_policy[each.key].json
431+
}
432+
433+
locals {
434+
platform_roles = {
435+
for role_key, role_res in aws_iam_role.platform_role :
436+
role_key => {
437+
role = role_res.name
438+
}
439+
}
440+
}
441+
442+
data "aws_iam_policy_document" "administrative_policy_doc" {
443+
statement {
444+
resources = ["*"]
445+
actions = ["Tag:GetResources", "Tag:TagResources", "Tag:UntagResources"]
446+
}
447+
}
448+
449+
resource "aws_iam_policy" "administrative_policy" {
450+
name = "administrative-policy"
451+
policy = data.aws_iam_policy_document.administrative_policy_doc.json
452+
}
453+
454+
resource "aws_iam_role_policy_attachment" "administrative_policy_attachment" {
455+
for_each = local.platform_roles
456+
role = each.value.role
457+
policy_arn = aws_iam_policy.administrative_policy.arn
458+
}`,
459+
expectedCount: 2,
460+
},
461+
{
462+
name: "direct for_each reference",
463+
terraform: `
464+
locals {
465+
roles = {
466+
lambda = "lambda.amazonaws.com"
467+
ecs-tasks = "ecs-tasks.amazonaws.com"
468+
}
469+
}
470+
471+
resource "aws_iam_role" "platform_role" {
472+
for_each = local.roles
473+
name = "platform-${each.key}"
474+
}
475+
476+
resource "aws_iam_role_policy_attachment" "test" {
477+
for_each = aws_iam_role.platform_role
478+
role = each.value.name
479+
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
480+
}`,
481+
expectedCount: 2,
482+
},
483+
{
484+
name: "for_each with computed local reference",
485+
terraform: `
486+
locals {
487+
role_principals = {
488+
lambda = "lambda.amazonaws.com"
489+
ecs-tasks = "ecs-tasks.amazonaws.com"
490+
}
491+
}
492+
493+
resource "aws_iam_role" "platform_role" {
494+
for_each = local.role_principals
495+
name = "platform-${each.key}"
496+
}
497+
498+
locals {
499+
platform_roles = {
500+
for role_key, role_res in aws_iam_role.platform_role :
501+
role_key => {
502+
role = role_res.name
503+
}
504+
}
505+
}
506+
507+
resource "aws_iam_role_policy_attachment" "test" {
508+
for_each = local.platform_roles
509+
role = each.value.role
510+
policy_arn = "arn:aws:iam::aws:policy/AmazonS3FullAccess"
511+
}`,
512+
expectedCount: 2,
513+
},
514+
}
515+
516+
for _, test := range tests {
517+
t.Run(test.name, func(t *testing.T) {
518+
modules := tftestutil.CreateModulesFromSource(t, test.terraform, ".tf")
519+
attachments := modules.GetResourcesByType("aws_iam_role_policy_attachment")
520+
521+
// Debug output for troubleshooting
522+
t.Logf("Total resources found: %d", len(attachments))
523+
for i, attachment := range attachments {
524+
t.Logf("Attachment %d: %s", i, attachment.Reference().String())
525+
t.Logf(" - FullName: %s", attachment.FullName())
526+
t.Logf(" - TypeLabel: %s", attachment.TypeLabel())
527+
t.Logf(" - NameLabel: %s", attachment.NameLabel())
528+
if key := attachment.Reference().RawKey(); !key.IsNull() && key.IsKnown() {
529+
t.Logf(" - Key: %s (%s)", key.GoString(), key.Type().GoString())
530+
}
531+
}
532+
533+
var attachmentRefs []string
534+
for _, a := range attachments {
535+
attachmentRefs = append(attachmentRefs, a.Reference().String())
536+
}
537+
538+
sort.Strings(attachmentRefs)
539+
540+
if len(attachments) != test.expectedCount {
541+
t.Fatalf("expected %d policy attachments, got %d: %v", test.expectedCount, len(attachments), attachmentRefs)
542+
}
543+
544+
// Validate that both lambda and ecs-tasks keys are present
545+
validateLambdaEcsKeys(t, attachmentRefs)
546+
})
547+
}
548+
}

pkg/iac/scanners/terraform/parser/evaluator.go

Lines changed: 66 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -142,6 +142,14 @@ func (e *evaluator) EvaluateAll(ctx context.Context) (terraform.Modules, map[str
142142
e.blocks = e.expandBlocks(e.blocks)
143143
e.blocks = e.expandBlocks(e.blocks)
144144

145+
// Re-evaluate locals after all expansions to ensure computed locals
146+
// that depend on expanded resources get the correct values
147+
e.ctx.Replace(e.getValuesByBlockType("locals"), "local")
148+
149+
// Final expansion round to handle any previously deferred for_each blocks
150+
// that now have correct local values
151+
e.blocks = e.expandBlockForEaches(e.blocks)
152+
145153
// rootModule is initialized here, but not fully evaluated until all submodules are evaluated.
146154
// Initializing it up front to keep the module hierarchy of parents correct.
147155
rootModule := terraform.NewModule(e.projectRootPath, e.modulePath, e.blocks, e.ignores)
@@ -309,7 +317,7 @@ func (e *evaluator) expandBlockForEaches(blocks terraform.Blocks) terraform.Bloc
309317

310318
forEachAttr := block.GetAttribute("for_each")
311319

312-
if forEachAttr.IsNil() || block.IsExpanded() || !isBlockSupportsForEachMetaArgument(block) {
320+
if forEachAttr.IsNil() || block.IsExpanded() || !isBlockSupportsForEachMetaArgument(block) || e.shouldDeferForEachExpansion(forEachAttr) {
313321
forEachFiltered = append(forEachFiltered, block)
314322
continue
315323
}
@@ -545,7 +553,8 @@ func (e *evaluator) getValuesByBlockType(blockType string) cty.Value {
545553
}
546554
values[b.Label()] = val
547555
case "locals", "moved", "import":
548-
maps.Copy(values, b.Values().AsValueMap())
556+
localValues := b.Values().AsValueMap()
557+
maps.Copy(values, localValues)
549558
case "provider", "module", "check":
550559
if b.Label() == "" {
551560
continue
@@ -597,12 +606,16 @@ func (e *evaluator) getResources() map[string]cty.Value {
597606
typeValues = make(map[string]cty.Value)
598607
values[ref.TypeLabel()] = typeValues
599608
}
600-
typeValues[ref.NameLabel()] = blockInstanceValues(b, typeValues, b.Values())
609+
610+
instanceVal := blockInstanceValues(b, typeValues, b.Values())
611+
typeValues[ref.NameLabel()] = instanceVal
601612
}
602613

603-
return lo.MapValues(values, func(v map[string]cty.Value, _ string) cty.Value {
614+
result := lo.MapValues(values, func(v map[string]cty.Value, _ string) cty.Value {
604615
return cty.ObjectVal(v)
605616
})
617+
618+
return result
606619
}
607620

608621
// blockInstanceValues returns a cty.Value containing the values of the block instances.
@@ -640,3 +653,52 @@ func blockInstanceValues(b *terraform.Block, typeValues map[string]cty.Value, va
640653
func isForEachKey(key cty.Value) bool {
641654
return key.Type().Equals(cty.Number) || key.Type().Equals(cty.String)
642655
}
656+
657+
func (e *evaluator) shouldDeferForEachExpansion(forEachAttr *terraform.Attribute) bool {
658+
// Check if the for_each references a local value
659+
for _, ref := range forEachAttr.AllReferences() {
660+
if ref.BlockType().Name() == "locals" {
661+
// Get the local value from context
662+
localName := ref.NameLabel()
663+
if localVal := e.ctx.Get("local", localName); localVal != cty.NilVal && localVal.IsKnown() {
664+
// Check if this local contains unresolved dynamic values or looks like
665+
// it was computed from a single resource object instead of an expanded collection
666+
if e.localHasDynamicValues(localVal) {
667+
return true
668+
}
669+
}
670+
}
671+
}
672+
return false
673+
}
674+
675+
func (e *evaluator) localHasDynamicValues(localVal cty.Value) bool {
676+
if !localVal.Type().IsObjectType() {
677+
return false
678+
}
679+
680+
valueMap := localVal.AsValueMap()
681+
if valueMap == nil {
682+
return false
683+
}
684+
685+
// Check if the local contains DynamicVal which indicates unresolved references
686+
for _, val := range valueMap {
687+
if val.Type() == cty.DynamicPseudoType {
688+
return true
689+
}
690+
691+
// If it's an object, check recursively for dynamic values
692+
if val.Type().IsObjectType() {
693+
if subMap := val.AsValueMap(); subMap != nil {
694+
for _, subVal := range subMap {
695+
if subVal.Type() == cty.DynamicPseudoType {
696+
return true
697+
}
698+
}
699+
}
700+
}
701+
}
702+
703+
return false
704+
}

0 commit comments

Comments
 (0)