OPA policies for Terraform
Rego rules that block public buckets and open ingress.
Every building site has an inspector. If the rules live only in that inspector's head, you get decisions that drift with their mood, arguments you win by being the loudest person in the room, and a rulebook that walks out the door the day they quit. Write the rules down and everything changes. Anyone can read them, a disagreement is about the text instead of the person, and you can check a wall against them before the concrete sets. **Policy as code** is that written rulebook for your infrastructure: the rules your company already has, in a form a machine can evaluate, so a program decides whether a change is allowed. Same answer at 5 p.m. on a Friday as at 10 a.m. on a Tuesday.
**Open Policy Agent (OPA)** is the program that reads the rulebook. It is a general purpose policy engine and a graduated project at the Cloud Native Computing Foundation, the same foundation that looks after Kubernetes. OPA knows nothing about Terraform, or Amazon Web Services (AWS), or containers. You hand it a JSON document (JavaScript Object Notation, the plain text format tools use to pass structured data around), it evaluates that document against rules written in **Rego** (OPA's query language, pronounced ray-go), and it hands back a decision. **Conftest** is the small command line tool that does the plumbing. It reads your file, hands it to OPA under the name `input`, loads every `.rego` file under `policy/`, and exits non-zero when a rule fires. That exit code is the whole enforcement mechanism. Every CI system (continuous integration, the automated checks that run on each proposed change) already knows how to fail a job when a command exits non-zero.
Why the Ready-Made Scanners Stop Short
Trivy (which absorbed tfsec) and Checkov ship hundreds of ready-made checks for the mistakes everybody makes: world-readable buckets, unencrypted disks, wildcard IAM (Identity and Access Management, the service that decides who can do what in a cloud account) policies. Run them. What a fixed catalogue cannot contain is *your* rules. Every resource carries an `owner` tag. Databases live only in `eu-central-1`. Nobody outside the platform team creates IAM roles. Rules like those stay tribal knowledge until a tired reviewer approves the plan that breaks one.
There is a second gap, and it cuts deeper. Catalogue scanners mostly judge one resource at a time, and the most dangerous cloud configurations are about what is *missing*. A bucket is rarely dangerous because of an attribute on the bucket. It is dangerous because nothing standing next to it says never make this public.
Turn the Plan Into a Document
Terraform will export its plan as JSON, and that export is the whole integration. `terraform plan -out` writes a binary plan file, a frozen decision about what Terraform intends to do. `terraform show -json` turns that file into text you can read, diff and query. Building the plan needs read access to the cloud account, because Terraform refreshes state first. Grading it needs nothing at all. The JSON is self-contained, evaluation happens offline, and the same plan always produces the same verdict. Here is a four-resource plan, trimmed below to the one resource we are going to argue about.
terraform plan -out=tfplan.binary
Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:+ createTerraform will perform the following actions:# aws_security_group_rule.allow_ssh will be created+ resource "aws_security_group_rule" "allow_ssh" {+ cidr_blocks = [+ "0.0.0.0/0",]+ from_port = 22+ id = (known after apply)+ protocol = "tcp"+ security_group_id = "sg-0a1b2c3d4e5f67890"+ security_group_rule_id = (known after apply)+ self = false+ source_security_group_id = (known after apply)+ to_port = 22+ type = "ingress"}Plan: 4 to add, 0 to change, 0 to destroy.Saved the plan to: tfplan.binaryTo perform exactly these actions, run the following command to apply:terraform apply "tfplan.binary"
Two parts of that document matter. `resource_changes` is an array with one entry per resource Terraform intends to touch: the `address` (`aws_s3_bucket.data`), the `type`, and a `change` object holding `actions` (`create`, `update`, `delete`, `no-op`), the current values in `before`, the planned values in `after`, and a map called `after_unknown` that marks everything Terraform cannot work out until apply time. A replacement arrives as `["delete", "create"]`, which is why testing for `create` catches rebuilds as well as brand new resources. The second part is `configuration`, your parsed source code, including which resource references which. Most policy examples online use only the first. You need both.
terraform show -json tfplan.binary > tfplan.jsonjq -c '.resource_changes[] | {address, type, actions: .change.actions}' tfplan.json
{"address":"aws_ebs_volume.scratch","type":"aws_ebs_volume","actions":["create"]}{"address":"aws_instance.web","type":"aws_instance","actions":["create"]}{"address":"aws_s3_bucket.data","type":"aws_s3_bucket","actions":["create"]}{"address":"aws_security_group_rule.allow_ssh","type":"aws_security_group_rule","actions":["create"]}
A Rule That Closes Port 22 to the Internet
A Rego rule is a query, not a script. Read `deny contains msg if { ... }` the way you would read a request to a librarian: find every combination of values that makes all the lines in the body true, and for each one, put `msg` in a set called `deny`. The lines are conditions joined by AND. Their order does not matter, there are no loops, and nothing is ever reassigned. You write `some rc in input.resource_changes` and OPA tries every element for you. OPA 1.0 made the `if` and `contains` keywords compulsory; the `import rego.v1` line keeps the same file compiling on the 0.x builds still baked into plenty of Conftest images. Conftest looks for rules named `deny`, `violation` and `warn` in `package main` by default, and every file in that package feeds the same `deny` set.
package mainimport rego.v1open_to_everyone := {"0.0.0.0/0", "::/0"}# A port range only means something for a specific protocol. "-1" is AWS for# "every protocol", and then from_port / to_port are ignored entirely.covers_port(rule, port) if {rule.from_port <= portrule.to_port >= port}covers_port(rule, _) if rule.protocol == "-1"covers_port(rule, _) if rule.ip_protocol == "-1"# Shape 1: standalone rule, still the most common thing in the wildssh_open_to_everyone(rc) if {rc.type == "aws_security_group_rule"rc.change.after.type == "ingress"some cidr in rc.change.after.cidr_blockscidr in open_to_everyonecovers_port(rc.change.after, 22)}# Shape 2: the newer per-CIDR resource, one rule holds one rangessh_open_to_everyone(rc) if {rc.type == "aws_vpc_security_group_ingress_rule"rc.change.after.cidr_ipv4 in open_to_everyonecovers_port(rc.change.after, 22)}# Shape 3: inline ingress block on the security group itselfssh_open_to_everyone(rc) if {rc.type == "aws_security_group"some ingress in rc.change.after.ingresssome cidr in ingress.cidr_blockscidr in open_to_everyonecovers_port(ingress, 22)}deny contains msg if {some rc in input.resource_changes"create" in rc.change.actionsssh_open_to_everyone(rc)msg := sprintf("%s opens port 22 to the whole internet", [rc.address])}deny contains msg if {some rc in input.resource_changes"create" in rc.change.actionsrc.type in {"aws_instance", "aws_db_instance", "aws_s3_bucket"}not rc.change.after.tags.ownermsg := sprintf("%s has no owner tag", [rc.address])}
Three resource types, one question. The AWS provider has changed how an ingress rule (ingress means traffic coming in from outside) is written twice, and real repositories contain all three shapes at once: an inline `ingress` block inside `aws_security_group`, a standalone `aws_security_group_rule`, and the newer `aws_vpc_security_group_ingress_rule`, which takes a single range in `cidr_ipv4` (CIDR is the `10.0.0.0/16` notation for a block of IP addresses) instead of a list in `cidr_blocks`. A policy that knows only the middle shape is a fence with a gate-shaped hole in it. Port 22 is SSH (Secure Shell, the protocol engineers use to log into a machine and run commands as themselves), which is why it is the port worth guarding.
`covers_port` is defined three times on purpose. Several definitions of one Rego function are OR'd together, and the last two exist because `protocol = "-1"` is AWS shorthand for every protocol on every port, which turns the `from_port` and `to_port` values (conventionally 0 and 0) into a lie. The second definition reads `protocol`, the third reads `ip_protocol`, because the newer resource renamed the field.
The second `deny` rule shows the habit that matters most in security policy: **deny on absence**. `not rc.change.after.tags.owner` is true when the tag is missing, when `tags` is `null`, and when nobody wrote a `tags` block at all. The resource has to prove it is compliant rather than merely dodge a known-bad value. Now run the gate.
conftest test tfplan.json --policy policy/echo "exit=$?"
FAIL - tfplan.json - main - aws_instance.web has no owner tagFAIL - tfplan.json - main - aws_security_group_rule.allow_ssh opens port 22 to the whole internet2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptionsexit=1
Public Buckets Are a Missing Neighbor Problem
Since version 4 of the AWS provider, one bucket is not one resource. `aws_s3_bucket` holds the name and the tags. The pieces that decide who can read it live in siblings: `aws_s3_bucket_acl`, `aws_s3_bucket_policy`, and the one that actually saves you, `aws_s3_bucket_public_access_block`. Think of it as a deadbolt fitted to the door frame instead of a note stuck on the door. Its four booleans tell S3 (Simple Storage Service, Amazon's object storage) to refuse public grants whatever anyone attaches later. `block_public_acls` and `block_public_policy` reject new public ACLs (access control lists, the older per-object permission system) and bucket policies as they are submitted. `ignore_public_acls` ignores any public ACL already attached. `restrict_public_buckets` cuts anonymous and cross-account access off from a bucket that already carries a public policy, leaving only the owning account and AWS services. An attacker does not need to break your Terraform. They need one hurried teammate attaching a bucket policy with `"Principal": "*"` to unblock a demo. With the block on, that API call is rejected.
AWS has switched Block Public Access on by default for new buckets since April 2023, and disabled ACLs on new buckets at the same time. That helps. The setting can still be turned off, older buckets predate it, and a default you never wrote down is a default you cannot show an auditor. So make the plan state it, and make the policy demand it.
package mainimport rego.v1# Every bucket address that a fully closed public access block points at.# This reads `configuration`, not `resource_changes`, because at plan time the# block's `bucket` attribute is (known after apply): the bucket has no id yet.locked_down_buckets contains ref if {some res in input.configuration.root_module.resourcesres.type == "aws_s3_bucket_public_access_block"res.expressions.block_public_acls.constant_value == trueres.expressions.block_public_policy.constant_value == trueres.expressions.ignore_public_acls.constant_value == trueres.expressions.restrict_public_buckets.constant_value == truesome ref in res.expressions.bucket.references}deny contains msg if {some rc in input.resource_changesrc.type == "aws_s3_bucket""create" in rc.change.actionsaddr := split(rc.address, "[")[0] # drop any count / for_each indexnot addr in locked_down_bucketsmsg := sprintf("%s has no public access block, so an ACL or bucket policy can still make it public", [rc.address])}# The loud version of the same mistakedeny contains msg if {some rc in input.resource_changesrc.type == "aws_s3_bucket_acl"rc.change.after.acl in {"public-read", "public-read-write"}msg := sprintf("%s sets a public canned ACL (%s)", [rc.address, rc.change.after.acl])}
`locked_down_buckets` collects every bucket address that a fully closed public access block points at, and it reads `configuration` for a reason. At plan time the block's `bucket` attribute is usually `(known after apply)`, because the bucket does not exist yet and has no id. Your source code has no such doubt, and Terraform records it. `references` lists both `aws_s3_bucket.data.id` and the shorter `aws_s3_bucket.data`, and that short form is exactly what `resource_changes` calls the resource. `split(rc.address, "[")[0]` drops the `[0]` that `count` and `for_each` append to an address. And if a colleague writes `block_public_acls = var.locked`, there is no `constant_value`, the condition is false, and the bucket is denied. That is the right direction to fail: unclear means blocked.
conftest test tfplan.json --policy policy/
FAIL - tfplan.json - main - aws_instance.web has no owner tagFAIL - tfplan.json - main - aws_s3_bucket.data has no public access block, so an ACL or bucket policy can still make it publicFAIL - tfplan.json - main - aws_security_group_rule.allow_ssh opens port 22 to the whole internet3 tests, 0 passed, 0 warnings, 3 failures, 0 exceptions
Both files declare `package main`, so their rules pile into one `deny` set and one report. Fix the findings the way you would fix a failing test: add the block below, narrow the security group rule to your office range, and put `owner = "platform-team"` on the instance. Then prove it.
resource "aws_s3_bucket_public_access_block" "data" {bucket = aws_s3_bucket.data.idblock_public_acls = trueblock_public_policy = trueignore_public_acls = truerestrict_public_buckets = true}
terraform plan -out=tfplan.binary > /dev/nullterraform show -json tfplan.binary > tfplan.jsonconftest test tfplan.json --policy policy/echo "exit=$?"
1 test, 1 passed, 0 warnings, 0 failures, 0 exceptionsexit=0
A gate can also pass for the wrong reason. Values Terraform cannot compute until apply time are not in `after` at all. They sit in `after_unknown` instead, and a rule that compares one of them against something is comparing against nothing.
jq '.resource_changes[]| select(.type == "aws_ebs_volume")| .change| {after: .after, unknown: .after_unknown}' tfplan.json
{"after": {"availability_zone": "eu-central-1a","multi_attach_enabled": false,"size": 100,"tags": null,"type": "gp3"},"unknown": {"arn": true,"encrypted": true,"id": true,"iops": true,"kms_key_id": true,"snapshot_id": true,"tags_all": true,"throughput": true}}
Test the Policy, Because It Fails Open
A Rego rule with a typo in a resource type does not throw an error. It matches nothing, every plan passes, and nobody hears a thing for months. Silence from a policy engine looks identical whether you are compliant or broken. That is why Rego gets unit tests like any other production code. Tests are ordinary rules named `test_*` that pin `input` to a hand-built fixture using the `with` keyword, and you write both directions for every rule: one plan that must be denied, one that must pass.
package mainimport rego.v1bucket_change := {"resource_changes": [{"address": "aws_s3_bucket.data","type": "aws_s3_bucket","change": {"actions": ["create"],"after": {"bucket": "acme-analytics-raw", "tags": {"owner": "platform-team"}},},}]}closed_block := {"root_module": {"resources": [{"address": "aws_s3_bucket_public_access_block.data","type": "aws_s3_bucket_public_access_block","expressions": {"bucket": {"references": ["aws_s3_bucket.data.id", "aws_s3_bucket.data"]},"block_public_acls": {"constant_value": true},"block_public_policy": {"constant_value": true},"ignore_public_acls": {"constant_value": true},"restrict_public_buckets": {"constant_value": true},},}]}}test_bucket_without_public_access_block_is_denied if {plan := object.union(bucket_change, {"configuration": {"root_module": {"resources": []}}})count(deny) == 1 with input as plan}test_bucket_with_public_access_block_is_allowed if {plan := object.union(bucket_change, {"configuration": closed_block})count(deny) == 0 with input as plan}
opa test policy/ -v
data.main.test_ssh_open_to_everyone_is_denied: PASS (1.24ms)data.main.test_ssh_from_the_office_is_allowed: PASS (712.6µs)data.main.test_bucket_without_public_access_block_is_denied: PASS (1.03ms)data.main.test_bucket_with_public_access_block_is_allowed: PASS (688.1µs)--------------------------------------------------------------------------------PASS: 4/4
`opa test` runs anything named `test_*` in the packages it loads, which is why the two network tests from the sibling file turn up in the same run. Conftest can run those tests without a second binary in your image, using `conftest verify --policy policy/`, and that is the version that belongs in CI. The fixture that hands the bucket an `owner` tag is not decoration: leave it out and the tag rule fires too, so `count(deny) == 1` fails for a reason that has nothing to do with S3. The negative test matters more than it looks. It is the one that breaks when you tighten a rule until it blocks everything, which is the other way policy engines get deleted.
One trap to close before it bites you. `input.configuration.root_module.resources` covers the root module only. Wrap that bucket in a module and the plan calls it `module.storage.aws_s3_bucket.data`, while the configuration files it under `module_calls.storage.module.resources` with the bare address. The cross-resource match quietly stops matching, and a rule that matches nothing is a pass. Walk `module_calls` recursively, and keep a module-shaped fixture in your tests so nobody can break that walk in silence.
Put the Gate Where the Credentials Are
A policy check on a pull request stops nothing if an engineer can run `terraform apply` from a laptop with their own admin keys. The gate has to sit on the only road into production. Take write credentials away from humans, hand them to the pipeline identity, and the plan that gets graded becomes the plan that gets applied. `set -euo pipefail` does the rest, because any non-zero exit ends the job.
#!/usr/bin/env bashset -euo pipefail# 1. unit-test the rules before trusting anything they sayconftest verify --policy policy/# 2. build the plan documentterraform plan -out=tfplan.binaryterraform show -json tfplan.binary > tfplan.json# 3. grade it: any deny message exits 1 and fails the job# --all-namespaces also picks up policies outside package mainconftest test tfplan.json --policy policy/ --all-namespaces
Ship the rules like software. Keep them in their own repository with owners and tagged releases, and let pipelines pull a pinned version instead of copying `.rego` files between repos. `conftest push ghcr.io/acme/tf-policies:1.4.0` publishes a bundle to any OCI registry (Open Container Initiative, the same registry format container images use) and `conftest pull` fetches it back. Introduce every new rule as `warn` first, which reports without failing unless you pass `--fail-on-warn`, watch how often it would have fired, then promote it to `deny`. Conftest's `exception` rules are the auditable escape hatch, which is why the summary line counts exceptions separately from failures. Run `opa fmt -w policy/` and `regal lint policy/` on the way in. HashiCorp Sentinel does a similar job, but it is proprietary and tied to HCP Terraform (HashiCorp's hosted service, formerly Terraform Cloud), which now runs OPA policy sets too.
One more thing worth doing this week. Run the gate against `main` on a schedule, not only on pull requests. A rule you write today does not retroactively fail the buckets you shipped last year, and nothing else is going to tell you how many of those there are. A nightly `conftest test` over a fresh plan gives you that number, and the number is the only honest measure of whether the policy is doing anything.