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.
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.
# 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:
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 planTerraform 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 = trueon RDS (AWS Secrets Manager holds the password) or write-only arguments on providers that support them. planruns code. Providers are executables, and adata "external"block runs any program you name. Runningterraform planon 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:
| Misconfiguration | Why it's dangerous | What good looks like |
|---|---|---|
| Public object storage | Anyone can list and download your data | Block Public Access on, access via IAM or signed URLs |
0.0.0.0/0 to SSH, RDP or database ports | Constant automated brute force and exploit attempts | Private subnets, SSM Session Manager or a VPN; allow specific CIDRs or security groups |
| Unencrypted storage and databases | Snapshots and backups readable if leaked | Encryption at rest, ideally with a customer-managed KMS key |
publicly_accessible = true on a database | Database endpoint reachable from the internet | Private subnets only |
IAM "Action": "*" or "Resource": "*" | Any compromise of that identity is total | Specific actions on specific ARNs (next lesson) |
| Logging disabled | No evidence when something goes wrong | CloudTrail, VPC flow logs and access logs on |
Secrets in .tf files or variables defaults | Committed to Git forever | Secret manager references, CI-injected variables marked sensitive |
Here's a configuration with three of them. Read it and spot the problems before scanning:
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:
pip install checkov
checkov -d . --compact --quietterraform 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:
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:
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:
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json
checkov -f tfplan.json --framework terraform_plan --compact --quietPolicy 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:
# 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.
conftest test tfplan.json -p policy/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 exceptionsPolicies are code, so test them. Files ending in _test.rego hold rules starting with test_, run with conftest verify:
# 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.
# .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 sinceApplying 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
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:
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:
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
}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;
planshows exactly what will change, andapplyshould 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.Why does the CI workflow scan `tfplan.json` rather than only the `.tf` files?
2.In Rego, what does it mean when a `deny contains msg if { ... }` rule produces no message for a given resource?
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.Where is the safest place to record a deliberate exception to a Checkov check?
Finished reading?
Mark it complete to fill in your roadmap.