diff --git a/docs/rules/aws_write_only_attributes.md b/docs/rules/aws_write_only_attributes.md new file mode 100644 index 00000000..0cf716b7 --- /dev/null +++ b/docs/rules/aws_write_only_attributes.md @@ -0,0 +1,59 @@ +# aws_write_only_arguments + +Recommends using available [write-only arguments](https://developer.hashicorp.com/terraform/language/resources/ephemeral/write-only) instead of the original sensitive attribute. This is only valid for Terraform v1.11+. + +## Example + +This example uses `aws_secretsmanager_secret_version`, but the rule applies to all resources with write-only arguments: + +```hcl +resource "aws_secretsmanager_secret_version" "test" { + secret_string = var.secret +} +``` + +``` +$ tflint +1 issue(s) found: + +Warning: [Fixable] "secret_string" is a non-ephemeral attribute, which means this secret is stored in state. Please use "secret_string_wo". (aws_write_only_arguments) + + on test.tf line 3: + 3: secret_string = var.secret + +``` + +## Why + +By default, sensitive attributes are still stored in state, just hidden from view in plan output. Other resources are able to refer to these attributes. Current versions of Terraform also include support for write-only arguments, which are not persisted to state. Other resources cannot refer to their values. + +Using write-only arguments mitigates the risk of a malicious actor obtaining privileged credentials by accessing Terraform state files directly. Prefer using them over the original sensitive attribute unless you need to refer to it in other blocks, such as a [root `output`](https://developer.hashicorp.com/terraform/language/values/outputs#ephemeral-avoid-storing-values-in-state-or-plan-files), that cannot be ephemeral. + +## How To Fix + +Replace the attribute with its write-only argument equivalent. Reference an ephemeral resource or ephemeral variable to ensure that the sensitive value is not persisted to state. + +```hcl +ephemeral "random_password" "test" { + length = 32 + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "aws_secretsmanager_secret_version" "test" { + secret_string_wo = ephemeral.random_password.test.value + secret_string_wo_version = 1 +} +``` + +```hcl +variable "test" { + type = string + ephemeral = true # Optional, non-ephemeral values can also be used for write-only arguments + description = "Input variable for a secret" +} + +resource "aws_secretsmanager_secret_version" "test" { + secret_string_wo = var.test + secret_string_wo_version = 1 +} +``` \ No newline at end of file diff --git a/rules/ephemeral/aws_write_only_arguments.go b/rules/ephemeral/aws_write_only_arguments.go new file mode 100644 index 00000000..6c04ac0a --- /dev/null +++ b/rules/ephemeral/aws_write_only_arguments.go @@ -0,0 +1,98 @@ +package ephemeral + +import ( + "fmt" + + "github.com/terraform-linters/tflint-plugin-sdk/hclext" + "github.com/terraform-linters/tflint-plugin-sdk/tflint" + "github.com/terraform-linters/tflint-ruleset-aws/project" + "github.com/zclconf/go-cty/cty" +) + +// AwsWriteOnlyArgumentsRule checks if a write-only argument is available for sensitive input attributes +type AwsWriteOnlyArgumentsRule struct { + tflint.DefaultRule + + writeOnlyArguments map[string][]writeOnlyArgument +} + +type writeOnlyArgument struct { + originalAttribute string + writeOnlyAlternative string + writeOnlyVersionAttribute string +} + +// NewAwsWriteOnlyArgumentsRule returns new rule with default attributes +func NewAwsWriteOnlyArgumentsRule() *AwsWriteOnlyArgumentsRule { + return &AwsWriteOnlyArgumentsRule{ + writeOnlyArguments: writeOnlyArguments, + } +} + +// Name returns the rule name +func (r *AwsWriteOnlyArgumentsRule) Name() string { + return "aws_write_only_arguments" +} + +// Enabled returns whether the rule is enabled by default +func (r *AwsWriteOnlyArgumentsRule) Enabled() bool { + return false +} + +// Severity returns the rule severity +func (r *AwsWriteOnlyArgumentsRule) Severity() tflint.Severity { + return tflint.WARNING +} + +// Link returns the rule reference link +func (r *AwsWriteOnlyArgumentsRule) Link() string { + return project.ReferenceLink(r.Name()) +} + +// Check checks whether the sensitive attribute exists +func (r *AwsWriteOnlyArgumentsRule) Check(runner tflint.Runner) error { + for resourceType, attributes := range r.writeOnlyArguments { + for _, resourceAttribute := range attributes { + resources, err := runner.GetResourceContent(resourceType, &hclext.BodySchema{ + Attributes: []hclext.AttributeSchema{ + {Name: resourceAttribute.originalAttribute}, + }, + }, nil) + if err != nil { + return err + } + + for _, resource := range resources.Blocks { + attribute, exists := resource.Body.Attributes[resourceAttribute.originalAttribute] + if !exists { + continue + } + + err := runner.EvaluateExpr(attribute.Expr, func(val cty.Value) error { + if !val.IsNull() { + if err := runner.EmitIssueWithFix( + r, + fmt.Sprintf("\"%s\" is a non-ephemeral attribute, which means this secret is stored in state. Please use write-only argument \"%s\".", resourceAttribute.originalAttribute, resourceAttribute.writeOnlyAlternative), + attribute.Expr.Range(), + func(f tflint.Fixer) error { + err := f.ReplaceText(attribute.NameRange, resourceAttribute.writeOnlyAlternative) + if err != nil { + return err + } + return f.InsertTextAfter(attribute.Range, fmt.Sprintf("\n %s = 1", resourceAttribute.writeOnlyVersionAttribute)) + }, + ); err != nil { + return fmt.Errorf("failed to call EmitIssueWithFix(): %w", err) + } + } + return nil + }, nil) + if err != nil { + return err + } + } + } + } + + return nil +} diff --git a/rules/ephemeral/aws_write_only_arguments_test.go b/rules/ephemeral/aws_write_only_arguments_test.go new file mode 100644 index 00000000..d3ccf390 --- /dev/null +++ b/rules/ephemeral/aws_write_only_arguments_test.go @@ -0,0 +1,71 @@ +package ephemeral + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/terraform-linters/tflint-plugin-sdk/helper" +) + +func Test_AwsWriteOnlyAttribute(t *testing.T) { + cases := []struct { + Name string + Content string + Expected helper.Issues + Fixed string + }{ + { + Name: "basic", + Content: ` +resource "aws_secretsmanager_secret_version" "test" { + secret_string = "test" +} +`, + Expected: helper.Issues{ + { + Rule: NewAwsWriteOnlyArgumentsRule(), + Message: `"secret_string" is a non-ephemeral attribute, which means this secret is stored in state. Please use write-only argument "secret_string_wo".`, + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 3, Column: 19}, + End: hcl.Pos{Line: 3, Column: 25}, + }, + }, + }, + Fixed: ` +resource "aws_secretsmanager_secret_version" "test" { + secret_string_wo = "test" + secret_string_wo_version = 1 +} +`, + }, + { + Name: "everything is fine", + Content: ` +resource "aws_secretsmanager_secret_version" "test" { + secret_string_wo = "test" + secret_string_wo_version = 1 +} +`, + Expected: helper.Issues{}, + }, + } + + rule := NewAwsWriteOnlyArgumentsRule() + + for _, tc := range cases { + filename := "resource.tf" + runner := helper.TestRunner(t, map[string]string{filename: tc.Content}) + + if err := rule.Check(runner); err != nil { + t.Fatalf("Unexpected error occurred: %s", err) + } + helper.AssertIssues(t, tc.Expected, runner.Issues) + + want := map[string]string{} + if tc.Fixed != "" { + want[filename] = tc.Fixed + } + helper.AssertChanges(t, want, runner.Changes()) + } +} diff --git a/rules/ephemeral/ephemeral.go b/rules/ephemeral/ephemeral.go new file mode 100644 index 00000000..e7c9be5a --- /dev/null +++ b/rules/ephemeral/ephemeral.go @@ -0,0 +1,3 @@ +//go:generate go run -tags generators ./generator/main.go + +package ephemeral diff --git a/rules/ephemeral/generator/main.go b/rules/ephemeral/generator/main.go new file mode 100644 index 00000000..02fecab5 --- /dev/null +++ b/rules/ephemeral/generator/main.go @@ -0,0 +1,66 @@ +package main + +import ( + "strings" + + tfjson "github.com/hashicorp/terraform-json" + utils "github.com/terraform-linters/tflint-ruleset-aws/rules/generator-utils" +) + +type writeOnlyArgument struct { + OriginalAttribute string + WriteOnlyAlternative string + WriteOnlyVersionAttribute string +} + +func main() { + awsProvider := utils.LoadProviderSchema("../../tools/provider-schema/schema.json") + + resourcesWithWriteOnly := map[string][]writeOnlyArgument{} + // Iterate over all resources in the AWS provider schema + for resourceName, resource := range awsProvider.ResourceSchemas { + if arguments := writeOnlyArguments(resource); len(arguments) > 0 { + // gather sensitive attributes with write only argument alternatives + resourcesWithWriteOnly[resourceName] = findReplaceableAttribute(arguments, resource) + } + } + + // Generate the write-only arguments variable to file + utils.GenerateFile("../../rules/ephemeral/write_only_arguments_gen.go", "../../rules/ephemeral/write_only_arguments_gen.go.tmpl", resourcesWithWriteOnly) +} + +func findReplaceableAttribute(arguments []string, resource *tfjson.Schema) []writeOnlyArgument { + writeOnlyArguments := []writeOnlyArgument{} + + for _, argument := range arguments { + // Check if the argument ends with "_wo" and if the original attribute without "_wo" suffix exists in the resource schema + attribute := strings.TrimSuffix(argument, "_wo") + versionAttribute := attribute + "_wo_version" + if strings.HasSuffix(argument, "_wo") && resource.Block.Attributes[attribute] != nil && resource.Block.Attributes[versionAttribute] != nil { + writeOnlyArguments = append(writeOnlyArguments, writeOnlyArgument{ + OriginalAttribute: attribute, + WriteOnlyAlternative: argument, + WriteOnlyVersionAttribute: versionAttribute, + }) + } + } + + return writeOnlyArguments +} + +func writeOnlyArguments(resource *tfjson.Schema) []string { + if resource == nil || resource.Block == nil { + return []string{} + } + + writeOnlyArguments := []string{} + + // Check if the resource has any write-only attributes + for name, attribute := range resource.Block.Attributes { + if attribute.WriteOnly { + writeOnlyArguments = append(writeOnlyArguments, name) + } + } + + return writeOnlyArguments +} diff --git a/rules/ephemeral/write_only_arguments_gen.go b/rules/ephemeral/write_only_arguments_gen.go new file mode 100644 index 00000000..47e58cea --- /dev/null +++ b/rules/ephemeral/write_only_arguments_gen.go @@ -0,0 +1,55 @@ +// This file generated by `generator/main.go`. DO NOT EDIT + +package ephemeral + +var writeOnlyArguments = map[string][]writeOnlyArgument{ + "aws_db_instance": { + { + originalAttribute: "password", + writeOnlyAlternative: "password_wo", + writeOnlyVersionAttribute: "password_wo_version", + }, + }, + "aws_docdb_cluster": { + { + originalAttribute: "master_password", + writeOnlyAlternative: "master_password_wo", + writeOnlyVersionAttribute: "master_password_wo_version", + }, + }, + "aws_rds_cluster": { + { + originalAttribute: "master_password", + writeOnlyAlternative: "master_password_wo", + writeOnlyVersionAttribute: "master_password_wo_version", + }, + }, + "aws_redshift_cluster": { + { + originalAttribute: "master_password", + writeOnlyAlternative: "master_password_wo", + writeOnlyVersionAttribute: "master_password_wo_version", + }, + }, + "aws_redshiftserverless_namespace": { + { + originalAttribute: "admin_user_password", + writeOnlyAlternative: "admin_user_password_wo", + writeOnlyVersionAttribute: "admin_user_password_wo_version", + }, + }, + "aws_secretsmanager_secret_version": { + { + originalAttribute: "secret_string", + writeOnlyAlternative: "secret_string_wo", + writeOnlyVersionAttribute: "secret_string_wo_version", + }, + }, + "aws_ssm_parameter": { + { + originalAttribute: "value", + writeOnlyAlternative: "value_wo", + writeOnlyVersionAttribute: "value_wo_version", + }, + }, +} diff --git a/rules/ephemeral/write_only_arguments_gen.go.tmpl b/rules/ephemeral/write_only_arguments_gen.go.tmpl new file mode 100644 index 00000000..769a3371 --- /dev/null +++ b/rules/ephemeral/write_only_arguments_gen.go.tmpl @@ -0,0 +1,15 @@ +// This file generated by `generator/main.go`. DO NOT EDIT + +package ephemeral + +var writeOnlyArguments = map[string][]writeOnlyArgument{ + {{- range $name, $value := . }} + "{{ $name }}": { {{- range $kk, $writeOnly := $value }} + { + originalAttribute: "{{ $writeOnly.OriginalAttribute }}", + writeOnlyAlternative: "{{ $writeOnly.WriteOnlyAlternative }}", + writeOnlyVersionAttribute: "{{ $writeOnly.WriteOnlyVersionAttribute }}", + }, + }, + {{- end -}}{{- end }} +} diff --git a/rules/provider.go b/rules/provider.go index 654407b8..4c937956 100644 --- a/rules/provider.go +++ b/rules/provider.go @@ -3,6 +3,7 @@ package rules import ( "github.com/terraform-linters/tflint-plugin-sdk/tflint" "github.com/terraform-linters/tflint-ruleset-aws/rules/api" + "github.com/terraform-linters/tflint-ruleset-aws/rules/ephemeral" "github.com/terraform-linters/tflint-ruleset-aws/rules/models" ) @@ -44,6 +45,7 @@ var manualRules = []tflint.Rule{ NewAwsSecurityGroupInlineRulesRule(), NewAwsSecurityGroupRuleDeprecatedRule(), NewAwsIAMRoleDeprecatedPolicyAttributesRule(), + ephemeral.NewAwsWriteOnlyArgumentsRule(), } // Rules is a list of all rules