From 860c7bb4f101138bdfca424ffbc6ed4c3f90f73e Mon Sep 17 00:00:00 2001 From: martinidelimon Date: Fri, 24 Oct 2025 17:11:30 +0200 Subject: [PATCH 1/3] feat: add support for OpenTofu IaC scanning --- pkg/input/detector_by_type.go | 9 + pkg/input/opentofu.go | 172 ++++++++++++++++++ pkg/input/opentofu_test.go | 141 ++++++++++++++ pkg/input/types.go | 37 ++++ .../terraform/configs/parser_config_dir.go | 16 +- 5 files changed, 369 insertions(+), 6 deletions(-) create mode 100644 pkg/input/opentofu.go create mode 100644 pkg/input/opentofu_test.go 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 "" } From 28de49e6173d8c2bd94c06bcd6c2e7ee926ec65b Mon Sep 17 00:00:00 2001 From: martinidelimon Date: Fri, 24 Oct 2025 17:38:41 +0200 Subject: [PATCH 2/3] Update module path for fork --- go.mod | 4 ++-- go.sum | 2 ++ 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/go.mod b/go.mod index ab3bc4c9..39121042 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/snyk/policy-engine +module github.com/MartinideLimon/policy-engine go 1.23.0 @@ -30,6 +30,7 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/open-policy-agent/opa v0.69.0 github.com/rs/zerolog v1.26.1 + github.com/snyk/policy-engine v1.1.0 github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 @@ -53,7 +54,6 @@ require ( cloud.google.com/go/storage v1.35.1 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/agnivade/levenshtein v1.2.0 // indirect - github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/aws/aws-sdk-go v1.44.122 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index 9f649089..9a8f7268 100644 --- a/go.sum +++ b/go.sum @@ -1023,6 +1023,8 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/snyk/policy-engine v1.1.0 h1:vFbFZbs3B0Y3XuGSur5om2meo4JEcCaKfNzshZFGOUs= +github.com/snyk/policy-engine v1.1.0/go.mod h1:SSZiMz6TiggRAk33duOueWeSG0Xwl0QoZo8hfPcEAh0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I= From a417da8219a0e4a1d0506c1607cb7c455d93cb6a Mon Sep 17 00:00:00 2001 From: martinidelimon Date: Fri, 24 Oct 2025 18:20:52 +0200 Subject: [PATCH 3/3] updated path module --- go.mod | 4 ++-- go.sum | 2 -- 2 files changed, 2 insertions(+), 4 deletions(-) diff --git a/go.mod b/go.mod index 39121042..ab3bc4c9 100644 --- a/go.mod +++ b/go.mod @@ -1,4 +1,4 @@ -module github.com/MartinideLimon/policy-engine +module github.com/snyk/policy-engine go 1.23.0 @@ -30,7 +30,6 @@ require ( github.com/mitchellh/go-homedir v1.1.0 github.com/open-policy-agent/opa v0.69.0 github.com/rs/zerolog v1.26.1 - github.com/snyk/policy-engine v1.1.0 github.com/spf13/afero v1.11.0 github.com/spf13/cobra v1.8.1 github.com/spf13/pflag v1.0.5 @@ -54,6 +53,7 @@ require ( cloud.google.com/go/storage v1.35.1 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect github.com/agnivade/levenshtein v1.2.0 // indirect + github.com/apparentlymart/go-dump v0.0.0-20190214190832-042adf3cf4a0 // indirect github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect github.com/aws/aws-sdk-go v1.44.122 // indirect github.com/beorn7/perks v1.0.1 // indirect diff --git a/go.sum b/go.sum index 9a8f7268..9f649089 100644 --- a/go.sum +++ b/go.sum @@ -1023,8 +1023,6 @@ github.com/sergi/go-diff v1.3.1 h1:xkr+Oxo4BOQKmkn/B9eMK0g5Kg/983T9DqqPHwYqD+8= github.com/sergi/go-diff v1.3.1/go.mod h1:aMJSSKb2lpPvRNec0+w3fl7LP9IOFzdc9Pa4NFbPK1I= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= -github.com/snyk/policy-engine v1.1.0 h1:vFbFZbs3B0Y3XuGSur5om2meo4JEcCaKfNzshZFGOUs= -github.com/snyk/policy-engine v1.1.0/go.mod h1:SSZiMz6TiggRAk33duOueWeSG0Xwl0QoZo8hfPcEAh0= github.com/spaolacci/murmur3 v0.0.0-20180118202830-f09979ecbc72/go.mod h1:JwIasOWyU6f++ZhiEuf87xNszmSA2myDM2Kzu9HwQUA= github.com/spf13/afero v1.3.3/go.mod h1:5KUK8ByomD5Ti5Artl0RtHeI5pTF7MIDuXL3yY520V4= github.com/spf13/afero v1.6.0/go.mod h1:Ai8FlHk4v/PARR026UzYexafAt9roJ7LcLMAmO6Z93I=