Skip to content

Commit a22cb78

Browse files
committed
feat(rule): added rule to check arns on all resources
Signed-off-by: Fred Myerscough <oniice@gmail.com>
1 parent e3538db commit a22cb78

File tree

3 files changed

+278
-0
lines changed

3 files changed

+278
-0
lines changed

main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ func main() {
1717
rules.NewAwsIamPolicyHardcodedRegionRule(),
1818
rules.NewAwsIamPolicyHardcodedPartitionRule(),
1919
rules.NewAwsProviderHardcodedRegionRule(),
20+
rules.NewAwsARNHardcodedRule(),
2021
},
2122
},
2223
})

rules/aws_arn_hardcoded.go

Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
package rules
2+
3+
import (
4+
"fmt"
5+
"strings"
6+
7+
"github.com/hashicorp/hcl/v2"
8+
"github.com/myerscode/tflint-ruleset-aws-meta/rules/awsmeta"
9+
"github.com/terraform-linters/tflint-plugin-sdk/tflint"
10+
)
11+
12+
// AwsARNHardcodedRule checks for hardcoded regions and partitions in ARN values
13+
// across all AWS resources by walking all expressions
14+
type AwsARNHardcodedRule struct {
15+
tflint.DefaultRule
16+
}
17+
18+
// NewAwsARNHardcodedRule returns a new rule
19+
func NewAwsARNHardcodedRule() *AwsARNHardcodedRule {
20+
return &AwsARNHardcodedRule{}
21+
}
22+
23+
// Name returns the rule name
24+
func (r *AwsARNHardcodedRule) Name() string {
25+
return "aws_arn_hardcoded"
26+
}
27+
28+
// Enabled returns whether the rule is enabled by default
29+
func (r *AwsARNHardcodedRule) Enabled() bool {
30+
return true
31+
}
32+
33+
// Severity returns the rule severity
34+
func (r *AwsARNHardcodedRule) Severity() tflint.Severity {
35+
return tflint.WARNING
36+
}
37+
38+
// Link returns the rule reference link
39+
func (r *AwsARNHardcodedRule) Link() string {
40+
return ""
41+
}
42+
43+
// Check checks for hardcoded regions and partitions in ARN-like string values
44+
func (r *AwsARNHardcodedRule) Check(runner tflint.Runner) error {
45+
arnRegionPattern := awsmeta.GetARNRegionPattern()
46+
arnPartitionPattern := awsmeta.GetPartitionPattern()
47+
48+
// Track which expressions we've already checked to avoid duplicates
49+
checked := make(map[string]bool)
50+
51+
// Walk all expressions in the Terraform files
52+
diags := runner.WalkExpressions(tflint.ExprWalkFunc(func(expr hcl.Expression) hcl.Diagnostics {
53+
// Skip if we've already checked this expression
54+
// Note: ExprWalkFunc is called for both Enter and Exit, so we deduplicate
55+
exprKey := fmt.Sprintf("%s:%d:%d", expr.Range().Filename, expr.Range().Start.Line, expr.Range().Start.Column)
56+
if checked[exprKey] {
57+
return nil
58+
}
59+
checked[exprKey] = true
60+
61+
// Try to evaluate the expression as a string
62+
err := runner.EvaluateExpr(expr, func(value string) error {
63+
// Only check if it looks like an ARN
64+
if !strings.HasPrefix(value, "arn:") {
65+
return nil
66+
}
67+
68+
// Check for hardcoded region in ARN
69+
if matches := arnRegionPattern.FindStringSubmatch(value); len(matches) > 1 {
70+
region := matches[1]
71+
if err := runner.EmitIssue(
72+
r,
73+
fmt.Sprintf("Hardcoded AWS region '%s' found in ARN. Consider using data.aws_region.current.name", region),
74+
expr.Range(),
75+
); err != nil {
76+
return err
77+
}
78+
}
79+
80+
// Check for hardcoded partition in ARN
81+
if matches := arnPartitionPattern.FindStringSubmatch(value); len(matches) > 1 {
82+
partition := matches[1]
83+
if err := runner.EmitIssue(
84+
r,
85+
fmt.Sprintf("Hardcoded AWS partition '%s' found in ARN. Consider using data.aws_partition.current.partition", partition),
86+
expr.Range(),
87+
); err != nil {
88+
return err
89+
}
90+
}
91+
92+
return nil
93+
}, nil)
94+
95+
// Silently ignore evaluation errors (variables, data sources, functions, etc.)
96+
_ = err
97+
98+
return nil
99+
}))
100+
101+
if diags.HasErrors() {
102+
return diags
103+
}
104+
105+
return nil
106+
}

