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.
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 / Checkov | Conftest + Rego | |
|---|---|---|
| what it knows | cloud misconfigurations: public buckets, open ports, unencrypted volumes, missing logging | whatever you write: naming, tagging, approved keys, forbidden actions |
| input | HCL directories, or a plan snapshot / plan JSON | plan JSON (terraform show -json) |
| setup | none; rules ship with the tool | a policy/ directory, tests, and someone who owns it |
| maintained by | the vendor, updated with each release | you, 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.
#!/usr/bin/env bashset -euo pipefailterraform init -input=falseterraform plan -input=false -out=tfplanterraform show -json tfplan > tfplan.jsontrivy config --exit-code 1 --severity HIGH,CRITICAL tfplan.json # layer 1: vendor rules on the planconftest test tfplan.json --policy policy/ # layer 2: ours; non-zero on any deny
Three rules in Rego v1
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 changedr.type == "aws_s3_bucket_public_access_block"not r.change.after.block_public_aclsmsg := sprintf("%s: public ACLs are not blocked", [r.address])}deny contains msg if {some r in changedr.type == "aws_db_instance"startswith(r.change.after.identifier, "prod-")r.change.after.kms_key_id != data.approved.prod_rds_keymsg := sprintf("%s: production RDS must use %s", [r.address, data.approved.prod_rds_key])}deny contains msg if {some r in changedtags := r.change.after.tags_all # absent on resources that cannot be taggednot tags["cost-center"]not r.change.after_unknown.tags_all # computed at apply: leave it to a post-apply checkmsg := sprintf("%s: missing cost-center tag", [r.address])}warn contains msg if {some r in input.resource_changes"delete" in r.change.actionsr.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.
package maintest_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}}}]}}
conftest verify --policy policy/2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions, 0 skippedconftest 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 requestFAIL - tfplan.json - main - aws_s3_bucket_public_access_block.assets: public ACLs are not blocked4 tests, 2 passed, 1 warning, 1 failure, 0 exceptionsexit=1the 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 isconftest test tfplan.json --policy policy/; echo exit=$? # --data forgottenWARN - tfplan.json - main - aws_db_instance.reports will be destroyed; needs a change ticket, not a merge request4 tests, 3 passed, 1 warning, 0 failures, 0 exceptionsexit=0this 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 greenThe job, and the credentials it has to hold
policy:stage: testimage: registry.acme.dev/tooling/terraform-policy:1.16-conftest0.69-trivy0.74 # one pinned image, rebuilt on a scheduleid_tokens:AWS_ID_TOKEN: { aud: https://gitlab.acme.dev }variables:AWS_ROLE_ARN: arn:aws:iam::111122223333:role/ci-terraform-plan-readonlyscript:- ./ci/policy.shartifacts:when: alwayspaths: [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
| Situation | Do | Do not |
|---|---|---|
| a rule fires on a resource that is legitimately exempt | an 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 exemption | comment the rule out: it stays out |
| a rule stopped firing after a provider or module change | the 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 unreviewed | assume 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 again | allow_failure: true to unblock a release: the layer is now optional and stays so |
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.