Policy-as-code & scanning
OPA/Conftest and Checkov on OpenTofu.
A building inspector does not take your word for it. They read the stamped drawings, then they walk the site and look at what is actually about to be poured. Policy-as-code is that second inspection, written down as machine-checkable rules and run by a robot on every change. No storage bucket reachable from the internet. No admin port open to the world. Every resource carrying an owner tag. Break a rule and the pipeline stops before anything reaches the cloud. HCP Terraform, the hosted service that used to be called Terraform Cloud, ships a policy language called Sentinel, but Sentinel is HashiCorp's own closed product and OpenTofu cannot use it. The open route is OPA (Open Policy Agent, a general-purpose rules engine that answers yes-or-no questions about structured data), driven by a wrapper called Conftest, with a static scanner such as Checkov as the fast first layer.
The two tools are not rivals. What separates them is which document they read. Checkov reads your HCL (HashiCorp Configuration Language, the .tf files you typed) and matches known-bad shapes in the text. Conftest reads the plan, which is OpenTofu's work order for this exact run, produced after every variable, module input, count and cross-resource reference has been resolved into a concrete value. Your code says what you meant. The plan says what will happen. The gap between the two is where trouble hides, and it is the gap an attacker aims for. Nobody sneaks in a line that says public. They point a firewall rule at a value that lives somewhere else and let the pipeline fetch it.
resource "aws_s3_bucket" "logs" {bucket = "acme-logs-prod"}# Looks careful. Two of the four switches are off, and a diff hides that well.resource "aws_s3_bucket_public_access_block" "logs" {bucket = aws_s3_bucket.logs.idblock_public_acls = trueblock_public_policy = falseignore_public_acls = truerestrict_public_buckets = false}# The address range is not in this repository at all.data "aws_ssm_parameter" "admin_cidr" {name = "/net/admin-cidr"}resource "aws_vpc_security_group_ingress_rule" "ssh" {security_group_id = var.bastion_sg_idcidr_ipv4 = nonsensitive(data.aws_ssm_parameter.admin_cidr.value)from_port = 22to_port = 22ip_protocol = "tcp"}
That configuration would survive most code reviews. The S3 (Simple Storage Service) bucket has a public access block attached, which looks responsible, and two of its four switches are on. The two that are off are the ones that matter. block_public_policy rejects any bucket policy that grants access to the world, and restrict_public_buckets stops a bucket that already carries such a policy from serving strangers. With both set to false, anyone who can write a bucket policy can publish your log archive. The other two switches govern ACLs (access control lists, the older per-object permission system that most accounts no longer use).
The SSH rule (Secure Shell, the standard way to log into a Linux machine over the network) is worse, because it carries no address range at all. The attribute is called cidr_ipv4 because firewall rules are written in CIDR notation (Classless Inter-Domain Routing, the a.b.c.d/n way of naming a block of addresses, where /32 means one machine and /0 means all of them). Here that value is read at runtime from AWS Systems Manager Parameter Store, a key-value store that lives in the cloud account rather than in git. Anyone who can edit /net/admin-cidr can widen your firewall without touching this repository, without a pull request, and without a reviewer ever seeing it.
Turn the plan into data
Two commands take you from configuration to something a rules engine can chew on. The first works out what would happen and saves it to a binary file. The second prints that file as JSON (JavaScript Object Notation, a plain-text format built for machines to parse rather than for people to read).
# save the plan; nothing is created yet$ tofu plan -out=tfplan.bin
data.aws_ssm_parameter.admin_cidr: Reading...data.aws_ssm_parameter.admin_cidr: Read complete after 0s [id=/net/admin-cidr]OpenTofu used the selected providers to generate the following execution plan.Resource actions are indicated with the following symbols:+ createOpenTofu will perform the following actions:# aws_s3_bucket.logs will be created+ resource "aws_s3_bucket" "logs" {+ acceleration_status = (known after apply)+ acl = (known after apply)+ arn = (known after apply)+ bucket = "acme-logs-prod"+ bucket_domain_name = (known after apply)+ bucket_prefix = (known after apply)+ bucket_regional_domain_name = (known after apply)+ force_destroy = false+ hosted_zone_id = (known after apply)+ id = (known after apply)+ object_lock_enabled = (known after apply)+ policy = (known after apply)+ region = (known after apply)+ request_payer = (known after apply)+ tags_all = (known after apply)+ website_domain = (known after apply)+ website_endpoint = (known after apply)}# aws_s3_bucket_public_access_block.logs will be created+ resource "aws_s3_bucket_public_access_block" "logs" {+ block_public_acls = true+ block_public_policy = false+ bucket = (known after apply)+ id = (known after apply)+ ignore_public_acls = true+ restrict_public_buckets = false}# aws_vpc_security_group_ingress_rule.ssh will be created+ resource "aws_vpc_security_group_ingress_rule" "ssh" {+ arn = (known after apply)+ cidr_ipv4 = "0.0.0.0/0"+ from_port = 22+ id = (known after apply)+ ip_protocol = "tcp"+ security_group_id = "sg-0a1b2c3d4e5f67890"+ security_group_rule_id = (known after apply)+ tags_all = (known after apply)+ to_port = 22}Plan: 3 to add, 0 to change, 0 to destroy.Saved the plan to: tfplan.binTo perform exactly these actions, run the following command to apply:tofu apply "tfplan.bin"
There it is. The address range the code never spelled out is now the literal string 0.0.0.0/0, which means every address on the internet, because OpenTofu read the parameter while planning. A reviewer staring at the HCL could not have seen that. A rule reading the plan cannot miss it.
$ tofu show -json tfplan.bin > plan.json$ jq -r '.resource_changes[] | "\(.change.actions | join(",")) \(.address)"' plan.json
create aws_s3_bucket.logscreate aws_s3_bucket_public_access_block.logscreate aws_vpc_security_group_ingress_rule.ssh
Every managed resource that will change lands in the resource_changes array, and each entry carries an address, a type, a mode and a change object. Data sources are normally read during the plan, so they are resolved before the array is written and do not appear as changes. There is an exception, and it matters when you write rules: if a data source cannot be read until apply time, OpenTofu defers it and it does show up in resource_changes with mode set to "data" and actions set to ["read"]. So filter on mode == "managed" instead of assuming every entry is a real resource. If you turned on plan encryption in the earlier lesson, none of this changes for you. The tofu show command reads the same key configuration and decrypts on the way out, so Conftest never sees the encrypted form.
# the second entry is the public access block$ jq '.resource_changes[1].change' plan.json
{"actions": ["create"],"before": null,"after": {"block_public_acls": true,"block_public_policy": false,"ignore_public_acls": true,"restrict_public_buckets": false},"after_unknown": {"bucket": true,"id": true},"before_sensitive": false,"after_sensitive": {}}
Three fields carry the weight. actions is a list: ["create"], ["update"], ["delete"], or ["delete","create"] when a resource has to be destroyed and rebuilt. after is the resolved future state. after_unknown marks the attributes OpenTofu cannot work out until apply time, which is why bucket sits there: it points at an identifier that does not exist yet. The mechanical detail to hold onto is that unknown attributes are dropped from after entirely rather than written as null. That hole is the single biggest source of policies that look like they work.
Before you go any further, treat plan.json as a secret. It holds every resolved value, including database passwords and API keys. Marking a variable sensitive only hides it from the human-readable plan, where it prints as (sensitive value); inside plan.json the real string still sits in after, with after_sensitive merely noting that it is secret. Never publish plan.json as a build artifact, never let a bot paste it into a pull-request comment, and delete it when the job ends. Run the plan stage with read-only cloud credentials while you are at it, because planning needs no write access.
Writing rules in Rego
Rego is the language OPA speaks, and the mental model is a complaints box on a factory wall. You define a set called deny, and every rule that matches drops one sentence into the box. Empty box, the change passes. Any sentence at all, and Conftest exits non-zero and the pipeline stops. Rules are unordered and independent, so adding one can never weaken another. That is exactly the property you want in a guardrail.
One syntax note will bite you on an inherited repository. OPA 1.0 changed Rego's default grammar. The old shape, deny[msg] { ... }, no longer parses, and current Conftest releases embed OPA 1.x, so an old policy file dies with a parse error complaining that the contains keyword is missing. The current shape uses contains and if. Adding import rego.v1 at the top keeps one file working on the new engine and on older ones back to OPA 0.59, where that import was introduced. To convert a legacy file wholesale, opa fmt --v0-v1 rewrites it for you.
package main# OPA 1.0 and later parse this syntax by default; the import keeps the# same file working on OPA 0.59 and later 0.x engines too.import rego.v1public_access_switches := {"block_public_acls","block_public_policy","ignore_public_acls","restrict_public_buckets",}# Judge real resources that are being created or changed, nothing else.# A deferred data source read also lands in resource_changes, and# deleting a badly configured bucket is good news, not a violation.is_changing(r) if {r.mode == "managed"some action in r.change.actionsaction in {"create", "update"}}deny contains msg if {some r in input.resource_changesr.type == "aws_s3_bucket_public_access_block"is_changing(r)off := {s | some s in public_access_switches; r.change.after[s] == false}count(off) > 0msg := sprintf("%s leaves public access open: %s", [r.address, concat(", ", sort(off))])}
Read that the way OPA runs it. Walk every entry in resource_changes. Keep the ones of the right type. Keep the managed ones being created or updated. Then build a set of the switches known to be false. If that set has anything in it, write one sentence naming the resource and the switches. The set comprehension is what keeps the output readable: four bad switches produce one clear line instead of four. Notice what the comparison does not catch. A switch missing from after never lands in off, because a missing key is undefined rather than false, and undefined quietly matches nothing. Hold that thought.
package mainimport rego.v1# Ports that should never be reachable from the whole internet.admin_ports := {22, 3389}# Two definitions of one helper mean "either of these counts".# 0.0.0.0/0 is every IPv4 address; ::/0 is every IPv6 address.open_to_world(after) if after.cidr_ipv4 == "0.0.0.0/0"open_to_world(after) if after.cidr_ipv6 == "::/0"deny contains msg if {some r in input.resource_changesr.type == "aws_vpc_security_group_ingress_rule"is_changing(r) # defined in s3.rego, same packageopen_to_world(r.change.after)some port in admin_portsr.change.after.from_port <= portr.change.after.to_port >= portmsg := sprintf("%s exposes port %d to the whole internet", [r.address, port])}
Both files declare package main, so they share everything in it. network.rego calls is_changing without importing anything, the way two chapters of one book share the same characters. That convenience has a price: lift network.rego into another repository on its own and it will not compile, so keep shared helpers in an obviously named file. Now look at the port comparison. A rule that opens 1 to 65535 never mentions 22 anywhere, yet the range covers it. Testing against from_port and to_port catches that, and rules that check a single port for equality are exactly the ones a wide-open range walks straight through. Port 3389 is in the set for the same reason 22 is: it is RDP (Remote Desktop Protocol, the Windows equivalent of SSH).
$ conftest test plan.json --policy policy/$ echo "exit code: $?"
FAIL - plan.json - main - aws_s3_bucket_public_access_block.logs leaves public access open: block_public_policy, restrict_public_bucketsFAIL - plan.json - main - aws_vpc_security_group_ingress_rule.ssh exposes port 22 to the whole internet2 tests, 0 passed, 0 warnings, 2 failures, 0 exceptionsexit code: 1
Exit code 1 is the whole product. Everything else is presentation. Conftest also understands warn rules, which print their message but leave the exit code at 0 unless you pass --fail-on-warn. That is a decent way to land a new rule in a busy repository: ship it as a warning, watch what it catches for a fortnight, then promote it to deny.
# A value nobody can know until apply time is a value no rule can judge.# Say so out loud instead of passing silently.deny contains msg if {some r in input.resource_changesr.type == "aws_vpc_security_group_ingress_rule"is_changing(r)r.change.after_unknown.cidr_ipv4 == truemsg := sprintf("%s has an unknown cidr_ipv4 at plan time, so no rule can check it", [r.address])}
The fast layer that reads your code
Writing every rule yourself would take a year. Static scanners arrive with hundreds already written: unencrypted volumes, permissive IAM (Identity and Access Management, the service that decides who may do what in your cloud account) policies, missing logging, public snapshots. Checkov, Trivy (which absorbed the older tfsec), Terrascan and KICS all parse OpenTofu and Terraform configuration, because the file format is identical, so the tools work unchanged. They read text, touch no cloud account, and finish in seconds, which makes them safe to run against a pull request that arrived from a stranger's fork.
# the Guide URLs are long, so trim them out of the log$ checkov -d . --framework terraform --compact --quiet | grep -v "Guide:"
terraform scan results:Passed checks: 10, Failed checks: 10, Skipped checks: 0Check: CKV_AWS_54: "Ensure S3 bucket has block public policy enabled"FAILED for resource: aws_s3_bucket_public_access_block.logsFile: /main.tf:6-12Check: CKV_AWS_56: "Ensure S3 bucket has 'restrict_public_buckets' enabled"FAILED for resource: aws_s3_bucket_public_access_block.logsFile: /main.tf:6-12Check: CKV_AWS_23: "Ensure every security group and rule has a description"FAILED for resource: aws_vpc_security_group_ingress_rule.sshFile: /main.tf:19-25Check: CKV2_AWS_62: "Ensure S3 buckets should have event notifications enabled"FAILED for resource: aws_s3_bucket.logsFile: /main.tf:1-3Check: CKV2_AWS_61: "Ensure that an S3 bucket has a lifecycle configuration"FAILED for resource: aws_s3_bucket.logsFile: /main.tf:1-3Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"FAILED for resource: aws_s3_bucket.logsFile: /main.tf:1-3Check: CKV_AWS_144: "Ensure that S3 bucket has cross-region replication enabled"FAILED for resource: aws_s3_bucket.logsFile: /main.tf:1-3Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"FAILED for resource: aws_s3_bucket.logsFile: /main.tf:1-3Check: CKV_AWS_145: "Ensure that S3 buckets are encrypted with KMS by default"FAILED for resource: aws_s3_bucket.logsFile: /main.tf:1-3Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"FAILED for resource: aws_s3_bucket.logsFile: /main.tf:1-3
Ten failures across three resources in about two seconds, every one of them with a file and a line range. Most are bucket hygiene you would expect: no versioning, no access logging, no default encryption key. It found both of the bad public-access switches, which is the fast layer doing its job. Now read the security group entry carefully. Checkov flagged aws_vpc_security_group_ingress_rule.ssh for exactly one thing, a missing description. It said nothing whatsoever about the address range, because the address range is not in the text.
Checkov is smarter than plain pattern matching. It renders variable defaults and locals, so if that rule had said cidr_ipv4 = var.admin_cidr with a default of "0.0.0.0/0" declared in the same repository, Checkov resolves the value and fails the check. What it cannot do is follow a value into a data source, another module's output, or a resource attribute that will not exist until apply. Swap the variable for the Parameter Store lookup and the finding disappears. You can watch that boundary move by editing a single line.
Two flags earn their place in CI (Continuous Integration, the robot that runs checks on every push). --compact drops the source snippets so the log stays readable, and --quiet prints only failures. The flag you will reach for and not find is severity gating. Checkov's severity metadata comes from its vendor platform, so filtering on HIGH or CRITICAL wants an API key. Without one it exits 1 on any failure at all, which is at least honest. Manage the noise with explicit --skip-check identifiers a reviewer can argue with, rather than a threshold that quietly drops findings. If you want severity inside the gate, Trivy carries its own and takes --severity HIGH,CRITICAL directly.
Checkov will also read the plan, and this is where the two-layer story stops being about tools at all. Point the same scanner at plan.json, tell it the framework is terraform_plan, and hand it the repository root so it can map findings back to source lines. Here only the check titles are shown, so the two runs sit side by side.
$ checkov -f plan.json --framework terraform_plan \--repo-root-for-plan-enrichment . --compact --quiet \| grep -E "^(Passed|Check:)"
Passed checks: 9, Failed checks: 11, Skipped checks: 0Check: CKV_AWS_54: "Ensure S3 bucket has block public policy enabled"Check: CKV_AWS_56: "Ensure S3 bucket has 'restrict_public_buckets' enabled"Check: CKV_AWS_23: "Ensure every security group and rule has a description"Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"Check: CKV2_AWS_62: "Ensure S3 buckets should have event notifications enabled"Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"Check: CKV_AWS_18: "Ensure the S3 bucket has access logging enabled"Check: CKV_AWS_144: "Ensure that S3 bucket has cross-region replication enabled"Check: CKV_AWS_21: "Ensure all data stored in the S3 bucket have versioning enabled"Check: CKV_AWS_145: "Ensure that S3 buckets are encrypted with KMS by default"Check: CKV2_AWS_61: "Ensure that an S3 bucket has a lifecycle configuration"
Eleven failures instead of ten, and the extra one is CKV_AWS_24: ingress from the whole internet to port 22. Same tool, same rule set, same repository. The only thing that changed is which document it read. That is the argument for gating on the plan, demonstrated without a line of Rego. It is also the reason to keep writing your own rules anyway: Checkov's catalogue covers mistakes everybody makes, while Conftest covers the things only your organisation knows, such as which accounts are allowed to hold customer data. Because of --repo-root-for-plan-enrichment, those plan findings still carry main.tf line ranges, so a reviewer gets a location to click rather than a bare resource address.
Both tools ship an escape hatch, and in both cases the hatch is a review problem rather than a technical one. Checkov honours a #checkov:skip=CKV_AWS_18:reason comment placed inside the resource block. Conftest has exception rules that switch off named policies. Either can be added in the very pull request that introduces the violation it hides, so make suppressions loud enough that somebody notices.
$ grep -rn "checkov:skip" --include='*.tf' .
./modules/rds/main.tf:41: #checkov:skip=CKV_AWS_16:encryption handled by the KMS key policy
One more failure mode is worth knowing because it fails silently in the worst direction. OpenTofu 1.8 and later accept both .tf and .tofu file extensions, and most scanners glob for *.tf only. Rename your files and Checkov parses zero resources, prints nothing, and exits 0. The pipeline goes green because nothing was checked. Assert on the resource count instead of trusting the tick.
$ checkov -d . --framework terraform -o json --quiet \| jq '.summary.resource_count // .resource_count'
3
Three, matching the three managed resources in main.tf, and note that the data source is not counted. Run the same command against a directory holding only main.tofu and it prints 0. The fallback in that jq expression is there for a reason: when Checkov finds nothing to scan it emits a bare summary object with no .summary wrapper, so the obvious query returns null instead of a number and a naive test passes. Wire that count into the gate, or keep your files as .tf unless you genuinely need OpenTofu-only syntax.
One gate, wired into CI
#!/usr/bin/env bash# Stop bad infrastructure before it reaches the cloud.set -euo pipefail# Both files hold resolved values in cleartext. Never leave them behind.trap 'rm -f plan.json tfplan.bin' EXIT# 1. cheap text scan of the code: seconds, no cloud access neededcheckov -d . --framework terraform --compact --quiet \--skip-check CKV_AWS_18 # accepted, tracked in the risk register# 2. prove the scanner actually parsed somethingtest "$(checkov -d . --framework terraform -o json --quiet \| jq '.summary.resource_count // .resource_count')" -gt 0# 3. suppressions are a review decision, so print them every rungrep -rn "checkov:skip" --include='*.tf' . || true# 4. the authoritative gate: your rules against the resolved plantofu plan -out=tfplan.bintofu show -json tfplan.bin > plan.jsonconftest test plan.json --policy policy/ --all-namespaces# 5. apply the exact plan that passed, not a freshly generated onetofu apply tfplan.bin
set -euo pipefail makes any failing command kill the script, which is what turns a non-zero exit from Conftest into a red pipeline. --all-namespaces tells Conftest to evaluate every package it finds rather than only main, so a policy filed under a team namespace still counts. Step 5 is the one people skip. Re-plan after the gate instead of applying tfplan.bin and you have applied something nobody checked, because between the two runs that Parameter Store lookup can return a different answer. When several repositories need the same rules, conftest pull oci://ghcr.io/acme/policies:v3 fetches them from a container registry using OCI (Open Container Initiative, the same standard that defines container image formats), so rules ship and get versioned like any other artifact.
Test the alarm, not only the building
A smoke alarm with a dead battery looks exactly like a working one, right up until there is a fire. A Rego rule with a typo in the resource type looks exactly like a passing gate: it matches nothing, writes no messages, and reports success forever. So unit-test the rules against fake plan input, in both directions, and run conftest verify in the same job that runs the policies.
package mainimport rego.v1# A minimal fake plan holding one public access block resource.pab(switches) := {"resource_changes": [{"address": "aws_s3_bucket_public_access_block.demo","mode": "managed","type": "aws_s3_bucket_public_access_block","change": {"actions": ["create"], "after": switches},}]}all_on := {"block_public_acls": true,"block_public_policy": true,"ignore_public_acls": true,"restrict_public_buckets": true,}test_allows_fully_blocked_bucket if {count(deny) == 0 with input as pab(all_on)}test_denies_open_bucket_policy if {count(deny) == 1 with input as pab(object.union(all_on, {"block_public_policy": false}))}
$ conftest verify --policy policy/
2 tests, 2 passed, 0 warnings, 0 failures, 0 exceptions
Unit tests prove the logic. They do not prove the wiring. Keep one known-bad plan under version control and make the pipeline assert that the real command fails on it. A wrong policy path, a typo in a flag, a job step that swallows the exit code: all of them show up here and nowhere else.
$ conftest test tests/known-bad-plan.json --policy policy/$ echo "exit code: $?"
FAIL - tests/known-bad-plan.json - main - aws_vpc_security_group_ingress_rule.ssh exposes port 22 to the whole internet3 tests, 2 passed, 0 warnings, 1 failure, 0 exceptionsexit code: 1
Store that file next to the policies, regenerate it whenever you add a rule family, and make the job that checks it a required status check on the branch so nobody can merge past a gate that has quietly stopped working. A gate you have watched fail on purpose is the only kind you can trust to fail by accident.
Try this
Run tofu plan -out=tfplan.bin on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: an unknown value is a hole in your gate. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.