diff --git a/pkg/iac/scanners/terraformplan/tfjson/parser/parser.go b/pkg/iac/scanners/terraformplan/tfjson/parser/parser.go index 7aaad2762f70..f10bb4981b96 100644 --- a/pkg/iac/scanners/terraformplan/tfjson/parser/parser.go +++ b/pkg/iac/scanners/terraformplan/tfjson/parser/parser.go @@ -14,6 +14,8 @@ import ( "github.com/aquasecurity/trivy/pkg/mapfs" ) +const TerraformMainFile = "main.tf" + type Parser struct { logger *log.Logger } @@ -51,149 +53,227 @@ func (p *Parser) Parse(reader io.Reader) (*PlanFile, error) { } func (p *PlanFile) ToFS() (fs.FS, error) { - - rootFS := mapfs.New() - - var fileResources []string - - resources, err := getResources(p.PlannedValues.RootModule, p.ResourceChanges, p.Configuration) + resources, err := buildPlanBlocks(p.PlannedValues.RootModule, p.ResourceChanges, p.Configuration) if err != nil { return nil, err } - for _, r := range resources { - fileResources = append(fileResources, r.ToHCL()) + var sb strings.Builder + for i, r := range resources { + if i > 0 { + sb.WriteByte('\n') + } + r.ToHCL(&sb) } - fileContent := strings.Join(fileResources, "\n\n") - if err := rootFS.WriteVirtualFile("main.tf", []byte(fileContent), os.ModePerm); err != nil { + content := sb.String() + fsys := mapfs.New() + if err := fsys.WriteVirtualFile(TerraformMainFile, []byte(content), os.ModePerm); err != nil { return nil, err } - return rootFS, nil - + return fsys, nil } -func getResources(module Module, resourceChanges []ResourceChange, configuration Configuration) ([]terraform.PlanBlock, error) { - var resources []terraform.PlanBlock +func buildPlanBlocks(module Module, resourceChanges []ResourceChange, configuration Configuration) ([]*terraform.PlanBlock, error) { + var resources []*terraform.PlanBlock for _, r := range module.Resources { - resourceName := r.Name - if strings.HasPrefix(r.Address, "module.") { - hashable := strings.TrimSuffix(strings.Split(r.Address, fmt.Sprintf(".%s.", r.Type))[0], ".data") - /* #nosec */ - hash := fmt.Sprintf("%x", md5.Sum([]byte(hashable))) - resourceName = fmt.Sprintf("%s_%s", r.Name, hash) + resourceExprs := getConfiguration(r.Address, configuration.RootModule) + schema := schemaForBlock(r, resourceExprs) + changes := getValues(r.Address, resourceChanges) + resource := decodeBlock(schema, changes.After) + // fill top-level block fileds + resource.BlockType = r.BlockType() + resource.Type = r.Type + resource.Name = moduleResourceName(r.Address, r.Type, r.Name) + resources = append(resources, resource) + } + + for _, m := range module.ChildModules { + cr, err := buildPlanBlocks(m.Module, resourceChanges, configuration) + if err != nil { + return nil, err } + resources = append(resources, cr...) + } - res := terraform.NewPlanBlock(r.Mode, r.Type, resourceName) + return resources, nil +} - changes := getValues(r.Address, resourceChanges) - // process the changes to get the after state - for k, v := range changes.After { - switch t := v.(type) { - case []any: - if len(t) == 0 { - continue - } - val := t[0] - switch v := val.(type) { - // is it a HCL block? - case map[string]any: - res.Blocks[k] = v - // just a normal attribute then - default: - res.Attributes[k] = v - } - default: - res.Attributes[k] = v - } - } +func decodeBlock(schema BlockSchema, rawBlock map[string]any) *terraform.PlanBlock { + block := &terraform.PlanBlock{ + Attributes: make(map[string]any), + } + + for k, child := range rawBlock { + handleChild(block, k, child, schema[k]) + } - resourceConfig := getConfiguration(r.Address, configuration.RootModule) - if resourceConfig != nil { + populateReferences(schema, block) + return block +} - for attr, val := range resourceConfig.Expressions { - if value, shouldReplace := unpackConfigurationValue(val, r); shouldReplace || !res.HasAttribute(attr) { - res.Attributes[attr] = value +func handleChild(block *terraform.PlanBlock, k string, child any, schema *SchemaNode) { + switch { + case schema == nil: + appendBlockOrAttribute(block, k, child) + case schema.Type == AttributeNode: + appendBlockOrAttribute(block, k, decodeAttribute(schema, child)) + case schema.Type == BlockNode: + nestedBlocks := decodeNestedBlocks(schema, k, normalizeToSlice(child)) + block.Blocks = append(block.Blocks, nestedBlocks...) + } +} + +func normalizeToSlice(v any) []any { + if s, ok := v.([]any); ok { + return s + } + return []any{v} +} + +func appendBlockOrAttribute(block *terraform.PlanBlock, name string, value any) { + if s, ok := value.([]any); ok && len(s) > 0 { + if m, ok := s[0].(map[string]any); ok { + block.Blocks = append(block.Blocks, &terraform.PlanBlock{ + Name: name, + Attributes: m, + }) + return + } + } + block.Attributes[name] = value +} + +func populateReferences(schema BlockSchema, block *terraform.PlanBlock) { + for k, childNodeSchema := range schema { + switch childNodeSchema.Type { + case BlockNode: + cb := block.GetOrCreateBlock(k) + for _, childrenBlockChema := range childNodeSchema.Children { + populateReferences(childrenBlockChema, cb) + } + case AttributeNode: + if ref, ok := childNodeSchema.Value.(terraform.PlanReference); ok { + if _, exists := block.Attributes[k]; !exists { + block.Attributes[k] = ref } } } - resources = append(resources, *res) } +} - for _, m := range module.ChildModules { - cr, err := getResources(m.Module, resourceChanges, configuration) - if err != nil { - return nil, err +func decodeNestedBlocks(schema *SchemaNode, name string, v []any) []*terraform.PlanBlock { + nestedBlocks := make([]*terraform.PlanBlock, 0, len(v)) + for i, el := range v { + m, ok := el.(map[string]any) + if !ok { + continue } - resources = append(resources, cr...) + nestedBlockSchema := make(BlockSchema) + if i < len(schema.Children) { + nestedBlockSchema = schema.Children[i] + } + nestedBlock := decodeBlock(nestedBlockSchema, m) + nestedBlock.Name = name + nestedBlocks = append(nestedBlocks, nestedBlock) } + return nestedBlocks +} - return resources, nil +func decodeAttribute(schema *SchemaNode, rawAttr any) any { + if schema.Value == nil { + return rawAttr + } + + // For attributes of type object or map, the schema does not include field names and looks like: + // "list_attr": { "references": ["local.foo"] }, + // Therefore, we cannot determine which specific fields are unknown + // and we can ignore references from expressions. + return rawAttr } -func unpackConfigurationValue(val any, r Resource) (any, bool) { - if t, ok := val.(map[string]any); ok { - for k, v := range t { - switch k { - case "references": - reference := v.([]any)[0].(string) - if strings.HasPrefix(r.Address, "module.") { - hashable := strings.TrimSuffix(strings.Split(r.Address, fmt.Sprintf(".%s.", r.Type))[0], ".data") - /* #nosec */ - hash := fmt.Sprintf("%x", md5.Sum([]byte(hashable))) - - parts := strings.Split(reference, ".") - var rejoin []string - - name := parts[1] - remainder := parts[2:] - if parts[0] == "data" { - rejoin = append(rejoin, parts[:2]...) - name = parts[2] - remainder = parts[3:] - } else { - rejoin = append(rejoin, parts[:1]...) - } - - rejoin = append(rejoin, fmt.Sprintf("%s_%s", name, hash)) - rejoin = append(rejoin, remainder...) - - reference = strings.Join(rejoin, ".") - } - return terraform.PlanReference{Value: reference}, false - case "constant_value": - return v, false +func unpackConfigurationValue(val map[string]any, r Resource) any { + for k, v := range val { + switch k { + case "references": + s, ok := v.([]any) + if !ok || len(s) == 0 { + return terraform.PlanReference{} } + + ref, ok := s[0].(string) + if !ok { + return terraform.PlanReference{} + } + return parseAttributeReference(r.Address, r.Type, ref) + case "constant_value": + return v } } - return nil, false + return nil } -func getConfiguration(address string, configuration ConfigurationModule) *ConfigurationResource { +// parseAttributeReference parses an attribute reference string and returns +// a PlanReference. The reference may point to another resource and is adjusted +// according to Terraform address rules. +func parseAttributeReference(rAddress, rType, reference string) terraform.PlanReference { + parts := strings.Split(reference, ".") + nameIdx := 1 + if parts[0] == "data" { + nameIdx = 2 + } + parts[nameIdx] = moduleResourceName(rAddress, rType, parts[nameIdx]) + reference = strings.Join(parts, ".") + return terraform.PlanReference{Value: reference} +} - workingAddress := address - var moduleParts []string - for strings.HasPrefix(workingAddress, "module.") { - workingAddressParts := strings.Split(workingAddress, ".") - moduleParts = append(moduleParts, workingAddressParts[1]) - workingAddress = strings.Join(workingAddressParts[2:], ".") +// moduleResourceName returns the resource name with a module hash if the resource +// is inside a Terraform module. Otherwise, it returns the original name. +func moduleResourceName(rAddress, rType, name string) string { + if !strings.HasPrefix(rAddress, "module.") { + return name } - workingModule := configuration + hashable := strings.TrimSuffix(strings.Split(rAddress, fmt.Sprintf(".%s.", rType))[0], ".data") + /* #nosec */ + hash := fmt.Sprintf("%x", md5.Sum([]byte(hashable))) + return fmt.Sprintf("%s_%s", name, hash) +} + +func getConfiguration(address string, configuration ConfigurationModule) ResourceExpressions { + moduleParts, resourceAddress := splitModuleAddress(address) + for _, moduleName := range moduleParts { - if module, ok := workingModule.ModuleCalls[moduleName]; ok { - workingModule = module.Module + if module, ok := configuration.ModuleCalls[moduleName]; ok { + configuration = module.Module } } - for _, resource := range workingModule.Resources { - if resource.Address == workingAddress { - return &resource + for _, resource := range configuration.Resources { + if resource.Address == resourceAddress { + return resource.Expressions } } - return nil + return make(ResourceExpressions) +} + +// splitModuleAddress splits a Terraform address into module parts and the final resource address. +// For example: "module.network.module.subnet.aws_subnet.this[0]" => +// moduleParts: ["network", "subnet"], resourceAddress: "aws_subnet.this[0]" +func splitModuleAddress(address string) (moduleParts []string, resourceAddress string) { + resourceAddress = address + for strings.HasPrefix(resourceAddress, "module.") { + parts := strings.Split(resourceAddress, ".") + if len(parts) > 3 { + moduleParts = append(moduleParts, parts[1]) + resourceAddress = strings.Join(parts[2:], ".") + } else { + break + } + } + return } func getValues(address string, resourceChange []ResourceChange) *ResourceChange { @@ -204,3 +284,70 @@ func getValues(address string, resourceChange []ResourceChange) *ResourceChange } return nil } + +type NodeType = int + +const ( + AttributeNode NodeType = iota + BlockNode +) + +type BlockSchema = map[string]*SchemaNode + +// SchemaNode represents a node in the Terraform resource schema. +type SchemaNode struct { + // Type specifies whether the node is a Block or an Attribute. + Type NodeType + + // Only used for Block nodes, contains nested BlockSchemas. + Children []BlockSchema + + // Only used for Attribute nodes. + // Can be nil, a raw Go value (number, bool, string), or a PlanReference + // if the attribute refers to another resource. + Value any +} + +func schemaForBlock(r Resource, expressions ResourceExpressions) BlockSchema { + schema := make(map[string]*SchemaNode) + for n, expr := range expressions { + nodeSchema := schemaForExpression(r, expr) + if nodeSchema != nil { + schema[n] = nodeSchema + } + } + return schema +} + +func schemaForExpression(r Resource, expr any) *SchemaNode { + switch v := expr.(type) { + case map[string]any: + attrKeys := []string{"constant_value", "references"} + for _, k := range attrKeys { + if _, exists := v[k]; exists { + attrVal := unpackConfigurationValue(v, r) + return &SchemaNode{ + Type: AttributeNode, + Value: attrVal, + } + } + } + return &SchemaNode{ + Type: BlockNode, + Children: []BlockSchema{schemaForBlock(r, v)}, + } + case []any: + children := make([]BlockSchema, 0, len(v)) + for _, el := range v { + if m, ok := el.(map[string]any); ok { + children = append(children, schemaForBlock(r, m)) + } + } + return &SchemaNode{ + Type: BlockNode, + Children: children, + } + } + + return nil +} diff --git a/pkg/iac/scanners/terraformplan/tfjson/parser/parser_test.go b/pkg/iac/scanners/terraformplan/tfjson/parser/parser_test.go new file mode 100644 index 000000000000..a28f09304e6d --- /dev/null +++ b/pkg/iac/scanners/terraformplan/tfjson/parser/parser_test.go @@ -0,0 +1,75 @@ +package parser_test + +import ( + "io/fs" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/aquasecurity/trivy/pkg/iac/scanners/terraformplan/tfjson/parser" +) + +func TestParser_ParseFile(t *testing.T) { + planFile, err := parser.New().ParseFile("../testdata/plan.json") + require.NoError(t, err) + assert.NotNil(t, planFile) + + fsys, err := planFile.ToFS() + require.NoError(t, err) + assert.NotNil(t, fsys) + + b, err := fs.ReadFile(fsys, parser.TerraformMainFile) + require.NoError(t, err) + + expected := `resource "aws_s3_bucket" "planbucket" { + bucket = "tfsec-plan-testing" + force_destroy = false + logging { + target_bucket = "arn:aws:s3:::iac-tfsec-dev" + } + versioning { + enabled = true + mfa_delete = false + } +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "example" { + bucket = aws_s3_bucket.planbucket.id + rule { + bucket_key_enabled = true + apply_server_side_encryption_by_default { + kms_master_key_id = "" + sse_algorithm = "AES256" + } + } +} + +resource "aws_security_group" "sg" { + description = "Managed by Terraform" + name = "sg" + revoke_rules_on_delete = false + tags = { + Name = "blah" + } + tags_all = { + Name = "blah" + } + ingress { + cidr_blocks = [ + "0.0.0.0/0", + ] + description = "" + from_port = 80 + ipv6_cidr_blocks = [] + prefix_list_ids = [] + protocol = "tcp" + security_groups = [] + self = false + to_port = 80 + } +} +` + + assert.Equal(t, expected, string(b)) +} diff --git a/pkg/iac/scanners/terraformplan/tfjson/parser/plan_file.go b/pkg/iac/scanners/terraformplan/tfjson/parser/plan_file.go index 6275c228182e..623d28f41c97 100644 --- a/pkg/iac/scanners/terraformplan/tfjson/parser/plan_file.go +++ b/pkg/iac/scanners/terraformplan/tfjson/parser/plan_file.go @@ -10,14 +10,23 @@ type Resource struct { SchemaVersion int `json:"schema_version"` } +func (r Resource) BlockType() string { + if r.Mode == "managed" { + return "resource" + } + return r.Mode +} + type ResourceChange struct { Resource Change `json:"change"` } +type ResourceExpressions map[string]any + type ConfigurationResource struct { Resource - Expressions map[string]any `json:"expressions"` + Expressions ResourceExpressions `json:"expressions"` } type Change struct { diff --git a/pkg/iac/scanners/terraformplan/tfjson/scanner.go b/pkg/iac/scanners/terraformplan/tfjson/scanner.go index 449bcd244a70..1504fb3bffc5 100644 --- a/pkg/iac/scanners/terraformplan/tfjson/scanner.go +++ b/pkg/iac/scanners/terraformplan/tfjson/scanner.go @@ -24,7 +24,7 @@ func (s *Scanner) Name() string { return "Terraform Plan JSON" } -func (s *Scanner) ScanFS(_ context.Context, fsys fs.FS, dir string) (scan.Results, error) { +func (s *Scanner) ScanFS(ctx context.Context, fsys fs.FS, dir string) (scan.Results, error) { var results scan.Results @@ -37,7 +37,7 @@ func (s *Scanner) ScanFS(_ context.Context, fsys fs.FS, dir string) (scan.Result return nil } - res, err := s.ScanFile(path, fsys) + res, err := s.ScanFile(ctx, path, fsys) if err != nil { return fmt.Errorf("failed to scan %s: %w", path, err) } @@ -66,7 +66,7 @@ func New(opts ...options.ScannerOption) *Scanner { return scanner } -func (s *Scanner) ScanFile(filepath string, fsys fs.FS) (scan.Results, error) { +func (s *Scanner) ScanFile(ctx context.Context, filepath string, fsys fs.FS) (scan.Results, error) { s.logger.Debug("Scanning file", log.FilePath(filepath)) file, err := fsys.Open(filepath) @@ -74,11 +74,10 @@ func (s *Scanner) ScanFile(filepath string, fsys fs.FS) (scan.Results, error) { return nil, err } defer file.Close() - return s.Scan(file) + return s.Scan(ctx, file) } -func (s *Scanner) Scan(reader io.Reader) (scan.Results, error) { - +func (s *Scanner) Scan(ctx context.Context, reader io.Reader) (scan.Results, error) { planFile, err := s.parser.Parse(reader) if err != nil { return nil, err @@ -89,5 +88,5 @@ func (s *Scanner) Scan(reader io.Reader) (scan.Results, error) { return nil, fmt.Errorf("failed to convert plan to FS: %w", err) } - return s.inner.ScanFS(context.TODO(), planFS, ".") + return s.inner.ScanFS(ctx, planFS, ".") } diff --git a/pkg/iac/scanners/terraformplan/tfjson/scanner_test.go b/pkg/iac/scanners/terraformplan/tfjson/scanner_test.go index 4d26738e14ff..99ea25f955d3 100644 --- a/pkg/iac/scanners/terraformplan/tfjson/scanner_test.go +++ b/pkg/iac/scanners/terraformplan/tfjson/scanner_test.go @@ -1,15 +1,17 @@ -package tfjson +package tfjson_test import ( "os" + "strings" "testing" + "testing/fstest" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" - "github.com/aquasecurity/trivy/internal/testutil" "github.com/aquasecurity/trivy/pkg/iac/rego" "github.com/aquasecurity/trivy/pkg/iac/scanners/options" + "github.com/aquasecurity/trivy/pkg/iac/scanners/terraformplan/tfjson" ) const defaultCheck = `package defsec.abcdefg @@ -37,91 +39,93 @@ deny[cause] { cause := bucket.name }` -func Test_TerraformScanner(t *testing.T) { +func TestScanner_ScanFS(t *testing.T) { t.Parallel() testCases := []struct { - name string - inputFile string - check string - options []options.ScannerOption + name string + input string + options []options.ScannerOption + expected []string }{ { - name: "old rego metadata", - inputFile: "test/testdata/plan.json", - check: defaultCheck, + name: "use builtin checks", + input: "testdata/plan.json", options: []options.ScannerOption{ - rego.WithPolicyDirs("rules"), + rego.WithEmbeddedPolicies(true), + rego.WithEmbeddedLibraries(true), + }, + expected: []string{ + "AVD-AWS-0093", + "AVD-AWS-0086", + "AVD-AWS-0132", + "AVD-AWS-0094", + "AVD-AWS-0087", + "AVD-AWS-0091", + "AVD-AWS-0099", + "AVD-AWS-0124", }, }, { - name: "with user namespace", - inputFile: "test/testdata/plan.json", - check: defaultCheck, + name: "with user namespace", + input: "testdata/plan.json", options: []options.ScannerOption{ - rego.WithPolicyDirs("rules"), + rego.WithPolicyReader(strings.NewReader(defaultCheck)), rego.WithPolicyNamespaces("user"), }, + expected: []string{"TEST123"}, }, { - name: "with templated plan json", - inputFile: "test/testdata/plan_with_template.json", - check: ` -# METADATA -# title: Bad buckets are bad -# description: Bad buckets are bad because they are not good. -# scope: package + name: "with templated plan json", + input: "testdata/plan_with_template.json", + options: []options.ScannerOption{ + rego.WithPolicyReader(strings.NewReader(`# METADATA # schemas: # - input: schema["cloud"] # custom: +# id: TEST123 # avd_id: AVD-TEST-0123 -# severity: CRITICAL -# short_code: very-bad-misconfig -# recommended_action: "Fix the s3 bucket" - package user.foobar.ABC001 deny[cause] { bucket := input.aws.s3.buckets[_] bucket.name.value == "${template-name-is-$evil}" cause := bucket.name -} -`, - options: []options.ScannerOption{ - rego.WithPolicyDirs("rules"), +}`)), rego.WithPolicyNamespaces("user"), }, + expected: []string{"TEST123"}, }, { - name: "plan with arbitrary name", - inputFile: "test/testdata/arbitrary_name.json", - check: defaultCheck, + name: "plan with arbitrary name", + input: "testdata/arbitrary_name.json", options: []options.ScannerOption{ - rego.WithPolicyDirs("rules"), + rego.WithPolicyReader(strings.NewReader(defaultCheck)), rego.WithPolicyNamespaces("user"), }, + expected: []string{"TEST123"}, }, } for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - b, _ := os.ReadFile(tc.inputFile) - fs := testutil.CreateFS(map[string]string{ - "/code/main.tfplan.json": string(b), - "/rules/test.rego": tc.check, - }) + b, err := os.ReadFile(tc.input) + require.NoError(t, err) - so := append(tc.options, rego.WithPolicyFilesystem(fs)) - scanner := New(so...) + fsys := fstest.MapFS{ + "main.tfplan.json": {Data: b}, + } - results, err := scanner.ScanFS(t.Context(), fs, "code") + scanner := tfjson.New(tc.options...) + results, err := scanner.ScanFS(t.Context(), fsys, ".") require.NoError(t, err) - require.Len(t, results.GetFailed(), 1) - - failure := results.GetFailed()[0] + var got []string + for _, r := range results.GetFailed() { + got = append(got, r.Rule().ID) + } - assert.Equal(t, "AVD-TEST-0123", failure.Rule().AVDID) + assert.ElementsMatch(t, tc.expected, got) }) } } diff --git a/pkg/iac/scanners/terraformplan/tfjson/test/parser_test.go b/pkg/iac/scanners/terraformplan/tfjson/test/parser_test.go deleted file mode 100644 index 85a63c6227ef..000000000000 --- a/pkg/iac/scanners/terraformplan/tfjson/test/parser_test.go +++ /dev/null @@ -1,21 +0,0 @@ -package json - -import ( - "testing" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/aquasecurity/trivy/pkg/iac/scanners/terraformplan/tfjson/parser" -) - -func Test_Parse_Plan_File(t *testing.T) { - planFile, err := parser.New().ParseFile("testdata/plan.json") - require.NoError(t, err) - - assert.NotNil(t, planFile) - fs, err := planFile.ToFS() - require.NoError(t, err) - - assert.NotNil(t, fs) -} diff --git a/pkg/iac/scanners/terraformplan/tfjson/test/scanner_test.go b/pkg/iac/scanners/terraformplan/tfjson/test/scanner_test.go deleted file mode 100644 index d70280da437f..000000000000 --- a/pkg/iac/scanners/terraformplan/tfjson/test/scanner_test.go +++ /dev/null @@ -1,39 +0,0 @@ -package json - -import ( - "os" - "testing" - "testing/fstest" - - "github.com/stretchr/testify/assert" - "github.com/stretchr/testify/require" - - "github.com/aquasecurity/trivy/pkg/iac/rego" - "github.com/aquasecurity/trivy/pkg/iac/scan" - "github.com/aquasecurity/trivy/pkg/iac/scanners/terraformplan/tfjson" -) - -func Test_Scanning_Plan(t *testing.T) { - scanner := tfjson.New( - rego.WithEmbeddedPolicies(true), - rego.WithEmbeddedLibraries(true), - ) - b, _ := os.ReadFile("testdata/plan.json") - testFS := fstest.MapFS{ - "testdata/plan.json": {Data: b}, - } - - results, err := scanner.ScanFile("testdata/plan.json", testFS) - require.NoError(t, err) - require.NotNil(t, results) - - var failedResults scan.Results - for _, r := range results { - if r.Status() == scan.StatusFailed { - failedResults = append(failedResults, r) - } - } - - assert.Len(t, failedResults, 8) - -} diff --git a/pkg/iac/scanners/terraformplan/tfjson/test/testdata/arbitrary_name.json b/pkg/iac/scanners/terraformplan/tfjson/testdata/arbitrary_name.json similarity index 100% rename from pkg/iac/scanners/terraformplan/tfjson/test/testdata/arbitrary_name.json rename to pkg/iac/scanners/terraformplan/tfjson/testdata/arbitrary_name.json diff --git a/pkg/iac/scanners/terraformplan/tfjson/test/testdata/plan.json b/pkg/iac/scanners/terraformplan/tfjson/testdata/plan.json similarity index 99% rename from pkg/iac/scanners/terraformplan/tfjson/test/testdata/plan.json rename to pkg/iac/scanners/terraformplan/tfjson/testdata/plan.json index 8a3588e19052..c3eaf7bda8e7 100644 --- a/pkg/iac/scanners/terraformplan/tfjson/test/testdata/plan.json +++ b/pkg/iac/scanners/terraformplan/tfjson/testdata/plan.json @@ -1 +1 @@ -{"format_version":"0.2","terraform_version":"1.0.3","variables":{"bucket_name":{"value":"tfsec-plan-testing"}},"planned_values":{"root_module":{"resources":[{"address":"aws_s3_bucket.planbucket","mode":"managed","type":"aws_s3_bucket","name":"planbucket","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"bucket":"tfsec-plan-testing","bucket_prefix":null,"force_destroy":false,"logging":[{"target_bucket":"arn:aws:s3:::iac-tfsec-dev","target_prefix":null}],"tags":null,"versioning":[{"enabled":true,"mfa_delete":false}]},"sensitive_values":{"cors_rule":[],"grant":[],"lifecycle_rule":[],"logging":[{}],"object_lock_configuration":[],"replication_configuration":[],"server_side_encryption_configuration":[],"tags_all":{},"versioning":[{}],"website":[]}},{"address":"aws_s3_bucket_server_side_encryption_configuration.example","mode":"managed","type":"aws_s3_bucket_server_side_encryption_configuration","name":"example","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"expected_bucket_owner":null,"rule":[{"apply_server_side_encryption_by_default":[{"kms_master_key_id":"","sse_algorithm":"AES256"}],"bucket_key_enabled":true}]},"sensitive_values":{"rule":[{"apply_server_side_encryption_by_default":[{}]}]}},{"address":"aws_security_group.sg","mode":"managed","type":"aws_security_group","name":"sg","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":1,"values":{"description":"Managed by Terraform","ingress":[{"cidr_blocks":["0.0.0.0/0"],"description":"","from_port":80,"ipv6_cidr_blocks":[],"prefix_list_ids":[],"protocol":"tcp","security_groups":[],"self":false,"to_port":80}],"name":"sg","revoke_rules_on_delete":false,"tags":{"Name":"blah"},"tags_all":{"Name":"blah"},"timeouts":null},"sensitive_values":{"egress":[],"ingress":[{"cidr_blocks":[false],"ipv6_cidr_blocks":[],"prefix_list_ids":[],"security_groups":[]}],"tags":{},"tags_all":{}}}]}},"resource_changes":[{"address":"aws_s3_bucket.planbucket","mode":"managed","type":"aws_s3_bucket","name":"planbucket","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"bucket":"tfsec-plan-testing","bucket_prefix":null,"force_destroy":false,"logging":[{"target_bucket":"arn:aws:s3:::iac-tfsec-dev","target_prefix":null}],"tags":null,"versioning":[{"enabled":true,"mfa_delete":false}]},"after_unknown":{"acceleration_status":true,"acl":true,"arn":true,"bucket_domain_name":true,"bucket_regional_domain_name":true,"cors_rule":true,"grant":true,"hosted_zone_id":true,"id":true,"lifecycle_rule":true,"logging":[{}],"object_lock_configuration":true,"object_lock_enabled":true,"policy":true,"region":true,"replication_configuration":true,"request_payer":true,"server_side_encryption_configuration":true,"tags_all":true,"versioning":[{}],"website":true,"website_domain":true,"website_endpoint":true},"before_sensitive":false,"after_sensitive":{"cors_rule":[],"grant":[],"lifecycle_rule":[],"logging":[{}],"object_lock_configuration":[],"replication_configuration":[],"server_side_encryption_configuration":[],"tags_all":{},"versioning":[{}],"website":[]}}},{"address":"aws_s3_bucket_server_side_encryption_configuration.example","mode":"managed","type":"aws_s3_bucket_server_side_encryption_configuration","name":"example","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"expected_bucket_owner":null,"rule":[{"apply_server_side_encryption_by_default":[{"kms_master_key_id":"","sse_algorithm":"AES256"}],"bucket_key_enabled":true}]},"after_unknown":{"bucket":true,"id":true,"rule":[{"apply_server_side_encryption_by_default":[{}]}]},"before_sensitive":false,"after_sensitive":{"rule":[{"apply_server_side_encryption_by_default":[{}]}]}}},{"address":"aws_security_group.sg","mode":"managed","type":"aws_security_group","name":"sg","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"description":"Managed by Terraform","ingress":[{"cidr_blocks":["0.0.0.0/0"],"description":"","from_port":80,"ipv6_cidr_blocks":[],"prefix_list_ids":[],"protocol":"tcp","security_groups":[],"self":false,"to_port":80}],"name":"sg","revoke_rules_on_delete":false,"tags":{"Name":"blah"},"tags_all":{"Name":"blah"},"timeouts":null},"after_unknown":{"arn":true,"egress":true,"id":true,"ingress":[{"cidr_blocks":[false],"ipv6_cidr_blocks":[],"prefix_list_ids":[],"security_groups":[]}],"name_prefix":true,"owner_id":true,"tags":{},"tags_all":{},"vpc_id":true},"before_sensitive":false,"after_sensitive":{"egress":[],"ingress":[{"cidr_blocks":[false],"ipv6_cidr_blocks":[],"prefix_list_ids":[],"security_groups":[]}],"tags":{},"tags_all":{}}}}],"prior_state":{"format_version":"0.2","terraform_version":"1.0.3","values":{"root_module":{"resources":[{"address":"data.aws_s3_bucket.logging_bucket","mode":"data","type":"aws_s3_bucket","name":"logging_bucket","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:s3:::iac-tfsec-dev","bucket":"iac-tfsec-dev","bucket_domain_name":"iac-tfsec-dev.s3.amazonaws.com","bucket_regional_domain_name":"iac-tfsec-dev.s3.amazonaws.com","hosted_zone_id":"Z3AQBSTGFYJSTF","id":"iac-tfsec-dev","region":"us-east-1","website_domain":null,"website_endpoint":null},"sensitive_values":{}}]}}},"configuration":{"provider_config":{"aws":{"name":"aws"}},"root_module":{"resources":[{"address":"aws_s3_bucket.planbucket","mode":"managed","type":"aws_s3_bucket","name":"planbucket","provider_config_key":"aws","expressions":{"bucket":{"references":["var.bucket_name"]},"logging":[{"target_bucket":{"references":["data.aws_s3_bucket.logging_bucket.arn","data.aws_s3_bucket.logging_bucket"]}}],"versioning":[{"enabled":{"constant_value":true}}]},"schema_version":0},{"address":"aws_s3_bucket_server_side_encryption_configuration.example","mode":"managed","type":"aws_s3_bucket_server_side_encryption_configuration","name":"example","provider_config_key":"aws","expressions":{"bucket":{"references":["aws_s3_bucket.planbucket.id","aws_s3_bucket.planbucket"]},"rule":[{"apply_server_side_encryption_by_default":[{"sse_algorithm":{"constant_value":"AES256"}}],"bucket_key_enabled":{"constant_value":true}}]},"schema_version":0},{"address":"aws_security_group.sg","mode":"managed","type":"aws_security_group","name":"sg","provider_config_key":"aws","expressions":{"name":{"constant_value":"sg"},"tags":{"constant_value":{"Name":"blah"}}},"schema_version":1},{"address":"data.aws_s3_bucket.logging_bucket","mode":"data","type":"aws_s3_bucket","name":"logging_bucket","provider_config_key":"aws","expressions":{"bucket":{"constant_value":"iac-tfsec-dev"}},"schema_version":0}],"variables":{"bucket_name":{"default":"tfsec-plan-testing"}}}}} +{"format_version":"0.2","terraform_version":"1.0.3","variables":{"bucket_name":{"value":"tfsec-plan-testing"}},"planned_values":{"root_module":{"resources":[{"address":"aws_s3_bucket.planbucket","mode":"managed","type":"aws_s3_bucket","name":"planbucket","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"bucket":"tfsec-plan-testing","bucket_prefix":null,"force_destroy":false,"logging":[{"target_bucket":"arn:aws:s3:::iac-tfsec-dev","target_prefix":null}],"tags":null,"versioning":[{"enabled":true,"mfa_delete":false}]},"sensitive_values":{"cors_rule":[],"grant":[],"lifecycle_rule":[],"logging":[{}],"object_lock_configuration":[],"replication_configuration":[],"server_side_encryption_configuration":[],"tags_all":{},"versioning":[{}],"website":[]}},{"address":"aws_s3_bucket_server_side_encryption_configuration.example","mode":"managed","type":"aws_s3_bucket_server_side_encryption_configuration","name":"example","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"expected_bucket_owner":null,"rule":[{"apply_server_side_encryption_by_default":[{"kms_master_key_id":"","sse_algorithm":"AES256"}],"bucket_key_enabled":true}]},"sensitive_values":{"rule":[{"apply_server_side_encryption_by_default":[{}]}]}},{"address":"aws_security_group.sg","mode":"managed","type":"aws_security_group","name":"sg","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":1,"values":{"description":"Managed by Terraform","ingress":[{"cidr_blocks":["0.0.0.0/0"],"description":"","from_port":80,"ipv6_cidr_blocks":[],"prefix_list_ids":[],"protocol":"tcp","security_groups":[],"self":false,"to_port":80}],"name":"sg","revoke_rules_on_delete":false,"tags":{"Name":"blah"},"tags_all":{"Name":"blah"},"timeouts":null},"sensitive_values":{"egress":[],"ingress":[{"cidr_blocks":[false],"ipv6_cidr_blocks":[],"prefix_list_ids":[],"security_groups":[]}],"tags":{},"tags_all":{}}}]}},"resource_changes":[{"address":"aws_s3_bucket.planbucket","mode":"managed","type":"aws_s3_bucket","name":"planbucket","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"bucket":"tfsec-plan-testing","bucket_prefix":null,"force_destroy":false,"logging":[{"target_bucket":"arn:aws:s3:::iac-tfsec-dev","target_prefix":null}],"tags":null,"versioning":[{"enabled":true,"mfa_delete":false}]},"after_unknown":{"acceleration_status":true,"acl":true,"arn":true,"bucket_domain_name":true,"bucket_regional_domain_name":true,"cors_rule":true,"grant":true,"hosted_zone_id":true,"id":true,"lifecycle_rule":true,"logging":[{}],"object_lock_configuration":true,"object_lock_enabled":true,"policy":true,"region":true,"replication_configuration":true,"request_payer":true,"server_side_encryption_configuration":true,"tags_all":true,"versioning":[{}],"website":true,"website_domain":true,"website_endpoint":true},"before_sensitive":false,"after_sensitive":{"cors_rule":[],"grant":[],"lifecycle_rule":[],"logging":[{}],"object_lock_configuration":[],"replication_configuration":[],"server_side_encryption_configuration":[],"tags_all":{},"versioning":[{}],"website":[]}}},{"address":"aws_s3_bucket_server_side_encryption_configuration.example","mode":"managed","type":"aws_s3_bucket_server_side_encryption_configuration","name":"example","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"expected_bucket_owner":null,"rule":[{"apply_server_side_encryption_by_default":[{"kms_master_key_id":"","sse_algorithm":"AES256"}],"bucket_key_enabled":true}]},"after_unknown":{"bucket":true,"id":true,"rule":[{"apply_server_side_encryption_by_default":[{}]}]},"before_sensitive":false,"after_sensitive":{"rule":[{"apply_server_side_encryption_by_default":[{}]}]}}},{"address":"aws_security_group.sg","mode":"managed","type":"aws_security_group","name":"sg","provider_name":"registry.terraform.io/hashicorp/aws","change":{"actions":["create"],"before":null,"after":{"description":"Managed by Terraform","ingress":[{"cidr_blocks":["0.0.0.0/0"],"description":"","from_port":80,"ipv6_cidr_blocks":[],"prefix_list_ids":[],"protocol":"tcp","security_groups":[],"self":false,"to_port":80}],"name":"sg","revoke_rules_on_delete":false,"tags":{"Name":"blah"},"tags_all":{"Name":"blah"},"timeouts":null},"after_unknown":{"arn":true,"egress":true,"id":true,"ingress":[{"cidr_blocks":[false],"ipv6_cidr_blocks":[],"prefix_list_ids":[],"security_groups":[]}],"name_prefix":true,"owner_id":true,"tags":{},"tags_all":{},"vpc_id":true},"before_sensitive":false,"after_sensitive":{"egress":[],"ingress":[{"cidr_blocks":[false],"ipv6_cidr_blocks":[],"prefix_list_ids":[],"security_groups":[]}],"tags":{},"tags_all":{}}}}],"prior_state":{"format_version":"0.2","terraform_version":"1.0.3","values":{"root_module":{"resources":[{"address":"data.aws_s3_bucket.logging_bucket","mode":"data","type":"aws_s3_bucket","name":"logging_bucket","provider_name":"registry.terraform.io/hashicorp/aws","schema_version":0,"values":{"arn":"arn:aws:s3:::iac-tfsec-dev","bucket":"iac-tfsec-dev","bucket_domain_name":"iac-tfsec-dev.s3.amazonaws.com","bucket_regional_domain_name":"iac-tfsec-dev.s3.amazonaws.com","hosted_zone_id":"Z3AQBSTGFYJSTF","id":"iac-tfsec-dev","region":"us-east-1","website_domain":null,"website_endpoint":null},"sensitive_values":{}}]}}},"configuration":{"provider_config":{"aws":{"name":"aws"}},"root_module":{"resources":[{"address":"aws_s3_bucket.planbucket","mode":"managed","type":"aws_s3_bucket","name":"planbucket","provider_config_key":"aws","expressions":{"bucket":{"references":["var.bucket_name"]},"logging":[{"target_bucket":{"references":["data.aws_s3_bucket.logging_bucket.arn","data.aws_s3_bucket.logging_bucket"]}}],"versioning":[{"enabled":{"constant_value":true}}]},"schema_version":0},{"address":"aws_s3_bucket_server_side_encryption_configuration.example","mode":"managed","type":"aws_s3_bucket_server_side_encryption_configuration","name":"example","provider_config_key":"aws","expressions":{"bucket":{"references":["aws_s3_bucket.planbucket.id","aws_s3_bucket.planbucket"]},"rule":[{"apply_server_side_encryption_by_default":[{"sse_algorithm":{"constant_value":"AES256"}}],"bucket_key_enabled":{"constant_value":true}}]},"schema_version":0},{"address":"aws_security_group.sg","mode":"managed","type":"aws_security_group","name":"sg","provider_config_key":"aws","expressions":{"name":{"constant_value":"sg"},"tags":{"constant_value":{"Name":"blah"}}},"schema_version":1},{"address":"data.aws_s3_bucket.logging_bucket","mode":"data","type":"aws_s3_bucket","name":"logging_bucket","provider_config_key":"aws","expressions":{"bucket":{"constant_value":"iac-tfsec-dev"}},"schema_version":0}],"variables":{"bucket_name":{"default":"tfsec-plan-testing"}}}}} \ No newline at end of file diff --git a/pkg/iac/scanners/terraformplan/tfjson/test/testdata/plan_with_template.json b/pkg/iac/scanners/terraformplan/tfjson/testdata/plan_with_template.json similarity index 100% rename from pkg/iac/scanners/terraformplan/tfjson/test/testdata/plan_with_template.json rename to pkg/iac/scanners/terraformplan/tfjson/testdata/plan_with_template.json diff --git a/pkg/iac/terraform/resource_block.go b/pkg/iac/terraform/resource_block.go index abce0fcb040e..51825306b1d8 100644 --- a/pkg/iac/terraform/resource_block.go +++ b/pkg/iac/terraform/resource_block.go @@ -1,10 +1,10 @@ package terraform import ( - "bytes" "fmt" + "io" + "sort" "strings" - "text/template" ) type PlanReference struct { @@ -15,155 +15,173 @@ type PlanBlock struct { Type string Name string BlockType string - Blocks map[string]map[string]any + Blocks []*PlanBlock Attributes map[string]any } -func NewPlanBlock(blockType, resourceType, resourceName string) *PlanBlock { - if blockType == "managed" { - blockType = "resource" +func (pb *PlanBlock) GetOrCreateBlock(name string) *PlanBlock { + for _, cb := range pb.Blocks { + if cb.Name == name { + return cb + } } - - return &PlanBlock{ - Type: resourceType, - Name: resourceName, - BlockType: blockType, - Blocks: make(map[string]map[string]any), + newChildBlock := &PlanBlock{ + Name: name, Attributes: make(map[string]any), } + pb.Blocks = append(pb.Blocks, newChildBlock) + return newChildBlock } -func (rb *PlanBlock) HasAttribute(attribute string) bool { - for k := range rb.Attributes { - if k == attribute { - return true - } +func (pb *PlanBlock) ToHCL(w io.Writer) { + r := &hclRenderer{ + w: w, + indent: "", } - return false + r.renderBlock(pb) } -func (rb *PlanBlock) ToHCL() string { +type hclRenderer struct { + w io.Writer + indent string +} - resourceTmpl, err := template.New("resource").Funcs(template.FuncMap{ - "RenderValue": renderTemplateValue, - "RenderPrimitive": renderPrimitive, - }).Parse(resourceTemplate) - if err != nil { - panic(err) - } +func (r *hclRenderer) write(s string) { + fmt.Fprint(r.w, s) +} + +func (r *hclRenderer) writeln(s string) { + fmt.Fprintln(r.w, s) +} + +func (r *hclRenderer) writef(format string, args ...any) { + fmt.Fprintf(r.w, format, args...) +} - var res bytes.Buffer - if err := resourceTmpl.Execute(&res, map[string]any{ - "BlockType": rb.BlockType, - "Type": rb.Type, - "Name": rb.Name, - "Attributes": rb.Attributes, - "Blocks": rb.Blocks, - }); err != nil { - return "" +func (r *hclRenderer) incIndent() { + r.indent += " " +} + +func (r *hclRenderer) decIndent() { + if len(r.indent) >= 2 { + r.indent = r.indent[:len(r.indent)-2] } - return res.String() } -var resourceTemplate = `{{ .BlockType }} "{{ .Type }}" "{{ .Name }}" { - {{ range $name, $value := .Attributes }}{{ if $value }}{{ $name }} {{ RenderValue $value }} - {{end}}{{ end }}{{ range $name, $block := .Blocks }}{{ $name }} { - {{ range $name, $value := $block }}{{ if $value }}{{ $name }} {{ RenderValue $value }} - {{end}}{{ end }}} -{{end}}}` +func (r *hclRenderer) renderBlock(b *PlanBlock) { + r.write(r.indent) + if b.BlockType != "" && b.Type != "" && b.Name != "" { + r.writef("%s \"%s\" \"%s\" {\n", b.BlockType, b.Type, b.Name) + } else { + r.writef("%s {\n", b.Name) + } -func renderTemplateValue(val any) string { - switch t := val.(type) { - case map[string]any: - return fmt.Sprintf("= %s", renderMap(t)) - case []any: - if isMapSlice(t) { - return renderSlice(t) + keys := make([]string, 0, len(b.Attributes)) + for k := range b.Attributes { + keys = append(keys, k) + } + sort.Strings(keys) + + for _, key := range keys { + value := b.Attributes[key] + if value == nil { + continue } - return fmt.Sprintf("= %s", renderSlice(t)) - default: - return fmt.Sprintf("= %s", renderPrimitive(val)) + r.renderAttributeLine(key, value) + } + + sort.Slice(b.Blocks, func(i, j int) bool { + return b.Blocks[i].Name < b.Blocks[j].Name + }) + + r.incIndent() + for _, child := range b.Blocks { + r.renderBlock(child) } + r.decIndent() + r.writeln(r.indent + "}") +} + +func (r *hclRenderer) renderAttributeLine(key string, val any) { + r.write(fmt.Sprintf("%s%s = ", r.indent+" ", key)) + r.renderAttributeValue(val) + r.writeln("") } -func renderPrimitive(val any) string { +func (r *hclRenderer) renderAttributeValue(val any) { switch t := val.(type) { - case PlanReference: - return fmt.Sprintf("%v", t.Value) - case string: - return parseStringPrimitive(t) case map[string]any: - return renderMap(t) + r.renderMap(t) case []any: - return renderSlice(t) + r.renderSlice(t) default: - return fmt.Sprintf("%#v", t) - } - -} - -func parseStringPrimitive(input string) string { - // we must escape templating - // ref: https://developer.hashicorp.com/terraform/language/expressions/strings#escape-sequences-1 - input = escapeSpecialSequences(input) - if strings.Contains(input, "\n") { - return fmt.Sprintf(`<