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.
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.
Static HCL scans run fast on every commit; OPA evaluates the resolved plan so computed values and module outputs are in scope.
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.
tfsec . --format junit --out tfsec.xmlscanning 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 1Drop 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.
policy_scan:stage: testimage: aquasec/tfsec:v1.28.11script:- tfsec . --format junit --out tfsec.xmlartifacts:reports:junit: tfsec.xmlrules:- if: $CI_PIPELINE_SOURCE == "merge_request_event"
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.
package main# every bucket must have public access fully blockeddeny[msg] {r := input.resource_changes[_]r.type == "aws_s3_bucket_public_access_block"not r.change.after.block_public_aclsmsg := sprintf("%s: public ACLs are not blocked", [r.address])}# org tag required on every taggable resourcedeny[msg] {r := input.resource_changes[_]r.change.after.tagsnot r.change.after.tags["cost-center"]msg := sprintf("%s: missing cost-center tag", [r.address])}
terraform init -input=falseterraform plan -out=tf.plan -input=falseterraform show -json tf.plan > plan.jsonconftest test plan.json -p policy/ # exits non-zero on any deny
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