Step 20 of 25

Infrastructure as code security

Read and write enough Terraform to spot misconfigurations, scan it with Checkov and Trivy, enforce rules with OPA/Conftest, and review plans in CI.

20 min 7 sections

Most cloud breaches don't involve clever exploits. They involve a storage bucket left public, a database port open to the internet, or an access policy with a wildcard. When infrastructure is clicked together in a console, those mistakes are invisible until someone finds them. When it's defined as code, every change is a diff you can review, scan and test before it exists. This lesson teaches enough Terraform to read and fix real configurations, the misconfigurations that matter most, how to catch them with scanners and your own policies, and how to wire plan review into CI.

Terraform in ten minutes

Terraform describes infrastructure in HCL files. You declare resources (things that should exist), Terraform compares them with what does exist, and produces a plan: the exact changes it would make. Nothing changes until you apply that plan.

hcl
# main.tf
terraform {
  required_version = ">= 1.10"
  required_providers {
    aws = {
      source  = "hashicorp/aws"
      version = "~> 6.0"
    }
  }
  backend "s3" {
    bucket       = "acme-terraform-state"
    key          = "orders/prod.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true   # S3-native state locking
  }
}

provider "aws" {
  region = var.region
  default_tags {
    tags = { owner = "orders-team", managed-by = "terraform" }
  }
}

variable "region" {
  type    = string
  default = "eu-west-1"
}

resource "aws_s3_bucket" "invoices" {
  bucket = "acme-orders-invoices-prod"
}

output "invoices_bucket_arn" {
  value = aws_s3_bucket.invoices.arn
}

The core workflow:

bash
terraform init                    # download providers, configure the backend
terraform fmt -check && terraform validate
terraform plan -out=tfplan        # compute the changes and save them
terraform apply tfplan            # apply exactly the saved plan
text
Terraform will perform the following actions:

  # aws_s3_bucket.invoices will be created
  + resource "aws_s3_bucket" "invoices" {
      + arn    = (known after apply)
      + bucket = "acme-orders-invoices-prod"
      ...
    }

Plan: 1 to add, 0 to change, 0 to destroy.

Two security facts about Terraform itself:

  • State is sensitive. Terraform records every resource's attributes in the state file, including generated passwords and keys, in plain text. Store state in an encrypted, access-controlled backend with locking, never in Git. Prefer designs that keep secrets out of state entirely, such as manage_master_user_password = true on RDS (AWS Secrets Manager holds the password) or write-only arguments on providers that support them.
  • plan runs code. Providers are executables, and a data "external" block runs any program you name. Running terraform plan on an untrusted pull request with cloud credentials is running untrusted code with those credentials.

The same concepts apply to OpenTofu, the open-source fork, which uses the same language and commands (tofu instead of terraform).

The misconfigurations that matter

A small set of mistakes accounts for most cloud incidents:

MisconfigurationWhy it's dangerousWhat good looks like
Public object storageAnyone can list and download your dataBlock Public Access on, access via IAM or signed URLs
0.0.0.0/0 to SSH, RDP or database portsConstant automated brute force and exploit attemptsPrivate subnets, SSM Session Manager or a VPN; allow specific CIDRs or security groups
Unencrypted storage and databasesSnapshots and backups readable if leakedEncryption at rest, ideally with a customer-managed KMS key
publicly_accessible = true on a databaseDatabase endpoint reachable from the internetPrivate subnets only
IAM "Action": "*" or "Resource": "*"Any compromise of that identity is totalSpecific actions on specific ARNs (next lesson)
Logging disabledNo evidence when something goes wrongCloudTrail, VPC flow logs and access logs on
Secrets in .tf files or variables defaultsCommitted to Git foreverSecret manager references, CI-injected variables marked sensitive

Here's a configuration with three of them. Read it and spot the problems before scanning:

