diff --git a/pkg/cmd/scan.go b/pkg/cmd/scan.go index 53ff97c2..28c51035 100644 --- a/pkg/cmd/scan.go +++ b/pkg/cmd/scan.go @@ -4,6 +4,8 @@ import ( "fmt" "os" "os/signal" + "path" + "path/filepath" "regexp" "strings" "syscall" @@ -15,9 +17,12 @@ import ( "github.com/sirupsen/logrus" "github.com/snyk/driftctl/build" "github.com/snyk/driftctl/pkg/analyser" + "github.com/snyk/driftctl/pkg/iac/config" + "github.com/snyk/driftctl/pkg/iac/terraform/state" "github.com/snyk/driftctl/pkg/memstore" "github.com/snyk/driftctl/pkg/remote/common" "github.com/snyk/driftctl/pkg/telemetry" + "github.com/snyk/driftctl/pkg/terraform/hcl" "github.com/snyk/driftctl/pkg/terraform/lock" "github.com/spf13/cobra" @@ -146,7 +151,7 @@ func NewScanCmd(opts *pkg.ScanOptions) *cobra.Command { fl.StringSliceP( "from", "f", - []string{"tfstate://terraform.tfstate"}, + []string{}, "IaC sources, by default try to find local terraform.tfstate file\n"+ "Accepted schemes are: "+strings.Join(supplier.GetSupportedSchemes(), ",")+"\n", ) @@ -259,6 +264,22 @@ func scanRun(opts *pkg.ScanOptions) error { globaloutput.ChangePrinter(globaloutput.NewConsolePrinter()) } + if len(opts.From) == 0 { + supplierConfigs, err := retrieveBackendsFromHCL("") + if err != nil { + return err + } + opts.From = append(opts.From, supplierConfigs...) + } + + if len(opts.From) == 0 { + opts.From = append(opts.From, config.SupplierConfig{ + Key: state.TerraformStateReaderSupplier, + Backend: backend.BackendKeyFile, + Path: "terraform.tfstate", + }) + } + providerLibrary := terraform.NewProviderLibrary() remoteLibrary := common.NewRemoteLibrary() @@ -360,3 +381,29 @@ func validateTfProviderVersionString(version string) error { } return nil } + +func retrieveBackendsFromHCL(workdir string) ([]config.SupplierConfig, error) { + matches, err := filepath.Glob(path.Join(workdir, "*.tf")) + if err != nil { + return nil, err + } + supplierConfigs := make([]config.SupplierConfig, 0) + + for _, match := range matches { + body, err := hcl.ParseTerraformFromHCL(match) + if err != nil { + logrus. + WithField("file", match). + WithField("error", err). + Debug("Error parsing backend block in Terraform file") + continue + } + + if supplierConfig := body.Backend.SupplierConfig(); supplierConfig != nil { + globaloutput.Printf(color.WhiteString("Using Terraform state %s found in %s. Use the --from flag to specify another state file.\n"), supplierConfig, match) + supplierConfigs = append(supplierConfigs, *supplierConfig) + } + } + + return supplierConfigs, nil +} diff --git a/pkg/cmd/scan_test.go b/pkg/cmd/scan_test.go index 91a093bf..232fb90b 100644 --- a/pkg/cmd/scan_test.go +++ b/pkg/cmd/scan_test.go @@ -4,6 +4,9 @@ import ( "testing" "github.com/snyk/driftctl/pkg" + "github.com/snyk/driftctl/pkg/iac/config" + "github.com/snyk/driftctl/pkg/iac/terraform/state" + "github.com/snyk/driftctl/pkg/iac/terraform/state/backend" "github.com/snyk/driftctl/test" "github.com/spf13/cobra" "github.com/stretchr/testify/assert" @@ -156,3 +159,37 @@ func Test_Options(t *testing.T) { }) } } + +func Test_RetrieveBackendsFromHCL(t *testing.T) { + cases := []struct { + name string + dir string + expected []config.SupplierConfig + wantErr error + }{ + { + name: "should parse s3 backend and ignore invalid file", + dir: "testdata/backend/s3", + expected: []config.SupplierConfig{ + { + Key: state.TerraformStateReaderSupplier, + Backend: backend.BackendKeyS3, + Path: "terraform-state-prod/network/terraform.tfstate", + }, + }, + }, + { + name: "should not find any match and return empty slice", + dir: "testdata/backend", + expected: []config.SupplierConfig{}, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + configs, err := retrieveBackendsFromHCL(tt.dir) + assert.Equal(t, tt.wantErr, err) + assert.Equal(t, tt.expected, configs) + }) + } +} diff --git a/pkg/cmd/testdata/backend/s3/invalid.tf b/pkg/cmd/testdata/backend/s3/invalid.tf new file mode 100644 index 00000000..06608d73 --- /dev/null +++ b/pkg/cmd/testdata/backend/s3/invalid.tf @@ -0,0 +1 @@ +invalid {} diff --git a/pkg/cmd/testdata/backend/s3/s3.tf b/pkg/cmd/testdata/backend/s3/s3.tf new file mode 100644 index 00000000..fbc1c983 --- /dev/null +++ b/pkg/cmd/testdata/backend/s3/s3.tf @@ -0,0 +1,7 @@ +terraform { + backend "s3" { + bucket = "terraform-state-prod" + key = "network/terraform.tfstate" + region = "us-east-1" + } +} diff --git a/pkg/iac/terraform/state/backend/gs_reader.go b/pkg/iac/terraform/state/backend/gs_reader.go index 0ab851a2..21dec056 100644 --- a/pkg/iac/terraform/state/backend/gs_reader.go +++ b/pkg/iac/terraform/state/backend/gs_reader.go @@ -53,6 +53,9 @@ func (s *GSBackend) Read(p []byte) (int, error) { } func (s *GSBackend) Close() error { + if s.storageClient == nil { + return nil + } if err := s.storageClient.Close(); err != nil { return err } diff --git a/pkg/terraform/hcl/backend.go b/pkg/terraform/hcl/backend.go new file mode 100644 index 00000000..48beb886 --- /dev/null +++ b/pkg/terraform/hcl/backend.go @@ -0,0 +1,81 @@ +package hcl + +import ( + "fmt" + "path" + + "github.com/hashicorp/hcl/v2" + "github.com/snyk/driftctl/pkg/iac/config" + "github.com/snyk/driftctl/pkg/iac/terraform/state" + "github.com/snyk/driftctl/pkg/iac/terraform/state/backend" +) + +type BackendBlock struct { + Name string `hcl:"name,label"` + Path string `hcl:"path,optional"` + WorkspaceDir string `hcl:"workspace_dir,optional"` + Bucket string `hcl:"bucket,optional"` + Key string `hcl:"key,optional"` + Region string `hcl:"region,optional"` + Prefix string `hcl:"prefix,optional"` + ContainerName string `hcl:"container_name,optional"` + Remain hcl.Body `hcl:",remain"` +} + +func (b BackendBlock) SupplierConfig() *config.SupplierConfig { + switch b.Name { + case "local": + return b.parseLocalBackend() + case "s3": + return b.parseS3Backend() + case "gcs": + return b.parseGCSBackend() + case "azurerm": + return b.parseAzurermBackend() + } + return nil +} + +func (b BackendBlock) parseLocalBackend() *config.SupplierConfig { + if b.Path == "" { + return nil + } + return &config.SupplierConfig{ + Key: state.TerraformStateReaderSupplier, + Backend: backend.BackendKeyFile, + Path: path.Join(b.WorkspaceDir, b.Path), + } +} + +func (b BackendBlock) parseS3Backend() *config.SupplierConfig { + if b.Bucket == "" || b.Key == "" { + return nil + } + return &config.SupplierConfig{ + Key: state.TerraformStateReaderSupplier, + Backend: backend.BackendKeyS3, + Path: path.Join(b.Bucket, b.Key), + } +} + +func (b BackendBlock) parseGCSBackend() *config.SupplierConfig { + if b.Bucket == "" || b.Prefix == "" { + return nil + } + return &config.SupplierConfig{ + Key: state.TerraformStateReaderSupplier, + Backend: backend.BackendKeyGS, + Path: fmt.Sprintf("%s.tfstate", path.Join(b.Bucket, b.Prefix)), + } +} + +func (b BackendBlock) parseAzurermBackend() *config.SupplierConfig { + if b.ContainerName == "" || b.Key == "" { + return nil + } + return &config.SupplierConfig{ + Key: state.TerraformStateReaderSupplier, + Backend: backend.BackendKeyAzureRM, + Path: path.Join(b.ContainerName, b.Key), + } +} diff --git a/pkg/terraform/hcl/backend_test.go b/pkg/terraform/hcl/backend_test.go new file mode 100644 index 00000000..903722a8 --- /dev/null +++ b/pkg/terraform/hcl/backend_test.go @@ -0,0 +1,78 @@ +package hcl + +import ( + "testing" + + "github.com/snyk/driftctl/pkg/iac/config" + "github.com/stretchr/testify/assert" +) + +func TestBackend_SupplierConfig(t *testing.T) { + cases := []struct { + name string + dir string + want *config.SupplierConfig + wantErr string + }{ + { + name: "test with no backend block", + dir: "testdata/no_backend_block.tf", + want: nil, + wantErr: "testdata/no_backend_block.tf:1,11-11: Missing backend block; A backend block is required.", + }, + { + name: "test with local backend block", + dir: "testdata/local_backend_block.tf", + want: &config.SupplierConfig{ + Key: "tfstate", + Path: "terraform-state-prod/network/terraform.tfstate", + }, + }, + { + name: "test with S3 backend block", + dir: "testdata/s3_backend_block.tf", + want: &config.SupplierConfig{ + Key: "tfstate", + Backend: "s3", + Path: "terraform-state-prod/network/terraform.tfstate", + }, + }, + { + name: "test with GCS backend block", + dir: "testdata/gcs_backend_block.tf", + want: &config.SupplierConfig{ + Key: "tfstate", + Backend: "gs", + Path: "tf-state-prod/terraform/state.tfstate", + }, + }, + { + name: "test with Azure backend block", + dir: "testdata/azurerm_backend_block.tf", + want: &config.SupplierConfig{ + Key: "tfstate", + Backend: "azurerm", + Path: "states/prod.terraform.tfstate", + }, + }, + } + + for _, tt := range cases { + t.Run(tt.name, func(t *testing.T) { + hcl, err := ParseTerraformFromHCL(tt.dir) + if tt.wantErr == "" { + assert.NoError(t, err) + } else { + assert.EqualError(t, err, tt.wantErr) + return + } + + if hcl.Backend.SupplierConfig() == nil { + assert.Nil(t, tt.want) + return + } + + assert.Equal(t, *tt.want, *hcl.Backend.SupplierConfig()) + }) + } +} diff --git a/pkg/terraform/hcl/hcl.go b/pkg/terraform/hcl/hcl.go new file mode 100644 index 00000000..d33a3d04 --- /dev/null +++ b/pkg/terraform/hcl/hcl.go @@ -0,0 +1,31 @@ +package hcl + +import ( + "github.com/hashicorp/hcl/v2/gohcl" + "github.com/hashicorp/hcl/v2/hclparse" +) + +type MainBodyBlock struct { + Terraform TerraformBlock `hcl:"terraform,block"` +} + +type TerraformBlock struct { + Backend BackendBlock `hcl:"backend,block"` +} + +func ParseTerraformFromHCL(filename string) (*TerraformBlock, error) { + var v MainBodyBlock + + parser := hclparse.NewParser() + f, diags := parser.ParseHCLFile(filename) + if diags.HasErrors() { + return nil, diags + } + + diags = gohcl.DecodeBody(f.Body, nil, &v) + if diags.HasErrors() { + return nil, diags + } + + return &v.Terraform, nil +} diff --git a/pkg/terraform/hcl/testdata/azurerm_backend_block.tf b/pkg/terraform/hcl/testdata/azurerm_backend_block.tf new file mode 100644 index 00000000..c55a93cc --- /dev/null +++ b/pkg/terraform/hcl/testdata/azurerm_backend_block.tf @@ -0,0 +1,8 @@ +terraform { + backend "azurerm" { + resource_group_name = "StorageAccount-ResourceGroup" + storage_account_name = "abcd1234" + container_name = "states" + key = "prod.terraform.tfstate" + } +} diff --git a/pkg/terraform/hcl/testdata/gcs_backend_block.tf b/pkg/terraform/hcl/testdata/gcs_backend_block.tf new file mode 100644 index 00000000..ab540a66 --- /dev/null +++ b/pkg/terraform/hcl/testdata/gcs_backend_block.tf @@ -0,0 +1,6 @@ +terraform { + backend "gcs" { + bucket = "tf-state-prod" + prefix = "terraform/state" + } +} diff --git a/pkg/terraform/hcl/testdata/local_backend_block.tf b/pkg/terraform/hcl/testdata/local_backend_block.tf new file mode 100644 index 00000000..6e8dd46c --- /dev/null +++ b/pkg/terraform/hcl/testdata/local_backend_block.tf @@ -0,0 +1,5 @@ +terraform { + backend "local" { + path = "terraform-state-prod/network/terraform.tfstate" + } +} diff --git a/pkg/terraform/hcl/testdata/no_backend_block.tf b/pkg/terraform/hcl/testdata/no_backend_block.tf new file mode 100644 index 00000000..75db7929 --- /dev/null +++ b/pkg/terraform/hcl/testdata/no_backend_block.tf @@ -0,0 +1 @@ +terraform {} diff --git a/pkg/terraform/hcl/testdata/s3_backend_block.tf b/pkg/terraform/hcl/testdata/s3_backend_block.tf new file mode 100644 index 00000000..fbc1c983 --- /dev/null +++ b/pkg/terraform/hcl/testdata/s3_backend_block.tf @@ -0,0 +1,7 @@ +terraform { + backend "s3" { + bucket = "terraform-state-prod" + key = "network/terraform.tfstate" + region = "us-east-1" + } +}