diff --git a/go.mod b/go.mod index c76b2a77c..b602e9660 100644 --- a/go.mod +++ b/go.mod @@ -18,6 +18,7 @@ require ( github.com/hashicorp/hcl/v2 v2.8.2 github.com/hashicorp/terraform v0.14.4 github.com/iancoleman/strcase v0.1.3 + github.com/itchyny/gojq v0.12.1 github.com/mattn/go-isatty v0.0.12 github.com/mitchellh/go-homedir v1.1.0 github.com/onsi/ginkgo v1.12.1 @@ -32,7 +33,7 @@ require ( golang.org/x/mod v0.4.1 // indirect golang.org/x/sys v0.0.0-20210218155724-8ebf48af031b gopkg.in/src-d/go-git.v4 v4.13.1 - gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 + gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b helm.sh/helm/v3 v3.4.0 honnef.co/go/tools v0.1.1 // indirect sigs.k8s.io/kustomize/api v0.7.2 diff --git a/go.sum b/go.sum index c5894914d..3a7ed5738 100644 --- a/go.sum +++ b/go.sum @@ -598,6 +598,13 @@ github.com/imdario/mergo v0.3.9/go.mod h1:2EnlNZ0deacrJVfApfmtdGgDfMuh/nq6Ok1EcJ github.com/inconshreveable/mousetrap v1.0.0 h1:Z8tu5sraLXCXIcARxBp/8cbvlwVa7Z1NHg9XEKhtSvM= github.com/inconshreveable/mousetrap v1.0.0/go.mod h1:PxqpIevigyE2G7u3NXJIT2ANytuPF1OarO4DADm73n8= github.com/influxdata/influxdb1-client v0.0.0-20191209144304-8bf82d3c094d/go.mod h1:qj24IKcXYK6Iy9ceXlo3Tc+vtHo9lIhSX5JddghvEPo= +github.com/itchyny/astgen-go v0.0.0-20210113000433-0da0671862a3 h1:l7vogWrq+zj8v5t/G69/eT13nAGs2H7cq+CI2nlnKdk= +github.com/itchyny/astgen-go v0.0.0-20210113000433-0da0671862a3/go.mod h1:296z3W7Xsrp2mlIY88ruDKscuvrkL6zXCNRtaYVshzw= +github.com/itchyny/go-flags v1.5.0/go.mod h1:lenkYuCobuxLBAd/HGFE4LRoW8D3B6iXRQfWYJ+MNbA= +github.com/itchyny/gojq v0.12.1 h1:pQJrG8LXgEbZe9hvpfjKg7UlBfieQQydIw3YQq+7WIA= +github.com/itchyny/gojq v0.12.1/go.mod h1:Y5Lz0qoT54ii+ucY/K3yNDy19qzxZvWNBMBpKUDQR/4= +github.com/itchyny/timefmt-go v0.1.1 h1:rLpnm9xxb39PEEVzO0n4IRp0q6/RmBc7Dy/rE4HrA0U= +github.com/itchyny/timefmt-go v0.1.1/go.mod h1:0osSSCQSASBJMsIZnhAaF1C2fCBTJZXrnj37mG8/c+A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99 h1:BQSFePA1RWJOlocH6Fxy8MmwDt+yVQYULKfN0RoTN8A= github.com/jbenet/go-context v0.0.0-20150711004518-d14ea06fba99/go.mod h1:1lJo3i6rXxKeerYnT8Nvf0QmHCRC1n8sfWVwXF2Frvo= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= @@ -697,6 +704,7 @@ github.com/mattn/go-oci8 v0.0.7/go.mod h1:wjDx6Xm9q7dFtHJvIlrI99JytznLw5wQ4R+9mN github.com/mattn/go-runewidth v0.0.0-20181025052659-b20a3daf6a39/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.2/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= github.com/mattn/go-runewidth v0.0.4/go.mod h1:LwmH8dsx7+W8Uxz3IHJYH5QSwggIsqBzpuz5H//U1FU= +github.com/mattn/go-runewidth v0.0.9/go.mod h1:H031xJmbD/WCDINGzjvQ9THkh0rPKHF+m2gUSrubnMI= github.com/mattn/go-shellwords v1.0.4/go.mod h1:3xCvwCdWdlDJUrvuMn7Wuy9eWs4pE8vqg+NOMyg4B2o= github.com/mattn/go-shellwords v1.0.10/go.mod h1:EZzvwXDESEeg03EKmM+RmDnNOPKG4lLtQsUlTZDWQ8Y= github.com/mattn/go-sqlite3 v1.9.0/go.mod h1:FPy6KqzDD04eiIsT53CuJW3U88zkxoIYsOqkbpncsNc= @@ -1238,6 +1246,7 @@ golang.org/x/sys v0.0.0-20200803210538-64077c9b5642/go.mod h1:h1NjWce9XRLGQEsW7w golang.org/x/sys v0.0.0-20200905004654-be1d3432aa8f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f h1:+Nyd8tzPX9R7BWHguqsrbFdRx3WQ/1ib8I44HXV5yTA= golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= +golang.org/x/sys v0.0.0-20210113181707-4bcb84eeeb78/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k= golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210218155724-8ebf48af031b h1:lAZ0/chPUDWwjqosYR0X4M490zQhMsiJ4K3DbA7o+3g= @@ -1317,7 +1326,6 @@ golang.org/x/tools v0.0.0-20200331025713-a30bf2db82d4/go.mod h1:Sl4aGygMT6LrqrWc golang.org/x/tools v0.0.0-20200501065659-ab2804fb9c9d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200512131952-2bc93b1c0c88/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200515010526-7d3b6ebf133d/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= -golang.org/x/tools v0.0.0-20200609164405-eb789aa7ce50/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200616133436-c1934b75d054/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200618134242-20370b0cb4b2/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20200729194436-6467de6f59a7/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= @@ -1325,8 +1333,6 @@ golang.org/x/tools v0.0.0-20200804011535-6c149bb5ef0d/go.mod h1:njjCfa9FT2d7l9Bc golang.org/x/tools v0.0.0-20200825202427-b303f430e36d/go.mod h1:njjCfa9FT2d7l9Bc6FUM5FLjQPp3cFF28FI3qnDFljA= golang.org/x/tools v0.0.0-20200904185747-39188db58858/go.mod h1:Cj7w3i3Rnn0Xh82ur9kSqwfTHTeVxaDqrfMjpcNT6bE= golang.org/x/tools v0.0.0-20201028111035-eafbe7b904eb/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= -golang.org/x/tools v0.0.0-20210115202250-e0d201561e39 h1:BTs2GMGSMWpgtCpv1CE7vkJTv7XcHdcLLnAMu7UbgTY= -golang.org/x/tools v0.0.0-20210115202250-e0d201561e39/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= golang.org/x/tools v0.1.0 h1:po9/4sTYwZU9lPhi1tOrb4hCv3qrhiQ77LZfGa2OjwY= golang.org/x/tools v0.1.0/go.mod h1:xkSsbof2nBLbhDlRMhhhyNLN/zl3eTqcnHD5viDpcZ0= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= @@ -1471,8 +1477,8 @@ gopkg.in/yaml.v2 v2.2.8/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v2 v2.3.0 h1:clyUAQHOM3G0M3f5vQj7LuJrETvjVot3Z5el9nffUtU= gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 h1:tQIYjPdBoyREyB9XMu+nnTclpTYkz2zFM+lzLJFO4gQ= -gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b h1:h8qDotaEPuJATrMmW04NCwg7v22aHH28wwpauUhK9Oo= +gopkg.in/yaml.v3 v3.0.0-20210107192922-496545a6307b/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gotest.tools v2.2.0+incompatible/go.mod h1:DsYFclhRJ6vuDpmuTbkuFWG+y2sxOXAzmJt81HFBacw= gotest.tools/v3 v3.0.2/go.mod h1:3SzNCllyD9/Y+b5r9JIKQ474KzkZyqLqEfYqMsX94Bk= helm.sh/helm/v3 v3.4.0 h1:rsut6hqQfjD3G/ic1XPh3KyasfOHZuDUhtyAJjuquew= @@ -1485,8 +1491,6 @@ honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWh honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg= honnef.co/go/tools v0.0.1-2020.1.3/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= honnef.co/go/tools v0.0.1-2020.1.4/go.mod h1:X/FiERA/W4tHapMX5mGpAtMSVEeEUOyHaw9vFzvIQ3k= -honnef.co/go/tools v0.1.0 h1:AWNL1W1i7f0wNZ8VwOKNJ0sliKvOF/adn0EHenfUh+c= -honnef.co/go/tools v0.1.0/go.mod h1:XtegFAyX/PfluP4921rXU5IkjkqBCDnUq4W8VCIoKvM= honnef.co/go/tools v0.1.1 h1:EVDuO03OCZwpV2t/tLLxPmPiomagMoBOgfPt0FM+4IY= honnef.co/go/tools v0.1.1/go.mod h1:NgwopIslSNH47DimFoV78dnkksY2EFtX0ajyb3K/las= k8s.io/api v0.19.0 h1:XyrFIJqTYZJ2DU7FBE/bSPz7b1HvbVBuBf07oeo6eTc= diff --git a/pkg/iac-providers/tfplan.go b/pkg/iac-providers/tfplan.go new file mode 100644 index 000000000..d316d48c3 --- /dev/null +++ b/pkg/iac-providers/tfplan.go @@ -0,0 +1,36 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package iacprovider + +import ( + "reflect" + + tfplanv1 "github.com/accurics/terrascan/pkg/iac-providers/tfplan/v1" +) + +// tfplan specific constants +const ( + tfplan supportedIacType = "tfplan" + tfplanV1 supportedIacVersion = "v1" + tfplanDefaultIacVersion = tfplanV1 +) + +// register tfplan as an IaC provider with terrascan +func init() { + // register iac provider + RegisterIacProvider(tfplan, tfplanV1, tfplanDefaultIacVersion, reflect.TypeOf(tfplanv1.TFPlan{})) +} diff --git a/pkg/iac-providers/tfplan/v1/load-dir.go b/pkg/iac-providers/tfplan/v1/load-dir.go new file mode 100644 index 000000000..4ae8b1259 --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/load-dir.go @@ -0,0 +1,33 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tfplan + +import ( + "fmt" + + "github.com/accurics/terrascan/pkg/iac-providers/output" +) + +var ( + errIacDirNotSupport = fmt.Errorf("tfplan should always be a file, not a directory. Please specify path to tfplan file with '-f' option") +) + +// LoadIacDir is not supported for tfplan IacType. Terraform plan should always +// be a file and not a directory +func (k *TFPlan) LoadIacDir(absRootDir string) (output.AllResourceConfigs, error) { + return output.AllResourceConfigs{}, errIacDirNotSupport +} diff --git a/pkg/iac-providers/tfplan/v1/load-dir_test.go b/pkg/iac-providers/tfplan/v1/load-dir_test.go new file mode 100644 index 000000000..b3d575c3e --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/load-dir_test.go @@ -0,0 +1,37 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tfplan + +import ( + "reflect" + "testing" +) + +func TestLoadIacDir(t *testing.T) { + + t.Run("directory not supported", func(t *testing.T) { + var ( + dirPath = "some-path" + tfplan = TFPlan{} + wantErr = errIacDirNotSupport + ) + _, err := tfplan.LoadIacDir(dirPath) + if !reflect.DeepEqual(wantErr, err) { + t.Errorf("error want: '%v', got: '%v'", wantErr, err) + } + }) +} diff --git a/pkg/iac-providers/tfplan/v1/load-file.go b/pkg/iac-providers/tfplan/v1/load-file.go new file mode 100644 index 000000000..e19f92810 --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/load-file.go @@ -0,0 +1,117 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tfplan + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "strings" + + "github.com/accurics/terrascan/pkg/iac-providers/output" + "github.com/accurics/terrascan/pkg/utils" + "go.uber.org/zap" +) + +const ( + jqQuery = `[.planned_values.root_module | .. | select(.type? != null and .address? != null and .mode? == "managed") | {id: .address?, type: .type?, name: .name?, config: .values?, source: ""}]` + tfPlanFormatVersion = "0.1" +) + +var ( + errIncorrectFormatVersion = fmt.Errorf("terraform format version shoule be '%s'", tfPlanFormatVersion) + errEmptyTerraformVersion = fmt.Errorf("terraform version cannot be empty in tfplan json") +) + +// LoadIacFile parses the given tfplan file from the given file path +func (t *TFPlan) LoadIacFile(absFilePath string) (allResourcesConfig output.AllResourceConfigs, err error) { + + zap.S().Debug("processing tfplan file") + + // read tfplan json file + tfjson, err := ioutil.ReadFile(absFilePath) + if err != nil { + errMsg := fmt.Sprintf("failed to read tfplan JSON file. error: '%v'", err) + zap.S().Debug(errMsg) + return allResourcesConfig, fmt.Errorf(errMsg) + } + + // validate if provide file is a valid tfplan file + if err := t.isValidTFPlanJSON(tfjson); err != nil { + return allResourcesConfig, fmt.Errorf("invalid terraform json file; error: '%v'", err) + } + + // run jq query on tfplan json + processed, err := utils.JQFilterWithQuery(jqQuery, tfjson) + if err != nil { + errMsg := fmt.Sprintf("failed to process tfplan JSON. error: '%v'", err) + zap.S().Debug(errMsg) + return allResourcesConfig, fmt.Errorf(errMsg) + } + + // decode processed out into output.ResourceConfig + var resourceConfigs []output.ResourceConfig + if err := json.Unmarshal(processed, &resourceConfigs); err != nil { + errMsg := fmt.Sprintf("failed to decode proceesed jq output. error: '%v'", err) + zap.S().Debug(errMsg) + return allResourcesConfig, fmt.Errorf(errMsg) + } + + // create AllResourceConfigs from resourceConfigs + allResourcesConfig = make(map[string][]output.ResourceConfig) + for _, r := range resourceConfigs { + r.ID = getTFID(r.ID) + if _, present := allResourcesConfig[r.Type]; !present { + allResourcesConfig[r.Type] = []output.ResourceConfig{r} + } else { + allResourcesConfig[r.Type] = append(allResourcesConfig[r.Type], r) + } + } + + // return output + return allResourcesConfig, nil +} + +// isValidTFPlanJSON validates whether the provided file is a valid tf json file +func (t *TFPlan) isValidTFPlanJSON(tfjson []byte) error { + + // decode tfjson into map[string]interface{} + if err := json.Unmarshal(tfjson, &t); err != nil { + return fmt.Errorf("failed to decode tfplan json. error: '%v'", err) + } + + // check format version + if t.FormatVersion != tfPlanFormatVersion { + return errIncorrectFormatVersion + } + + // check terraform version + if t.TerraformVersion == "" { + return errEmptyTerraformVersion + } + + return nil +} + +// getTFID returns a valid resource ID for terraform +func getTFID(id string) string { + split := strings.Split(id, ".") + if len(split) <= 2 { + return strings.Join(split, ".") + } + return strings.Join(split[len(split)-2:], ".") +} diff --git a/pkg/iac-providers/tfplan/v1/load-file_test.go b/pkg/iac-providers/tfplan/v1/load-file_test.go new file mode 100644 index 000000000..4172ad108 --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/load-file_test.go @@ -0,0 +1,173 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tfplan + +import ( + "bytes" + "encoding/json" + "fmt" + "io/ioutil" + "reflect" + "testing" + + "github.com/accurics/terrascan/pkg/iac-providers/output" +) + +func TestLoadIacFile(t *testing.T) { + + table := []struct { + name string + filePath string + tfplan TFPlan + want output.AllResourceConfigs + wantErr error + }{ + { + name: "invalid filepath", + filePath: "not-there", + tfplan: TFPlan{}, + wantErr: fmt.Errorf("failed to read tfplan JSON file. error: 'open not-there: no such file or directory'"), + }, + { + name: "invalid json", + filePath: "./testdata/invalid-json.json", + tfplan: TFPlan{}, + wantErr: fmt.Errorf("invalid terraform json file; error: 'failed to decode tfplan json. error: 'invalid character 'I' looking for beginning of value''"), + }, + { + name: "invalid tfplan json", + filePath: "./testdata/invalid-tfplan.json", + tfplan: TFPlan{}, + wantErr: fmt.Errorf("invalid terraform json file; error: 'terraform format version shoule be '0.1''"), + }, + { + name: "valid tfplan json", + filePath: "./testdata/valid-tfplan.json", + tfplan: TFPlan{}, + wantErr: nil, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + _, gotErr := tt.tfplan.LoadIacFile(tt.filePath) + if !reflect.DeepEqual(gotErr, tt.wantErr) { + t.Errorf("unexpected error; gotErr: '%v', wantErr: '%v'", gotErr, tt.wantErr) + } + }) + } + + t.Run("validate tfplan iac output", func(t *testing.T) { + var ( + tfplan = TFPlan{} + tfplanFile = "./testdata/valid-tfplan.json" + tfplanOutput = "./testdata/valid-tfplan-resource-config.json" + wantErr error = nil + ) + + got, err := tfplan.LoadIacFile(tfplanFile) + if !reflect.DeepEqual(err, wantErr) { + t.Errorf("error want: '%v', got: '%v'", wantErr, err) + } + + gotBytes, _ := json.MarshalIndent(got, "", " ") + gotBytes = append(gotBytes, []byte{'\n'}...) + wantBytes, _ := ioutil.ReadFile(tfplanOutput) + if !reflect.DeepEqual(bytes.TrimSpace(gotBytes), bytes.TrimSpace(wantBytes)) { + t.Errorf("unexpected error; got '%v', want: '%v'", string(gotBytes), string(wantBytes)) + } + }) +} + +func TestIsValidTFPlanJSON(t *testing.T) { + + tfplan := TFPlan{} + + table := []struct { + name string + tfjson []byte + wantErr error + }{ + { + name: "invalid json", + tfjson: []byte("I am invalid"), + wantErr: fmt.Errorf("failed to decode tfplan json. error: 'invalid character 'I' looking for beginning of value'"), + }, + { + name: "incorrect terraform format version", + tfjson: []byte(`{"format_version": "bad version"}`), + wantErr: errIncorrectFormatVersion, + }, + { + name: "empty terraform version", + tfjson: []byte(`{"format_version": "0.1"}`), + wantErr: errEmptyTerraformVersion, + }, + { + name: "valid tfplan json", + tfjson: []byte(`{"format_version": "0.1", "terraform_version": "0.12.3"}`), + wantErr: nil, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + err := tfplan.isValidTFPlanJSON(tt.tfjson) + if !reflect.DeepEqual(err, tt.wantErr) { + t.Errorf("error got: '%v', want: '%v'", err, tt.wantErr) + } + }) + } +} + +func TestGetTFID(t *testing.T) { + + table := []struct { + name string + input string + want string + }{ + { + name: "empty input", + input: "", + want: "", + }, + { + name: "regular terraform id", + input: "x.y", + want: "x.y", + }, + { + name: "long terraform id", + input: "x.y.z", + want: "y.z", + }, + { + name: "extra long terraform id", + input: "w.x.y.z", + want: "y.z", + }, + } + + for _, tt := range table { + got := getTFID(tt.input) + if got != tt.want { + t.Errorf("got: '%v', want: '%v'", got, tt.want) + } + } + +} diff --git a/pkg/iac-providers/tfplan/v1/testdata/invalid-json.json b/pkg/iac-providers/tfplan/v1/testdata/invalid-json.json new file mode 100644 index 000000000..70f20a4fb --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/testdata/invalid-json.json @@ -0,0 +1 @@ +I am an invalid json diff --git a/pkg/iac-providers/tfplan/v1/testdata/invalid-tfplan.json b/pkg/iac-providers/tfplan/v1/testdata/invalid-tfplan.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/testdata/invalid-tfplan.json @@ -0,0 +1 @@ +{} diff --git a/pkg/iac-providers/tfplan/v1/testdata/valid-tfplan-resource-config.json b/pkg/iac-providers/tfplan/v1/testdata/valid-tfplan-resource-config.json new file mode 100644 index 000000000..fb2ffa3b8 --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/testdata/valid-tfplan-resource-config.json @@ -0,0 +1,64 @@ +{ + "aws_s3_bucket": [ + { + "id": "aws_s3_bucket.demo-example", + "name": "demo-example", + "source": "", + "line": 0, + "type": "aws_s3_bucket", + "config": { + "acl": "private", + "bucket": "demoexample-1", + "bucket_prefix": null, + "cors_rule": [], + "force_destroy": false, + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "replication_configuration": [], + "server_side_encryption_configuration": [], + "tags": null, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [] + }, + "skip_rules": null + }, + { + "id": "aws_s3_bucket.demo-s3", + "name": "demo-s3", + "source": "", + "line": 0, + "type": "aws_s3_bucket", + "config": { + "acl": "private", + "bucket": "sample_prefix_test20-terraformcloud", + "bucket_prefix": null, + "cors_rule": [], + "force_destroy": false, + "grant": [], + "lifecycle_rule": [], + "logging": [], + "object_lock_configuration": [], + "policy": null, + "replication_configuration": [], + "server_side_encryption_configuration": [], + "tags": null, + "versioning": [ + { + "enabled": false, + "mfa_delete": false + } + ], + "website": [] + }, + "skip_rules": null + } + ] +} diff --git a/pkg/iac-providers/tfplan/v1/testdata/valid-tfplan.json b/pkg/iac-providers/tfplan/v1/testdata/valid-tfplan.json new file mode 100644 index 000000000..151b3f537 --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/testdata/valid-tfplan.json @@ -0,0 +1 @@ +{"format_version":"0.1","terraform_version":"0.13.5","variables":{"s3_bucket_prefix":{"value":"sample_prefix_test20"}},"planned_values":{"root_module":{"resources":[{"address":"aws_s3_bucket.demo-example","mode":"managed","type":"aws_s3_bucket","name":"demo-example","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"acl":"private","bucket":"demoexample-1","bucket_prefix":null,"cors_rule":[],"force_destroy":false,"grant":[],"lifecycle_rule":[],"logging":[],"object_lock_configuration":[],"policy":null,"replication_configuration":[],"server_side_encryption_configuration":[],"tags":null,"versioning":[{"enabled":false,"mfa_delete":false}],"website":[]}},{"address":"aws_s3_bucket.demo-s3","mode":"managed","type":"aws_s3_bucket","name":"demo-s3","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"acl":"private","bucket":"sample_prefix_test20-terraformcloud","bucket_prefix":null,"cors_rule":[],"force_destroy":false,"grant":[],"lifecycle_rule":[],"logging":[],"object_lock_configuration":[],"policy":null,"replication_configuration":[],"server_side_encryption_configuration":[],"tags":null,"versioning":[{"enabled":false,"mfa_delete":false}],"website":[]}}]}},"resource_changes":[{"address":"aws_s3_bucket.demo-example","mode":"managed","type":"aws_s3_bucket","name":"demo-example","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"acl":"private","bucket":"demoexample-1","bucket_prefix":null,"cors_rule":[],"force_destroy":false,"grant":[],"lifecycle_rule":[],"logging":[],"object_lock_configuration":[],"policy":null,"replication_configuration":[],"server_side_encryption_configuration":[],"tags":null,"versioning":[{"enabled":false,"mfa_delete":false}],"website":[]},"after_unknown":{"acceleration_status":true,"arn":true,"bucket_domain_name":true,"bucket_regional_domain_name":true,"cors_rule":[],"grant":[],"hosted_zone_id":true,"id":true,"lifecycle_rule":[],"logging":[],"object_lock_configuration":[],"region":true,"replication_configuration":[],"request_payer":true,"server_side_encryption_configuration":[],"versioning":[{}],"website":[],"website_domain":true,"website_endpoint":true}}},{"address":"aws_s3_bucket.demo-s3","mode":"managed","type":"aws_s3_bucket","name":"demo-s3","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"acl":"private","bucket":"sample_prefix_test20-terraformcloud","bucket_prefix":null,"cors_rule":[],"force_destroy":false,"grant":[],"lifecycle_rule":[],"logging":[],"object_lock_configuration":[],"policy":null,"replication_configuration":[],"server_side_encryption_configuration":[],"tags":null,"versioning":[{"enabled":false,"mfa_delete":false}],"website":[]},"after_unknown":{"acceleration_status":true,"arn":true,"bucket_domain_name":true,"bucket_regional_domain_name":true,"cors_rule":[],"grant":[],"hosted_zone_id":true,"id":true,"lifecycle_rule":[],"logging":[],"object_lock_configuration":[],"region":true,"replication_configuration":[],"request_payer":true,"server_side_encryption_configuration":[],"versioning":[{}],"website":[],"website_domain":true,"website_endpoint":true}}}],"configuration":{"provider_config":{"aws":{"name":"aws","expressions":{"region":{"constant_value":"us-east-1"}}}},"root_module":{"resources":[{"address":"aws_s3_bucket.demo-example","mode":"managed","type":"aws_s3_bucket","name":"demo-example","provider_config_key":"aws","expressions":{"bucket":{"constant_value":"demoexample-1"},"versioning":[{"enabled":{"constant_value":false},"mfa_delete":{"constant_value":false}}]},"schema_version":0},{"address":"aws_s3_bucket.demo-s3","mode":"managed","type":"aws_s3_bucket","name":"demo-s3","provider_config_key":"aws","expressions":{"bucket":{"references":["var.s3_bucket_prefix"]},"versioning":[{"enabled":{"constant_value":false},"mfa_delete":{"constant_value":false}}]},"schema_version":0}],"variables":{"s3_bucket_prefix":{"default":"sample_prefix_test20"}}}}} diff --git a/pkg/iac-providers/tfplan/v1/types.go b/pkg/iac-providers/tfplan/v1/types.go new file mode 100644 index 000000000..8aaacfe5e --- /dev/null +++ b/pkg/iac-providers/tfplan/v1/types.go @@ -0,0 +1,23 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package tfplan + +// TFPlan implements the IacProvider interface +type TFPlan struct { + FormatVersion string `json:"format_version"` + TerraformVersion string `json:"terraform_version"` +} diff --git a/pkg/utils/jqhelper.go b/pkg/utils/jqhelper.go new file mode 100644 index 000000000..239ac55a9 --- /dev/null +++ b/pkg/utils/jqhelper.go @@ -0,0 +1,69 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package utils + +import ( + "context" + "encoding/json" + "fmt" + "time" + + "github.com/itchyny/gojq" + "go.uber.org/zap" +) + +// JQFilterWithQuery runs jq query on the given input and returns the output +func JQFilterWithQuery(jqQuery string, jsonInput []byte) ([]byte, error) { + + var processed []byte + + // convert read json input into map[string]interface{} + var input map[string]interface{} + if err := json.Unmarshal(jsonInput, &input); err != nil { + return processed, fmt.Errorf("failed to decode input JSON. error: '%v'", err) + } + + // parse jq query + query, err := gojq.Parse(jqQuery) + if err != nil { + return processed, fmt.Errorf("failed to parse jq query. error: '%v'", err) + } + + // run jq query on input + ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) + defer cancel() + iter := query.RunWithContext(ctx, input) + for { + v, ok := iter.Next() + if !ok { + break + } + if err, ok := v.(error); ok { + zap.S().Warn("error in processing jq query; error: '%v'", err) + continue + } + + jqout, err := json.Marshal(v) + if err != nil { + zap.S().Warn("failed to encode jq output into JSON. error: '%v'", err) + continue + } + processed = append(processed, jqout...) + } + + return processed, nil +} diff --git a/pkg/utils/jqhelper_test.go b/pkg/utils/jqhelper_test.go new file mode 100644 index 000000000..4860d2005 --- /dev/null +++ b/pkg/utils/jqhelper_test.go @@ -0,0 +1,77 @@ +/* + Copyright (C) 2020 Accurics, Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +*/ + +package utils + +import ( + "fmt" + "reflect" + "testing" +) + +func TestJQFilterWithQuery(t *testing.T) { + + table := []struct { + name string + jqQuery string + input []byte + want []byte + wantErr error + }{ + { + name: "invalid json", + jqQuery: "", + input: []byte{}, + want: []byte{}, + wantErr: fmt.Errorf("failed to decode input JSON. error: 'unexpected end of JSON input'"), + }, + { + name: "invalid query", + jqQuery: "am invalid", + input: []byte("{}"), + want: []byte(""), + wantErr: fmt.Errorf("failed to parse jq query. error: 'unexpected token \"invalid\"'"), + }, + { + name: "jq error", + jqQuery: "def f: f; f, f", + input: []byte("{}"), + want: []byte(""), + wantErr: nil, + }, + { + name: "simple query", + jqQuery: ".foo", + input: []byte("{\"foo\": 128}"), + want: []byte("128"), + wantErr: nil, + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + got, err := JQFilterWithQuery(tt.jqQuery, tt.input) + + if string(got) != string(tt.want) { + t.Errorf("got: '%v', want: '%v'", string(got), string(tt.want)) + } + + if !reflect.DeepEqual(err, tt.wantErr) { + t.Errorf("error got: '%v', want: '%v'", err, tt.wantErr) + } + }) + } +} diff --git a/test/e2e/help/golden/help_scan.txt b/test/e2e/help/golden/help_scan.txt index 9c1f1c053..3e7a251f8 100644 --- a/test/e2e/help/golden/help_scan.txt +++ b/test/e2e/help/golden/help_scan.txt @@ -10,8 +10,8 @@ Flags: -h, --help help for scan -d, --iac-dir string path to a directory containing one or more IaC files (default ".") -f, --iac-file string path to a single IaC file - -i, --iac-type string iac type (helm, k8s, kustomize, terraform) - --iac-version string iac version (helm: v3, k8s: v1, kustomize: v3, terraform: v12, v13, v14) + -i, --iac-type string iac type (helm, k8s, kustomize, terraform, tfplan) + --iac-version string iac version (helm: v3, k8s: v1, kustomize: v3, terraform: v12, v13, v14, tfplan: v1) -p, --policy-path stringArray policy path directory -t, --policy-type strings policy type (all, aws, azure, gcp, github, k8s) (default [all]) -r, --remote-type string type of remote backend (git, s3, gcs, http, terraform-registry) diff --git a/test/e2e/scan/golden/scan_help.txt b/test/e2e/scan/golden/scan_help.txt index 9c1f1c053..3e7a251f8 100644 --- a/test/e2e/scan/golden/scan_help.txt +++ b/test/e2e/scan/golden/scan_help.txt @@ -10,8 +10,8 @@ Flags: -h, --help help for scan -d, --iac-dir string path to a directory containing one or more IaC files (default ".") -f, --iac-file string path to a single IaC file - -i, --iac-type string iac type (helm, k8s, kustomize, terraform) - --iac-version string iac version (helm: v3, k8s: v1, kustomize: v3, terraform: v12, v13, v14) + -i, --iac-type string iac type (helm, k8s, kustomize, terraform, tfplan) + --iac-version string iac version (helm: v3, k8s: v1, kustomize: v3, terraform: v12, v13, v14, tfplan: v1) -p, --policy-path stringArray policy path directory -t, --policy-type strings policy type (all, aws, azure, gcp, github, k8s) (default [all]) -r, --remote-type string type of remote backend (git, s3, gcs, http, terraform-registry)