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·4 min readIntermediate·By the SecOpsLog team · command-tested

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. Together they turn infrastructure review from a human checklist into a gate that fails the pipeline when policy breaks.

This note wires both scanners into a GitLab pipeline, shows where each tool sits in the plan lifecycle, and explains why org rules belong on plan JSON rather than raw HCL. If Terraform state and modules are still fuzzy, start with Terraform before adding policy layers.

Policy gate on every Terraform change

Static HCL scans run fast on every commit; OPA evaluates the resolved plan so computed values and module outputs are in scope.

1Author changeedit .tf / modules2tfsec / Checkovscan HCL in CI3terraform planproduce tf.plan4plan → JSONterraform show -json5conftest testRego deny rules6MR blocksnon-zero exit7Applyonly after both pass

tfsec: instant value on raw HCL

tfsec runs on the raw HCL and needs zero setup — point it at the directory and it flags the usual suspects. It catches public ACLs, open security groups, unencrypted disks, and dozens of other cloud defaults before you ever run terraform plan. That makes it the right first gate: fast, no cloud credentials, and useful on day one.

bash — tfseclive
tfsec . --format junit --out tfsec.xml
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

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. Pin the scanner image tag the same way you pin Terraform providers; a floating latest on your policy tool is the supply-chain problem you are here to prevent.

.gitlab-ci.yml
policy_scan:
stage: test
image: aquasec/tfsec:v1.28.11
script:
- tfsec . --format junit --out tfsec.xml
artifacts:
reports:
junit: tfsec.xml
rules:
- if: $CI_PIPELINE_SOURCE == "merge_request_event"
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

OPA for rules tfsec cannot know

Built-in scanners do not know that your buckets must block public access and carry a cost-center tag, or that RDS instances in production must use a specific KMS key. Write those as Rego and evaluate them against the plan JSON — that way you catch computed values, not just what is literally in the HCL. A module that sets block_public_acls from a variable only shows up correctly after terraform plan. Store Rego next to the modules it guards and version them together so policy and infrastructure change in the same merge request.

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])
}
# org tag required on every taggable resource
deny[msg] {
r := input.resource_changes[_]
r.change.after.tags
not r.change.after.tags["cost-center"]
msg := sprintf("%s: missing cost-center tag", [r.address])
}
review.sh
terraform init -input=false
terraform plan -out=tf.plan -input=false
terraform show -json tf.plan > plan.json
conftest test plan.json -p policy/ # exits non-zero on any deny
Policy on HCL misses computed values
Running OPA only against .tf files means you evaluate strings, not the resolved plan. Variables, module outputs, and data sources can hide a public bucket until apply. Always test Rego against plan JSON in CI — and keep a unit test fixture with a known-bad plan so refactors do not silently weaken policy.

Where this goes next

Keep the policies in the repo next to the modules they guard, and version them together. When you are 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. The OPA & Rego course covers Conftest, testing Rego, and Gatekeeper end to end.

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

Related posts