diff --git a/pkg/cmd/scan.go b/pkg/cmd/scan.go index 53dd3b0f..7fe1e653 100644 --- a/pkg/cmd/scan.go +++ b/pkg/cmd/scan.go @@ -130,6 +130,13 @@ func NewScanCmd() *cobra.Command { "Use those HTTP headers to query the provided URL.\n"+ "Only used with tfstate+http(s) backend for now.\n", ) + fl.StringVarP(&opts.BackendOptions.TerraformCloudToken, + "terraform-cloud-token", + "", + "", + "Terraform Cloud / Enterprise API token.\n"+ + "Only used with tfstate+tfcloud backend.\n", + ) fl.BoolVar(&opts.StrictMode, "strict", false, diff --git a/pkg/iac/terraform/state/backend/backend.go b/pkg/iac/terraform/state/backend/backend.go index 48fedd15..85f90391 100644 --- a/pkg/iac/terraform/state/backend/backend.go +++ b/pkg/iac/terraform/state/backend/backend.go @@ -20,7 +20,8 @@ var supportedBackends = []string{ type Backend io.ReadCloser type Options struct { - Headers map[string]string + Headers map[string]string + TerraformCloudToken string } func IsSupported(backend string) bool { diff --git a/pkg/iac/terraform/state/backend/cloud_reader.go b/pkg/iac/terraform/state/backend/cloud_reader.go index 988b508d..2e55b2bb 100644 --- a/pkg/iac/terraform/state/backend/cloud_reader.go +++ b/pkg/iac/terraform/state/backend/cloud_reader.go @@ -7,28 +7,29 @@ import ( "net/http" "github.com/pkg/errors" + "github.com/sirupsen/logrus" ) const BackendKeyCloud = "tfcloud" const TerraformCloudAPI = "https://app.terraform.io/api/v2" -type Attributes struct { +type TFCloudAttributes struct { HostedStateDownloadUrl string `json:"hosted-state-download-url"` } -type Data struct { - Attributes Attributes `json:"attributes"` +type TFCloudData struct { + Attributes TFCloudAttributes `json:"attributes"` } -type Body struct { - Data Data `json:"data"` +type TFCloudBody struct { + Data TFCloudData `json:"data"` } func NewCloudReader(workspaceId string, opts *Options) (*HTTPBackend, error) { req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/workspaces/%s/current-state-version", TerraformCloudAPI, workspaceId), nil) req.Header.Add("Content-Type", "application/vnd.api+json") - req.Header.Add("Authorization", opts.Headers["Authorization"]) + req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", opts.TerraformCloudToken)) if err != nil { return nil, err @@ -37,12 +38,8 @@ func NewCloudReader(workspaceId string, opts *Options) (*HTTPBackend, error) { client := &http.Client{} res, err := client.Do(req) - if res.StatusCode == 404 { - return nil, errors.Errorf("Error reading state from Terraform Cloud/Enterprise workspace: wrong workspace id") - } - - if res.StatusCode == 401 { - return nil, errors.Errorf("Error reading state from Terraform Cloud/Enterprise workspace: bad authentication token") + if res.StatusCode < 200 || res.StatusCode >= 400 { + return nil, errors.Errorf("error requesting Cloud backend state: status code: %d", res.StatusCode) } if err != nil { @@ -51,14 +48,15 @@ func NewCloudReader(workspaceId string, opts *Options) (*HTTPBackend, error) { bodyBytes, _ := ioutil.ReadAll(res.Body) - body := Body{} + body := TFCloudBody{} err = json.Unmarshal(bodyBytes, &body) if err != nil { - fmt.Println("error:", err) - panic(err.Error()) + return nil, err } + rawURL := body.Data.Attributes.HostedStateDownloadUrl + logrus.WithFields(logrus.Fields{"hosted-state-download-url": rawURL}).Trace("Cloud backend response") opt := Options{} return NewHTTPReader(rawURL, &opt) diff --git a/pkg/iac/terraform/state/backend/cloud_reader_test.go b/pkg/iac/terraform/state/backend/cloud_reader_test.go index 4f617443..93dc2bc4 100644 --- a/pkg/iac/terraform/state/backend/cloud_reader_test.go +++ b/pkg/iac/terraform/state/backend/cloud_reader_test.go @@ -17,66 +17,82 @@ func TestNewCloudReader(t *testing.T) { options *Options } tests := []struct { - name string - args args - url string - wantURL string - wantErr error - responder httpmock.Responder + name string + args args + url string + wantURL string + wantErr error + mock func() }{ { name: "Should fetch URL with auth header", args: args{ workspaceId: "workspaceId", options: &Options{ - Headers: map[string]string{ - "Authorization": "Bearer TOKEN", - }, + TerraformCloudToken: "TOKEN", }, }, - url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", - wantURL: "https://archivist.terraform.io/v1/object/test", - wantErr: nil, - responder: httpmock.NewBytesResponder(http.StatusOK, []byte(`{"data":{"attributes":{"hosted-state-download-url":"https://archivist.terraform.io/v1/object/test"}}}`)), + url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", + wantURL: "https://archivist.terraform.io/v1/object/test", + wantErr: nil, + mock: func() { + httpmock.Reset() + httpmock.RegisterResponder( + "GET", + "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", + httpmock.NewBytesResponder(http.StatusOK, []byte(`{"data":{"attributes":{"hosted-state-download-url":"https://archivist.terraform.io/v1/object/test"}}}`)), + ) + httpmock.RegisterResponder( + "GET", + "https://archivist.terraform.io/v1/object/test", + httpmock.NewBytesResponder(http.StatusOK, []byte(`{}`)), + ) + }, }, { name: "Should fail with wrong workspaceId", args: args{ workspaceId: "wrong_workspaceId", options: &Options{ - Headers: map[string]string{ - "Authorization": "Bearer TOKEN", - }, + TerraformCloudToken: "TOKEN", }, }, - url: "https://app.terraform.io/api/v2/workspaces/wrong_workspaceId/current-state-version", - wantURL: "", - wantErr: errors.New("Error reading state from Terraform Cloud/Enterprise workspace: wrong workspace id"), - responder: httpmock.NewBytesResponder(http.StatusNotFound, []byte{}), + url: "https://app.terraform.io/api/v2/workspaces/wrong_workspaceId/current-state-version", + wantURL: "", + mock: func() { + httpmock.Reset() + httpmock.RegisterResponder( + "GET", + "https://app.terraform.io/api/v2/workspaces/wrong_workspaceId/current-state-version", + httpmock.NewBytesResponder(http.StatusNotFound, []byte{}), + ) + }, + wantErr: errors.New("error requesting Cloud backend state: status code: 404"), }, { name: "Should fail with bad authentication token", args: args{ workspaceId: "workspaceId", options: &Options{ - Headers: map[string]string{ - "Authorization": "Bearer WRONG_TOKEN", - }, + TerraformCloudToken: "TOKEN", }, }, - url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", - wantURL: "", - wantErr: errors.New("Error reading state from Terraform Cloud/Enterprise workspace: bad authentication token"), - responder: httpmock.NewBytesResponder(http.StatusUnauthorized, []byte{}), + url: "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", + wantURL: "", + mock: func() { + httpmock.Reset() + httpmock.RegisterResponder( + "GET", + "https://app.terraform.io/api/v2/workspaces/workspaceId/current-state-version", + httpmock.NewBytesResponder(http.StatusUnauthorized, []byte{}), + ) + }, + wantErr: errors.New("error requesting Cloud backend state: status code: 401"), }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - httpmock.Reset() - httpmock.RegisterResponder("GET", tt.url, tt.responder) - if tt.name == "Should fetch URL with auth header" { - httpmock.RegisterResponder("GET", "https://archivist.terraform.io/v1/object/test", httpmock.NewBytesResponder(http.StatusOK, []byte(`{}`))) - } + tt.mock() got, err := NewCloudReader(tt.args.workspaceId, tt.args.options) if tt.wantErr != nil { assert.EqualError(t, err, tt.wantErr.Error())