Skip to content

Terraform plan and Rego policy lab

Exercises secure, insecure, unknown, and deleted-control Terraform plan states through local policy fixtures.

DevSecOps 2 min read

Implementation: Partially tested

Implementation

terraform.rego Rego · 157 lines
labs/iac-policy/policy/terraform.rego
1package terraform.security
2 
3import rego.v1
4 
5active(resource) if {
6	not "delete" in resource.change.actions
7}
8 
9deleted(resource) if {
10	"delete" in resource.change.actions
11}
12 
13has_bucket_control(resource_type, bucket) if {
14	some control in input.resource_changes
15	control.type == resource_type
16	active(control)
17	control.change.after.bucket == bucket
18}
19 
20has_required_tags(resource) if {
21	is_object(resource.change.after.tags)
22	is_string(resource.change.after.tags.Environment)
23	resource.change.after.tags.Environment != ""
24	is_string(resource.change.after.tags.Owner)
25	resource.change.after.tags.Owner != ""
26}
27 
28wildcard(value) if {
29	is_string(value)
30	value == "*"
31}
32 
33wildcard(value) if {
34	is_array(value)
35	"*" in value
36}
37 
38deny contains {
39	"address": resource.address,
40	"reason": "S3 bucket has a public ACL",
41} if {
42	some resource in input.resource_changes
43	resource.type == "aws_s3_bucket_acl"
44	active(resource)
45	resource.change.after.acl in {"public-read", "public-read-write", "authenticated-read"}
46}
47 
48deny contains {
49	"address": resource.address,
50	"reason": "S3 bucket is missing a complete public-access block",
51} if {
52	some resource in input.resource_changes
53	resource.type == "aws_s3_bucket"
54	active(resource)
55	not has_bucket_control("aws_s3_bucket_public_access_block", resource.change.after.bucket)
56}
57 
58deny contains {
59	"address": resource.address,
60	"reason": "S3 bucket is missing server-side encryption configuration",
61} if {
62	some resource in input.resource_changes
63	resource.type == "aws_s3_bucket"
64	active(resource)
65	not has_bucket_control("aws_s3_bucket_server_side_encryption_configuration", resource.change.after.bucket)
66}
67 
68deny contains {
69	"address": resource.address,
70	"reason": "S3 bucket is missing access logging configuration",
71} if {
72	some resource in input.resource_changes
73	resource.type == "aws_s3_bucket"
74	active(resource)
75	not has_bucket_control("aws_s3_bucket_logging", resource.change.after.bucket)
76}
77 
78deny contains {
79	"address": resource.address,
80	"reason": "security group exposes ingress to the public internet",
81} if {
82	some resource in input.resource_changes
83	resource.type == "aws_security_group"
84	active(resource)
85	some ingress in resource.change.after.ingress
86	some cidr in ingress.cidr_blocks
87	cidr in {"0.0.0.0/0", "::/0"}
88}
89 
90deny contains {
91	"address": resource.address,
92	"reason": "database is publicly accessible",
93} if {
94	some resource in input.resource_changes
95	resource.type == "aws_db_instance"
96	active(resource)
97	resource.change.after.publicly_accessible == true
98}
99 
100deny contains {
101	"address": resource.address,
102	"reason": "database storage encryption is disabled",
103} if {
104	some resource in input.resource_changes
105	resource.type == "aws_db_instance"
106	active(resource)
107	resource.change.after.storage_encrypted != true
108	not resource.change.after_unknown.storage_encrypted
109}
110 
111deny contains {
112	"address": resource.address,
113	"reason": "security-relevant database value is unknown at policy evaluation",
114} if {
115	some resource in input.resource_changes
116	resource.type == "aws_db_instance"
117	active(resource)
118	some field in {"publicly_accessible", "storage_encrypted"}
119	resource.change.after_unknown[field] == true
120}
121 
122deny contains {
123	"address": resource.address,
124	"reason": "IAM policy allows wildcard action and resource",
125} if {
126	some resource in input.resource_changes
127	resource.type == "aws_iam_policy"
128	active(resource)
129	document := json.unmarshal(resource.change.after.policy)
130	some statement in document.Statement
131	statement.Effect == "Allow"
132	wildcard(statement.Action)
133	wildcard(statement.Resource)
134}
135 
136deny contains {
137	"address": resource.address,
138	"reason": "authorization-driving Environment and Owner tags are required",
139} if {
140	some resource in input.resource_changes
141	resource.type in {"aws_s3_bucket", "aws_security_group", "aws_db_instance", "aws_iam_policy"}
142	active(resource)
143	not has_required_tags(resource)
144}
145 
146deny contains {
147	"address": resource.address,
148	"reason": "plan deletes a modeled security control",
149} if {
150	some resource in input.resource_changes
151	resource.type in {
152		"aws_s3_bucket_public_access_block",
153		"aws_s3_bucket_server_side_encryption_configuration",
154		"aws_s3_bucket_logging",
155	}
156	deleted(resource)
157}

