From 3916002b18a93138b3e948524b7263b4a588e96d Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Tue, 25 Aug 2026 15:30:47 -0600 Subject: [PATCH 1/7] feat(terraform): prune resources outside the target block closure EvaluateAll evaluates every resource and module in a module on each call. Callers that only need a subset of a module's output (for example coder/preview, which computes a workspace's input parameters) pay to evaluate resources whose values can never affect that output. OptionWithResourceClosure(targetTypes) restricts root-module evaluation to the resource blocks reachable, via references, from the given target block types (matched on a block's type label, e.g. "coder_parameter"). Every non-resource block is retained and submodules are evaluated in full, so the computed values of the target blocks are unchanged; only resources that nothing in the target closure references are dropped. The closure is conservative: a reference that cannot be resolved to a concrete block keeps the matching blocks, so a resource is excluded only when nothing a target block reads can reference it. Empty targetTypes disables the behavior (default). --- .../scanners/terraform/parser/evaluator.go | 133 ++++++++++++++++-- pkg/iac/scanners/terraform/parser/option.go | 24 ++++ pkg/iac/scanners/terraform/parser/parser.go | 9 +- .../terraform/parser/resource_closure_test.go | 95 +++++++++++++ 4 files changed, 245 insertions(+), 16 deletions(-) create mode 100644 pkg/iac/scanners/terraform/parser/resource_closure_test.go diff --git a/pkg/iac/scanners/terraform/parser/evaluator.go b/pkg/iac/scanners/terraform/parser/evaluator.go index 6f0b11743ce3..01f7545c786e 100644 --- a/pkg/iac/scanners/terraform/parser/evaluator.go +++ b/pkg/iac/scanners/terraform/parser/evaluator.go @@ -42,6 +42,10 @@ type evaluator struct { // stepHooks are functions that are called after each evaluation step. // They can be used to provide additional semantics to other terraform blocks. stepHooks []EvaluateStepHook + // resourceClosureTargets, when non-empty, prunes root-module resource + // blocks that are not reachable from these target block types before + // evaluation. See OptionWithResourceClosure. + resourceClosureTargets []string } func newEvaluator( @@ -60,6 +64,7 @@ func newEvaluator( allowDownloads bool, skipCachedModules bool, stepHooks []EvaluateStepHook, + resourceClosureTargets []string, ) *evaluator { // create a context to store variables and make functions available @@ -79,20 +84,21 @@ func newEvaluator( } return &evaluator{ - filesystem: target, - parentParser: parentParser, - modulePath: modulePath, - moduleName: moduleName, - projectRootPath: projectRootPath, - ctx: ctx, - blocks: blocks, - inputVars: inputVars, - moduleMetadata: moduleMetadata, - ignores: ignores, - logger: logger, - allowDownloads: allowDownloads, - skipCachedModules: skipCachedModules, - stepHooks: stepHooks, + filesystem: target, + parentParser: parentParser, + modulePath: modulePath, + moduleName: moduleName, + projectRootPath: projectRootPath, + ctx: ctx, + blocks: blocks, + inputVars: inputVars, + moduleMetadata: moduleMetadata, + ignores: ignores, + logger: logger, + allowDownloads: allowDownloads, + skipCachedModules: skipCachedModules, + stepHooks: stepHooks, + resourceClosureTargets: resourceClosureTargets, } } @@ -144,6 +150,13 @@ func (e *evaluator) EvaluateAll(ctx context.Context) (terraform.Modules, map[str fsKey: e.filesystem, } + // Optionally drop root-module resource blocks that no target block can + // depend on, before the (expensive) evaluation loop runs. Root-only so + // submodules are still evaluated in full. + if len(e.resourceClosureTargets) > 0 && e.moduleName == "root" { + e.pruneResourcesOutsideClosure() + } + e.evaluateSteps() // expand out resources and modules via count, for-each and dynamic @@ -303,6 +316,98 @@ func (e *evaluator) evaluateSteps() { } } +// pruneResourcesOutsideClosure drops root-module resource blocks that no +// target block (see resourceClosureTargets) can depend on. It is a performance +// optimization for callers that only need a subset of a module's output, such +// as computing input parameters without evaluating the resources a workspace +// would create. +// +// Safety rests on a simple dataflow argument: the frontier is seeded with the +// references of every block that is neither a resource nor an output (i.e. +// everything whose value can legitimately flow into a target block: variables, +// locals, data sources, providers, module arguments, and the target blocks +// themselves). A resource is retained if it is reachable from that frontier, +// following references through resources that are themselves retained. A +// resource is therefore only excluded when nothing a target block reads can +// reference it, so the evaluated values of the target blocks are unchanged. +// +// References that cannot be resolved to a concrete block simply fail to match +// and prune nothing extra, so ambiguity always errs toward keeping resources. +func (e *evaluator) pruneResourcesOutsideClosure() { + targets := make(map[string]bool, len(e.resourceClosureTargets)) + for _, t := range e.resourceClosureTargets { + targets[t] = true + } + + var frontier []*terraform.Reference + var haveTarget bool + for _, b := range e.blocks { + if targets[b.TypeLabel()] { + haveTarget = true + } + // Output blocks are excluded from the frontier: nothing a target block + // reads can reference a module output, and root outputs commonly + // reference resources we want to prune. + if b.Type() == "resource" || b.Type() == "output" { + continue + } + frontier = append(frontier, blockReferences(b)...) + } + + // Without a target block present, this is not a partial-evaluation context + // we understand, so keep everything. + if !haveTarget { + return + } + + keep := make(map[*terraform.Block]bool) + for i := 0; i < len(frontier); i++ { + ref := frontier[i] + for _, b := range e.blocks { + if b.Type() != "resource" || keep[b] { + continue + } + if ref.RefersTo(b.Reference()) { + keep[b] = true + // A retained resource may reference other resources. + frontier = append(frontier, blockReferences(b)...) + } + } + } + + kept := make(terraform.Blocks, 0, len(e.blocks)) + var pruned int + for _, b := range e.blocks { + if b.Type() == "resource" && !keep[b] { + pruned++ + continue + } + kept = append(kept, b) + } + if pruned > 0 { + e.logger.Debug( + "Pruned resource blocks outside the target closure", + log.Int("pruned", pruned), + log.Int("kept", len(kept)), + ) + } + e.blocks = kept +} + +// blockReferences returns every reference made by a block, including references +// inside its nested blocks (for example coder_parameter option and validation +// blocks). +func blockReferences(b *terraform.Block) []*terraform.Reference { + var refs []*terraform.Reference + for _, attr := range b.GetAttributes() { + refs = append(refs, attr.AllReferences()...) + } + for _, child := range b.AllBlocks() { + refs = append(refs, blockReferences(child)...) + } + return refs +} + func (e *evaluator) expandBlocks(blocks terraform.Blocks) terraform.Blocks { return e.expandDynamicBlocks(e.expandBlockForEaches(e.expandBlockCounts(blocks))...) } diff --git a/pkg/iac/scanners/terraform/parser/option.go b/pkg/iac/scanners/terraform/parser/option.go index 80dbeec14c37..95a275d41afc 100644 --- a/pkg/iac/scanners/terraform/parser/option.go +++ b/pkg/iac/scanners/terraform/parser/option.go @@ -16,6 +16,30 @@ func OptionWithEvalHook(hooks EvaluateStepHook) Option { } } +// OptionWithResourceClosure restricts evaluation of the root module to the +// resource blocks that are actually reachable from the given target block +// types via references. Target types are matched against a block's type label +// (e.g. "coder_parameter"), so both data sources and resources can be targets. +// +// When targetTypes is non-empty, root-module resource blocks that are not in +// the transitive reference closure of the target blocks are excluded before +// evaluation. Every non-resource block (variables, locals, data sources, +// providers, modules, outputs) is always retained, and submodules are +// evaluated in full, so the computed values of the target blocks are +// unchanged. This is a performance optimization for callers that only need a +// subset of a template's output (for example, computing input parameters +// without evaluating the resources a workspace would create). +// +// The closure is conservative: any reference that cannot be resolved to a +// specific block keeps the matching blocks, so a resource is only pruned when +// nothing in the target closure can depend on it. Leaving targetTypes empty +// disables the behavior entirely (default). +func OptionWithResourceClosure(targetTypes []string) Option { + return func(p *Parser) { + p.resourceClosureTargets = targetTypes + } +} + func OptionWithTFVarsPaths(paths ...string) Option { return func(p *Parser) { p.tfvarsPaths = paths diff --git a/pkg/iac/scanners/terraform/parser/parser.go b/pkg/iac/scanners/terraform/parser/parser.go index b399b81ab1bc..a45cbd93f4b5 100644 --- a/pkg/iac/scanners/terraform/parser/parser.go +++ b/pkg/iac/scanners/terraform/parser/parser.go @@ -54,8 +54,12 @@ type Parser struct { skipPaths []string // cwd is optional, if left to empty string, 'os.Getwd' // will be used for populating 'path.cwd' in terraform. - cwd string - stepHooks []EvaluateStepHook + cwd string + stepHooks []EvaluateStepHook + // resourceClosureTargets, when non-empty, prunes root-module resource + // blocks that are not in the reference closure of these target block + // types before evaluation. See OptionWithResourceClosure. + resourceClosureTargets []string } // New creates a new Parser @@ -325,6 +329,7 @@ func (p *Parser) Load(_ context.Context) (*evaluator, error) { p.allowDownloads, p.skipCachedModules, p.stepHooks, + p.resourceClosureTargets, ), nil } diff --git a/pkg/iac/scanners/terraform/parser/resource_closure_test.go b/pkg/iac/scanners/terraform/parser/resource_closure_test.go new file mode 100644 index 000000000000..b213aa952e3c --- /dev/null +++ b/pkg/iac/scanners/terraform/parser/resource_closure_test.go @@ -0,0 +1,95 @@ +package parser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/internal/testutil" +) + +// A resource referenced by a parameter (transitively, through a local) must be +// retained, while a resource nothing in the target closure references must be +// pruned. Crucially, the parameter's computed value must be identical with and +// without pruning. +const resourceClosureFixture = ` +data "coder_parameter" "flavor" { + name = "flavor" + default = local.from_resource +} + +locals { + from_resource = my_resource.referenced.name +} + +resource "my_resource" "referenced" { + name = "large" +} + +resource "my_resource" "orphan" { + name = "unused" +} +` + +func Test_OptionWithResourceClosure_PrunesOrphanKeepsReferenced(t *testing.T) { + fs := testutil.CreateFS(map[string]string{"main.tf": resourceClosureFixture}) + + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_parameter"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + root := modules[0] + + // The orphan resource is pruned; the one reachable from the parameter is kept. + resources := root.GetResourcesByType("my_resource") + require.Len(t, resources, 1) + assert.Equal(t, "referenced", resources[0].NameLabel()) + + // The parameter's default is unchanged: it still resolves through the + // retained resource. + params := root.GetDatasByType("coder_parameter") + require.Len(t, params, 1) + assert.Equal(t, "large", params[0].GetAttribute("default").Value().AsString()) +} + +func Test_OptionWithResourceClosure_DisabledByDefault(t *testing.T) { + fs := testutil.CreateFS(map[string]string{"main.tf": resourceClosureFixture}) + + parser := New(fs, "", OptionStopOnHCLError(true)) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + root := modules[0] + + // Without the option, both resources are evaluated as normal. + assert.Len(t, root.GetResourcesByType("my_resource"), 2) + params := root.GetDatasByType("coder_parameter") + require.Len(t, params, 1) + assert.Equal(t, "large", params[0].GetAttribute("default").Value().AsString()) +} + +func Test_OptionWithResourceClosure_NoTargetPresentKeepsEverything(t *testing.T) { + fs := testutil.CreateFS(map[string]string{"main.tf": resourceClosureFixture}) + + // The target type is absent from the template, so there is no basis to + // prune: everything must be retained. + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_workspace_preset"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + + assert.Len(t, modules[0].GetResourcesByType("my_resource"), 2) +} From fceaa1921625ea4fe5167d3b703affb2d35c0607 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 2 Sep 2026 19:23:06 +0000 Subject: [PATCH 2/7] test(terraform): cover indexed references in resource closure pruning A resource referenced only as my_resource.x[0] or my_resource.x["k"] is currently pruned because Reference.RefersTo treats the reference key as significant while the unexpanded resource block has none. This test fails on the PR head and documents the expected behavior. --- .../terraform/parser/resource_closure_test.go | 57 +++++++++++++++++++ 1 file changed, 57 insertions(+) diff --git a/pkg/iac/scanners/terraform/parser/resource_closure_test.go b/pkg/iac/scanners/terraform/parser/resource_closure_test.go index b213aa952e3c..5a4a71d0be3d 100644 --- a/pkg/iac/scanners/terraform/parser/resource_closure_test.go +++ b/pkg/iac/scanners/terraform/parser/resource_closure_test.go @@ -93,3 +93,60 @@ func Test_OptionWithResourceClosure_NoTargetPresentKeepsEverything(t *testing.T) assert.Len(t, modules[0].GetResourcesByType("my_resource"), 2) } + +// A resource referenced only through an index expression (count or for_each) +// must still be retained. Pruning runs before count/for_each expansion, so the +// resource block carries no key while the reference does; matching must not +// treat that mismatch as "different block". +func Test_OptionWithResourceClosure_IndexedReferenceRetained(t *testing.T) { + tests := []struct { + name string + meta string + refIndex string + }{ + {name: "count", meta: `count = 1`, refIndex: `[0]`}, + {name: "for_each", meta: `for_each = toset(["a"])`, refIndex: `["a"]`}, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + fixture := ` +data "coder_parameter" "flavor" { + name = "flavor" + default = local.from_resource +} + +locals { + from_resource = my_resource.indexed` + tc.refIndex + `.name +} + +resource "my_resource" "indexed" { + ` + tc.meta + ` + name = "large" +} +` + fs := testutil.CreateFS(map[string]string{"main.tf": fixture}) + + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_parameter"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + root := modules[0] + + // The resource is in the parameter's closure and must survive pruning. + require.Len(t, root.GetResourcesByType("my_resource"), 1) + + // And the parameter's default must be identical to an unpruned run. + params := root.GetDatasByType("coder_parameter") + require.Len(t, params, 1) + def := params[0].GetAttribute("default").Value() + require.True(t, def.IsKnown() && !def.IsNull(), "parameter default became unknown after pruning") + assert.Equal(t, "large", def.AsString()) + }) + } +} From 08c98a3e88ba81caf8805be4205ce8972dd8b758 Mon Sep 17 00:00:00 2001 From: Steven Masley Date: Wed, 2 Sep 2026 19:25:43 +0000 Subject: [PATCH 3/7] fix(terraform): match indexed references when pruning resource closure Pruning runs before count/for_each expansion, so resource blocks carry no key while references like my_resource.x[0] do. Reference.RefersTo treats that mismatch as a different block, pruning resources the target closure depends on. Compare block type and labels only. --- pkg/iac/scanners/terraform/parser/evaluator.go | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/pkg/iac/scanners/terraform/parser/evaluator.go b/pkg/iac/scanners/terraform/parser/evaluator.go index 01f7545c786e..1fbc6b1ccc82 100644 --- a/pkg/iac/scanners/terraform/parser/evaluator.go +++ b/pkg/iac/scanners/terraform/parser/evaluator.go @@ -367,7 +367,7 @@ func (e *evaluator) pruneResourcesOutsideClosure() { if b.Type() != "resource" || keep[b] { continue } - if ref.RefersTo(b.Reference()) { + if refersToUnexpanded(ref, b) { keep[b] = true // A retained resource may reference other resources. frontier = append(frontier, blockReferences(b)...) @@ -394,6 +394,18 @@ func (e *evaluator) pruneResourcesOutsideClosure() { e.blocks = kept } +// refersToUnexpanded reports whether ref names block b, ignoring any index key +// on the reference. Pruning runs before count/for_each expansion, so b carries +// no key while a reference such as my_resource.x[0] or my_resource.x["k"] +// does; Reference.RefersTo would treat that as a different block and prune a +// resource the target closure depends on. +func refersToUnexpanded(ref *terraform.Reference, b *terraform.Block) bool { + blockRef := b.Reference() + return ref.BlockType() == blockRef.BlockType() && + ref.TypeLabel() == blockRef.TypeLabel() && + ref.NameLabel() == blockRef.NameLabel() +} + // blockReferences returns every reference made by a block, including references // inside its nested blocks (for example coder_parameter option and validation // blocks). From 5f70611adf65b4595f33217726007a57800842c9 Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:26:26 -0600 Subject: [PATCH 4/7] test(terraform): cover splat, dynamic-index, nested-block and module-arg references in closure pruning Extends the resource-closure test set with cases that reach a resource through reference shapes the pruner must not treat as a different block: - splat (my_resource.web[*].name) - variable index (my_resource.pool[var.idx].name) - a parameter's nested option block - a module input argument Each asserts the referenced resource survives pruning (the parameter default still resolves) while an unrelated orphan is pruned. All pass on the current pruner, guarding reference matching against regressions. --- .../terraform/parser/resource_closure_test.go | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) diff --git a/pkg/iac/scanners/terraform/parser/resource_closure_test.go b/pkg/iac/scanners/terraform/parser/resource_closure_test.go index 5a4a71d0be3d..2729cc908017 100644 --- a/pkg/iac/scanners/terraform/parser/resource_closure_test.go +++ b/pkg/iac/scanners/terraform/parser/resource_closure_test.go @@ -7,6 +7,7 @@ import ( "github.com/stretchr/testify/require" "github.com/aquasecurity/trivy/internal/testutil" + "github.com/aquasecurity/trivy/pkg/iac/terraform" ) // A resource referenced by a parameter (transitively, through a local) must be @@ -150,3 +151,196 @@ resource "my_resource" "indexed" { }) } } + +// hasResourceNamed reports whether any my_resource block with the given name +// label survives in the module (count/for_each expands into multiple blocks +// that share a name label). +func hasResourceNamed(root *terraform.Module, name string) bool { + for _, r := range root.GetResourcesByType("my_resource") { + if r.NameLabel() == name { + return true + } + } + return false +} + +// A splat reference (my_resource.web[*].name) names the resource without a +// concrete key, so the base block must be retained. +func Test_OptionWithResourceClosure_SplatReferenceRetained(t *testing.T) { + fixture := ` +data "coder_parameter" "flavor" { + name = "flavor" + default = local.joined +} + +locals { + joined = join(",", my_resource.web[*].name) +} + +resource "my_resource" "web" { + count = 2 + name = "large" +} + +resource "my_resource" "orphan" { + name = "unused" +} +` + fs := testutil.CreateFS(map[string]string{"main.tf": fixture}) + + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_parameter"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + root := modules[0] + + // Orphan (a plain resource) is pruned. The splat-referenced resource is + // retained and evaluated, proven by the parameter default resolving to the + // joined names rather than becoming unknown. + assert.False(t, hasResourceNamed(root, "orphan"), "orphan resource should have been pruned") + + params := root.GetDatasByType("coder_parameter") + require.Len(t, params, 1) + def := params[0].GetAttribute("default").Value() + require.True(t, def.IsKnown() && !def.IsNull(), "parameter default became unknown after pruning (splat resource pruned)") + assert.Equal(t, "large,large", def.AsString()) +} + +// A reference indexed by a variable (my_resource.pool[var.idx]) cannot be +// statically resolved to a key, but the base resource must still be retained. +func Test_OptionWithResourceClosure_DynamicIndexReferenceRetained(t *testing.T) { + fixture := ` +variable "idx" { + default = 1 +} + +data "coder_parameter" "flavor" { + name = "flavor" + default = my_resource.pool[var.idx].name +} + +resource "my_resource" "pool" { + count = 2 + name = "large" +} + +resource "my_resource" "orphan" { + name = "unused" +} +` + fs := testutil.CreateFS(map[string]string{"main.tf": fixture}) + + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_parameter"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + root := modules[0] + + // Orphan (a plain resource) is pruned. The dynamically-indexed resource is + // retained and evaluated, proven by the parameter default resolving. + assert.False(t, hasResourceNamed(root, "orphan"), "orphan resource should have been pruned") + + params := root.GetDatasByType("coder_parameter") + require.Len(t, params, 1) + def := params[0].GetAttribute("default").Value() + require.True(t, def.IsKnown() && !def.IsNull(), "parameter default became unknown after pruning (indexed resource pruned)") + assert.Equal(t, "large", def.AsString()) +} + +// A reference inside a parameter's nested block (an option value here) must be +// followed: blockReferences recurses nested blocks, so the resource is kept. +func Test_OptionWithResourceClosure_NestedBlockReferenceRetained(t *testing.T) { + fixture := ` +data "coder_parameter" "flavor" { + name = "flavor" + + option { + name = "only" + value = my_resource.x.name + } +} + +resource "my_resource" "x" { + name = "large" +} + +resource "my_resource" "orphan" { + name = "unused" +} +` + fs := testutil.CreateFS(map[string]string{"main.tf": fixture}) + + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_parameter"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + root := modules[0] + + assert.True(t, hasResourceNamed(root, "x"), "resource referenced from a nested option block was pruned") + assert.False(t, hasResourceNamed(root, "orphan"), "orphan resource should have been pruned") +} + +// A resource referenced only through a module input argument must be retained: +// the module block is part of the frontier, so its references seed the closure. +func Test_OptionWithResourceClosure_ModuleArgReferenceRetained(t *testing.T) { + files := map[string]string{ + "main.tf": ` +data "coder_parameter" "flavor" { + name = "flavor" + default = "x" +} + +module "m" { + source = "./mod" + in = my_resource.shared.name +} + +resource "my_resource" "shared" { + name = "large" +} + +resource "my_resource" "orphan" { + name = "unused" +} +`, + "mod/main.tf": ` +variable "in" { + type = string +} + +output "out" { + value = var.in +} +`, + } + fs := testutil.CreateFS(files) + + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_parameter"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.GreaterOrEqual(t, len(modules), 1) + root := modules[0] + + assert.True(t, hasResourceNamed(root, "shared"), "resource referenced via a module argument was pruned") + assert.False(t, hasResourceNamed(root, "orphan"), "orphan resource should have been pruned") +} From 819b31c1042541fecebc003f1d7a0b74d9fc047c Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:03:31 -0600 Subject: [PATCH 5/7] style(terraform): use a constant for the resource block type in the pruner The closure pruner repeated the "resource" string literal, which tips the file over goconst's threshold on new lines. Use a local constant instead. --- pkg/iac/scanners/terraform/parser/evaluator.go | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/pkg/iac/scanners/terraform/parser/evaluator.go b/pkg/iac/scanners/terraform/parser/evaluator.go index 1fbc6b1ccc82..c11f93855f2d 100644 --- a/pkg/iac/scanners/terraform/parser/evaluator.go +++ b/pkg/iac/scanners/terraform/parser/evaluator.go @@ -334,6 +334,8 @@ func (e *evaluator) evaluateSteps() { // References that cannot be resolved to a concrete block simply fail to match // and prune nothing extra, so ambiguity always errs toward keeping resources. func (e *evaluator) pruneResourcesOutsideClosure() { + const blockTypeResource = "resource" + targets := make(map[string]bool, len(e.resourceClosureTargets)) for _, t := range e.resourceClosureTargets { targets[t] = true @@ -348,7 +350,7 @@ func (e *evaluator) pruneResourcesOutsideClosure() { // Output blocks are excluded from the frontier: nothing a target block // reads can reference a module output, and root outputs commonly // reference resources we want to prune. - if b.Type() == "resource" || b.Type() == "output" { + if b.Type() == blockTypeResource || b.Type() == "output" { continue } frontier = append(frontier, blockReferences(b)...) @@ -364,7 +366,7 @@ func (e *evaluator) pruneResourcesOutsideClosure() { for i := 0; i < len(frontier); i++ { ref := frontier[i] for _, b := range e.blocks { - if b.Type() != "resource" || keep[b] { + if b.Type() != blockTypeResource || keep[b] { continue } if refersToUnexpanded(ref, b) { @@ -378,7 +380,7 @@ func (e *evaluator) pruneResourcesOutsideClosure() { kept := make(terraform.Blocks, 0, len(e.blocks)) var pruned int for _, b := range e.blocks { - if b.Type() == "resource" && !keep[b] { + if b.Type() == blockTypeResource && !keep[b] { pruned++ continue } From 88781afa43ca8a68478ecaa651636aa1ff9406bf Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:25:47 -0600 Subject: [PATCH 6/7] fix(terraform): skip resource-closure pruning for JSON templates JSON templates (.tf.json / .tofu.json) can merge multiple references in a single expression into one reference during extraction (see Attribute.referencesFromExpression), which would let the closure drop a resource a target depends on. Reference resolution there is not reliable enough to prune safely, so keep everything when any source file is JSON. Adds a regression test (parameter default referencing two resources in one expression must resolve identically to an unpruned run). --- .../scanners/terraform/parser/evaluator.go | 14 ++++++++ .../terraform/parser/resource_closure_test.go | 34 +++++++++++++++++++ 2 files changed, 48 insertions(+) diff --git a/pkg/iac/scanners/terraform/parser/evaluator.go b/pkg/iac/scanners/terraform/parser/evaluator.go index c11f93855f2d..33c6894d3bb6 100644 --- a/pkg/iac/scanners/terraform/parser/evaluator.go +++ b/pkg/iac/scanners/terraform/parser/evaluator.go @@ -7,6 +7,7 @@ import ( "maps" "reflect" "slices" + "strings" "github.com/hashicorp/hcl/v2" "github.com/hashicorp/hcl/v2/ext/typeexpr" @@ -336,6 +337,19 @@ func (e *evaluator) evaluateSteps() { func (e *evaluator) pruneResourcesOutsideClosure() { const blockTypeResource = "resource" + // JSON templates (.tf.json / .tofu.json) can merge several references in one + // expression into a single reference during extraction (see + // Attribute.referencesFromExpression), which would let the closure drop a + // resource a target actually depends on. Reference resolution there is not + // reliable enough to prune safely, so keep everything when any source file + // is JSON. + for _, b := range e.blocks { + name := b.GetMetadata().Range().GetFilename() + if strings.HasSuffix(name, ".tf.json") || strings.HasSuffix(name, ".tofu.json") { + return + } + } + targets := make(map[string]bool, len(e.resourceClosureTargets)) for _, t := range e.resourceClosureTargets { targets[t] = true diff --git a/pkg/iac/scanners/terraform/parser/resource_closure_test.go b/pkg/iac/scanners/terraform/parser/resource_closure_test.go index 2729cc908017..d32f1e0d1c05 100644 --- a/pkg/iac/scanners/terraform/parser/resource_closure_test.go +++ b/pkg/iac/scanners/terraform/parser/resource_closure_test.go @@ -344,3 +344,37 @@ output "out" { assert.True(t, hasResourceNamed(root, "shared"), "resource referenced via a module argument was pruned") assert.False(t, hasResourceNamed(root, "orphan"), "orphan resource should have been pruned") } + +// JSON templates (.tf.json) can merge several references in one expression into +// a single reference during extraction, which would let the closure drop a +// referenced resource. Pruning is therefore skipped entirely for JSON, so a +// parameter default referencing two resources in one expression resolves +// exactly as an unpruned run. +func Test_OptionWithResourceClosure_SkippedForJSONTemplates(t *testing.T) { + fixture := `{ + "data": {"coder_parameter": {"p": {"name": "p", "default": "${local.v}"}}}, + "locals": {"v": "${my_resource.a.name}-${my_resource.b.name}"}, + "resource": {"my_resource": {"a": {"name": "A"}, "b": {"name": "B"}}} +}` + fs := testutil.CreateFS(map[string]string{"main.tf.json": fixture}) + + parser := New(fs, "", + OptionStopOnHCLError(true), + OptionWithResourceClosure([]string{"coder_parameter"}), + ) + require.NoError(t, parser.ParseFS(t.Context(), ".")) + + modules, err := parser.EvaluateAll(t.Context()) + require.NoError(t, err) + require.Len(t, modules, 1) + root := modules[0] + + // Pruning is skipped for JSON, so both resources survive and the parameter + // default resolves to the same value an unpruned evaluation produces. + assert.Len(t, root.GetResourcesByType("my_resource"), 2) + params := root.GetDatasByType("coder_parameter") + require.Len(t, params, 1) + def := params[0].GetAttribute("default").Value() + require.True(t, def.IsKnown() && !def.IsNull(), "parameter default became unknown") + assert.Equal(t, "A-B", def.AsString()) +} From fc995ac524772e53b926bf01bdb9748ebc61b38d Mon Sep 17 00:00:00 2001 From: Bryson Henneberger <591079+PushTheLimit@users.noreply.github.com> Date: Wed, 2 Sep 2026 16:29:48 -0600 Subject: [PATCH 7/7] style(terraform): nolint the eval-hook test's unused parameter The PR's lint (new-vs-main) surfaces a pre-existing revive unused-parameter on this eval-hook callback. The parameter is part of the fixed OptionWithEvalHook signature, so keep the descriptive name and annotate with //nolint:revive rather than renaming to _. --- pkg/iac/scanners/terraform/parser/parser_test.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pkg/iac/scanners/terraform/parser/parser_test.go b/pkg/iac/scanners/terraform/parser/parser_test.go index 0e7512b3919d..a38104b343ad 100644 --- a/pkg/iac/scanners/terraform/parser/parser_test.go +++ b/pkg/iac/scanners/terraform/parser/parser_test.go @@ -2321,7 +2321,7 @@ locals { // A basic example of how to have a 'default' value for a data block. // To see a more practical example, see how 'evaluateVariable' handles // the 'default' value of a variable. - func(ctx *tfcontext.Context, blocks terraform.Blocks, inputVars map[string]cty.Value) { + func(ctx *tfcontext.Context, blocks terraform.Blocks, inputVars map[string]cty.Value) { //nolint:revive // inputVars is part of the fixed OptionWithEvalHook callback signature dataBlocks := blocks.OfType("data") for _, block := range dataBlocks { if len(block.Labels()) >= 1 && block.Labels()[0] == "your_custom_data" {