diff --git a/docs/functions.md b/docs/functions.md index 4cc9794..205cdb0 100644 --- a/docs/functions.md +++ b/docs/functions.md @@ -732,6 +732,56 @@ terraform.removed_blocks({"from": "any"}, {}) ] ``` +## `terraform.ephemeral_resources` + +```rego +resources := terraform.ephemeral_resources(resource_type, schema, options) +``` + +Returns Terraform ephemeral resources. + +- `resource_type` (string): resource type to retrieve. "*" is a special character that returns all ephemeral resources. +- `schema` (schema): schema for attributes referenced in rules. +- `options` (object[string: string]): options to change the retrieve/evaluate behavior. + +Returns: + +- `resources` (array[object]): Terraform "ephemeral" blocks. + +The `schema` and `options` are equivalent to the arguments of the `terraform.resources` function. + +Examples: + +```hcl +ephemeral "random_password" "db_password" { + length = 16 + override_special = "!#$%&*()-_=+[]{}<>:?" +} +``` + +```rego +terraform.ephemeral_resources("random_password", {"length": "number"}, {}) +``` + +```json +[ + { + "type": "random_password", + "name": "db_password", + "config": { + "owners": { + "value": 16, + "unknown": false, + "sensitive": false, + "ephemeral": false, + "range": {...} + } + }, + "decl_range": {...} + } +] +``` + ## `terraform.module_range` ```rego diff --git a/integration/ephemerals/.tflint.hcl b/integration/ephemerals/.tflint.hcl new file mode 100644 index 0000000..3d0b8cc --- /dev/null +++ b/integration/ephemerals/.tflint.hcl @@ -0,0 +1,9 @@ +plugin "terraform" { + enabled = false +} + +plugin "opa" { + enabled = true + + policy_dir = "policies" +} diff --git a/integration/ephemerals/main.tf b/integration/ephemerals/main.tf new file mode 100644 index 0000000..cffba3e --- /dev/null +++ b/integration/ephemerals/main.tf @@ -0,0 +1,18 @@ +ephemeral "random_password" "db_password" { + length = 16 + override_special = "!#$%&*()-_=+[]{}<>:?" +} + +resource "aws_secretsmanager_secret" "db_password" { + name = "db_password" +} + +resource "aws_secretsmanager_secret_version" "db_password" { + secret_id = aws_secretsmanager_secret.db_password.id + secret_string_wo = ephemeral.random_password.db_password.result + secret_string_wo_version = 1 +} + +ephemeral "aws_secretsmanager_secret_version" "db_password" { + secret_id = aws_secretsmanager_secret_version.db_password.secret_id +} diff --git a/integration/ephemerals/policies/main.rego b/integration/ephemerals/policies/main.rego new file mode 100644 index 0000000..b3eecf0 --- /dev/null +++ b/integration/ephemerals/policies/main.rego @@ -0,0 +1,11 @@ +package tflint + +import rego.v1 + +deny_weak_password contains issue if { + passwords := terraform.ephemeral_resources("random_password", {"length": "number"}, {}) + length := passwords[_].config.length + length.value < 32 + + issue := tflint.issue("Password must be at least 32 characters long", length.range) +} diff --git a/integration/ephemerals/policies/main_test.rego b/integration/ephemerals/policies/main_test.rego new file mode 100644 index 0000000..4fdab51 --- /dev/null +++ b/integration/ephemerals/policies/main_test.rego @@ -0,0 +1,22 @@ +package tflint +import future.keywords + +mock_ephemeral_resources(type, schema, options) := terraform.mock_ephemeral_resources(type, schema, options, {"main.tf": ` +ephemeral "random_password" "db_password" { + length = 16 + override_special = "!#$%&*()-_=+[]{}<>:?" +}`}) + +test_deny_weak_password_passed if { + issues := deny_weak_password with terraform.ephemeral_resources as mock_ephemeral_resources + + count(issues) == 1 + issue := issues[_] + issue.msg == "Password must be at least 32 characters long" +} + +test_deny_weak_password_failed if { + issues := deny_weak_password with terraform.ephemeral_resources as mock_ephemeral_resources + + count(issues) == 0 +} diff --git a/integration/ephemerals/result.json b/integration/ephemerals/result.json new file mode 100644 index 0000000..57e17f4 --- /dev/null +++ b/integration/ephemerals/result.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "opa_deny_weak_password", + "severity": "error", + "link": "policies/main.rego:5" + }, + "message": "Password must be at least 32 characters long", + "range": { + "filename": "main.tf", + "start": { + "line": 2, + "column": 22 + }, + "end": { + "line": 2, + "column": 24 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integration/ephemerals/result_test.json b/integration/ephemerals/result_test.json new file mode 100644 index 0000000..8fab1a2 --- /dev/null +++ b/integration/ephemerals/result_test.json @@ -0,0 +1,25 @@ +{ + "issues": [ + { + "rule": { + "name": "opa_test_deny_weak_password_failed", + "severity": "error", + "link": "policies/main_test.rego:18" + }, + "message": "test failed", + "range": { + "filename": "", + "start": { + "line": 0, + "column": 0 + }, + "end": { + "line": 0, + "column": 0 + } + }, + "callers": [] + } + ], + "errors": [] +} diff --git a/integration/integration_test.go b/integration/integration_test.go index 0f7eeb4..447e8e5 100644 --- a/integration/integration_test.go +++ b/integration/integration_test.go @@ -247,6 +247,17 @@ func TestIntegration(t *testing.T) { dir: "legacy_rego_syntax", test: true, }, + { + name: "ephemerals", + command: exec.Command("tflint", "--format", "json", "--force"), + dir: "ephemerals", + }, + { + name: "ephemerals (test)", + command: exec.Command("tflint", "--format", "json", "--force"), + dir: "ephemerals", + test: true, + }, } dir, _ := os.Getwd() diff --git a/opa/functions.go b/opa/functions.go index a64d45a..20bf05c 100644 --- a/opa/functions.go +++ b/opa/functions.go @@ -30,6 +30,7 @@ func Functions(runner tflint.Runner) []func(*rego.Rego) { importsFunc(runner).asOption(), checksFunc(runner).asOption(), removedBlocksFunc(runner).asOption(), + ephemeralResourcesFunc(runner).asOption(), moduleRangeFunc(runner).asOption(), issueFunc().asOption(), } @@ -50,6 +51,7 @@ func TesterFunctions(runner tflint.Runner) []*tester.Builtin { importsFunc(runner).asTester(), checksFunc(runner).asTester(), removedBlocksFunc(runner).asTester(), + ephemeralResourcesFunc(runner).asTester(), moduleRangeFunc(runner).asTester(), issueFunc().asTester(), } @@ -72,6 +74,7 @@ func MockFunctions() []func(*rego.Rego) { mockFunction2(importsFunc).asOption(), mockFunction2(checksFunc).asOption(), mockFunction2(removedBlocksFunc).asOption(), + mockFunction3(ephemeralResourcesFunc).asOption(), } } @@ -90,6 +93,7 @@ func TesterMockFunctions() []*tester.Builtin { mockFunction2(importsFunc).asTester(), mockFunction2(checksFunc).asTester(), mockFunction2(removedBlocksFunc).asTester(), + mockFunction3(ephemeralResourcesFunc).asTester(), } } @@ -562,6 +566,36 @@ func removedBlocksFunc(runner tflint.Runner) *function2 { } } +// terraform.ephemeral_resources: resources := terraform.ephemeral_resources(resource_type, schema, options) +// +// Returns Terraform ephemeral resources. +// +// resource_type (string) resource type to retrieve. "*" is a special character that returns all resources. +// schema (schema) schema for attributes referenced in rules. +// options (options) options to change the retrieve/evaluate behavior. +// +// Returns: +// +// resources (array[typed_block]) Terraform "ephemeral" blocks +func ephemeralResourcesFunc(runner tflint.Runner) *function3 { + return &function3{ + function: function{ + Decl: ®o.Function{ + Name: "terraform.ephemeral_resources", + Decl: types.NewFunction( + types.Args(types.S, schemaTy, optionsTy), + types.NewArray(nil, typedBlockTy), + ), + Memoize: true, + Nondeterministic: true, + }, + }, + Func: func(_ rego.BuiltinContext, resourceType *ast.Term, schema *ast.Term, options *ast.Term) (*ast.Term, error) { + return typedBlockFunc(resourceType, schema, options, "ephemeral", runner) + }, + } +} + // terraform.module_range: range := terraform.module_range() // // Returns a range for the current Terraform module. diff --git a/opa/functions_test.go b/opa/functions_test.go index 2a19fc7..123a6d7 100644 --- a/opa/functions_test.go +++ b/opa/functions_test.go @@ -1543,6 +1543,117 @@ variable "foo" {}`, } } +func TestEphemeralResourcesFunc(t *testing.T) { + tests := []struct { + name string + config string + resourceType string + schema map[string]any + options map[string]string + want []map[string]any + }{ + { + name: "ephemeral resource", + config: ` +ephemeral "aws_secretsmanager_secret_version" "db_password" { + secret_id = "secret_id" +}`, + resourceType: "aws_secretsmanager_secret_version", + schema: map[string]any{"secret_id": "string"}, + want: []map[string]any{ + { + "type": "aws_secretsmanager_secret_version", + "name": "db_password", + "config": map[string]any{ + "secret_id": map[string]any{ + "value": "secret_id", + "unknown": false, + "sensitive": false, + "ephemeral": false, + "range": map[string]any{ + "filename": "main.tf", + "start": map[string]int{ + "line": 3, + "column": 15, + "byte": 77, + }, + "end": map[string]int{ + "line": 3, + "column": 26, + "byte": 88, + }, + }, + }, + }, + "decl_range": map[string]any{ + "filename": "main.tf", + "start": map[string]int{ + "line": 2, + "column": 1, + "byte": 1, + }, + "end": map[string]int{ + "line": 2, + "column": 60, + "byte": 60, + }, + }, + }, + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + resourceType, err := ast.InterfaceToValue(test.resourceType) + if err != nil { + t.Fatal(err) + } + schema, err := ast.InterfaceToValue(test.schema) + if err != nil { + t.Fatal(err) + } + options, err := ast.InterfaceToValue(test.options) + if err != nil { + t.Fatal(err) + } + config, err := ast.InterfaceToValue(map[string]string{"main.tf": test.config}) + if err != nil { + t.Fatal(err) + } + want, err := ast.InterfaceToValue(test.want) + if err != nil { + t.Fatal(err) + } + + runner, diags := NewTestRunner(map[string]string{"main.tf": test.config}) + if diags.HasErrors() { + t.Fatal(diags) + } + + ctx := rego.BuiltinContext{} + got, err := ephemeralResourcesFunc(runner).Func(ctx, ast.NewTerm(resourceType), ast.NewTerm(schema), ast.NewTerm(options)) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(want.String(), got.Value.String()); diff != "" { + t.Error(diff) + } + + ctx = rego.BuiltinContext{} + got, err = mockFunction3(ephemeralResourcesFunc).Func(ctx, ast.NewTerm(resourceType), ast.NewTerm(schema), ast.NewTerm(options), ast.NewTerm(config)) + if err != nil { + t.Fatal(err) + } + + if diff := cmp.Diff(want.String(), got.Value.String()); diff != "" { + t.Error(diff) + } + }) + } +} + func TestModuleRangeFunc(t *testing.T) { tests := []struct { name string