# 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.

DevSecOps Engineer roadmap · Secure the platform · 20 min read · https://levelcode.org/roadmaps/devsecops/iac-security

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:

| 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:

```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
}
```

> [!TIP]
> The same Rego skills carry over to Kubernetes admission control (OPA Gatekeeper), API authorisation and CI configuration checks. Conftest can test Kubernetes manifests, Dockerfiles and GitHub workflow files just as easily as Terraform plans.

## 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.

> [!WARNING]
> Never run `terraform plan` with cloud credentials on code from a fork, for example with a `pull_request_target` trigger that checks out the PR's head. A malicious `data "external"` block or provider would run with your plan role. GitHub withholds secrets and OIDC tokens from fork PRs on the normal `pull_request` trigger for exactly this reason. GitLab's equivalent protection is to run plan jobs only on protected branches, or for merge requests from the project itself.

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.

**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.

## Quiz

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

   A. Checkov cannot read HCL
   B. The plan is smaller and faster to scan
   C. The plan contains fully resolved values from variables, modules, count and for_each that static file scanning can't see
   D. The plan JSON is encrypted, so it is safer to share

   **Answer: C.** A module input or variable might set cidr_blocks to 0.0.0.0/0. Only after evaluation is the final value known, and the plan JSON contains it.

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

   A. At least one line in the rule body was false or undefined for that resource
   B. The policy has a syntax error
   C. Conftest skipped the resource
   D. The resource is in a different namespace

   **Answer: A.** A rule body is a conjunction: every expression must hold. Any false comparison or missing field makes that iteration contribute nothing to the deny set.

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

   A. Plan would apply the changes automatically
   B. Plan can execute arbitrary code, for example through a data "external" block or a malicious provider, with your credentials
   C. Fork PRs cannot contain .tf files
   D. Terraform refuses to plan code from forks

   **Answer: B.** Plan is not a passive read. Providers and external data sources run as programs on the runner, so untrusted code would run with whatever access the credentials grant.

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

   A. Globally disable the check in CI so it never fires
   B. In a wiki page listing exceptions
   C. Nowhere; delete the resource
   D. A `#checkov:skip=CHECK_ID:reason` comment inside the resource, so it's reviewed with the code

   **Answer: D.** A resource-level skip with a reason is visible in the diff, scoped to one resource, and reviewed like any other change. A global disable silently removes the check for every future resource.
