diff --git a/go.mod b/go.mod index eafff9c61..58e137fa2 100644 --- a/go.mod +++ b/go.mod @@ -18,14 +18,11 @@ require ( github.com/pkg/errors v0.9.1 github.com/spf13/afero v1.3.4 github.com/spf13/cobra v1.0.0 - github.com/spf13/pflag v1.0.5 // indirect github.com/zclconf/go-cty v1.2.1 - go.uber.org/zap v1.10.0 - golang.org/x/net v0.0.0-20200822124328-c89045814202 // indirect - golang.org/x/sys v0.0.0-20200814200057-3d37ad5750ed // indirect - golang.org/x/tools v0.0.0-20201009162240-fcf82128ed91 // indirect + go.uber.org/zap v1.13.0 + golang.org/x/tools v0.0.0-20201110030525-169ad6d6ecb2 // indirect gopkg.in/src-d/go-git.v4 v4.13.1 - gopkg.in/yaml.v2 v2.3.0 gopkg.in/yaml.v3 v3.0.0-20200615113413-eeeca48fe776 - honnef.co/go/tools v0.0.1-2020.1.5 // indirect + helm.sh/helm/v3 v3.4.0 + honnef.co/go/tools v0.0.1-2020.1.6 // indirect ) diff --git a/pkg/iac-providers/helm.go b/pkg/iac-providers/helm.go new file mode 100644 index 000000000..7eff46fbb --- /dev/null +++ b/pkg/iac-providers/helm.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" + + helmv3 "github.com/accurics/terrascan/pkg/iac-providers/helm/v3" +) + +// terraform specific constants +const ( + helm supportedIacType = "helm" + helmV3 supportedIacVersion = "v3" + helmDefaultIacVersion = helmV3 +) + +// register helm as an IaC provider with terrascan +func init() { + // register iac provider + RegisterIacProvider(helm, helmV3, helmDefaultIacVersion, reflect.TypeOf(helmv3.HelmV3{})) +} diff --git a/pkg/iac-providers/helm/v3/load-dir.go b/pkg/iac-providers/helm/v3/load-dir.go new file mode 100644 index 000000000..8cdc75c52 --- /dev/null +++ b/pkg/iac-providers/helm/v3/load-dir.go @@ -0,0 +1,259 @@ +/* + 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 helmv3 + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" + "path/filepath" + + k8sv1 "github.com/accurics/terrascan/pkg/iac-providers/kubernetes/v1" + + "github.com/accurics/terrascan/pkg/iac-providers/output" + "github.com/accurics/terrascan/pkg/utils" + "go.uber.org/zap" + "gopkg.in/yaml.v3" + "helm.sh/helm/v3/pkg/chart" + "helm.sh/helm/v3/pkg/chartutil" + "helm.sh/helm/v3/pkg/engine" +) + +var ( + errSkipTestDir = fmt.Errorf("skipping test directory") + errNoHelmChartsFound = fmt.Errorf("no helm charts found") + errBadChartName = fmt.Errorf("bad chart name in Chart.yaml") + errBadChartVersion = fmt.Errorf("bad chart version in Chart.yaml") +) + +// LoadIacDir loads all helm charts under the specified directory +func (h *HelmV3) LoadIacDir(absRootDir string) (output.AllResourceConfigs, error) { + + allResourcesConfig := make(map[string][]output.ResourceConfig) + + // find all Chart.yaml files within the specified directory structure + fileMap, err := utils.FindFilesBySuffix(absRootDir, []string{helmChartFilename}) + if err != nil { + zap.S().Error("error while searching for helm charts", zap.String("root dir", absRootDir), zap.Error(err)) + return allResourcesConfig, err + } + + if len(fileMap) == 0 { + zap.S().Error("", zap.String("root dir", absRootDir), zap.Error(err)) + err = errNoHelmChartsFound + return allResourcesConfig, err + } + + // fileDir now contains the chart path + iacDocumentMap := make(map[string][]*utils.IacDocument) + for fileDir, chartFilename := range fileMap { + chartPath := filepath.Join(fileDir, *chartFilename[0]) + zap.S().Debug("processing chart", zap.String("chart path", chartPath), zap.Error(err)) + + var iacDocuments []*utils.IacDocument + var chartMap map[string]interface{} + iacDocuments, chartMap, err = h.loadChart(chartPath) + if err != nil && err != errSkipTestDir { + zap.S().Warn("error occurred while loading chart", zap.String("chart path", chartPath), zap.Error(err)) + continue + } + + iacDocumentMap[chartPath] = iacDocuments + + var config *output.ResourceConfig + config, err = h.createHelmChartResource(chartPath, chartMap) + if err != nil { + zap.S().Debug("failed to create helm chart resource", zap.Any("config", config)) + continue + } + + allResourcesConfig[config.Type] = append(allResourcesConfig[config.Type], *config) + } + + for _, iacDocuments := range iacDocumentMap { + for _, doc := range iacDocuments { + // @TODO add k8s version check + var k k8sv1.K8sV1 + var config *output.ResourceConfig + config, err = k.Normalize(doc) + if err != nil { + zap.S().Warn("unable to normalize data", zap.Error(err), zap.String("file", doc.FilePath)) + continue + } + + config.Line = 1 + config.Source = doc.FilePath + + allResourcesConfig[config.Type] = append(allResourcesConfig[config.Type], *config) + } + } + + return allResourcesConfig, nil +} + +// createHelmChartResource returns normalized Helm Chart resource data +func (h *HelmV3) createHelmChartResource(chartPath string, chartData map[string]interface{}) (*output.ResourceConfig, error) { + var config output.ResourceConfig + + jsonData, err := json.Marshal(chartData) + if err != nil { + zap.S().Warn("unable to marshal chart to json", zap.String("chart path", chartPath)) + return nil, err + } + + configData := make(map[string]interface{}) + if err = json.Unmarshal(jsonData, &configData); err != nil { + zap.S().Warn("unable to unmarshal normalized config data", zap.String("chart path", chartPath)) + zap.S().Debug("failed config data", zap.Any("config", configData)) + return nil, err + } + + chartName, ok := chartData["name"].(string) + if !ok { + zap.S().Warn("unable to determine chart name", zap.String("chart path", chartPath)) + return nil, err + } + + config.Type = "helm_chart" + config.Name = chartName + config.Line = 0 + config.Source = chartPath + config.ID = config.Type + "." + config.Name + config.Config = configData + + return &config, nil +} + +func (h *HelmV3) loadChart(chartPath string) ([]*utils.IacDocument, map[string]interface{}, error) { + iacDocuments := make([]*utils.IacDocument, 0) + chartMap := make(map[string]interface{}) + + // load the chart file and values file from the specified chart path + chartFileBytes, err := ioutil.ReadFile(chartPath) + if err != nil { + zap.S().Warn("unable to read", zap.String("file", chartPath)) + return iacDocuments, chartMap, err + } + + if err = yaml.Unmarshal(chartFileBytes, &chartMap); err != nil { + zap.S().Warn("unable to unmarshal values", zap.String("file", chartPath)) + return iacDocuments, chartMap, err + } + + var fileInfo os.FileInfo + chartDir := filepath.Dir(chartPath) + valuesFile := filepath.Join(chartDir, helmValuesFilename) + fileInfo, err = os.Stat(valuesFile) + if err != nil { + zap.S().Warn("unable to stat values.yaml", zap.String("chart path", chartPath)) + return iacDocuments, chartMap, err + } + + var valueFileBytes []byte + valueFileBytes, err = ioutil.ReadFile(valuesFile) + if err != nil { + zap.S().Warn("unable to read values.yaml", zap.String("file", fileInfo.Name())) + return iacDocuments, chartMap, err + } + + var valueMap map[string]interface{} + if err = yaml.Unmarshal(valueFileBytes, &valueMap); err != nil { + zap.S().Warn("unable to unmarshal values.yaml", zap.String("file", fileInfo.Name())) + return iacDocuments, chartMap, err + } + + // for each template file found, render and save an iacDocument + var templateFileMap map[string][]*string + templateFileMap, err = utils.FindFilesBySuffix(filepath.Join(chartDir, helmTemplateDir), h.getHelmTemplateExtensions()) + if err != nil { + zap.S().Warn("error while calling FindFilesBySuffix", zap.String("filepath", fileInfo.Name())) + return iacDocuments, chartMap, err + } + for templateDir, templateFiles := range templateFileMap { + if filepath.Base(templateDir) == helmTestDir { + zap.S().Debug("skipping test dir", zap.String("dir", templateDir)) + return iacDocuments, chartMap, errSkipTestDir + } + chartFiles := make([]*chart.File, 0) + for _, templateFile := range templateFiles { + var fileData []byte + fileData, err = ioutil.ReadFile(filepath.Join(templateDir, *templateFile)) + if err != nil { + zap.S().Warn("unable to read template file", zap.String("file", *templateFile)) + return iacDocuments, chartMap, err + } + + chartFiles = append(chartFiles, &chart.File{ + Name: filepath.Join(helmTemplateDir, *templateFile), + Data: fileData, + }) + } + + chartName, ok := chartMap["name"].(string) + if !ok { + return iacDocuments, chartMap, errBadChartName + } + + var chartVersion string + chartVersion, ok = chartMap["version"].(string) + if !ok { + return iacDocuments, chartMap, errBadChartVersion + } + + c := &chart.Chart{ + Metadata: &chart.Metadata{Name: chartName, Version: chartVersion}, + Templates: chartFiles, + } + + var v chartutil.Values + v, err = chartutil.CoalesceValues(c, chartutil.Values{ + "Values": valueMap, + "Release": chartutil.Values{ + "Name": defaultChartName, + }, + }) + if err != nil { + zap.S().Warn("error encountered in CoalesceValues", zap.String("chart path", chartPath)) + return iacDocuments, chartMap, err + } + + var renderData map[string]string + renderData, err = engine.Render(c, v) + if err != nil { + zap.S().Warn("error encountered while rendering chart", zap.String("chart path", chartPath), + zap.String("template dir", templateDir)) + return iacDocuments, chartMap, err + } + + for renderFile := range renderData { + iacDocuments = append(iacDocuments, &utils.IacDocument{ + Data: []byte(renderData[renderFile]), + Type: utils.YAMLDoc, + StartLine: 1, + EndLine: 1, + FilePath: renderFile, + }) + } + } + + return iacDocuments, chartMap, nil +} + +func (h *HelmV3) getHelmTemplateExtensions() []string { + return []string{"yaml", "tpl"} +} diff --git a/pkg/iac-providers/helm/v3/load-dir_test.go b/pkg/iac-providers/helm/v3/load-dir_test.go new file mode 100644 index 000000000..19b209ab9 --- /dev/null +++ b/pkg/iac-providers/helm/v3/load-dir_test.go @@ -0,0 +1,169 @@ +/* + 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 helmv3 + +import ( + "fmt" + "os" + "reflect" + "syscall" + "testing" + + "gopkg.in/yaml.v3" + + "github.com/accurics/terrascan/pkg/iac-providers/output" +) + +func TestLoadIacDir(t *testing.T) { + + table := []struct { + name string + dirPath string + helmv3 HelmV3 + want output.AllResourceConfigs + wantErr error + }{ + { + name: "happy path (credit to madhuakula/kubernetes-goat)", + dirPath: "./testdata/happy-path", + helmv3: HelmV3{}, + wantErr: nil, + }, + { + name: "bad directory", + dirPath: "./testdata/bad-dir", + helmv3: HelmV3{}, + wantErr: &os.PathError{Err: syscall.ENOENT, Op: "lstat", Path: "./testdata/bad-dir"}, + }, + { + name: "no helm charts in directory", + dirPath: "./testdata/no-helm-charts", + helmv3: HelmV3{}, + wantErr: errNoHelmChartsFound, + }, + { + name: "unreadable chart file", + dirPath: "./testdata/bad-chart-file", + helmv3: HelmV3{}, + wantErr: nil, // these errors are ignored + }, + } + + for _, tt := range table { + t.Run(tt.name, func(t *testing.T) { + _, gotErr := tt.helmv3.LoadIacDir(tt.dirPath) + if !reflect.DeepEqual(gotErr, tt.wantErr) { + t.Errorf("unexpected error; gotErr: '%v', wantErr: '%v'", gotErr, tt.wantErr) + } + }) + } + +} + +func TestLoadChart(t *testing.T) { + + table := []struct { + name string + chartPath string + helmv3 HelmV3 + want output.AllResourceConfigs + wantErr error + }{ + { + name: "happy path (credit to madhuakula/kubernetes-goat)", + chartPath: "./testdata/happy-path/Chart.yaml", + helmv3: HelmV3{}, + wantErr: nil, + }, + { + name: "unreadable chart file", + chartPath: "./testdata/bad-chart-file", + helmv3: HelmV3{}, + wantErr: &os.PathError{Err: syscall.EISDIR, Op: "read", Path: "./testdata/bad-chart-file"}, + }, + { + name: "unmarshal bad chart", + chartPath: "./testdata/bad-chart-file/Chart.yaml", + helmv3: HelmV3{}, + wantErr: &yaml.TypeError{Errors: []string{"line 1: cannot unmarshal !!str `:bad ba...` into map[string]interface {}"}}, + }, + { + name: "chart path with no values.yaml", + chartPath: "./testdata/chart-no-values/Chart.yaml", + helmv3: HelmV3{}, + wantErr: &os.PathError{Err: syscall.ENOENT, Op: "stat", Path: "testdata/chart-no-values/values.yaml"}, + }, + { + name: "chart path with unreadable values.yaml", + chartPath: "./testdata/chart-unreadable-values/Chart.yaml", + helmv3: HelmV3{}, + wantErr: &os.PathError{Err: syscall.EISDIR, Op: "read", Path: "testdata/chart-unreadable-values/values.yaml"}, + }, + { + name: "chart path with unreadable values.yaml", + chartPath: "./testdata/chart-bad-values/Chart.yaml", + helmv3: HelmV3{}, + wantErr: &yaml.TypeError{Errors: []string{"line 1: cannot unmarshal !!str `:bad