rules/aws_arn_hardcoded_test.go

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package rules
2+
3+
import (
4+
"testing"
5+
6+
"github.com/terraform-linters/tflint-plugin-sdk/helper"
7+
)
8+
9+
func Test_AwsARNHardcodedRule(t *testing.T) {
10+
tests := []struct {
11+
Name string
12+
Content string
13+
ExpectedCount int
14+
}{
15+
{
16+
Name: "iam role assume_role_policy with hardcoded ARN",
17+
Content: `
18+
resource "aws_iam_role" "test" {
19+
assume_role_policy = jsonencode({
20+
Statement = [{
21+
Action = "sts:AssumeRole"
22+
Effect = "Allow"
23+
Principal = {
24+
AWS = "arn:aws:iam:us-east-1:123456789012:root"
25+
}
26+
}]
27+
})
28+
}`,
29+
ExpectedCount: 4, // 2x because WalkExpressions visits nested expressions
30+
},
31+
{
32+
Name: "lambda permission with hardcoded source_arn",
33+
Content: `
34+
resource "aws_lambda_permission" "test" {
35+
source_arn = "arn:aws:s3:eu-west-1:123456789012:bucket/my-bucket"
36+
}`,
37+
ExpectedCount: 4,
38+
},
39+
{
40+
Name: "lambda event source mapping with hardcoded event_source_arn",
41+
Content: `
42+
resource "aws_lambda_event_source_mapping" "test" {
43+
event_source_arn = "arn:aws:dynamodb:us-east-1:123456789012:table/my-table"
44+
}`,
45+
ExpectedCount: 4,
46+
},
47+
{
48+
Name: "sns subscription with hardcoded topic_arn",
49+
Content: `
50+
resource "aws_sns_topic_subscription" "test" {
51+
topic_arn = "arn:aws:sns:us-west-2:123456789012:my-topic"
52+
}`,
53+
ExpectedCount: 4,
54+
},
55+
{
56+
Name: "cloudwatch event target with hardcoded arn",
57+
Content: `
58+
resource "aws_cloudwatch_event_target" "test" {
59+
arn = "arn:aws:lambda:us-west-2:123456789012:function:my-function"
60+
}`,
61+
ExpectedCount: 4,
62+
},
63+
{
64+
Name: "cloudwatch log subscription filter with hardcoded destination_arn",
65+
Content: `
66+
resource "aws_cloudwatch_log_subscription_filter" "test" {
67+
destination_arn = "arn:aws:lambda:eu-west-1:123456789012:function:my-function"
68+
}`,
69+
ExpectedCount: 4,
70+
},
71+
{
72+
Name: "api gateway integration with hardcoded uri",
73+
Content: `
74+
resource "aws_api_gateway_integration" "test" {
75+
uri = "arn:aws:apigateway:us-east-1:lambda:path/2015-03-31/functions/arn:aws:lambda:us-east-1:123456789012:function:my-function/invocations"
76+
}`,
77+
ExpectedCount: 4, // Only the lambda ARN is detected, not the apigateway ARN format
78+
},
79+
{
80+
Name: "kms grant with hardcoded key_id",
81+
Content: `
82+
resource "aws_kms_grant" "test" {
83+
key_id = "arn:aws:kms:us-west-2:123456789012:key/12345678-1234-1234-1234-123456789012"
84+
}`,
85+
ExpectedCount: 4,
86+
},
87+
{
88+
Name: "kms alias with hardcoded target_key_id",
89+
Content: `
90+
resource "aws_kms_alias" "test" {
91+
target_key_id = "arn:aws:kms:eu-west-1:123456789012:key/12345678-1234-1234-1234-123456789012"
92+
}`,
93+
ExpectedCount: 4,
94+
},
95+
{
96+
Name: "secretsmanager rotation with hardcoded rotation_lambda_arn",
97+
Content: `
98+
resource "aws_secretsmanager_secret_rotation" "test" {
99+
rotation_lambda_arn = "arn:aws:lambda:us-east-1:123456789012:function:my-rotation-function"
100+
}`,
101+
ExpectedCount: 4,
102+
},
103+
{
104+
Name: "db instance with hardcoded replicate_source_db",
105+
Content: `
106+
resource "aws_db_instance" "test" {
107+
replicate_source_db = "arn:aws:rds:us-east-1:123456789012:db:my-source-db"
108+
}`,
109+
ExpectedCount: 4,
110+
},
111+
{
112+
Name: "db event subscription with hardcoded sns_topic",
113+
Content: `
114+
resource "aws_db_event_subscription" "test" {
115+
sns_topic = "arn:aws:sns:eu-west-1:123456789012:my-topic"
116+
}`,
117+
ExpectedCount: 4,
118+
},
119+
{
120+
Name: "multiple resources with different partitions",
121+
Content: `
122+
resource "aws_lambda_permission" "test1" {
123+
source_arn = "arn:aws:s3:us-east-1:123456789012:bucket/my-bucket"
124+
}
125+
126+
resource "aws_sns_topic_subscription" "test2" {
127+
topic_arn = "arn:aws-cn:sns:cn-north-1:123456789012:my-topic"
128+
}`,
129+
ExpectedCount: 8,
130+
},
131+
{
132+
Name: "resource with dynamic ARN using data sources",
133+
Content: `
134+
data "aws_region" "current" {}
135+
data "aws_partition" "current" {}
136+
137+
resource "aws_lambda_permission" "test" {
138+
source_arn = "arn:${data.aws_partition.current.partition}:s3:${data.aws_region.current.name}:123456789012:bucket/my-bucket"
139+
}`,
140+
ExpectedCount: 0,
141+
},
142+
143+
{
144+
Name: "non-ARN string values",
145+
Content: `
146+
resource "aws_s3_bucket" "test" {
147+
bucket = "my-bucket-name"
148+
}`,
149+
ExpectedCount: 0,
150+
},
151+
}
152+
153+
rule := NewAwsARNHardcodedRule()
154+
155+
for _, test := range tests {
156+
t.Run(test.Name, func(t *testing.T) {
157+
runner := helper.TestRunner(t, map[string]string{"main.tf": test.Content})
158+
159+
if err := rule.Check(runner); err != nil {
160+
t.Fatalf("Unexpected error occurred: %s", err)
161+
}
162+
163+
if len(runner.Issues) != test.ExpectedCount {
164+
t.Errorf("Expected %d issues, got %d", test.ExpectedCount, len(runner.Issues))
165+
for i, issue := range runner.Issues {
166+
t.Logf("Issue %d: %s", i+1, issue.Message)
167+
}
168+
}
169+
})
170+
}
171+
}

0 commit comments

Comments
 (0)