BlogIaC

Policy-as-code for Terraform: tfsec 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·10 min readIntermediate

A public S3 bucket or an SSH port open to 0.0.0.0/0 is a one-line diff. The cheapest place to catch it is the merge request — not the AWS console at 2am. Two tools cover it: tfsec for the common cloud-misconfig defaults, and OPA for the rules that are specific to your org.

tfsec runs on the raw HCL and needs zero setup — point it at the directory and it flags the usual suspects:

bash — tfseclive
tfsec .
scanning 14 files ...
 
Result 1 CRITICAL S3 bucket has a public ACL
main.tf:23 resource "aws_s3_bucket" "assets"
aws-s3-no-public-access-with-acl
 
Result 2 HIGH Security group rule allows 0.0.0.0/0 on port 22
network.tf:8 resource "aws_security_group" "web"
 
2 potential problems detected — exit 1
Two scanners, two jobs
tfsec / Checkov
Hundreds of built-in rules
Runs on raw HCL
Zero setup, instant value
Cloud-misconfig defaults
OPA / Conftest
Your org-specific rules
Runs on the plan JSON
Rego you write and test
Naming, tagging, cost policy

tfsec in the pipeline

Drop tfsec into a test job and emit JUnit so findings show up inline on the merge request. Fail the pipeline on its exit code — a warning nobody sees is not a control.

.gitlab-ci.yml
policy_scan:
stage: test
image: aquasec/tfsec:latest
script:
- tfsec . --format junit > tfsec.xml
artifacts:
reports:
junit: tfsec.xml

OPA for your own rules

Built-in scanners don't know that your buckets must block public access or that every resource needs a cost-center tag. Write those as Rego and evaluate them against the plan JSON — that way you catch computed values, not just what's literally in the HCL.

policy/s3.rego
package main
# every bucket must have public access fully blocked
deny[msg] {
r := input.resource_changes[_]
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])
}
review.sh
terraform plan -out=tf.plan
terraform show -json tf.plan > plan.json
conftest test plan.json # exits non-zero on any deny
Test against the plan, not the HCL
Running policy on plan JSON means you evaluate the real, resolved values — including anything from variables, modules and data sources. Policy on raw HCL misses everything Terraform computes at plan time.

Where this goes next

Keep the policies in the repo next to the modules they guard, and version them together. When you're ready to enforce at runtime as well as review, the same Rego runs in OPA Gatekeeper as a Kubernetes admission controller — one policy language from plan to cluster.

Go deeper in a courseOPA & RegoRego, Conftest and Gatekeeper — policy-as-code in depth.View course

Related posts