Policy-as-Code for Terraform: Catch Cloud Misconfigurations Before They Ship

S
Simon Melotte
October 15, 2026 10 min read

Every cloud team I talk to has some kind of IaC scanner. Far fewer have one that engineers actually trust. The usual story: someone adds a scanner to the pipeline, it produces 200 findings on day one, the job gets switched to "soft fail", and six months later nobody reads the output anymore.

The tooling is not the problem. The problem is treating policy-as-code as a checkbox instead of a product your engineers consume. Here is the pattern I keep coming back to: an off-the-shelf scanner covers the baseline, and a small set of your own policies, evaluated against the Terraform plan, encodes what actually matters to your organisation.

Everything below is runnable. I tested it with Terraform 1.16, Checkov 3.x and Conftest 0.71 (which embeds OPA 1.x).

Why the plan, and not just the code?

Most IaC scanners read your .tf files directly. That is fast and works before you even have credentials, which is why it belongs early in the pipeline. But static HCL has blind spots:

  • Variables and modules. A security group rule built from a variable, or buried three modules deep, may not be fully resolvable from the source alone.
  • Provider-level behaviour. Tags set through the AWS provider's default_tags block do not appear on the resource in your code, but they do appear in tags_all in the plan.
  • Intent. Only the plan tells you what Terraform is about to do. "This change destroys and recreates the production database" is invisible in a diff of .tf files and very visible in a plan.

Terraform can export the plan as JSON with terraform show -json, and the format is documented by HashiCorp. The interesting part is the resource_changes array: every resource, its planned actions (create, update, delete, or a combination), and its before and after values. That is a perfect input for a policy engine.

Layer 1: baseline scanning with Checkov

For the generic stuff (open SSH to the world, missing flow logs, unencrypted volumes) you should not write your own rules. Hundreds of them already exist. I use Checkov here. Full disclosure: Checkov is maintained by Prisma Cloud, which is part of Palo Alto Networks, my employer. Trivy and KICS fill the same role, and the pattern in this article works with any of them.

Take this deliberately bad example:

resource "aws_security_group" "bastion" {
  name        = "bastion"
  description = "Bastion access"
  vpc_id      = aws_vpc.main.id

  ingress {
    description = "SSH from anywhere (do not do this)"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }

  tags = {
    owner               = "platform-team"
    data_classification = "internal"
  }
}

Run Checkov against the directory:

pip install checkov
checkov -d . --framework terraform --compact

Among the results you get:

Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
	FAILED for resource: aws_security_group.bastion

Checkov can also scan the plan JSON directly (checkov -f tfplan.json), which helps when modules make the source hard to resolve.

Exceptions are a feature, not a failure

The single most important thing for adoption is a clean, reviewable way to say "yes, I know, and here is why". Checkov supports inline suppressions with a mandatory reason:

resource "aws_security_group" "bastion" {
  #checkov:skip=CKV_AWS_24:Temporary break-glass access, ticket SEC-1234
  ingress {
    # ...
  }
}

The suppression lives next to the code, goes through pull request review, and shows up as SKIPPED in the report. That beats an exceptions spreadsheet nobody maintains. My opinion: require a ticket reference in the reason, and periodically grep your repositories for checkov:skip to review what has quietly become permanent.

Layer 2: your own rules with OPA and Conftest

Baseline scanners know about AWS. They do not know about you. Every organisation has rules that no vendor will ship out of the box:

  • Every resource must carry owner and data_classification tags, because your cost reporting and incident response depend on them.
  • An automated pipeline must never destroy a stateful resource such as an S3 bucket, a database or a KMS key.
  • Only approved regions, approved instance families, approved module sources.

This is where Open Policy Agent and its Rego language shine, and Conftest makes it trivial to run OPA policies against a file in CI. Here is a policy that implements the first two rules against the plan JSON:

# policy/terraform.rego
package main

import rego.v1

required_tags := {"owner", "data_classification"}

# Resource types that must never be destroyed by an automated pipeline
protected_types := {"aws_s3_bucket", "aws_db_instance", "aws_dynamodb_table", "aws_kms_key"}

# Only look at resources Terraform will create or update
changed_resources contains rc if {
	some rc in input.resource_changes
	rc.mode == "managed"
	some action in rc.change.actions
	action in {"create", "update"}
}

# A resource supports tags if the provider exposes tags_all, either as a
# known value or as "known after apply" (that is what you get when no tags are set)
taggable(rc) if "tags_all" in object.keys(rc.change.after)

taggable(rc) if rc.change.after_unknown.tags_all == true

tag_keys(rc) := {key | some key, _ in object.get(rc.change.after, "tags_all", {})} | {key | some key, _ in object.get(rc.change.after, "tags", {})}

deny contains msg if {
	some rc in changed_resources
	taggable(rc)
	missing := required_tags - tag_keys(rc)
	count(missing) > 0
	msg := sprintf("%s is missing required tags: %v", [rc.address, sort(missing)])
}

deny contains msg if {
	some rc in input.resource_changes
	rc.type in protected_types
	"delete" in rc.change.actions
	msg := sprintf("%s would be destroyed (actions: %v). Protected resources need a manual change process.", [rc.address, rc.change.actions])
}

Two details in there come straight from testing, not from theory.

