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) +}