Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
133 changes: 119 additions & 14 deletions pkg/iac/scanners/terraform/parser/evaluator.go
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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
Expand All @@ -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,
}
}

Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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))...)
}
Expand Down
24 changes: 24 additions & 0 deletions pkg/iac/scanners/terraform/parser/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
9 changes: 7 additions & 2 deletions pkg/iac/scanners/terraform/parser/parser.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -325,6 +329,7 @@ func (p *Parser) Load(_ context.Context) (*evaluator, error) {
p.allowDownloads,
p.skipCachedModules,
p.stepHooks,
p.resourceClosureTargets,
), nil
}

Expand Down
95 changes: 95 additions & 0 deletions pkg/iac/scanners/terraform/parser/resource_closure_test.go
Original file line number Diff line number Diff line change
@@ -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)
}