hcl
resource "aws_security_group" "bastion" {
  name   = "bastion"
  vpc_id = var.vpc_id

  ingress {
    description = "SSH"
    from_port   = 22
    to_port     = 22
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
  egress {
    from_port   = 0
    to_port     = 0
    protocol    = "-1"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_s3_bucket" "exports" {
  bucket = "acme-orders-exports"
}

resource "aws_s3_bucket_public_access_block" "exports" {
  bucket                  = aws_s3_bucket.exports.id
  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
}

resource "aws_db_instance" "orders" {
  identifier          = "orders"
  engine              = "postgres"
  engine_version      = "17"
  instance_class      = "db.t4g.medium"
  allocated_storage   = 50
  username            = "orders"
  password            = "Sup3rSecret!"
  publicly_accessible = true
  skip_final_snapshot = true
}

SSH is open to the world, the bucket has public access protection explicitly switched off, and the database is public, unencrypted (storage_encrypted defaults to false) and has a password hard-coded in Git.

Scanning with Checkov and Trivy

IaC scanners parse your configuration and evaluate hundreds of built-in checks. Checkov is one of the most widely used:

bash
pip install checkov
checkov -d . --compact --quiet
text
terraform scan results:

Passed checks: 21, Failed checks: 9, Skipped checks: 0

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
	File: /network.tf:1-19
Check: CKV_AWS_17: "Ensure all data stored in RDS is not publicly accessible"
	FAILED for resource: aws_db_instance.orders
	File: /database.tf:1-12
Check: CKV_AWS_16: "Ensure all data stored in the RDS is securely encrypted at rest"
	FAILED for resource: aws_db_instance.orders
	File: /database.tf:1-12
Check: CKV2_AWS_6: "Ensure that S3 bucket has a Public Access block"
	FAILED for resource: aws_s3_bucket.exports
	File: /storage.tf:1-3
...

--compact hides code snippets and --quiet shows only failures. Trivy, which you used for images, scans IaC with the same tool and similar checks:

bash
trivy config --severity HIGH,CRITICAL .

Scanners are opinionated. Checkov will also tell you that every bucket should have cross-region replication, event notifications and access logging, which may not suit a bucket of build caches. Decide which checks your organisation enforces, and record intentional exceptions at the resource, with a reason:

hcl
resource "aws_s3_bucket" "build_cache" {
  #checkov:skip=CKV_AWS_144:Cache is disposable; cross-region replication not needed
  #checkov:skip=CKV2_AWS_62:No consumers need event notifications
  bucket = "acme-build-cache"
}

Scanning .tf files misses values that are only known after evaluation: module inputs, variables, count and for_each. Scanning the plan sees the fully resolved result:

bash
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
checkov -f tfplan.json --framework terraform_plan --compact --quiet

Policy as code with OPA and Conftest

Built-in checks cover generic best practice. Your organisation also has its own rules: "every resource must have an owner tag", "only these instance types in production", "no SSH from the internet, ever, regardless of what a skip comment says". Open Policy Agent (OPA) evaluates rules written in Rego, a declarative query language, against any JSON. Conftest is a CLI that runs Rego policies against files, and Terraform plan JSON is an ideal input.

The plan JSON has a resource_changes array. Each element has an address, a type, and a change with actions (such as ["create"]) and the proposed state in after. Policies iterate over it:

rego
# policy/terraform.rego
package main

import rego.v1

required_tags := {"owner", "data-classification"}

deny contains msg if {
	some rc in input.resource_changes
	rc.type == "aws_security_group"
	some rule in rc.change.after.ingress
	"0.0.0.0/0" in rule.cidr_blocks
	rule.from_port <= 22
	rule.to_port >= 22
	msg := sprintf("%s: SSH (port 22) must not be open to 0.0.0.0/0", [rc.address])
}

deny contains msg if {
	some rc in input.resource_changes
	rc.type == "aws_db_instance"
	rc.change.after.publicly_accessible == true
	msg := sprintf("%s: databases must not be publicly accessible", [rc.address])
}

deny contains msg if {
	some rc in input.resource_changes
	rc.type == "aws_s3_bucket"
	"create" in rc.change.actions
	provided := {k | some k, _ in object.get(rc.change.after, "tags_all", {})}
	missing := required_tags - provided
	count(missing) > 0
	msg := sprintf("%s: missing required tags %v", [rc.address, missing])
}

warn contains msg if {
	some rc in input.resource_changes
	"delete" in rc.change.actions
	rc.type in {"aws_db_instance", "aws_s3_bucket", "aws_kms_key"}
	msg := sprintf("%s will be DESTROYED: confirm this is intended", [rc.address])
}

How to read Rego: each deny contains msg if { … } rule adds a message to the deny set when every line in its body is true. some rc in input.resource_changes iterates; a line that is false, or refers to a field that doesn't exist, makes that iteration produce nothing. Multiple rules with the same name are OR'd together. Conftest fails on any deny and reports warn without failing.

bash
conftest test tfplan.json -p policy/
text
WARN - tfplan.json - main - aws_s3_bucket.legacy_exports will be DESTROYED: confirm this is intended
FAIL - tfplan.json - main - aws_db_instance.orders: databases must not be publicly accessible
FAIL - tfplan.json - main - aws_security_group.bastion: SSH (port 22) must not be open to 0.0.0.0/0

4 tests, 1 passed, 1 warning, 2 failures, 0 exceptions

Policies are code, so test them. Files ending in _test.rego hold rules starting with test_, run with conftest verify:

rego
# policy/terraform_test.rego
package main

import rego.v1

open_ssh := {"resource_changes": [{
	"address": "aws_security_group.bastion",
	"type": "aws_security_group",
	"change": {
		"actions": ["create"],
		"after": {"ingress": [{"cidr_blocks": ["0.0.0.0/0"], "from_port": 22, "to_port": 22, "protocol": "tcp"}]},
	},
}]}

test_open_ssh_is_denied if {
	count(deny) == 1 with input as open_ssh
}

test_private_ssh_is_allowed if {
	private := json.patch(open_ssh, [{"op": "replace", "path": "/resource_changes/0/change/after/ingress/0/cidr_blocks", "value": ["10.0.0.0/8"]}])
	count(deny) == 0 with input as private
}

Plan review in CI

Put it together so every infrastructure change is planned, scanned, policy-checked and reviewed before apply. Credentials come from OIDC federation (next lesson), with a read-only role for plans and a separate, more privileged role for apply that only the protected main branch can assume.

yaml
# .github/workflows/terraform.yml
name: terraform
on:
  pull_request:
    paths: ["infra/**"]
  push:
    branches: [main]
    paths: ["infra/**"]
permissions:
  contents: read
  id-token: write
defaults:
  run:
    working-directory: infra
jobs:
  plan:
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
          aws-region: eu-west-1
      - run: terraform init -input=false
      - run: terraform plan -input=false -no-color -out=tfplan
      - run: terraform show -json tfplan > tfplan.json
      - name: Checkov
        run: |
          pip install checkov==3.3.27
          checkov -f tfplan.json --framework terraform_plan --compact --quiet
      - name: Conftest
        run: >-
          docker run --rm -v "$PWD:/project" openpolicyagent/conftest:v0.71.1
          test tfplan.json -p policy/
      - name: Plan summary for reviewers
        if: always()
        run: terraform show -no-color tfplan >> "$GITHUB_STEP_SUMMARY"

  plan-main:
    if: github.event_name == 'push'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-plan
          aws-region: eu-west-1
      - run: terraform init -input=false
      - run: terraform plan -input=false -no-color -out=tfplan
      - run: terraform show -no-color tfplan >> "$GITHUB_STEP_SUMMARY"
      - uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
        with:
          name: tfplan
          path: infra/tfplan
          retention-days: 1        # plan files can contain secrets: keep them briefly

  apply:
    needs: plan-main
    runs-on: ubuntu-latest
    environment: production        # an approver reads plan-main's summary, then approves
    steps:
      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
        with:
          persist-credentials: false
      - uses: hashicorp/setup-terraform@dfe3c3f87815947d99a8997f908cb6525fc44e9e # v4.0.1
      - uses: aws-actions/configure-aws-credentials@e1253824e5c10ff9df46874f81ed3ec929e19cfd # v6.3.0
        with:
          role-to-assume: arn:aws:iam::123456789012:role/terraform-apply
          aws-region: eu-west-1
      - run: terraform init -input=false
      - uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
        with:
          name: tfplan
          path: infra
      - run: terraform apply -input=false tfplan   # exactly the approved plan; refused if state changed since

Applying the saved file, rather than running apply -auto-approve, matters: a fresh apply would compute a new plan after approval, and anything that changed in between (drift, another merge) would go out unreviewed. Terraform refuses a saved plan whose state has moved on, so the worst case is a failed job, not a surprise.

What reviewers should check in a plan, beyond the scanners:

  • Destroys and replacements. -/+ means a resource is being destroyed and recreated. For a database, that is data loss.
  • Changes nobody asked for. Unexpected diffs often mean drift: someone changed the resource in the console. Find out why before overwriting it.
  • Security-relevant attributes. IAM policies, security group rules, encryption and public-access settings deserve a human look even when the scanners pass.

Schedule a nightly terraform plan -detailed-exitcode too. Exit code 2 means the real infrastructure differs from the code: drift worth an alert.

Practice

Take the insecure configuration from the misconfigurations section. Fix it so that Checkov's CKV_AWS_24, CKV_AWS_17, CKV_AWS_16 and CKV2_AWS_6 pass and no password appears in code or state. Then add a Conftest deny rule that rejects any aws_db_instance without storage_encrypted = true, with a unit test.

Show solution
hcl
resource "aws_security_group" "bastion" {
  name   = "bastion"
  vpc_id = var.vpc_id
  # No ingress: admins connect with SSM Session Manager, which needs no open ports.
  egress {
    description = "HTTPS to AWS APIs"
    from_port   = 443
    to_port     = 443
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]
  }
}

resource "aws_s3_bucket" "exports" {
  bucket = "acme-orders-exports"
  tags   = { data-classification = "confidential" }
}

resource "aws_s3_bucket_public_access_block" "exports" {
  bucket                  = aws_s3_bucket.exports.id
  block_public_acls       = true
  block_public_policy     = true
  ignore_public_acls      = true
  restrict_public_buckets = true
}

resource "aws_db_instance" "orders" {
  identifier                  = "orders"
  engine                      = "postgres"
  engine_version              = "17"
  instance_class              = "db.t4g.medium"
  allocated_storage           = 50
  username                    = "orders"
  manage_master_user_password = true   # stored and rotated in Secrets Manager
  storage_encrypted           = true
  publicly_accessible         = false
  db_subnet_group_name        = aws_db_subnet_group.private.name
  deletion_protection         = true
  skip_final_snapshot         = false
  final_snapshot_identifier   = "orders-final"
}

manage_master_user_password means Terraform never sees the password, so it isn't in state. The old hard-coded password is in Git history, so treat it as compromised and make sure the new database never used it.

Policy rule, added to policy/terraform.rego:

rego
deny contains msg if {
	some rc in input.resource_changes
	rc.type == "aws_db_instance"
	some action in rc.change.actions
	action in {"create", "update"}
	not rc.change.after.storage_encrypted == true
	msg := sprintf("%s: storage_encrypted must be true", [rc.address])
}

not ... == true catches both false and a missing attribute. Test, in policy/terraform_test.rego:

rego
unencrypted_db := {"resource_changes": [{
	"address": "aws_db_instance.orders",
	"type": "aws_db_instance",
	"change": {"actions": ["create"], "after": {"storage_encrypted": false, "publicly_accessible": false}},
}]}

test_unencrypted_db_is_denied if {
	count(deny) == 1 with input as unencrypted_db
}

test_encrypted_db_is_allowed if {
	encrypted := json.patch(unencrypted_db, [{"op": "replace", "path": "/resource_changes/0/change/after/storage_encrypted", "value": true}])
	count(deny) == 0 with input as encrypted
}
bash
conftest verify -p policy/
terraform plan -out=tfplan && terraform show -json tfplan > tfplan.json
checkov -f tfplan.json --framework terraform_plan --compact --quiet
conftest test tfplan.json -p policy/

Key takeaways

  • Terraform turns infrastructure into reviewable diffs; plan shows exactly what will change, and apply should run only a reviewed plan.
  • State holds secrets and must live in an encrypted, locked, access-controlled backend; prefer designs that keep secrets out of state altogether.
  • Public storage, internet-open admin and database ports, unencrypted data, wildcard IAM and missing logs cause most cloud incidents.
  • Scan both the code and the plan JSON with Checkov or Trivy, and record intentional exceptions at the resource with a reason.
  • Encode organisation-specific rules in Rego, run them with Conftest against the plan, and unit-test the policies.
  • In CI, plan with a read-only role on PRs, apply with a separate role from the protected branch, and never plan untrusted fork code with credentials.

Check yourself

Quiz

0/4 correct · 4 left

  1. 1.Why does the CI workflow scan `tfplan.json` rather than only the `.tf` files?

  2. 2.In Rego, what does it mean when a `deny contains msg if { ... }` rule produces no message for a given resource?

  3. 3.A pull request from an external fork changes your Terraform. Why must you not run `terraform plan` on it with your cloud credentials?

  4. 4.Where is the safest place to record a deliberate exception to a Checkov check?

Finished reading?

Mark it complete to fill in your roadmap.