Terraform and IaC — source audit
TL;DR: Terraform (and CloudFormation, Bicep, Pulumi) declarations are your cloud security boundary. Audit risks: overly broad IAM policies, secrets in state, modules from untrusted sources,
count/for_eachblowups, plain-HTTP buckets, public ingress on management ports, and provider versions pinned mutably. The state file is often the highest-value secret in the entire codebase. Companion to terraform-state-extraction and github-actions-workflow-source-audit.
Inputs to audit
1
2
3
4
find . -name '*.tf' -o -name '*.tfvars' -o -name 'terragrunt.hcl'
find . -name 'cloudformation*.yml' -o -name 'cloudformation*.yaml' -o -name '*.cfn.yaml'
find . -name 'main.bicep' -o -name '*.bicep'
find . -name 'Pulumi.yaml' -o -name '*.ts' -path '*/pulumi/*'
Bug class 1 — wildcards in IAM
1
2
3
4
5
6
7
8
9
10
# BAD
resource "aws_iam_policy" "dev_full" {
policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = "*" # ← any action
Resource = "*" # ← any resource
}]
})
}
Grep:
1
2
3
4
grep -rnE '"Action"\s*:\s*"\*"' .
grep -rnE '"Resource"\s*:\s*"\*"' .
grep -rnE '"Principal"\s*:\s*"\*"' . # publicly assumable role
grep -rnE 'NotAction|NotResource' # negation = effectively wide
Quick mental model:
Action: "s3:*"— bad on a write-capable role; OK on a read-only role limited to one bucket.Resource: "*"— bad on anything outside management/audit roles.Principal: "*"— public; only on truly public resources (CloudFront, etc.).
Bug class 2 — assume-role trust too loose
1
2
3
4
5
6
7
8
9
10
# BAD
resource "aws_iam_role" "ci" {
assume_role_policy = jsonencode({
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "ec2.amazonaws.com" } # any EC2 in the account
}]
})
}
For GitHub OIDC trust (gha-oidc-sub-claim-wildcards):
1
2
3
4
5
6
7
8
9
10
11
# BAD
Condition = {
StringLike = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/*:*"
}
}
# GOOD
StringEquals = {
"token.actions.githubusercontent.com:sub" = "repo:my-org/my-repo:ref:refs/heads/main"
}
Bug class 3 — secrets in state
Terraform state stores resource arguments verbatim — including passwords, API keys, private keys.
1
2
3
resource "aws_db_instance" "db" {
password = var.db_password # this value ends up in state, plaintext
}
State file is therefore equivalent to the full secret inventory. Audit:
- Where is state stored? S3 with SSE? KMS-encrypted? Versioning?
- Who can read state?
aws_s3_bucket_policyon the state bucket. - Is state behind
aws_dynamodb_tablefor locking? (DoS surface if not.)
Greppable:
1
2
grep -rnE 'password\s*=|secret\s*=|api_key\s*=|private_key\s*=' .
grep -rn 'backend\s*"s3"' .
For any secret in state: use AWS Secrets Manager / Parameter Store reference instead, or ephemeral resources with sensitive = true + provider-side fetch.
Bug class 4 — secrets in *.tfvars
1
2
# secrets.tfvars
db_password = "Hunter2-Production"
Greppable:
1
2
grep -rn '\.tfvars$' .
grep -rn 'password\s*=' *.tfvars 2>/dev/null
.tfvars files often end up committed to git. Search git log -p -- '*.tfvars'.
Bug class 5 — third-party modules from untrusted sources
1
2
3
module "vpc" {
source = "github.com/some-author/terraform-aws-vpc" # no version pin
}
Without a ?ref= pinning to commit SHA or signed tag, the module can be swapped under you.
Fix:
1
2
3
module "vpc" {
source = "git::https://github.com/some-author/terraform-aws-vpc.git?ref=a1b2c3d4..."
}
For Terraform Registry modules: version = "1.2.3" with tfsec/checkov policy that enforces “no ~> constraints on third-party modules”.
Bug class 6 — public ingress on management
1
2
3
4
5
6
7
resource "aws_security_group_rule" "ssh" {
type = "ingress"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"] # BAD: SSH from the world
}
Grep:
1
2
grep -rnB2 -A4 '0\.0\.0\.0/0' .
grep -rnB2 -A4 '::/0' . # IPv6 any
For any 0.0.0.0/0 ingress: confirm it’s on a public load-balancer port (80/443) only. SSH, RDP, DB ports, admin UIs — never.
Bug class 7 — bucket / queue without encryption
1
2
3
4
resource "aws_s3_bucket" "data" {
bucket = "my-data"
# no server_side_encryption_configuration → SSE off
}
Modern AWS defaults SSE-S3 since 2023, but legacy IaC may still need explicit blocks. Audit:
1
2
3
grep -rn 'aws_s3_bucket' . | head
grep -rn 'server_side_encryption' .
grep -rn 'kms_key_id' .
Same for aws_sqs_queue, aws_sns_topic, aws_rds_cluster (storage_encrypted).
Bug class 8 — public S3 buckets
1
2
3
4
5
6
7
8
resource "aws_s3_bucket_public_access_block" "block" {
bucket = aws_s3_bucket.data.id
block_public_acls = false # BAD
block_public_policy = false # BAD
ignore_public_acls = false # BAD
restrict_public_buckets = false # BAD
}
These should all be true for non-public buckets. For genuinely public buckets (static site, CDN origin), the policy itself is more important than the block.
Bug class 9 — count / for_each blowups
1
2
3
4
resource "aws_instance" "fleet" {
count = var.instance_count # passed via tfvars
...
}
A misconfigured CI passing instance_count = 10000 provisions a fleet of bills overnight. Bound:
1
2
3
4
5
6
7
variable "instance_count" {
type = number
validation {
condition = var.instance_count >= 1 && var.instance_count <= 50
error_message = "instance_count must be 1-50"
}
}
Bug class 10 — Lambda / function code from URL
1
2
3
4
5
resource "aws_lambda_function" "handler" {
s3_bucket = "vendor-distribution" # third-party
s3_key = "handler.zip" # mutable
...
}
If the bucket is third-party-owned, you trust their object versioning to not be tampered with. Pin by s3_object_version, or build the zip in your own repo.
Bug class 11 — provider version unpinned
1
2
3
4
5
terraform {
required_providers {
aws = "~> 5" # ranges accept new minor versions
}
}
Lockfile (.terraform.lock.hcl) freezes versions for your runs but not for new contributors. Audit:
.terraform.lock.hclis committed.- CI re-runs
terraform init -upgradeonly on explicit version bumps.
Bug class 12 — KMS key policy
1
2
3
4
5
6
7
8
9
10
resource "aws_kms_key" "data" {
policy = jsonencode({
Statement = [{
Effect = "Allow"
Principal = "*"
Action = "kms:*"
Resource = "*"
}]
})
}
KMS keys are particularly dangerous to leave permissive. The kms:* action allows decryption of anything encrypted under that key by anyone who can call the API.
CloudFormation parallels
Same patterns, different YAML:
!Subinterpolation injection.Fn::Subwith attacker-controlled values.- IAM PolicyDocument wildcards.
Capabilities: CAPABILITY_AUTO_EXPANDfor nested stacks — confirm the nested templates are trusted.
Tools
- tfsec / tflint — Terraform-specific security linters.
- checkov — multi-format (TF + CFN + K8s + Helm + Dockerfile).
- terrascan — multi-format.
- kics — multi-format, OWASP-aligned.
- OPA / Conftest — policy-as-code with rego against parsed plans.
Use in CI to block on critical findings.
Source-audit checklist
- No
*in IAMAction/Resource/Principaloutside well-justified roles. - OIDC trust policies pin
subclaim tightly. - No secrets in
.tfor.tfvars. - State backend is encrypted, access-controlled, locked.
- Third-party modules pinned to commit SHA / signed version.
- No
0.0.0.0/0on management ports. - SSE/KMS on data resources.
aws_s3_bucket_public_access_blockdefaults to true.count/for_eachdriven by validated variables.- Provider versions locked.
- KMS key policies scoped.