diff --git a/pkg/iac/scanners/terraform/parser/evaluator.go b/pkg/iac/scanners/terraform/parser/evaluator.go index 6f0b11743ce3..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" @@ -42,6 +43,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 +65,7 @@ func newEvaluator( allowDownloads bool, skipCachedModules bool, stepHooks []EvaluateStepHook, + resourceClosureTargets []string, ) *evaluator { // create a context to store variables and make functions available @@ -79,20 +85,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 +151,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 +317,125 @@ 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() { + 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 + } + + 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() == blockTypeResource || 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() != blockTypeResource || keep[b] { + continue + } + if refersToUnexpanded(ref, b) { + 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() == blockTypeResource && !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 +} + +// 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). +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/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" { 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..d32f1e0d1c05 --- /dev/null +++ b/pkg/iac/scanners/terraform/parser/resource_closure_test.go @@ -0,0 +1,380 @@ +package parser + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "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 +// 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) +} + +// 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()) + }) + } +} + +// 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") +} + +// 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()) +}