diff --git a/go.mod b/go.mod index ac210ff..470ca4a 100644 --- a/go.mod +++ b/go.mod @@ -7,7 +7,7 @@ require ( github.com/Masterminds/semver v1.5.0 github.com/alecthomas/chroma v0.10.0 // indirect github.com/apparentlymart/go-cidr v1.1.0 - github.com/aquasecurity/defsec v0.93.2-0.20231020041402-7ccc46780c09 + github.com/aquasecurity/defsec v0.93.2-0.20231024055158-015ab97ce898 github.com/aquasecurity/trivy-policies v0.3.1-0.20231021040354-0572a07131c2 github.com/aws/aws-sdk-go v1.44.245 // indirect github.com/aws/smithy-go v1.14.2 @@ -39,6 +39,8 @@ require ( require github.com/mitchellh/mapstructure v1.5.0 +require golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea + require ( cloud.google.com/go v0.110.4 // indirect cloud.google.com/go/compute v1.21.0 // indirect diff --git a/go.sum b/go.sum index 2ea4e33..15dabeb 100644 --- a/go.sum +++ b/go.sum @@ -238,8 +238,8 @@ github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6 github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo= github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY= github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4= -github.com/aquasecurity/defsec v0.93.2-0.20231020041402-7ccc46780c09 h1:dYBDwBnNzDsJr6l+FkrkrvWysAKc6VAO/leOcjvJfaA= -github.com/aquasecurity/defsec v0.93.2-0.20231020041402-7ccc46780c09/go.mod h1:J30VViSgmoW2Ic/6aqVJO2qvuADsmZ3MYuNxPcU6Vt0= +github.com/aquasecurity/defsec v0.93.2-0.20231024055158-015ab97ce898 h1:gu7XQvv2CswgzOdOFHg/AmtR4vBonG35XvGxHHvcIr4= +github.com/aquasecurity/defsec v0.93.2-0.20231024055158-015ab97ce898/go.mod h1:J30VViSgmoW2Ic/6aqVJO2qvuADsmZ3MYuNxPcU6Vt0= github.com/aquasecurity/trivy-policies v0.3.1-0.20231021040354-0572a07131c2 h1:Xkm2i9Dy98p/DMR0smfog487zaTJ11hLVL+PvIgVWyM= github.com/aquasecurity/trivy-policies v0.3.1-0.20231021040354-0572a07131c2/go.mod h1:Wqj81EIp4lDQGVzbPalKLNucR7c96YLQbfdA60KpEkQ= github.com/arbovm/levenshtein v0.0.0-20160628152529-48b4e1c0c4d0 h1:jfIu9sQUG6Ig+0+Ap1h4unLjW6YQJpKZVmUzxsD4E/Q= @@ -836,6 +836,8 @@ golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u0 golang.org/x/exp v0.0.0-20200119233911-0405dc783f0a/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4= golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM= golang.org/x/exp v0.0.0-20200224162631-6cc2880d07d6/go.mod h1:3jZMyOhIsHpP37uCMkUooju7aAi5cS1Q23tOzKc+0MU= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea h1:vLCWI/yYrdEHyN2JzIzPO3aaQJHQdp89IZBA/+azVC4= +golang.org/x/exp v0.0.0-20230510235704-dd950f8aeaea/go.mod h1:V1LtkGg67GoY2N1AnLN78QLrzxkLyJw7RJb1gzOOz9w= golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js= golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE= diff --git a/pkg/scanners/terraform/parser/evaluator.go b/pkg/scanners/terraform/parser/evaluator.go index 1859021..9ad68fb 100644 --- a/pkg/scanners/terraform/parser/evaluator.go +++ b/pkg/scanners/terraform/parser/evaluator.go @@ -151,7 +151,7 @@ func (e *evaluator) EvaluateAll(ctx context.Context) (terraform.Modules, map[str parseDuration += time.Since(start) e.debug.Log("Starting submodule evaluation...") - var modules []*terraform.Module + var modules terraform.Modules for _, definition := range e.loadModules(ctx) { submodules, outputs, err := definition.Parser.EvaluateAll(ctx) if err != nil { @@ -187,7 +187,16 @@ func (e *evaluator) EvaluateAll(ctx context.Context) (terraform.Modules, map[str e.debug.Log("Module evaluation complete.") parseDuration += time.Since(start) - return append([]*terraform.Module{terraform.NewModule(e.projectRootPath, e.modulePath, e.blocks, e.ignores)}, modules...), fsMap, parseDuration + rootModule := terraform.NewModule(e.projectRootPath, e.modulePath, e.blocks, e.ignores, e.isModuleLocal()) + for _, m := range modules { + m.SetParent(rootModule) + } + return append(terraform.Modules{rootModule}, modules...), fsMap, parseDuration +} + +func (e *evaluator) isModuleLocal() bool { + // the module source is empty only for local modules + return e.parentParser.moduleSource == "" } func (e *evaluator) expandBlocks(blocks terraform.Blocks) terraform.Blocks { diff --git a/pkg/scanners/terraform/parser/parser_integration_test.go b/pkg/scanners/terraform/parser/parser_integration_test.go new file mode 100644 index 0000000..d7f4f39 --- /dev/null +++ b/pkg/scanners/terraform/parser/parser_integration_test.go @@ -0,0 +1,51 @@ +package parser + +import ( + "context" + "testing" + + "github.com/aquasecurity/trivy-iac/test/testutil" + "github.com/stretchr/testify/require" +) + +func Test_DefaultRegistry(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + fs := testutil.CreateFS(t, map[string]string{ + "code/test.tf": ` +module "registry" { + source = "terraform-aws-modules/vpc/aws" +} +`, + }) + + parser := New(fs, "", OptionStopOnHCLError(true)) + if err := parser.ParseFS(context.TODO(), "code"); err != nil { + t.Fatal(err) + } + modules, _, err := parser.EvaluateAll(context.TODO()) + require.NoError(t, err) + require.Len(t, modules, 2) +} + +func Test_SpecificRegistry(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + fs := testutil.CreateFS(t, map[string]string{ + "code/test.tf": ` +module "registry" { + source = "registry.terraform.io/terraform-aws-modules/vpc/aws" +} +`, + }) + + parser := New(fs, "", OptionStopOnHCLError(true)) + if err := parser.ParseFS(context.TODO(), "code"); err != nil { + t.Fatal(err) + } + modules, _, err := parser.EvaluateAll(context.TODO()) + require.NoError(t, err) + require.Len(t, modules, 2) +} diff --git a/pkg/scanners/terraform/parser/parser_test.go b/pkg/scanners/terraform/parser/parser_test.go index fb0e27b..241367b 100644 --- a/pkg/scanners/terraform/parser/parser_test.go +++ b/pkg/scanners/terraform/parser/parser_test.go @@ -592,44 +592,6 @@ resource "something" "blah" { assert.Equal(t, true, values[2].GetMetadata().IsResolvable()) } -func Test_DefaultRegistry(t *testing.T) { - - fs := testutil.CreateFS(t, map[string]string{ - "code/test.tf": ` -module "registry" { - source = "terraform-aws-modules/vpc/aws" -} -`, - }) - - parser := New(fs, "", OptionStopOnHCLError(true)) - if err := parser.ParseFS(context.TODO(), "code"); err != nil { - t.Fatal(err) - } - modules, _, err := parser.EvaluateAll(context.TODO()) - require.NoError(t, err) - require.Len(t, modules, 2) -} - -func Test_SpecificRegistry(t *testing.T) { - - fs := testutil.CreateFS(t, map[string]string{ - "code/test.tf": ` -module "registry" { - source = "registry.terraform.io/terraform-aws-modules/vpc/aws" -} -`, - }) - - parser := New(fs, "", OptionStopOnHCLError(true)) - if err := parser.ParseFS(context.TODO(), "code"); err != nil { - t.Fatal(err) - } - modules, _, err := parser.EvaluateAll(context.TODO()) - require.NoError(t, err) - require.Len(t, modules, 2) -} - func Test_NullDefaultValueForVar(t *testing.T) { fs := testutil.CreateFS(t, map[string]string{ "test.tf": ` diff --git a/pkg/scanners/terraform/scanner.go b/pkg/scanners/terraform/scanner.go index afacd5f..4f6411b 100644 --- a/pkg/scanners/terraform/scanner.go +++ b/pkg/scanners/terraform/scanner.go @@ -14,7 +14,9 @@ import ( "github.com/aquasecurity/defsec/pkg/framework" "github.com/aquasecurity/defsec/pkg/scan" "github.com/aquasecurity/defsec/pkg/scanners/options" + "github.com/aquasecurity/defsec/pkg/terraform" "github.com/aquasecurity/defsec/pkg/types" + "golang.org/x/exp/slices" "github.com/aquasecurity/trivy-iac/pkg/extrafs" "github.com/aquasecurity/trivy-iac/pkg/rego" @@ -158,6 +160,31 @@ func (s *Scanner) initRegoScanner(srcFS fs.FS) (*rego.Scanner, error) { return regoScanner, nil } +// terraformRootModule represents the module to be used as the root module for Terraform deployment. +type terraformRootModule struct { + rootPath string + childs terraform.Modules + fsMap map[string]fs.FS +} + +func excludeNonRootModules(modules []terraformRootModule) []terraformRootModule { + var result []terraformRootModule + var childPaths []string + + for _, module := range modules { + childPaths = append(childPaths, module.childs.ChildModulesPaths()...) + } + + for _, module := range modules { + // if the path of the root module matches the path of the child module, + // then we should not scan it + if !slices.Contains(childPaths, module.rootPath) { + result = append(result, module) + } + } + return result +} + func (s *Scanner) ScanFSWithMetrics(ctx context.Context, target fs.FS, dir string) (scan.Results, Metrics, error) { var metrics Metrics @@ -185,14 +212,12 @@ func (s *Scanner) ScanFSWithMetrics(ctx context.Context, target fs.FS, dir strin var allResults scan.Results // parse all root module directories + var rootModules []terraformRootModule for _, dir := range rootDirs { s.debug.Log("Scanning root module '%s'...", dir) p := parser.New(target, "", s.parserOpt...) - s.execLock.RLock() - e := executor.New(s.executorOpt...) - s.execLock.RUnlock() if err := p.ParseFS(ctx, dir); err != nil { return nil, metrics, err @@ -210,12 +235,24 @@ func (s *Scanner) ScanFSWithMetrics(ctx context.Context, target fs.FS, dir strin metrics.Parser.Timings.DiskIODuration += parserMetrics.Timings.DiskIODuration metrics.Parser.Timings.ParseDuration += parserMetrics.Timings.ParseDuration - results, execMetrics, err := e.Execute(modules) + rootModules = append(rootModules, terraformRootModule{ + rootPath: dir, + childs: modules, + fsMap: p.GetFilesystemMap(), + }) + } + + rootModules = excludeNonRootModules(rootModules) + + for _, module := range rootModules { + s.execLock.RLock() + e := executor.New(s.executorOpt...) + s.execLock.RUnlock() + results, execMetrics, err := e.Execute(module.childs) if err != nil { return nil, metrics, err } - fsMap := p.GetFilesystemMap() for i, result := range results { if result.Metadata().Range().GetFS() != nil { continue @@ -224,7 +261,7 @@ func (s *Scanner) ScanFSWithMetrics(ctx context.Context, target fs.FS, dir strin if key == "" { continue } - if filesystem, ok := fsMap[key]; ok { + if filesystem, ok := module.fsMap[key]; ok { override := scan.Results{ result, } diff --git a/pkg/scanners/terraform/scanner_integration_test.go b/pkg/scanners/terraform/scanner_integration_test.go new file mode 100644 index 0000000..f7bdcc2 --- /dev/null +++ b/pkg/scanners/terraform/scanner_integration_test.go @@ -0,0 +1,130 @@ +package terraform + +import ( + "bytes" + "context" + "fmt" + "testing" + + "github.com/aquasecurity/defsec/pkg/scanners/options" + "github.com/aquasecurity/trivy-iac/test/testutil" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func Test_ScanRemoteModule(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + fs := testutil.CreateFS(t, map[string]string{ + "main.tf": ` +module "s3_bucket" { + source = "terraform-aws-modules/s3-bucket/aws" + + bucket = "my-s3-bucket" +} +`, + "/rules/bucket_name.rego": ` +# METADATA +# schemas: +# - input: schema.input +# custom: +# avd_id: AVD-AWS-0001 +# input: +# selector: +# - type: cloud +# subtypes: +# - service: s3 +# provider: aws +package defsec.test.aws1 +deny[res] { + bucket := input.aws.s3.buckets[_] + bucket.name.value == "" + res := result.new("The name of the bucket must not be empty", bucket) +}`, + }) + + debugLog := bytes.NewBuffer([]byte{}) + + scanner := New( + options.ScannerWithDebug(debugLog), + options.ScannerWithPolicyFilesystem(fs), + options.ScannerWithPolicyDirs("rules"), + options.ScannerWithEmbeddedPolicies(false), + options.ScannerWithEmbeddedLibraries(false), + options.ScannerWithRegoOnly(true), + ScannerWithAllDirectories(true), + ) + + results, err := scanner.ScanFS(context.TODO(), fs, ".") + require.NoError(t, err) + + assert.Len(t, results.GetPassed(), 1) + + if t.Failed() { + fmt.Printf("Debug logs:\n%s\n", debugLog.String()) + } +} + +func Test_ScanChildUseRemoteModule(t *testing.T) { + if testing.Short() { + t.Skip("skipping integration test in short mode") + } + fs := testutil.CreateFS(t, map[string]string{ + "main.tf": ` +module "this" { + source = "./modules/s3" + bucket = "my-s3-bucket" +} +`, + "modules/s3/main.tf": ` +variable "bucket" { + type = string +} + +module "s3_bucket" { + source = "github.com/terraform-aws-modules/terraform-aws-s3-bucket?ref=v3.15.1" + bucket = var.bucket +} +`, + "rules/bucket_name.rego": ` +# METADATA +# schemas: +# - input: schema.input +# custom: +# avd_id: AVD-AWS-0001 +# input: +# selector: +# - type: cloud +# subtypes: +# - service: s3 +# provider: aws +package defsec.test.aws1 +deny[res] { + bucket := input.aws.s3.buckets[_] + bucket.name.value == "" + res := result.new("The name of the bucket must not be empty", bucket) +}`, + }) + + debugLog := bytes.NewBuffer([]byte{}) + + scanner := New( + options.ScannerWithDebug(debugLog), + options.ScannerWithPolicyFilesystem(fs), + options.ScannerWithPolicyDirs("rules"), + options.ScannerWithEmbeddedPolicies(false), + options.ScannerWithEmbeddedLibraries(false), + options.ScannerWithRegoOnly(true), + ScannerWithAllDirectories(true), + ) + + results, err := scanner.ScanFS(context.TODO(), fs, ".") + require.NoError(t, err) + + assert.Len(t, results.GetPassed(), 1) + + if t.Failed() { + fmt.Printf("Debug logs:\n%s\n", debugLog.String()) + } +} diff --git a/pkg/scanners/terraform/scanner_test.go b/pkg/scanners/terraform/scanner_test.go index a6c32ed..7f1919e 100644 --- a/pkg/scanners/terraform/scanner_test.go +++ b/pkg/scanners/terraform/scanner_test.go @@ -1109,3 +1109,101 @@ bucket_name = "test" fmt.Printf("Debug logs:\n%s\n", debugLog.String()) } } + +func Test_DoNotScanNonRootModules(t *testing.T) { + fs := testutil.CreateFS(t, map[string]string{ + "/code/app1/main.tf": ` +module "s3" { + source = "./modules/s3" + bucket_name = "test" +} +`, + "/code/app1/modules/s3/main.tf": ` +variable "bucket_name" { + type = string +} + +resource "aws_s3_bucket" "main" { + bucket = var.bucket_name +} +`, + "/code/app1/app2/main.tf": ` +module "s3" { + source = "../modules/s3" + bucket_name = "test" +} + +module "ec2" { + source = "./modules/ec2" +} +`, + "/code/app1/app2/modules/ec2/main.tf": ` +variable "security_group_description" { + type = string +} +resource "aws_security_group" "main" { + description = var.security_group_description +} +`, + "/rules/bucket_name.rego": ` +# METADATA +# schemas: +# - input: schema.input +# custom: +# avd_id: AVD-AWS-0001 +# input: +# selector: +# - type: cloud +# subtypes: +# - service: s3 +# provider: aws +package defsec.test.aws1 +deny[res] { + bucket := input.aws.s3.buckets[_] + bucket.name.value == "" + res := result.new("The name of the bucket must not be empty", bucket) +} +`, + "/rules/sec_group_description.rego": ` +# METADATA +# schemas: +# - input: schema.input +# custom: +# avd_id: AVD-AWS-0002 +# input: +# selector: +# - type: cloud +# subtypes: +# - service: ec2 +# provider: aws +package defsec.test.aws2 +deny[res] { + group := input.aws.ec2.securitygroups[_] + group.description.value == "" + res := result.new("The description of the security group must not be empty", group) +} +`, + }) + + debugLog := bytes.NewBuffer([]byte{}) + scanner := New( + options.ScannerWithDebug(debugLog), + options.ScannerWithPolicyFilesystem(fs), + options.ScannerWithPolicyDirs("rules"), + options.ScannerWithEmbeddedPolicies(false), + options.ScannerWithEmbeddedLibraries(false), + options.ScannerWithRegoOnly(true), + ScannerWithAllDirectories(true), + ) + + results, err := scanner.ScanFS(context.TODO(), fs, "code") + require.NoError(t, err) + + assert.Len(t, results.GetPassed(), 2) + require.Len(t, results.GetFailed(), 1) + assert.Equal(t, "AVD-AWS-0002", results.GetFailed()[0].Rule().AVDID) + + if t.Failed() { + fmt.Printf("Debug logs:\n%s\n", debugLog.String()) + } +}