Skip to content

Commit ab59f0d

Browse files
authored
aws_write_only_arguments: recommend write-only arguments where available (#860)
1 parent 4c8f35d commit ab59f0d

8 files changed

+369
-0
lines changed
+59
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# aws_write_only_arguments
2+
3+
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+.
4+
5+
## Example
6+
7+
This example uses `aws_secretsmanager_secret_version`, but the rule applies to all resources with write-only arguments:
8+
9+
```hcl
10+
resource "aws_secretsmanager_secret_version" "test" {
11+
secret_string = var.secret
12+
}
13+
```
14+
15+
```
16+
$ tflint
17+
1 issue(s) found:
18+
19+
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)
20+
21+
on test.tf line 3:
22+
3: secret_string = var.secret
23+
24+
```
25+
26+
## Why
27+
28+
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.
29+
30+
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.
31+
32+
## How To Fix
33+
34+
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.
35+
36+
```hcl
37+
ephemeral "random_password" "test" {
38+
length = 32
39+
override_special = "!#$%&*()-_=+[]{}<>:?"
40+
}
41+
42+
resource "aws_secretsmanager_secret_version" "test" {
43+
secret_string_wo = ephemeral.random_password.test.value
44+
secret_string_wo_version = 1
45+
}
46+
```
47+
48+
```hcl
49+
variable "test" {
50+
type = string
51+
ephemeral = true # Optional, non-ephemeral values can also be used for write-only arguments
52+
description = "Input variable for a secret"
53+
}
54+
55+
resource "aws_secretsmanager_secret_version" "test" {
56+
secret_string_wo = var.test
57+
secret_string_wo_version = 1
58+
}
59+
```
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package ephemeral
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/terraform-linters/tflint-plugin-sdk/hclext"
7+
"github.com/terraform-linters/tflint-plugin-sdk/tflint"
8+
"github.com/terraform-linters/tflint-ruleset-aws/project"
9+
"github.com/zclconf/go-cty/cty"
10+
)
11+
12+
// AwsWriteOnlyArgumentsRule checks if a write-only argument is available for sensitive input attributes
13+
type AwsWriteOnlyArgumentsRule struct {
14+
tflint.DefaultRule
15+
16+
writeOnlyArguments map[string][]writeOnlyArgument
17+
}
18+
19+
type writeOnlyArgument struct {
20+
originalAttribute string
21+
writeOnlyAlternative string
22+
writeOnlyVersionAttribute string
23+
}
24+
25+
// NewAwsWriteOnlyArgumentsRule returns new rule with default attributes
26+
func NewAwsWriteOnlyArgumentsRule() *AwsWriteOnlyArgumentsRule {
27+
return &AwsWriteOnlyArgumentsRule{
28+
writeOnlyArguments: writeOnlyArguments,
29+
}
30+
}
31+
32+
// Name returns the rule name
33+
func (r *AwsWriteOnlyArgumentsRule) Name() string {
34+
return "aws_write_only_arguments"
35+
}
36+
37+
// Enabled returns whether the rule is enabled by default
38+
func (r *AwsWriteOnlyArgumentsRule) Enabled() bool {
39+
return false
40+
}
41+
42+
// Severity returns the rule severity
43+
func (r *AwsWriteOnlyArgumentsRule) Severity() tflint.Severity {
44+
return tflint.WARNING
45+
}
46+
47+
// Link returns the rule reference link
48+
func (r *AwsWriteOnlyArgumentsRule) Link() string {
49+
return project.ReferenceLink(r.Name())
50+
}
51+
52+
// Check checks whether the sensitive attribute exists
53+
func (r *AwsWriteOnlyArgumentsRule) Check(runner tflint.Runner) error {
54+
for resourceType, attributes := range r.writeOnlyArguments {
55+
for _, resourceAttribute := range attributes {
56+
resources, err := runner.GetResourceContent(resourceType, &hclext.BodySchema{
57+
Attributes: []hclext.AttributeSchema{
58+
{Name: resourceAttribute.originalAttribute},
59+
},
60+
}, nil)
61+
if err != nil {
62+
return err
63+
}
64+
65+
for _, resource := range resources.Blocks {
66+
attribute, exists := resource.Body.Attributes[resourceAttribute.originalAttribute]
67+
if !exists {
68+
continue
69+
}
70+
71+
err := runner.EvaluateExpr(attribute.Expr, func(val cty.Value) error {
72+
if !val.IsNull() {
73+
if err := runner.EmitIssueWithFix(
74+
r,
75+
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),
76+
attribute.Expr.Range(),
77+
func(f tflint.Fixer) error {
78+
err := f.ReplaceText(attribute.NameRange, resourceAttribute.writeOnlyAlternative)
79+
if err != nil {
80+
return err
81+
}
82+
return f.InsertTextAfter(attribute.Range, fmt.Sprintf("\n %s = 1", resourceAttribute.writeOnlyVersionAttribute))
83+
},
84+
); err != nil {
85+
return fmt.Errorf("failed to call EmitIssueWithFix(): %w", err)
86+
}
87+
}
88+
return nil
89+
}, nil)
90+
if err != nil {
91+
return err
92+
}
93+
}
94+
}
95+
}
96+
97+
return nil
98+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package ephemeral
2+
3+
import (
4+
"testing"
5+
6+
"github.com/hashicorp/hcl/v2"
7+
"github.com/terraform-linters/tflint-plugin-sdk/helper"
8+
)
9+
10+
func Test_AwsWriteOnlyAttribute(t *testing.T) {
11+
cases := []struct {
12+
Name string
13+
Content string
14+
Expected helper.Issues
15+
Fixed string
16+
}{
17+
{
18+
Name: "basic",
19+
Content: `
20+
resource "aws_secretsmanager_secret_version" "test" {
21+
secret_string = "test"
22+
}
23+
`,
24+
Expected: helper.Issues{
25+
{
26+
Rule: NewAwsWriteOnlyArgumentsRule(),
27+
Message: `"secret_string" is a non-ephemeral attribute, which means this secret is stored in state. Please use write-only argument "secret_string_wo".`,
28+
Range: hcl.Range{
29+
Filename: "resource.tf",
30+
Start: hcl.Pos{Line: 3, Column: 19},
31+
End: hcl.Pos{Line: 3, Column: 25},
32+
},
33+
},
34+
},
35+
Fixed: `
36+
resource "aws_secretsmanager_secret_version" "test" {
37+
secret_string_wo = "test"
38+
secret_string_wo_version = 1
39+
}
40+
`,
41+
},
42+
{
43+
Name: "everything is fine",
44+
Content: `
45+
resource "aws_secretsmanager_secret_version" "test" {
46+
secret_string_wo = "test"
47+
secret_string_wo_version = 1
48+
}
49+
`,
50+
Expected: helper.Issues{},
51+
},
52+
}
53+
54+
rule := NewAwsWriteOnlyArgumentsRule()
55+
56+
for _, tc := range cases {
57+
filename := "resource.tf"
58+
runner := helper.TestRunner(t, map[string]string{filename: tc.Content})
59+
60+
if err := rule.Check(runner); err != nil {
61+
t.Fatalf("Unexpected error occurred: %s", err)
62+
}
63+
helper.AssertIssues(t, tc.Expected, runner.Issues)
64+
65+
want := map[string]string{}
66+
if tc.Fixed != "" {
67+
want[filename] = tc.Fixed
68+
}
69+
helper.AssertChanges(t, want, runner.Changes())
70+
}
71+
}