Run it

  • npm run verify:terraform
  • npm run verify:opa
  • node labs/iac-policy/tests/run-tests.js

This lab demonstrates why source scanning, plan evaluation, provider-side controls, and drift monitoring are separate layers. It does not deploy infrastructure.

Tested scope

  • Terraform CLI 1.14.6 for formatting and offline initialization/validation.
  • Open Policy Agent 1.17.0 for Rego v1 unit tests and fixture evaluation.
  • Terraform plan JSON fixtures use format_version: "1.2" and intentionally model only the resource shapes consumed by the policy.

Run the dependency-free structural tests:

node labs/iac-policy/tests/run-tests.js

Run the native policy tests against each positive or negative serialized-plan fixture:

opa test labs/iac-policy/policy/terraform.rego labs/iac-policy/policy/secure_fixture_test.rego labs/iac-policy/fixtures/secure_plan.json -v
opa test labs/iac-policy/policy/terraform.rego labs/iac-policy/policy/insecure_fixture_test.rego labs/iac-policy/fixtures/insecure_plan.json -v
opa test labs/iac-policy/policy/terraform.rego labs/iac-policy/policy/unknown_fixture_test.rego labs/iac-policy/fixtures/unknown_plan.json -v
opa test labs/iac-policy/policy/terraform.rego labs/iac-policy/policy/deleted_fixture_test.rego labs/iac-policy/fixtures/deleted_control_plan.json -v

Validate the two backend examples without contacting AWS:

terraform -chdir=labs/iac-policy/terraform/insecure init -backend=false
terraform -chdir=labs/iac-policy/terraform/insecure validate
terraform -chdir=labs/iac-policy/terraform/hardened init -backend=false
terraform -chdir=labs/iac-policy/terraform/hardened validate

Evidence and negative cases

fixtures/secure_plan.json is accepted. The negative fixtures demonstrate:

  • public object-storage configuration and absent public-access controls;
  • absent encryption and access logging;
  • unrestricted network ingress;
  • a public, unencrypted database;
  • wildcard IAM permissions;
  • missing authorization-driving tags;
  • security-relevant values that are unknown at policy-evaluation time; and
  • deletion of a public-access control.

The Rego policy fails closed for the modeled unknown values. That is a policy choice: some organizations instead defer a decision until values are known. If you defer the decision, whatever checks it later still has to block until it's actually known.

Backend comparison

The insecure example embeds a deprecated DynamoDB lock table and does not opt into S3 lockfile locking, version recovery, or KMS encryption. The hardened example uses use_lockfile = true, an explicit KMS key, and documents the S3 permissions required for the state and .tflock objects. Bucket versioning, access logging, and the KMS key are bootstrap controls outside the backend block and must be enforced separately.

sensitive = true affects CLI/UI display; it does not remove a value from state. State readers should therefore be treated as readers of potentially sensitive data.

Limitations

  • The plan fixtures are reviewed test inputs, not output from a live cloud account.
  • This policy covers a deliberately small AWS resource set. It is not a complete cloud security baseline.
  • Terraform validation checks syntax and internal consistency. It does not prove that backend permissions, KMS policy, provider behavior, or runtime state match the design.
  • OPA plan evaluation cannot replace service control policies, cloud configuration rules, posture management, or drift investigation.

References