Policy-as-code for Terraform: Trivy, Checkov and OPA in review

Catch public buckets and open security groups in the plan, not in prod. Policies live next to the modules they guard.

Dec 16, 2025·Updated ·6 min readIntermediate·By SecOpsLog · command-tested

A built-in scanner knows that a security group open to 0.0.0.0/0 on port 22 is a finding. It does not know that in this organisation every production RDS instance has to use the key alias/prod-data, that a bucket without a cost-center tag will be unbillable at month end, or that nobody is allowed to destroy a database from a merge request pipeline. Those are policies, not misconfigurations, and they need a rule language and a place to run. The gate that works has two layers: a scanner with hundreds of maintained rules for the first kind, and Conftest running Rego you wrote for the second, both against the same Terraform plan. (tfsec, the scanner older guides put in the first layer, has been folded into Trivy; its own README sends new users there.)

Two layers, two kinds of rule

Trivy config / CheckovConftest + Rego
what it knowscloud misconfigurations: public buckets, open ports, unencrypted volumes, missing loggingwhatever you write: naming, tagging, approved keys, forbidden actions
inputHCL directories, or a plan snapshot / plan JSONplan JSON (terraform show -json)
setupnone; rules ship with the toola policy/ directory, tests, and someone who owns it
maintained bythe vendor, updated with each releaseyou, in the same repository as the modules

Evaluate the plan, not the files

Terraform's JSON plan is the honest input. Every resource appears under resource_changes with its address, the actions Terraform intends (create, update, delete, or both for a replacement) and the after values with variables, locals and module inputs resolved. A rule about block_public_acls written against raw HCL has to guess what var.public resolves to in this environment; against the plan it reads the value. Trivy and Checkov evaluate HCL variables and modules themselves, so the first layer can run on the directory before a plan exists, but the plan is where both layers agree on what will actually be applied.

ci/policy.sh
#!/usr/bin/env bash
set -euo pipefail
terraform init -input=false
terraform plan -input=false -out=tfplan
terraform show -json tfplan > tfplan.json
trivy config --exit-code 1 --severity HIGH,CRITICAL tfplan.json # layer 1: vendor rules on the plan
conftest test tfplan.json --policy policy/ # layer 2: ours; non-zero on any deny

Three rules in Rego v1

policy/terraform.rego
package main
# Rego v1 (OPA 1.x, Conftest 0.69): "contains" and "if" are required, "some ... in" iterates.
changed := [r | some r in input.resource_changes; "no-op" != r.change.actions[0]]
deny contains msg if {
some r in changed
r.type == "aws_s3_bucket_public_access_block"
not r.change.after.block_public_acls
msg := sprintf("%s: public ACLs are not blocked", [r.address])
}
deny contains msg if {
some r in changed
r.type == "aws_db_instance"
startswith(r.change.after.identifier, "prod-")
r.change.after.kms_key_id != data.approved.prod_rds_key
msg := sprintf("%s: production RDS must use %s", [r.address, data.approved.prod_rds_key])
}
deny contains msg if {
some r in changed
tags := r.change.after.tags_all # absent on resources that cannot be tagged
not tags["cost-center"]
not r.change.after_unknown.tags_all # computed at apply: leave it to a post-apply check
msg := sprintf("%s: missing cost-center tag", [r.address])
}
warn contains msg if {
some r in input.resource_changes
"delete" in r.change.actions
r.type in {"aws_db_instance", "aws_s3_bucket", "aws_kms_key"}
msg := sprintf("%s will be destroyed; needs a change ticket, not a merge request", [r.address])
}

The third rule shows the trap in plan-based policy: a value that is only known after apply (a tag built from a resource id, a name generated by a module) is missing from after, in whole or in part, and flagged in after_unknown. A rule that reads only after fails every such resource for a tag it will have. The approved key comes from data, a policy/data.yaml Conftest loads with --data, so the ARN lives in one reviewed file rather than inside the rule. That indirection has a failure mode the run made visible: a job that forgets --data does not error, because comparing against an undefined value makes the rule body undefined, and the wrong key passes with a green pipeline. The policy tests should include one case that depends on data, so conftest verify fails when the file is missing. The warn rule reports without failing: the destroy proceeds, and it proceeds visibly, which for a merge request pipeline is usually the right amount of enforcement; --fail-on-warn turns the same warning into exit 1 for the branches where a destroy must never reach apply unreviewed.

