From 688e66b7e7f8d01a41046f9f018bc84139033ee7 Mon Sep 17 00:00:00 2001 From: aristosvo <8375124+aristosvo@users.noreply.github.com> Date: Sun, 4 May 2025 06:41:07 +0300 Subject: [PATCH 1/2] feat: add suggestions to replace data sources with ephemeral alternatives --- docs/rules/aws_ephemeral_resources.md | 44 +++++++++ rules/ephemeral/aws_ephemeral_resources.go | 90 +++++++++++++++++++ .../ephemeral/aws_ephemeral_resources_test.go | 58 ++++++++++++ rules/ephemeral/ephemeral_resources_gen.go | 12 +++ .../ephemeral/ephemeral_resources_gen.go.tmpl | 9 ++ rules/ephemeral/generator/main.go | 16 +++- rules/provider.go | 1 + tools/provider-schema/.terraform-version | 2 +- 8 files changed, 230 insertions(+), 2 deletions(-) create mode 100644 docs/rules/aws_ephemeral_resources.md create mode 100644 rules/ephemeral/aws_ephemeral_resources.go create mode 100644 rules/ephemeral/aws_ephemeral_resources_test.go create mode 100644 rules/ephemeral/ephemeral_resources_gen.go create mode 100644 rules/ephemeral/ephemeral_resources_gen.go.tmpl diff --git a/docs/rules/aws_ephemeral_resources.md b/docs/rules/aws_ephemeral_resources.md new file mode 100644 index 00000000..1f48e3b1 --- /dev/null +++ b/docs/rules/aws_ephemeral_resources.md @@ -0,0 +1,44 @@ +# aws_ephemeral_resources + +Recommends using available [ephemeral resources](https://developer.hashicorp.com/terraform/language/resources/ephemeral/reference) instead of the original data source. This is only valid for Terraform v1.10+. + +## Example + +This example uses `aws_secretsmanager_random_password`, but the rule applies to all data sources with an ephemeral equivalent: + +```hcl +data "aws_secretsmanager_random_password" "test" { + password_length = 50 + exclude_numbers = true +} +``` + +``` +$ tflint +1 issue(s) found: + +Warning: [Fixable] "aws_secretsmanager_random_password" is a non-ephemeral data source, which means that all (sensitive) attributes are stored in state. Please use ephemeral resource "aws_secretsmanager_random_password" instead. (aws_ephemeral_resources) + + on test.tf line 2: + 2: data "aws_secretsmanager_random_password" "test" + +``` + +## 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 ephemeral resources, which are not persisted to state. Other resources can refer to their values, but executing of the lookup is defered until the apply stage. + +Using ephemeral resources mitigates the risk of a malicious actor obtaining privileged credentials by accessing Terraform state files directly. Prefer using them over the original data sources for sensitive data. + +## How To Fix + +Replace the data source with its ephemeral resource equivalent. Use resources with write-only arguments or in provider configuration to ensure that the sensitive value is not persisted to state. + +In case of the previously shown `aws_secretsmanager_random_password` data source, replace `data` by `ephemeral`: + +```hcl +ephemeral "aws_secretsmanager_random_password" "test" { + password_length = 50 + exclude_numbers = true +} +``` diff --git a/rules/ephemeral/aws_ephemeral_resources.go b/rules/ephemeral/aws_ephemeral_resources.go new file mode 100644 index 00000000..7a0dc774 --- /dev/null +++ b/rules/ephemeral/aws_ephemeral_resources.go @@ -0,0 +1,90 @@ +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" +) + +// AwsEphemeralResourcesRule checks for data sources which can be replaced by ephemeral resources +type AwsEphemeralResourcesRule struct { + tflint.DefaultRule + + replacingEphemeralResources []string +} + +// NewAwsEphemeralResourcesRule returns new rule with default attributes +func NewAwsEphemeralResourcesRule() *AwsEphemeralResourcesRule { + return &AwsEphemeralResourcesRule{ + replacingEphemeralResources: replacingEphemeralResources, + } +} + +// Name returns the rule name +func (r *AwsEphemeralResourcesRule) Name() string { + return "aws_ephemeral_resources" +} + +// Enabled returns whether the rule is enabled by default +func (r *AwsEphemeralResourcesRule) Enabled() bool { + return false +} + +// Severity returns the rule severity +func (r *AwsEphemeralResourcesRule) Severity() tflint.Severity { + return tflint.WARNING +} + +// Link returns the rule reference link +func (r *AwsEphemeralResourcesRule) Link() string { + return project.ReferenceLink(r.Name()) +} + +// Check checks if there is an ephemeral resource which can replace an data source +func (r *AwsEphemeralResourcesRule) Check(runner tflint.Runner) error { + for _, resourceType := range r.replacingEphemeralResources { + resources, err := GetDataSourceContent(runner, resourceType, &hclext.BodySchema{}, nil) + if err != nil { + return err + } + + for _, resource := range resources.Blocks { + if err := runner.EmitIssueWithFix( + r, + fmt.Sprintf("\"%s\" is a non-ephemeral data source, which means that all (sensitive) attributes are stored in state. Please use ephemeral resource \"%s\" instead.", resourceType, resourceType), + resource.TypeRange, + func(f tflint.Fixer) error { + return f.ReplaceText(resource.TypeRange, "ephemeral") + }, + ); err != nil { + return fmt.Errorf("failed to call EmitIssueWithFix(): %w", err) + } + } + } + + return nil +} + +func GetDataSourceContent(r tflint.Runner, name string, schema *hclext.BodySchema, opts *tflint.GetModuleContentOption) (*hclext.BodyContent, error) { + body, err := r.GetModuleContent(&hclext.BodySchema{ + Blocks: []hclext.BlockSchema{ + {Type: "data", LabelNames: []string{"type", "name"}, Body: schema}, + }, + }, opts) + if err != nil { + return nil, err + } + + content := &hclext.BodyContent{Blocks: []*hclext.Block{}} + for _, resource := range body.Blocks { + if resource.Labels[0] != name { + continue + } + + content.Blocks = append(content.Blocks, resource) + } + + return content, nil +} diff --git a/rules/ephemeral/aws_ephemeral_resources_test.go b/rules/ephemeral/aws_ephemeral_resources_test.go new file mode 100644 index 00000000..9510e58d --- /dev/null +++ b/rules/ephemeral/aws_ephemeral_resources_test.go @@ -0,0 +1,58 @@ +package ephemeral + +import ( + "testing" + + "github.com/hashicorp/hcl/v2" + "github.com/terraform-linters/tflint-plugin-sdk/helper" +) + +func Test_AwsEphemeralResources(t *testing.T) { + cases := []struct { + Name string + Content string + Expected helper.Issues + Fixed string + }{ + { + Name: "basic aws_eks_cluster_auth", + Content: ` +data "aws_eks_cluster_auth" "test" { +} +`, + Expected: helper.Issues{ + { + Rule: NewAwsEphemeralResourcesRule(), + Message: `"aws_eks_cluster_auth" is a non-ephemeral data source, which means that all (sensitive) attributes are stored in state. Please use ephemeral resource "aws_eks_cluster_auth" instead.`, + Range: hcl.Range{ + Filename: "resource.tf", + Start: hcl.Pos{Line: 2, Column: 1}, + End: hcl.Pos{Line: 2, Column: 5}, + }, + }, + }, + Fixed: ` +ephemeral "aws_eks_cluster_auth" "test" { +} +`, + }, + } + + rule := NewAwsEphemeralResourcesRule() + + 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_resources_gen.go b/rules/ephemeral/ephemeral_resources_gen.go new file mode 100644 index 00000000..f4ea3422 --- /dev/null +++ b/rules/ephemeral/ephemeral_resources_gen.go @@ -0,0 +1,12 @@ +// This file generated by `generator/main.go`. DO NOT EDIT + +package ephemeral + +var replacingEphemeralResources = []string{ + "aws_eks_cluster_auth", + "aws_kms_secrets", + "aws_lambda_invocation", + "aws_secretsmanager_random_password", + "aws_secretsmanager_secret_version", + "aws_ssm_parameter", +} \ No newline at end of file diff --git a/rules/ephemeral/ephemeral_resources_gen.go.tmpl b/rules/ephemeral/ephemeral_resources_gen.go.tmpl new file mode 100644 index 00000000..b5272e74 --- /dev/null +++ b/rules/ephemeral/ephemeral_resources_gen.go.tmpl @@ -0,0 +1,9 @@ +// This file generated by `generator/main.go`. DO NOT EDIT + +package ephemeral + +var replacingEphemeralResources = []string{ + {{- range $value := . }} + "{{ $value}}", + {{- end }} +} \ No newline at end of file diff --git a/rules/ephemeral/generator/main.go b/rules/ephemeral/generator/main.go index 02fecab5..738f358f 100644 --- a/rules/ephemeral/generator/main.go +++ b/rules/ephemeral/generator/main.go @@ -1,6 +1,7 @@ package main import ( + "slices" "strings" tfjson "github.com/hashicorp/terraform-json" @@ -25,8 +26,21 @@ func main() { } } - // Generate the write-only arguments variable to file + // Generate the write-only arguments variable utils.GenerateFile("../../rules/ephemeral/write_only_arguments_gen.go", "../../rules/ephemeral/write_only_arguments_gen.go.tmpl", resourcesWithWriteOnly) + + ephemeralResourcesAsDataAlternative := []string{} + // Iterate over all ephemeral resources in the AWS provider schema + for resourceName, _ := range awsProvider.EphemeralResourceSchemas { + if awsProvider.DataSourceSchemas[resourceName] != nil { + ephemeralResourcesAsDataAlternative = append(ephemeralResourcesAsDataAlternative, resourceName) + } + } + + slices.Sort(ephemeralResourcesAsDataAlternative) + + // Generate the ephemeral resources variable + utils.GenerateFile("../../rules/ephemeral/ephemeral_resources_gen.go", "../../rules/ephemeral/ephemeral_resources_gen.go.tmpl", ephemeralResourcesAsDataAlternative) } func findReplaceableAttribute(arguments []string, resource *tfjson.Schema) []writeOnlyArgument { diff --git a/rules/provider.go b/rules/provider.go index 4c937956..d5066418 100644 --- a/rules/provider.go +++ b/rules/provider.go @@ -46,6 +46,7 @@ var manualRules = []tflint.Rule{ NewAwsSecurityGroupRuleDeprecatedRule(), NewAwsIAMRoleDeprecatedPolicyAttributesRule(), ephemeral.NewAwsWriteOnlyArgumentsRule(), + ephemeral.NewAwsEphemeralResourcesRule(), } // Rules is a list of all rules diff --git a/tools/provider-schema/.terraform-version b/tools/provider-schema/.terraform-version index 45a1b3f4..ca717669 100644 --- a/tools/provider-schema/.terraform-version +++ b/tools/provider-schema/.terraform-version @@ -1 +1 @@ -1.1.2 +1.11.2 From 45e41a721567997407a396aaeaa17dbcdf3d442a Mon Sep 17 00:00:00 2001 From: aristosvo <8375124+aristosvo@users.noreply.github.com> Date: Tue, 6 May 2025 21:18:48 +0300 Subject: [PATCH 2/2] fix: implement remarks --- docs/rules/README.md | 9 +++++++++ rules/ephemeral/aws_ephemeral_resources.go | 7 ++----- rules/ephemeral/aws_ephemeral_resources_test.go | 10 ---------- 3 files changed, 11 insertions(+), 15 deletions(-) diff --git a/docs/rules/README.md b/docs/rules/README.md index 537ad1fc..649391d5 100644 --- a/docs/rules/README.md +++ b/docs/rules/README.md @@ -80,6 +80,15 @@ These rules enforce best practices and naming conventions: |[aws_security_group_rule_deprecated](aws_security_group_rule_deprecated.md)|Disallow using `aws_security_group_rule` resource|| |[aws_provider_missing_default_tags](aws_provider_missing_default_tags.md)|Require specific tags for all AWS providers default tags|| +### Removing secrets from state + +These rules recommend best practices to keep sensitive information from state: + +|Rule|Description|Enabled by default| +| --- | --- | --- | +|[aws_ephemeral_resources](aws_ephemeral_resources.md)|Recommends using available ephemeral resources instead of the original data source. This is only valid for Terraform v1.10+.|| +|[aws_write_only_arguments](aws_write_only_arguments.md)|Recommends using available write-only arguments instead of the original sensitive attribute. This is only valid for Terraform v1.11+.|| + ### SDK-based Validations 700+ rules based on the aws-sdk validations are also available: diff --git a/rules/ephemeral/aws_ephemeral_resources.go b/rules/ephemeral/aws_ephemeral_resources.go index 7a0dc774..d63880a4 100644 --- a/rules/ephemeral/aws_ephemeral_resources.go +++ b/rules/ephemeral/aws_ephemeral_resources.go @@ -51,15 +51,12 @@ func (r *AwsEphemeralResourcesRule) Check(runner tflint.Runner) error { } for _, resource := range resources.Blocks { - if err := runner.EmitIssueWithFix( + if err := runner.EmitIssue( r, fmt.Sprintf("\"%s\" is a non-ephemeral data source, which means that all (sensitive) attributes are stored in state. Please use ephemeral resource \"%s\" instead.", resourceType, resourceType), resource.TypeRange, - func(f tflint.Fixer) error { - return f.ReplaceText(resource.TypeRange, "ephemeral") - }, ); err != nil { - return fmt.Errorf("failed to call EmitIssueWithFix(): %w", err) + return fmt.Errorf("failed to call EmitIssue(): %w", err) } } } diff --git a/rules/ephemeral/aws_ephemeral_resources_test.go b/rules/ephemeral/aws_ephemeral_resources_test.go index 9510e58d..f8058e99 100644 --- a/rules/ephemeral/aws_ephemeral_resources_test.go +++ b/rules/ephemeral/aws_ephemeral_resources_test.go @@ -31,10 +31,6 @@ data "aws_eks_cluster_auth" "test" { }, }, }, - Fixed: ` -ephemeral "aws_eks_cluster_auth" "test" { -} -`, }, } @@ -48,11 +44,5 @@ ephemeral "aws_eks_cluster_auth" "test" { 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()) } }