diff --git a/pkg/input/detector_by_type.go b/pkg/input/detector_by_type.go index a35c94a9..eb088f99 100644 --- a/pkg/input/detector_by_type.go +++ b/pkg/input/detector_by_type.go @@ -27,6 +27,7 @@ func detectorByInputType(inputType *Type) (Detector, error) { &TfPlanDetector{}, &TfDetector{}, &TfStateDetector{}, + &OpenTofuDetector{}, &KubernetesDetector{}, &ArmDetector{}, ), nil @@ -38,6 +39,14 @@ func detectorByInputType(inputType *Type) (Detector, error) { return &TfDetector{}, nil case TerraformState.Name: return &TfStateDetector{}, nil + case OpenTofuHCL.Name: + return &OpenTofuDetector{}, nil + case OpenTofuPlan.Name: + // OpenTofu Plan uses the same detector as Terraform Plan + return &TfPlanDetector{}, nil + case OpenTofuState.Name: + // OpenTofu State uses the same detector as Terraform State + return &TfStateDetector{}, nil case Kubernetes.Name: return &KubernetesDetector{}, nil case Arm.Name: diff --git a/pkg/input/opentofu.go b/pkg/input/opentofu.go new file mode 100644 index 00000000..f809f37b --- /dev/null +++ b/pkg/input/opentofu.go @@ -0,0 +1,172 @@ +// © 2022-2023 Snyk Limited All rights reserved. +// Copyright 2021 Fugue, 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 input + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/snyk/policy-engine/pkg/hcl_interpreter" + "github.com/snyk/policy-engine/pkg/models" +) + +// This is the loader that supports reading files and directories of OpenTofu HCL (.tofu) +// files. The implementation is in the `./pkg/hcl_interpreter/` package in this +// repository: this file just wraps that. That directory also contains a +// README explaining how everything fits together. +type OpenTofuDetector struct{} + +func (t *OpenTofuDetector) DetectFile(i *File, opts DetectOptions) (IACConfiguration, error) { + if !opts.IgnoreExt && !hasOpenTofuExt(i.Path) { + return nil, fmt.Errorf("%w: %v", UnrecognizedFileExtension, i.Ext()) + } + dir := filepath.Dir(i.Path) + moduleTree, + err := hcl_interpreter.ParseFiles( + nil, + i.Fs, + false, + dir, + hcl_interpreter.EmptyModuleName, + []string{i.Path}, + opts.VarFiles, + ) + if err != nil { + return nil, fmt.Errorf("%w: %v", FailedToParseInput, err) + } + + return newOpenTofuConfiguration(moduleTree) +} + +func (t *OpenTofuDetector) DetectDirectory(i *Directory, opts DetectOptions) (IACConfiguration, error) { + // First check that a `.tofu` file exists in the directory. + tofuExists := false + children, err := i.Children() + if err != nil { + return nil, err + } + for _, child := range children { + if c, ok := child.(*File); ok && hasOpenTofuExt(c.Path) { + tofuExists = true + } + } + if !tofuExists { + return nil, nil + } + + moduleRegister := hcl_interpreter.NewTerraformRegister(i.Fs, i.Path) + moduleTree, err := hcl_interpreter.ParseDirectory( + moduleRegister, + i.Fs, + i.Path, + hcl_interpreter.EmptyModuleName, + opts.VarFiles, + ) + if err != nil { + return nil, fmt.Errorf("%w: %v", FailedToParseInput, err) + } + + return newOpenTofuConfiguration(moduleTree) +} + +type OpenTofuConfiguration struct { + moduleTree *hcl_interpreter.ModuleTree + evaluation *hcl_interpreter.Evaluation + resources map[string]map[string]models.ResourceState +} + +func newOpenTofuConfiguration(moduleTree *hcl_interpreter.ModuleTree) (*OpenTofuConfiguration, error) { + analysis := hcl_interpreter.AnalyzeModuleTree(moduleTree) + evaluation, err := hcl_interpreter.EvaluateAnalysis(analysis) + if err != nil { + return nil, fmt.Errorf("%w: %v", FailedToParseInput, err) + } + + evaluationResources := evaluation.Resources() + resources := make([]models.ResourceState, len(evaluationResources)) + for i := range evaluationResources { + resources[i] = evaluationResources[i].Model + } + + namespace := moduleTree.FilePath() + for i := range resources { + resources[i].Namespace = namespace + resources[i].Tags = tfExtractTags(resources[i]) + } + + return &OpenTofuConfiguration{ + moduleTree: moduleTree, + evaluation: evaluation, + resources: groupResourcesByType(resources), + }, nil +} + +func (c *OpenTofuConfiguration) LoadedFiles() []string { + return c.moduleTree.LoadedFiles() +} + +func (c *OpenTofuConfiguration) Location(path []interface{}) (LocationStack, error) { + // Format is {resourceNamespace, resourceType, resourceId, attributePath...} + if len(path) < 3 { + return nil, nil + } + + resourceId, ok := path[2].(string) + if !ok { + return nil, fmt.Errorf("Expected string resource ID in path") + } + + ranges := c.evaluation.Location(resourceId, path[3:]) + locs := LocationStack{} + for _, r := range ranges { + locs = append(locs, Location{ + Path: r.Filename, + Line: r.Start.Line, + Col: r.Start.Column, + }) + } + return locs, nil +} + +func (c *OpenTofuConfiguration) ToState() models.State { + return models.State{ + InputType: OpenTofuHCL.Name, + EnvironmentProvider: "iac", + Meta: map[string]interface{}{ + "filepath": c.moduleTree.FilePath(), + }, + Resources: c.resources, + Scope: map[string]interface{}{ + "filepath": c.moduleTree.FilePath(), + }, + } +} + +func (c *OpenTofuConfiguration) Errors() []error { + errors := []error{} + errors = append(errors, c.moduleTree.Errors()...) + errors = append(errors, c.evaluation.Errors()...) + return errors +} + +func (l *OpenTofuConfiguration) Type() *Type { + return OpenTofuHCL +} + +func hasOpenTofuExt(path string) bool { + return strings.HasSuffix(path, ".tofu") || strings.HasSuffix(path, ".tofu.json") +} diff --git a/pkg/input/opentofu_test.go b/pkg/input/opentofu_test.go new file mode 100644 index 00000000..5ef9c332 --- /dev/null +++ b/pkg/input/opentofu_test.go @@ -0,0 +1,141 @@ +// © 2023 Snyk Limited All rights reserved. +// +// 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 input_test + +import ( + "testing" + + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + + "github.com/snyk/policy-engine/pkg/input" +) + +func TestOpenTofuDetector(t *testing.T) { + detector := &input.OpenTofuDetector{} + + t.Run("detects valid .tofu file", func(t *testing.T) { + fs := afero.NewMemMapFs() + content := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" +}` + afero.WriteFile(fs, "/test.tofu", []byte(content), 0644) + + f := &input.File{ + Path: "/test.tofu", + Fs: fs, + } + + config, err := detector.DetectFile(f, input.DetectOptions{}) + assert.NoError(t, err) + assert.NotNil(t, config) + assert.Equal(t, input.OpenTofuHCL, config.Type()) + }) + + t.Run("detects valid .tofu.json file", func(t *testing.T) { + fs := afero.NewMemMapFs() + content := `{ + "resource": { + "aws_s3_bucket": { + "example": { + "bucket": "my-bucket" + } + } + } +}` + afero.WriteFile(fs, "/test.tofu.json", []byte(content), 0644) + + f := &input.File{ + Path: "/test.tofu.json", + Fs: fs, + } + + config, err := detector.DetectFile(f, input.DetectOptions{}) + assert.NoError(t, err) + assert.NotNil(t, config) + assert.Equal(t, input.OpenTofuHCL, config.Type()) + }) + + t.Run("rejects file with wrong extension", func(t *testing.T) { + fs := afero.NewMemMapFs() + content := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" +}` + afero.WriteFile(fs, "/test.txt", []byte(content), 0644) + + f := &input.File{ + Path: "/test.txt", + Fs: fs, + } + + config, err := detector.DetectFile(f, input.DetectOptions{}) + assert.ErrorIs(t, err, input.UnrecognizedFileExtension) + assert.Nil(t, config) + }) + + t.Run("ignores extension when IgnoreExt is true", func(t *testing.T) { + fs := afero.NewMemMapFs() + content := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" +}` + afero.WriteFile(fs, "/test.txt", []byte(content), 0644) + + f := &input.File{ + Path: "/test.txt", + Fs: fs, + } + + config, err := detector.DetectFile(f, input.DetectOptions{IgnoreExt: true}) + assert.NoError(t, err) + assert.NotNil(t, config) + }) + + t.Run("detects directory with .tofu files", func(t *testing.T) { + fs := afero.NewMemMapFs() + content := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" +}` + fs.MkdirAll("/testdir", 0755) + afero.WriteFile(fs, "/testdir/main.tofu", []byte(content), 0644) + + d := &input.Directory{ + Path: "/testdir", + Fs: fs, + } + + config, err := detector.DetectDirectory(d, input.DetectOptions{}) + assert.NoError(t, err) + assert.NotNil(t, config) + assert.Equal(t, input.OpenTofuHCL, config.Type()) + }) + + t.Run("returns nil for directory without .tofu files", func(t *testing.T) { + fs := afero.NewMemMapFs() + content := `resource "aws_s3_bucket" "example" { + bucket = "my-bucket" +}` + fs.MkdirAll("/testdir", 0755) + afero.WriteFile(fs, "/testdir/main.txt", []byte(content), 0644) + + d := &input.Directory{ + Path: "/testdir", + Fs: fs, + } + + config, err := detector.DetectDirectory(d, input.DetectOptions{}) + assert.NoError(t, err) + assert.Nil(t, config) + }) +} \ No newline at end of file diff --git a/pkg/input/types.go b/pkg/input/types.go index 15ac16fb..0c9fb611 100644 --- a/pkg/input/types.go +++ b/pkg/input/types.go @@ -153,6 +153,36 @@ var Terraform = &Type{ }, } +// OpenTofuHCL represents OpenTofu HCL source code inputs. +var OpenTofuHCL = &Type{ + Name: "opentofu_hcl", + Aliases: []string{"opentofu-hcl", "tofu_hcl", "tofu-hcl"}, +} + +// OpenTofuPlan represents OpenTofu Plan JSON inputs. +var OpenTofuPlan = &Type{ + Name: "opentofu_plan", + Aliases: []string{"opentofu-plan", "tofu_plan", "tofu-plan"}, +} + +// OpenTofuState represents OpenTofu State JSON inputs. +var OpenTofuState = &Type{ + Name: "opentofu_state", + Aliases: []string{"opentofu-state", "tofu_state", "tofu-state"}, +} + +// OpenTofu is an aggregate input type that encompasses all input types that contain +// OpenTofu resource types. +var OpenTofu = &Type{ + Name: "opentofu", + Aliases: []string{"tofu"}, + Children: Types{ + OpenTofuHCL, + OpenTofuPlan, + OpenTofuState, + }, +} + // Auto is an aggregate type that contains all of the IaC input types that this package // supports. var Auto = &Type{ @@ -164,6 +194,9 @@ var Auto = &Type{ TerraformHCL, TerraformPlan, TerraformState, + OpenTofuHCL, + OpenTofuPlan, + OpenTofuState, }, } @@ -175,6 +208,7 @@ var Any = &Type{ CloudFormation, Kubernetes, Terraform, + OpenTofu, }, } @@ -188,4 +222,7 @@ var SupportedInputTypes = Types{ TerraformHCL, TerraformPlan, TerraformState, + OpenTofuHCL, + OpenTofuPlan, + OpenTofuState, } diff --git a/pkg/internal/terraform/configs/parser_config_dir.go b/pkg/internal/terraform/configs/parser_config_dir.go index 2923af93..1a550140 100644 --- a/pkg/internal/terraform/configs/parser_config_dir.go +++ b/pkg/internal/terraform/configs/parser_config_dir.go @@ -9,7 +9,7 @@ import ( "github.com/hashicorp/hcl/v2" ) -// LoadConfigDir reads the .tf and .tf.json files in the given directory +// LoadConfigDir reads the .tf, .tf.json, .tofu and .tofu.json files in the given directory // as config files (using LoadConfigFile) and then combines these files into // a single Module. // @@ -26,8 +26,8 @@ import ( // will simply return an empty module in that case. Callers should first call // Parser.IsConfigDir if they wish to recognize that situation. // -// .tf files are parsed using the HCL native syntax while .tf.json files are -// parsed using the HCL JSON syntax. +// .tf and .tofu files are parsed using the HCL native syntax while .tf.json and +// .tofu.json files are parsed using the HCL JSON syntax. func (p *Parser) LoadConfigDir(path string) (*Module, hcl.Diagnostics) { primaryPaths, overridePaths, diags := p.dirFiles(path) if diags.HasErrors() { @@ -57,8 +57,8 @@ func (p Parser) ConfigDirFiles(dir string) (primary, override []string, diags hc } // IsConfigDir determines whether the given path refers to a directory that -// exists and contains at least one Terraform config file (with a .tf or -// .tf.json extension.) +// exists and contains at least one Terraform/OpenTofu config file (with a .tf, +// .tf.json, .tofu or .tofu.json extension.) func (p *Parser) IsConfigDir(path string) bool { primaryPaths, overridePaths, _ := p.dirFiles(path) return (len(primaryPaths) + len(overridePaths)) > 0 @@ -122,13 +122,17 @@ func (p *Parser) dirFiles(dir string) (primary, override []string, diags hcl.Dia return } -// fileExt returns the Terraform configuration extension of the given +// fileExt returns the Terraform/OpenTofu configuration extension of the given // path, or a blank string if it is not a recognized extension. func fileExt(path string) string { if strings.HasSuffix(path, ".tf") { return ".tf" } else if strings.HasSuffix(path, ".tf.json") { return ".tf.json" + } else if strings.HasSuffix(path, ".tofu") { + return ".tofu" + } else if strings.HasSuffix(path, ".tofu.json") { + return ".tofu.json" } else { return "" }