policy/terraform_test.rego
package main
test_public_acls_denied if {
count(deny) == 1 with input as {"resource_changes": [{
"address": "aws_s3_bucket_public_access_block.assets",
"type": "aws_s3_bucket_public_access_block",
"change": {"actions": ["create"], "after": {"block_public_acls": false}, "after_unknown": {}}
}]}
}
test_unknown_tags_not_denied if {
count(deny) == 0 with input as {"resource_changes": [{
"address": "aws_sqs_queue.jobs", "type": "aws_sqs_queue",
"change": {"actions": ["create"], "after": {}, "after_unknown": {"tags_all": true}}
}]}
}
bash — observed: the policy directory tests itself, then a plan with a public bucket, a destroy and a queue whose tags are computedobserved
conftest verify --policy policy/
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions, 0 skipped
conftest test tfplan.json --policy policy/ --data policy/data.yaml; echo exit=$?
WARN - tfplan.json - main - aws_db_instance.reports will be destroyed; needs a change ticket, not a merge request
FAIL - tfplan.json - main - aws_s3_bucket_public_access_block.assets: public ACLs are not blocked
4 tests, 2 passed, 1 warning, 1 failure, 0 exceptions
exit=1
the merge request shows the failure, the pipeline stops before apply. The queue with tags_all only in after_unknown is not denied; with its tags known and empty it is
conftest test tfplan.json --policy policy/; echo exit=$? # --data forgotten
WARN - tfplan.json - main - aws_db_instance.reports will be destroyed; needs a change ticket, not a merge request
4 tests, 3 passed, 1 warning, 0 failures, 0 exceptions
exit=0
this plan had the production RDS on the wrong key: without data.yaml the comparison against data.approved.prod_rds_key is undefined, the rule never fires, and the run is green

The job, and the credentials it has to hold

.gitlab-ci.yml
policy:
stage: test
image: registry.acme.dev/tooling/terraform-policy:1.16-conftest0.69-trivy0.74 # one pinned image, rebuilt on a schedule
id_tokens:
AWS_ID_TOKEN: { aud: https://gitlab.acme.dev }
variables:
AWS_ROLE_ARN: arn:aws:iam::111122223333:role/ci-terraform-plan-readonly
script:
- ./ci/policy.sh
artifacts:
when: always
paths: [tfplan.json]
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"

A plan needs provider credentials: data sources are read at plan time and so is the current state. That makes the policy job the one job in the merge request pipeline that talks to the cloud on behalf of an unreviewed branch, and its role should be able to read and nothing else. A branch can add an external data source or a provider block that runs code during plan; with a read-only role that is a nuisance, with the apply role it is a credential theft.

When the gate is wrong: recovery in order of preference

SituationDoDo not
a rule fires on a resource that is legitimately exemptan exempt list of addresses in data.yaml and one line in the rule (not r.address in data.exempt), added in the same merge request with the ticket in a comment next to the address; conftest verify gets a test for the exemptioncomment the rule out: it stays out
a rule stopped firing after a provider or module changethe known-bad fixture in conftest verify should have caught it; add the case that did not exist, fix the rule, and treat the green pipelines in between as unreviewedassume the estate got cleaner
the policy job cannot run (image, credentials, plan failure)the job fails, and apply needs it (needs: [policy] or a protected-branch rule), so nothing is applied until it runs againallow_failure: true to unblock a release: the layer is now optional and stays so
What was run for this article
Conftest 0.69.0 (the openpolicyagent/conftest:v0.69.0 image, Docker Engine 28.5.2, linux/arm64) with the policy directory exactly as printed here, against a hand-written plan JSON in the documented terraform show -json shape: a public access block with block_public_acls false, a destroy of aws_db_instance.reports, a production RDS on the approved key, an SQS queue with tags_all only in after_unknown, and a no-op bucket. Nine exit codes are asserted: verify, test with and without --data, a wrong-key variant, the queue with known empty tags, and --fail-on-warn. No terraform plan was produced and no cloud provider was contacted; the Trivy layer, the GitLab job and the recovery table are not executed here and follow the documented model.
A policy nobody tests weakens silently
Rego rules rot the same way application code does: a provider release renames an attribute, a module starts computing a value, and a deny rule that used to fire now matches nothing, with a green pipeline as the only signal. Keep a known-bad plan fixture in the policy repository, assert that the rules still fail on it in conftest verify, and treat a rule that stops firing as a bug, not as good news.

Both layers belong to the same review: the vendor rules catch what every account gets wrong, and the Checkov gate is the alternative first layer with its own suppression discipline. The Rego does not stop at the plan. With a different input shape, the same rules run in Gatekeeper at admission time, so a naming or tagging policy written once for Terraform reaches the cluster without being written twice.

Related posts

Quick reference