Skip to content

Commit e530dc1

Browse files
committed
Add new rule: terraform_map_duplicate_values
1 parent bbeaf5d commit e530dc1

File tree

4 files changed

+386
-0
lines changed

4 files changed

+386
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
# terraform_map_duplicate_values
2+
3+
Disallow duplicate values in a map object.
4+
5+
## Example
6+
7+
```hcl
8+
locals {
9+
map = {
10+
foo = 1
11+
bar = 1 // duplicate value
12+
}
13+
}
14+
```
15+
16+
```
17+
$ tflint
18+
1 issue(s) found:
19+
20+
Warning: Duplicate key: "bar", first defined at main.tf:4,5-8 (terraform_map_duplicate_values)
21+
22+
on main.tf line 5:
23+
5: bar = 3 // duplicated value
24+
25+
Reference: https://github.com/terraform-linters/tflint-ruleset-terraform/blob/v0.11.0/docs/rules/terraform_map_duplicate_values.md
26+
```
27+
28+
## Why
29+
30+
Sometimes, you want to maintain a map that contains only unique values (e.g., do not want to get duplicated SSM parameters values). This rule will catch such mistakes early.
31+
The map structure is not a set, so it is possible to have duplicate values in a map, so make sure you run this rule only on files where you want to enforce unique values.
32+
33+
## How To Fix
34+
35+
Remove the duplicate values and leave the correct value.