rules/ephemeral/ephemeral.go

+3
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
//go:generate go run -tags generators ./generator/main.go
2+
3+
package ephemeral

rules/ephemeral/generator/main.go

+66
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
1+
package main
2+
3+
import (
4+
"strings"
5+
6+
tfjson "github.com/hashicorp/terraform-json"
7+
utils "github.com/terraform-linters/tflint-ruleset-aws/rules/generator-utils"
8+
)
9+
10+
type writeOnlyArgument struct {
11+
OriginalAttribute string
12+
WriteOnlyAlternative string
13+
WriteOnlyVersionAttribute string
14+
}
15+
16+
func main() {
17+
awsProvider := utils.LoadProviderSchema("../../tools/provider-schema/schema.json")
18+
19+
resourcesWithWriteOnly := map[string][]writeOnlyArgument{}
20+
// Iterate over all resources in the AWS provider schema
21+
for resourceName, resource := range awsProvider.ResourceSchemas {
22+
if arguments := writeOnlyArguments(resource); len(arguments) > 0 {
23+
// gather sensitive attributes with write only argument alternatives
24+
resourcesWithWriteOnly[resourceName] = findReplaceableAttribute(arguments, resource)
25+
}
26+
}
27+
28+
// Generate the write-only arguments variable to file
29+
utils.GenerateFile("../../rules/ephemeral/write_only_arguments_gen.go", "../../rules/ephemeral/write_only_arguments_gen.go.tmpl", resourcesWithWriteOnly)
30+
}
31+
32+
func findReplaceableAttribute(arguments []string, resource *tfjson.Schema) []writeOnlyArgument {
33+
writeOnlyArguments := []writeOnlyArgument{}
34+
35+
for _, argument := range arguments {
36+
// Check if the argument ends with "_wo" and if the original attribute without "_wo" suffix exists in the resource schema
37+
attribute := strings.TrimSuffix(argument, "_wo")
38+
versionAttribute := attribute + "_wo_version"
39+
if strings.HasSuffix(argument, "_wo") && resource.Block.Attributes[attribute] != nil && resource.Block.Attributes[versionAttribute] != nil {
40+
writeOnlyArguments = append(writeOnlyArguments, writeOnlyArgument{
41+
OriginalAttribute: attribute,
42+
WriteOnlyAlternative: argument,
43+
WriteOnlyVersionAttribute: versionAttribute,
44+
})
45+
}
46+
}
47+
48+
return writeOnlyArguments
49+
}
50+
51+
func writeOnlyArguments(resource *tfjson.Schema) []string {
52+
if resource == nil || resource.Block == nil {
53+
return []string{}
54+
}
55+
56+
writeOnlyArguments := []string{}
57+
58+
// Check if the resource has any write-only attributes
59+
for name, attribute := range resource.Block.Attributes {
60+
if attribute.WriteOnly {
61+
writeOnlyArguments = append(writeOnlyArguments, name)
62+
}
63+
}
64+
65+
return writeOnlyArguments
66+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
// This file generated by `generator/main.go`. DO NOT EDIT
2+
3+
package ephemeral
4+
5+
var writeOnlyArguments = map[string][]writeOnlyArgument{
6+
"aws_db_instance": {
7+
{
8+
originalAttribute: "password",
9+
writeOnlyAlternative: "password_wo",
10+
writeOnlyVersionAttribute: "password_wo_version",
11+
},
12+
},
13+
"aws_docdb_cluster": {
14+
{
15+
originalAttribute: "master_password",
16+
writeOnlyAlternative: "master_password_wo",
17+
writeOnlyVersionAttribute: "master_password_wo_version",
18+
},
19+
},
20+
"aws_rds_cluster": {
21+
{
22+
originalAttribute: "master_password",
23+
writeOnlyAlternative: "master_password_wo",
24+
writeOnlyVersionAttribute: "master_password_wo_version",
25+
},
26+
},
27+
"aws_redshift_cluster": {
28+
{
29+
originalAttribute: "master_password",
30+
writeOnlyAlternative: "master_password_wo",
31+
writeOnlyVersionAttribute: "master_password_wo_version",
32+
},
33+
},
34+
"aws_redshiftserverless_namespace": {
35+
{
36+
originalAttribute: "admin_user_password",
37+
writeOnlyAlternative: "admin_user_password_wo",
38+
writeOnlyVersionAttribute: "admin_user_password_wo_version",
39+
},
40+
},
41+
"aws_secretsmanager_secret_version": {
42+
{
43+
originalAttribute: "secret_string",
44+
writeOnlyAlternative: "secret_string_wo",
45+
writeOnlyVersionAttribute: "secret_string_wo_version",
46+
},
47+
},
48+
"aws_ssm_parameter": {
49+
{
50+
originalAttribute: "value",
51+
writeOnlyAlternative: "value_wo",
52+
writeOnlyVersionAttribute: "value_wo_version",
53+
},
54+
},
55+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// This file generated by `generator/main.go`. DO NOT EDIT
2+
3+
package ephemeral
4+
5+
var writeOnlyArguments = map[string][]writeOnlyArgument{
6+
{{- range $name, $value := . }}
7+
"{{ $name }}": { {{- range $kk, $writeOnly := $value }}
8+
{
9+
originalAttribute: "{{ $writeOnly.OriginalAttribute }}",
10+
writeOnlyAlternative: "{{ $writeOnly.WriteOnlyAlternative }}",
11+
writeOnlyVersionAttribute: "{{ $writeOnly.WriteOnlyVersionAttribute }}",
12+
},
13+
},
14+
{{- end -}}{{- end }}
15+
}

rules/provider.go

+2
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package rules
33
import (
44
"github.com/terraform-linters/tflint-plugin-sdk/tflint"
55
"github.com/terraform-linters/tflint-ruleset-aws/rules/api"
6+
"github.com/terraform-linters/tflint-ruleset-aws/rules/ephemeral"
67
"github.com/terraform-linters/tflint-ruleset-aws/rules/models"
78
)
89

@@ -44,6 +45,7 @@ var manualRules = []tflint.Rule{
4445
NewAwsSecurityGroupInlineRulesRule(),
4546
NewAwsSecurityGroupRuleDeprecatedRule(),
4647
NewAwsIAMRoleDeprecatedPolicyAttributesRule(),
48+
ephemeral.NewAwsWriteOnlyArgumentsRule(),
4749
}
4850

4951
// Rules is a list of all rules

0 commit comments

Comments
 (0)