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
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}
1package terraform.security
2
3import rego.v1
4
5test_secure_plan_is_accepted if {
6 plan := {
7 "format_version": data.format_version,
8 "terraform_version": data.terraform_version,
9 "resource_changes": data.resource_changes,
10 }
11 results := deny with input as plan
12 count(results) == 0
13}
1"use strict";
2
3const assert = require("node:assert/strict");
4const fs = require("node:fs");
5const path = require("node:path");
6
7const lab = path.resolve(__dirname, "..");
8const read = (relative) => fs.readFileSync(path.join(lab, relative), "utf8");
9const readJson = (relative) => JSON.parse(read(relative));
10
11const secure = readJson("fixtures/secure_plan.json");
12const insecure = readJson("fixtures/insecure_plan.json");
13const unknown = readJson("fixtures/unknown_plan.json");
14const deleted = readJson("fixtures/deleted_control_plan.json");
15
16for (const [name, plan] of Object.entries({ secure, insecure, unknown, deleted })) {
17 assert.equal(plan.format_version, "1.2", `${name}: unexpected plan format`);
18 assert.ok(Array.isArray(plan.resource_changes), `${name}: resource_changes missing`);
19}
20
21const insecureTypes = new Set(insecure.resource_changes.map((item) => item.type));
22assert.ok(insecureTypes.has("aws_s3_bucket_acl"));
23assert.ok(insecureTypes.has("aws_security_group"));
24assert.ok(insecureTypes.has("aws_db_instance"));
25assert.match(
26 insecure.resource_changes.find((item) => item.type === "aws_iam_policy").change.after.policy,
27 /"Action":"\*","Resource":"\*"/,
28);
29
30const unknownDatabase = unknown.resource_changes[0];
31assert.equal(unknownDatabase.change.after_unknown.publicly_accessible, true);
32assert.equal(unknownDatabase.change.after_unknown.storage_encrypted, true);
33assert.deepEqual(deleted.resource_changes[0].change.actions, ["delete"]);
34
35const insecureBackend = read("terraform/insecure/backend.tf");
36assert.match(insecureBackend, /dynamodb_table\s*=/);
37assert.match(insecureBackend, /encrypt\s*=\s*false/);
38assert.doesNotMatch(insecureBackend, /use_lockfile\s*=\s*true/);
39
40const hardenedBackend = read("terraform/hardened/backend.tf");
41assert.match(hardenedBackend, /use_lockfile\s*=\s*true/);
42assert.match(hardenedBackend, /encrypt\s*=\s*true/);
43assert.match(hardenedBackend, /kms_key_id\s*=/);
44assert.doesNotMatch(hardenedBackend, /dynamodb_table\s*=/);
45
46const policy = read("policy/terraform.rego");
47for (const requiredReason of [
48 "public ACL",
49 "public-access block",
50 "server-side encryption",
51 "access logging",
52 "public internet",
53 "publicly accessible",
54 "wildcard action and resource",
55 "unknown at policy evaluation",
56 "deletes a modeled security control",
57]) {
58 assert.match(policy, new RegExp(requiredReason.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")));
59}
60
61console.log("PASS: Terraform backend and plan-policy structural fixtures completed.");
Run it
npm run verify:terraformnpm run verify:opanode 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.6for formatting and offline initialization/validation. - Open Policy Agent
1.17.0for 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.