diff --git a/pkg/cmd/driftctl_test.go b/pkg/cmd/driftctl_test.go index 88211a1e..f7fb1fa6 100644 --- a/pkg/cmd/driftctl_test.go +++ b/pkg/cmd/driftctl_test.go @@ -82,7 +82,7 @@ func TestDriftctlCmd_Scan(t *testing.T) { env: map[string]string{ "DCTL_FROM": "test", }, - err: fmt.Errorf("Unable to parse from flag 'test': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://"), + err: fmt.Errorf("Unable to parse from flag 'test': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://,tfstate+azurerm://"), }, { env: map[string]string{ diff --git a/pkg/cmd/scan_test.go b/pkg/cmd/scan_test.go index f562d73c..8ba736b7 100644 --- a/pkg/cmd/scan_test.go +++ b/pkg/cmd/scan_test.go @@ -80,14 +80,14 @@ func TestScanCmd_Invalid(t *testing.T) { {args: []string{"scan", "-f"}, expected: `flag needs an argument: 'f' in -f`}, {args: []string{"scan", "--from"}, expected: `flag needs an argument: --from`}, {args: []string{"scan", "--from"}, expected: `flag needs an argument: --from`}, - {args: []string{"scan", "--from", "tosdgjhgsdhgkjs"}, expected: "Unable to parse from flag 'tosdgjhgsdhgkjs': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://"}, - {args: []string{"scan", "--from", "://"}, expected: "Unable to parse from flag '://': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://"}, - {args: []string{"scan", "--from", "://test"}, expected: "Unable to parse from flag '://test': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://"}, - {args: []string{"scan", "--from", "tosdgjhgsdhgkjs://"}, expected: "Unable to parse from flag 'tosdgjhgsdhgkjs://': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://"}, - {args: []string{"scan", "--from", "terraform+foo+bar://test"}, expected: "Unable to parse from scheme 'terraform+foo+bar': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://"}, + {args: []string{"scan", "--from", "tosdgjhgsdhgkjs"}, expected: "Unable to parse from flag 'tosdgjhgsdhgkjs': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://,tfstate+azurerm://"}, + {args: []string{"scan", "--from", "://"}, expected: "Unable to parse from flag '://': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://,tfstate+azurerm://"}, + {args: []string{"scan", "--from", "://test"}, expected: "Unable to parse from flag '://test': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://,tfstate+azurerm://"}, + {args: []string{"scan", "--from", "tosdgjhgsdhgkjs://"}, expected: "Unable to parse from flag 'tosdgjhgsdhgkjs://': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://,tfstate+azurerm://"}, + {args: []string{"scan", "--from", "terraform+foo+bar://test"}, expected: "Unable to parse from scheme 'terraform+foo+bar': \nAccepted schemes are: tfstate://,tfstate+s3://,tfstate+http://,tfstate+https://,tfstate+tfcloud://,tfstate+gs://,tfstate+azurerm://"}, {args: []string{"scan", "--from", "unsupported://test"}, expected: "Unsupported IaC source 'unsupported': \nAccepted values are: tfstate"}, - {args: []string{"scan", "--from", "tfstate+foobar://test"}, expected: "Unsupported IaC backend 'foobar': \nAccepted values are: s3,http,https,tfcloud,gs"}, - {args: []string{"scan", "--from", "tfstate:///tmp/test", "--from", "tfstate+toto://test"}, expected: "Unsupported IaC backend 'toto': \nAccepted values are: s3,http,https,tfcloud,gs"}, + {args: []string{"scan", "--from", "tfstate+foobar://test"}, expected: "Unsupported IaC backend 'foobar': \nAccepted values are: s3,http,https,tfcloud,gs,azurerm"}, + {args: []string{"scan", "--from", "tfstate:///tmp/test", "--from", "tfstate+toto://test"}, expected: "Unsupported IaC backend 'toto': \nAccepted values are: s3,http,https,tfcloud,gs,azurerm"}, {args: []string{"scan", "--filter", "Type='test'"}, expected: "unable to parse filter expression: SyntaxError: Expected tRbracket, received: tUnknown"}, {args: []string{"scan", "--filter", "Type='test'", "--filter", "Type='test2'"}, expected: "Filter flag should be specified only once"}, {args: []string{"scan", "--tf-provider-version", ".30.2"}, expected: "Invalid version argument .30.2, expected a valid semver string (e.g. 2.13.4)"}, diff --git a/pkg/helpers/azure/blob.go b/pkg/helpers/azure/blob.go new file mode 100644 index 00000000..7b8e275a --- /dev/null +++ b/pkg/helpers/azure/blob.go @@ -0,0 +1,27 @@ +package azure + +import ( + "os" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/pkg/errors" +) + +func GetBlobSharedKey() (*azblob.SharedKeyCredential, error) { + storageAccountName, exist := os.LookupEnv("AZURE_STORAGE_ACCOUNT") + if !exist { + return nil, errors.New("AZURE_STORAGE_ACCOUNT should be defined to be able to read state from azure backend") + } + + storageAccountKey, exist := os.LookupEnv("AZURE_STORAGE_KEY") + if !exist { + return nil, errors.New("AZURE_STORAGE_KEY should be defined to be able to read state from azure backend") + } + + credential, err := azblob.NewSharedKeyCredential(storageAccountName, storageAccountKey) + if err != nil { + return nil, err + } + + return credential, nil +} diff --git a/pkg/iac/supplier/supplier_test.go b/pkg/iac/supplier/supplier_test.go index 51066a7b..2b0b6072 100644 --- a/pkg/iac/supplier/supplier_test.go +++ b/pkg/iac/supplier/supplier_test.go @@ -114,6 +114,7 @@ func TestGetSupportedSchemes(t *testing.T) { "tfstate+https://", "tfstate+tfcloud://", "tfstate+gs://", + "tfstate+azurerm://", } if got := GetSupportedSchemes(); !reflect.DeepEqual(got, want) { diff --git a/pkg/iac/terraform/state/backend/azureblob_reader.go b/pkg/iac/terraform/state/backend/azureblob_reader.go new file mode 100644 index 00000000..cbb8dc9e --- /dev/null +++ b/pkg/iac/terraform/state/backend/azureblob_reader.go @@ -0,0 +1,69 @@ +package backend + +import ( + "context" + "fmt" + "io" + "strings" + + "github.com/Azure/azure-sdk-for-go/sdk/storage/azblob" + "github.com/pkg/errors" + "github.com/snyk/driftctl/pkg/helpers/azure" +) + +const BackendKeyAzureRM = "azurerm" + +type AzureRMBackend struct { + reader io.ReadCloser + storageClient azblob.BlockBlobClient +} + +func NewAzureRMReader(path string) (*AzureRMBackend, error) { + bucketPath := strings.Split(path, "/") + if len(bucketPath) < 2 || bucketPath[1] == "" { + return nil, errors.Errorf("Unable to parse azurerm backend storage path: %s. Must be CONTAINER/PATH/TO/OBJECT", path) + } + containerName := bucketPath[0] + objectPath := strings.Join(bucketPath[1:], "/") + + credential, err := azure.GetBlobSharedKey() + if err != nil { + return nil, err + } + blobClient, err := azblob.NewBlockBlobClientWithSharedKey( + fmt.Sprintf( + "https://%s.blob.core.windows.net/%s/%s", + credential.AccountName(), + containerName, + objectPath, + ), + credential, + nil, + ) + if err != nil { + return nil, err + } + + return &AzureRMBackend{ + storageClient: blobClient, + }, nil +} + +func (s *AzureRMBackend) Read(p []byte) (int, error) { + if s.reader == nil { + ctx := context.Background() + data, err := s.storageClient.Download(ctx, nil) + if err != nil { + return 0, err + } + s.reader = data.Body(azblob.RetryReaderOptions{}) + } + return s.reader.Read(p) +} + +func (s *AzureRMBackend) Close() error { + if s.reader != nil { + return s.reader.Close() + } + return errors.New("Unable to close reader as nothing was opened") +} diff --git a/pkg/iac/terraform/state/backend/azureblob_reader_test.go b/pkg/iac/terraform/state/backend/azureblob_reader_test.go new file mode 100644 index 00000000..4c74a742 --- /dev/null +++ b/pkg/iac/terraform/state/backend/azureblob_reader_test.go @@ -0,0 +1,70 @@ +package backend + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestNewAzureRMReader(t *testing.T) { + tests := []struct { + name string + path string + preTest func(t *testing.T) + wantErr assert.ErrorAssertionFunc + }{ + { + name: "invalid path", + path: "containerName/", + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Equal(t, "Unable to parse azurerm backend storage path: containerName/. Must be CONTAINER/PATH/TO/OBJECT", err.Error()) + return true + }, + }, + { + name: "valid path but missing AZURE_STORAGE_ACCOUNT", + path: "containerName/valid.tfstate", + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Equal(t, "AZURE_STORAGE_ACCOUNT should be defined to be able to read state from azure backend", err.Error()) + return true + }, + }, + { + name: "valid path but missing AZURE_STORAGE_KEY", + path: "containerName/valid.tfstate", + preTest: func(t *testing.T) { + t.Setenv("AZURE_STORAGE_ACCOUNT", "foobar") + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + assert.Equal(t, "AZURE_STORAGE_KEY should be defined to be able to read state from azure backend", err.Error()) + return true + }, + }, + // This is not supposed to do any network call during azure client init + // It this behavior change that logic should be moved in the Read function like we already + // did for some other backend + { + name: "valid", + path: "containerName/valid.tfstate", + preTest: func(t *testing.T) { + t.Setenv("AZURE_STORAGE_ACCOUNT", "foobar") + t.Setenv("AZURE_STORAGE_KEY", "barfoo") + }, + wantErr: func(t assert.TestingT, err error, i ...interface{}) bool { + return false + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.preTest != nil { + tt.preTest(t) + } + _, err := NewAzureRMReader(tt.path) + if !tt.wantErr(t, err, fmt.Sprintf("NewAzureRMReader(%v)", tt.path)) { + return + } + }) + } +} diff --git a/pkg/iac/terraform/state/backend/backend.go b/pkg/iac/terraform/state/backend/backend.go index eb0619b5..c320cc4e 100644 --- a/pkg/iac/terraform/state/backend/backend.go +++ b/pkg/iac/terraform/state/backend/backend.go @@ -16,6 +16,7 @@ var supportedBackends = []string{ BackendKeyHTTPS, BackendKeyTFCloud, BackendKeyGS, + BackendKeyAzureRM, } type Backend io.ReadCloser @@ -56,6 +57,8 @@ func GetBackend(config config.SupplierConfig, opts *Options) (Backend, error) { return NewTFCloudReader(config.Path, opts), nil case BackendKeyGS: return NewGSReader(config.Path) + case BackendKeyAzureRM: + return NewAzureRMReader(config.Path) default: return nil, errors.Errorf("Unsupported backend '%s'", backend) }