InsightsInfrastructure as Code
Policy as code for Terraform plans
Checking Terraform plan JSON with OPA and Conftest in CI, starting with a handful of rules that prevent real damage, and handling exceptions without turning policy into a blocker.
Published August 19, 20255 min read
Code review catches a lot in Terraform changes, but reviewers read the diff of the configuration, not the effect. A one-line change to a module version can replace a database. A variable set in another file can open a security group to the internet. The reviewer sees neither unless they also read the plan, and long plans do not get read carefully.
Policy as code moves the checks that should never depend on a reviewer's attention into the pipeline. The plan is evaluated against rules, and a violation fails the build with a message saying what is wrong and why.
Check the plan, not the code
There are two places to apply policy: the HCL source, or the plan Terraform produces from it. Static scanners such as Checkov and Trivy read source files, and they are useful for broad hygiene. They struggle with anything that depends on variables, module outputs or data sources, because those values are not known until plan time.
The plan has resolved all of that. It lists every resource that will change, the action (create, update, delete, or replace), and the values before and after. Terraform can export it as JSON:
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.jsonThe parts that matter for policy are under resource_changes. Each entry has an address, a type, and a change object with actions, before and after. Values that are not known until apply appear in after_unknown instead. Sensitive values are redacted in this output, which is one more reason to write rules about structure rather than secrets.
Conftest and Rego
Conftest is a small CLI around Open Policy Agent. It evaluates Rego policies against structured files and returns a non-zero exit code on failures, which is all a CI step needs. Rules live in a policy/ directory, in package main by default, and each deny rule produces a message.
The rules below use the Rego v1 syntax that OPA 1.0 made the default, with contains and if.
Rule 1: no unplanned deletion of stateful resources
The most expensive Terraform mistakes are replacements of things that hold data. A changed attribute that forces replacement shows up as a delete followed by a create, and it is easy to miss in a long plan.
package main
stateful_types := {
"aws_db_instance",
"aws_rds_cluster",
"aws_dynamodb_table",
"aws_s3_bucket",
"aws_efs_file_system",
"google_sql_database_instance",
}
deny contains msg if {
rc := input.resource_changes[_]
stateful_types[rc.type]
"delete" in rc.change.actions
msg := sprintf("%s would be deleted or replaced. Stateful resources need an approved exception.", [rc.address])
}Rule 2: no ingress from the internet on admin ports
package main
admin_ports := {22, 3389, 5432, 3306}
deny contains msg if {
rc := input.resource_changes[_]
rc.type == "aws_vpc_security_group_ingress_rule"
after := rc.change.after
after.cidr_ipv4 == "0.0.0.0/0"
some port in admin_ports
after.from_port <= port
after.to_port >= port
msg := sprintf("%s opens port %d to the internet.", [rc.address, port])
}A complete version also covers inline ingress blocks on aws_security_group and the older aws_security_group_rule resource, and IPv6 ::/0.
Rule 3: required tags
Ownership tags make cost allocation and incident routing possible. Checking tags_all includes tags inherited from the provider's default_tags, so teams are not forced to repeat them on every resource:
package main
required_tags := {"team", "service", "environment"}
deny contains msg if {
rc := input.resource_changes[_]
rc.mode == "managed"
some action in rc.change.actions
action in {"create", "update"}
tags := object.get(rc.change.after, "tags_all", null)
tags != null
missing := required_tags - {k | tags[k]}
count(missing) > 0
msg := sprintf("%s is missing tags: %v", [rc.address, missing])
}The tags != null check skips resource types that do not support tags. Without it, the rule would fail on every IAM policy attachment.
Start with a few high-value rules
It is tempting to import a large rule pack on the first day. The usual result is hundreds of findings on existing infrastructure, a policy step marked as allowed to fail, and nobody reading its output again.
Start with rules that meet three tests:
- The failure is expensive. Data loss, public exposure, or a security boundary removed.
- The rule is precise. It almost never fires on a correct change.
- The fix is obvious. The message tells the author what to do.
Deletion of stateful resources, public network exposure, public storage buckets, and IAM policies granting * on * usually make the first list. Naming conventions and instance size limits can wait, and many are better handled by a module that only offers the allowed values.
Write a unit test for each rule. Conftest runs Rego tests with conftest verify, against small plan fragments that should pass and fail. Policy code that is not tested breaks quietly the first time a provider renames an attribute.
An exceptions process
Some violations are legitimate. A database really does need replacing during a planned migration. A bastion really is meant to accept SSH from a vendor's address range. If the only way through is to disable the check, people will disable the check.
Keep exceptions as data, next to the policy, reviewed like code:
exceptions:
- address: module.orders_db.aws_db_instance.this
rule: stateful-delete
reason: Engine upgrade requires replacement, data restored from snapshot
ticket: PLAT-1182
expires: "2025-09-01T00:00:00Z"The rule then skips addresses with a current exception:
deny contains msg if {
rc := input.resource_changes[_]
stateful_types[rc.type]
"delete" in rc.change.actions
not excepted(rc.address, "stateful-delete")
msg := sprintf("%s would be deleted or replaced.", [rc.address])
}
excepted(address, rule) if {
e := data.exceptions[_]
e.address == address
e.rule == rule
time.now_ns() < time.parse_rfc3339_ns(e.expires)
}And CI passes the file as data:
conftest test tfplan.json --policy policy/ --data policy/exceptions.yamlThree properties make this work. Exceptions name a specific resource address, not a whole workspace. Every exception has an expiry, so temporary ones do not become permanent by default. And the exceptions file has its own code owners, usually the platform or security team, so granting one is a visible decision rather than a quiet edit.
Where it runs
Run the check in the same job that produces the plan for review, and post failures to the pull request so the author sees them next to the diff. If you apply from a separate job, the apply should use the saved plan file that was checked, not a fresh plan, or the policy result describes a different change.
HCP Terraform and Terraform Enterprise have built-in policy checks with Sentinel or OPA, and Spacelift and env0 have their own policy engines. The rules above translate to any of them, since all evaluate the same plan data.
Where to start
- Add
terraform show -jsonto the plan job and save the output as a build artifact. - Write the stateful deletion rule and the public ingress rule, with tests, and run them as
warn. - Review two weeks of warnings, fix false positives, and switch them to
deny. - Add the exceptions file with expiry dates and code owners.
- Add one rule at a time after that, each prompted by a real near miss.