rules/preset.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ var PresetRules = map[string][]tflint.Rule{
1212
NewTerraformDocumentedVariablesRule(),
1313
NewTerraformEmptyListEqualityRule(),
1414
NewTerraformMapDuplicateKeysRule(),
15+
NewTerraformMapDuplicateValuesRule(),
1516
NewTerraformModulePinnedSourceRule(),
1617
NewTerraformModuleVersionRule(),
1718
NewTerraformNamingConventionRule(),
Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
package rules
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/hashicorp/hcl/v2"
7+
"github.com/hashicorp/hcl/v2/hclsyntax"
8+
"github.com/terraform-linters/tflint-plugin-sdk/logger"
9+
"github.com/terraform-linters/tflint-plugin-sdk/tflint"
10+
"github.com/terraform-linters/tflint-ruleset-terraform/project"
11+
"github.com/zclconf/go-cty/cty"
12+
"github.com/zclconf/go-cty/cty/convert"
13+
)
14+
15+
// This rule checks for map literals with duplicate values
16+
type TerraformMapDuplicateValuesRule struct {
17+
tflint.DefaultRule
18+
}
19+
20+
func NewTerraformMapDuplicateValuesRule() *TerraformMapDuplicateValuesRule {
21+
return &TerraformMapDuplicateValuesRule{}
22+
}
23+
24+
func (r *TerraformMapDuplicateValuesRule) Name() string {
25+
return "terraform_map_duplicate_values"
26+
}
27+
28+
func (r *TerraformMapDuplicateValuesRule) Enabled() bool {
29+
return true
30+
}
31+
32+
func (r *TerraformMapDuplicateValuesRule) Severity() tflint.Severity {
33+
return tflint.WARNING
34+
}
35+
36+
func (r *TerraformMapDuplicateValuesRule) Link() string {
37+
return project.ReferenceLink(r.Name())
38+
}
39+
40+
func (r *TerraformMapDuplicateValuesRule) Check(runner tflint.Runner) error {
41+
path, err := runner.GetModulePath()
42+
if err != nil {
43+
return err
44+
}
45+
if !path.IsRoot() {
46+
// This rule does not evaluate child modules
47+
return nil
48+
}
49+
50+
diags := runner.WalkExpressions(tflint.ExprWalkFunc(func(e hcl.Expression) hcl.Diagnostics {
51+
return r.checkObjectConsExpr(e, runner)
52+
}))
53+
if diags.HasErrors() {
54+
return diags
55+
}
56+
57+
return nil
58+
}
59+
60+
func (r *TerraformMapDuplicateValuesRule) checkObjectConsExpr(e hcl.Expression, runner tflint.Runner) hcl.Diagnostics {
61+
objExpr, ok := e.(*hclsyntax.ObjectConsExpr)
62+
if !ok {
63+
return nil
64+
}
65+
66+
var diags hcl.Diagnostics
67+
values := make(map[string]hcl.Range)
68+
69+
for _, item := range objExpr.Items {
70+
valExpr := item.ValueExpr
71+
var val cty.Value
72+
73+
err := runner.EvaluateExpr(valExpr, &val, nil)
74+
if err != nil {
75+
logger.Debug("Failed to evaluate value. The value will be ignored", "range", valExpr.Range(), "error", err.Error())
76+
continue
77+
}
78+
79+
if !val.IsKnown() || val.IsNull() || val.IsMarked() {
80+
logger.Debug("Unprocessable value, continuing", "range", valExpr.Range())
81+
continue
82+
}
83+
// Map values must be strings, but some values ​​can be converted to strings and become valid values,
84+
// so try to convert them here.
85+
if converted, err := convert.Convert(val, cty.String); err == nil {
86+
val = converted
87+
}
88+
89+
// ignore unprocessable values and boolean values
90+
if val.Type() != cty.String || val.AsString() == "true" || val.AsString() == "false" {
91+
logger.Debug("Unprocessable value, continuing", "range", valExpr.Range())
92+
continue
93+
}
94+
95+
if declRange, exists := values[val.AsString()]; exists {
96+
if err := runner.EmitIssue(
97+
r,
98+
fmt.Sprintf("Duplicate value: %q, first defined at %s", val.AsString(), declRange),
99+
valExpr.Range(),
100+
); err != nil {
101+
diags = append(diags, &hcl.Diagnostic{
102+
Severity: hcl.DiagError,
103+
Summary: "failed to call EmitIssue()",
104+
Detail: err.Error(),
105+
})
106+
107+
return diags
108+
}
109+
110+
continue
111+
}
112+
113+
values[val.AsString()] = valExpr.Range()
114+
}
115+
116+
return diags
117+
}
Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
package rules
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_TerraformMapDuplicateValues(t *testing.T) {
11+
cases := []struct {
12+
Name string
13+
Content string
14+
Expected helper.Issues
15+
Fixed string
16+
}{
17+
{
18+
Name: "No duplicates",
19+
Content: `
20+
resource "null_resource" "test" {
21+
test = {
22+
a = 1
23+
b = 2
24+
c = 3
25+
}
26+
}`,
27+
Expected: helper.Issues{},
28+
},
29+
{
30+
Name: "duplicate values in map literal",
31+
Content: `
32+
resource "null_resource" "test" {
33+
triggers = {
34+
a = "b"
35+
c = "b"
36+
}
37+
}`,
38+
Expected: helper.Issues{
39+
{
40+
Rule: NewTerraformMapDuplicateValuesRule(),
41+
Message: `Duplicate value: "b", first defined at module.tf:4,13-16`,
42+
Range: hcl.Range{
43+
Filename: "module.tf",
44+
Start: hcl.Pos{Line: 5, Column: 13},
45+
End: hcl.Pos{Line: 5, Column: 16},
46+
},
47+
},
48+
},
49+
},
50+
{
51+
Name: "duplicate values with quoting",
52+
Content: `
53+
resource "null_resource" "test" {
54+
triggers = {
55+
a = "b"
56+
c = "b"
57+
}
58+
}`,
59+
Expected: helper.Issues{
60+
{
61+
Rule: NewTerraformMapDuplicateValuesRule(),
62+
Message: `Duplicate value: "b", first defined at module.tf:4,13-16`,
63+
Range: hcl.Range{
64+
Filename: "module.tf",
65+
Start: hcl.Pos{Line: 5, Column: 13},
66+
End: hcl.Pos{Line: 5, Column: 16},
67+
},
68+
},
69+
},
70+
},
71+
{
72+
Name: "Using variables as values",
73+
Content: `
74+
variable "a" {
75+
type = string
76+
default = "b"
77+
}
78+
79+
resource "null_resource" "test" {
80+
map = {
81+
key1 = var.a
82+
key2 = "b"
83+
}
84+
}`,
85+
Expected: helper.Issues{
86+
{
87+
Rule: NewTerraformMapDuplicateValuesRule(),
88+
Message: `Duplicate value: "b", first defined at module.tf:9,11-16`,
89+
Range: hcl.Range{
90+
Filename: "module.tf",
91+
Start: hcl.Pos{Line: 10, Column: 11},
92+
End: hcl.Pos{Line: 10, Column: 14},
93+
},
94+
},
95+
},
96+
},
97+
{
98+
Name: "Using a variable as a value without a default",
99+
Content: `
100+
variable "unknown" {
101+
type = string
102+
}
103+
104+
resource "null_resource" "test" {
105+
map = {
106+
key1 = "x"
107+
key2 = var.unknown
108+
}
109+
}`,
110+
Expected: helper.Issues{},
111+
},
112+
{
113+
Name: "Multiple duplicates in same map",
114+
Content: `
115+
resource "null_resource" "test" {
116+
map = {
117+
key1 = "a"
118+
key2 = "a"
119+
key3 = "a"
120+
}
121+
}`,
122+
Expected: helper.Issues{
123+
{
124+
Rule: NewTerraformMapDuplicateValuesRule(),
125+
Message: `Duplicate value: "a", first defined at module.tf:4,11-14`,
126+
Range: hcl.Range{
127+
Filename: "module.tf",
128+
Start: hcl.Pos{Line: 5, Column: 11},
129+
End: hcl.Pos{Line: 5, Column: 14},
130+
},
131+
},
132+
{
133+
Rule: NewTerraformMapDuplicateValuesRule(),
134+
Message: `Duplicate value: "a", first defined at module.tf:4,11-14`,
135+
Range: hcl.Range{
136+
Filename: "module.tf",
137+
Start: hcl.Pos{Line: 6, Column: 11},
138+
End: hcl.Pos{Line: 6, Column: 14},
139+
},
140+
},
141+
},
142+
},
143+
{
144+
Name: "Using same value in different maps is okay",
145+
Content: `
146+
resource "null_resource" "test" {
147+
map1 = {
148+
key1 = "x"
149+
}
150+
map2 = {
151+
key2 = "x"
152+
}
153+
}`,
154+
Expected: helper.Issues{},
155+
},
156+
{
157+
Name: "Using sensitive variable values",
158+
Content: `
159+
variable "sensitive" {
160+
default = "secret"
161+
sensitive = true
162+
}
163+
164+
resource "null_resource" "test" {
165+
map = {
166+
key1 = var.sensitive
167+
key2 = "secret"
168+
}
169+
}`,
170+
// Do not report sensitive duplicate values to prevent unintentional exposure of sensitive values
171+
Expected: helper.Issues{},
172+
},
173+
{
174+
Name: "Using non-string values",
175+
Content: `
176+
resource "null_resource" "test" {
177+
map = {
178+
key1 = 1
179+
key2 = 1
180+
key3 = {}
181+
}
182+
}`,
183+
Expected: helper.Issues{
184+
{
185+
Rule: NewTerraformMapDuplicateValuesRule(),
186+
Message: `Duplicate value: "1", first defined at module.tf:4,12-13`,
187+
Range: hcl.Range{
188+
Filename: "module.tf",
189+
Start: hcl.Pos{Line: 5, Column: 12},
190+
End: hcl.Pos{Line: 5, Column: 13},
191+
},
192+
},
193+
},
194+
},
195+
{
196+
Name: "values in for expressions",
197+
Content: `
198+
resource "null_resource" "test" {
199+
list = [for a in ["foo", "bar"] : {
200+
key1 = "${a}_baz"
201+
key2 = "foo_baz"
202+
}]
203+
}`,
204+
// The current implementation cannot find duplicate values in for expressions.
205+
Expected: helper.Issues{},
206+
},
207+
{
208+
Name: "ignore boolean string values",
209+
Content: `
210+
resource "null_resource" "test" {
211+
map = {
212+
key1 = true
213+
key2 = true
214+
}
215+
}`,
216+
Expected: helper.Issues{},
217+
},
218+
}
219+
220+
rule := NewTerraformMapDuplicateValuesRule()
221+
222+
for _, tc := range cases {
223+
t.Run(tc.Name, func(t *testing.T) {
224+
runner := testRunner(t, map[string]string{"module.tf": tc.Content})
225+
226+
if err := rule.Check(runner); err != nil {
227+
t.Fatalf("Unexpected error occurred: %s", err)
228+
}
229+
230+
helper.AssertIssues(t, tc.Expected, runner.Runner.(*helper.Runner).Issues)
231+
})
232+
}
233+
}

0 commit comments

Comments
 (0)