First, tags_all. My first version only looked at tags_all in the after object, and an untagged VPC sailed straight through. When a resource has no tags at all, the AWS provider marks tags_all as "known after apply", so it shows up in after_unknown instead of after. A policy that only handles the happy path will silently pass the exact resources you wanted to catch. Using tags_all rather than tags also means tags inherited from the provider's default_tags are counted, so teams are not punished for doing the right thing centrally.

Second, the destroy rule checks for "delete" anywhere in the actions list. A replacement shows up as ["delete", "create"] (or the reverse with create_before_destroy), and a replaced bucket is just as gone as a deleted one.

Generate the plan and test it:

terraform init -input=false
terraform plan -input=false -out=tfplan
terraform show -json tfplan > tfplan.json
conftest test tfplan.json --policy policy/
FAIL - tfplan.json - main - aws_vpc.main is missing required tags: ["data_classification", "owner"]

2 tests, 1 passed, 0 warnings, 1 failure, 0 exceptions

The message names the resource and the fix. A failure an engineer can resolve in thirty seconds builds trust; a cryptic one builds resentment.

Test your policies like code

Policies are code, so they get unit tests. OPA has a built-in test runner and Conftest exposes it as conftest verify. Rules prefixed with test_ are executed against inputs you mock:

# policy/terraform_test.rego
package main

import rego.v1

test_untagged_resource_denied if {
	some msg in deny with input as {"resource_changes": [{
		"address": "aws_vpc.main",
		"type": "aws_vpc",
		"mode": "managed",
		"change": {"actions": ["create"], "after": {"tags": null}, "after_unknown": {"tags_all": true}},
	}]}
	contains(msg, "owner")
}

test_bucket_replacement_denied if {
	some msg in deny with input as {"resource_changes": [{
		"address": "aws_s3_bucket.logs",
		"type": "aws_s3_bucket",
		"mode": "managed",
		"change": {"actions": ["delete", "create"], "after": {}, "after_unknown": {}},
	}]}
	contains(msg, "would be destroyed")
}
conftest verify --policy policy/

That tags_all bug I mentioned? It is now a regression test. Every bug you find in a policy should become one.

Wiring it into the pipeline

Here is a GitHub Actions workflow that runs both layers on every pull request touching the infrastructure code. It authenticates to AWS with OIDC and a read-only planning role, so there are no long-lived keys in your repository secrets.

# .github/workflows/terraform-policy.yml
name: terraform-policy

on:
  pull_request:
    paths: ["infra/**", "policy/**"]

permissions:
  contents: read
  id-token: write # required for OIDC

jobs:
  policy:
    runs-on: ubuntu-latest
    defaults:
      run:
        working-directory: infra
    env:
      CONFTEST_VERSION: "0.71.1"
    steps:
      - uses: actions/checkout@v7

      - uses: hashicorp/setup-terraform@v4
        with:
          terraform_version: "1.16.5"
          terraform_wrapper: false

      - uses: aws-actions/configure-aws-credentials@v6
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-plan-readonly
          aws-region: eu-west-1

      - name: Layer 1 - baseline scan (Checkov)
        run: |
          pipx install checkov
          checkov -d . --framework terraform --compact

      - name: Terraform plan
        run: |
          terraform init -input=false
          terraform plan -input=false -out=tfplan
          terraform show -json tfplan > tfplan.json

      - name: Install Conftest
        run: |
          curl -sSL "https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz" | tar xz conftest
          sudo mv conftest /usr/local/bin/

      - name: Layer 2 - organisation policies (Conftest)
        run: |
          conftest verify --policy ../policy
          conftest test tfplan.json --policy ../policy

In production, pin the actions to a commit SHA rather than a tag, and keep the planning role strictly read-only. The plan step needs to read state and query the provider, nothing more.

Rolling it out without losing your engineers

The technical part is the easy half. A few things I have seen make or break adoption:

  1. Start in warn mode, with a date. Conftest supports warn rules alongside deny. Ship a new policy as a warning, announce when it becomes blocking, then flip it. Soft fail without a deadline is just permanent noise.
  2. Block on a short list. Pick the handful of baseline checks you would genuinely stop a release for and enforce those (Checkov's --check and --hard-fail-on options help here). Report the rest. Fifteen blocking checks that everyone respects beat four hundred that everyone ignores.
  3. Keep policies in their own repository. Version them, review them, and let platform and security teams co-own them. Conftest can also pull policies from a remote source, so every pipeline consumes the same tested bundle.
  4. Measure suppressions, not just failures. A rising number of checkov:skip comments tells you a policy is wrong, unclear or too strict. That is product feedback.

What this does not cover

Shift-left guardrails stop known-bad changes from going through your pipeline. They do nothing about the console change someone made at 2 a.m. during an incident, the resources created by a script outside Terraform, or the perfectly compliant configuration that becomes exploitable because of a new vulnerability in the workload running on it. That is why scanning the code and monitoring the running cloud are two halves of the same job, ideally tied together so a runtime finding can be traced back to the line of Terraform that created it. Drift detection and runtime posture are topics for upcoming articles.

For now, if you take one thing away: write fewer policies, make each one precise, test it like code, and give engineers a clean way to disagree. That is what turns a scanner into a guardrail.

What is the one organisation-specific rule you wish your pipeline enforced today?

Share: