🏗️ IaC Security

Terraform / IaC Security Reference

Common Terraform misconfigurations, scanning tools (tfsec, Checkov, Terrascan), secure module patterns, and CI/CD gate integration.

🚨 Common Terraform Misconfigurations — Bad vs Good

S3 Bucket — Public Access Not Blocked Critical AWS
❌ Vulnerable Terraform
# BAD — no public access block resource "aws_s3_bucket" "data" { bucket = "company-data" }
✅ Secure Terraform
# GOOD — explicit public access block resource "aws_s3_bucket" "data" { bucket = "company-data" } resource "aws_s3_bucket_public_access_block" "data" { bucket = aws_s3_bucket.data.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true } resource "aws_s3_bucket_server_side_encryption_configuration" "data" { bucket = aws_s3_bucket.data.id rule { apply_server_side_encryption_by_default { sse_algorithm = "AES256" } } }
Security Group — Open to World Critical AWS
❌ Vulnerable Terraform
# BAD — SSH open to entire internet resource "aws_security_group_rule" "ssh" { type = "ingress" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] # DANGEROUS security_group_id = aws_security_group.main.id }
✅ Secure Terraform
# GOOD — restrict to known CIDR or use SSM resource "aws_security_group_rule" "ssh" { type = "ingress" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = [var.vpn_cidr] # Only your VPN/office IP security_group_id = aws_security_group.main.id } # Even better: no SSH rule at all — use SSM Session Manager # resource "aws_iam_role_policy_attachment" "ssm" { # role = aws_iam_role.ec2.name # policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore" # }
RDS — Publicly Accessible Critical AWS
❌ Vulnerable Terraform
# BAD — RDS exposed to internet resource "aws_db_instance" "main" { identifier = "prod-db" engine = "mysql" instance_class = "db.t3.medium" publicly_accessible = true # DANGEROUS }
✅ Secure Terraform
# GOOD — private, encrypted, with backup resource "aws_db_instance" "main" { identifier = "prod-db" engine = "mysql" instance_class = "db.t3.medium" publicly_accessible = false # Private only storage_encrypted = true # Encrypt at rest deletion_protection = true # Prevent accidental deletion backup_retention_period = 7 # 7-day backups db_subnet_group_name = aws_db_subnet_group.private.name vpc_security_group_ids = [aws_security_group.rds.id] multi_az = true # HA }
IAM Role — Wildcard Permissions Critical AWS
❌ Vulnerable Terraform
# BAD — full admin access on IAM role resource "aws_iam_role_policy" "app" { name = "app-policy" role = aws_iam_role.app.id policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "*" # DANGEROUS Resource = "*" # DANGEROUS }] }) }
✅ Secure Terraform
# GOOD — least privilege resource "aws_iam_role_policy" "app" { name = "app-policy" role = aws_iam_role.app.id policy = jsonencode({ Version = "2012-10-17" Statement = [ { Effect = "Allow" Action = ["s3:GetObject","s3:PutObject"] Resource = "arn:aws:s3:::${var.bucket_name}/*" }, { Effect = "Allow" Action = ["secretsmanager:GetSecretValue"] Resource = aws_secretsmanager_secret.app.arn } ] }) }
Azure Storage — Public Blob Access Critical AZURE
❌ Vulnerable Terraform
# BAD — allows public blob access resource "azurerm_storage_account" "main" { name = "companystorage" resource_group_name = azurerm_resource_group.main.name location = "Central India" account_tier = "Standard" account_replication_type = "LRS" # allow_blob_public_access defaults to true in older providers }
✅ Secure Terraform
# GOOD — all security settings explicit resource "azurerm_storage_account" "main" { name = "companystorage" resource_group_name = azurerm_resource_group.main.name location = "Central India" account_tier = "Standard" account_replication_type = "GRS" allow_blob_public_access = false # Block public access enable_https_traffic_only = true # Force HTTPS min_tls_version = "TLS1_2" # Minimum TLS 1.2 blob_properties { delete_retention_policy { days = 30 } versioning_enabled = true } }
GCP Firewall — SSH Open to World Critical GCP
❌ Vulnerable Terraform
# BAD — SSH open to internet resource "google_compute_firewall" "ssh" { name = "allow-ssh" network = google_compute_network.main.name allow { protocol = "tcp" ports = ["22"] } source_ranges = ["0.0.0.0/0"] # DANGEROUS }
✅ Secure Terraform
# GOOD — SSH only via IAP resource "google_compute_firewall" "ssh_iap" { name = "allow-ssh-iap" network = google_compute_network.main.name allow { protocol = "tcp" ports = ["22"] } # Only allow from IAP (Google Identity-Aware Proxy) source_ranges = ["35.235.240.0/20"] target_tags = ["ssh-iap"] } # Enable OS Login for SA-based SSH auth resource "google_project_iam_binding" "os_login" { project = var.project_id role = "roles/compute.osLogin" members = var.ssh_users }
Hardcoded Secrets in Terraform Critical ALL
❌ Vulnerable Terraform
# BAD — credential hardcoded in resource resource "aws_db_instance" "main" { username = "admin" password = "P@ssw0rd123!" # DANGEROUS — in version control } # BAD — secret in variable without sensitive flag variable "db_password" { default = "P@ssw0rd123!" # Exposed in tfstate }
✅ Secure Terraform
# GOOD — use Secrets Manager / Key Vault data "aws_secretsmanager_secret_version" "db" { secret_id = aws_secretsmanager_secret.db.id } resource "aws_db_instance" "main" { username = "admin" password = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)["password"] } # Or use random password + store in Secrets Manager resource "random_password" "db" { length = 24 special = true } resource "aws_secretsmanager_secret_version" "db" { secret_id = aws_secretsmanager_secret.db.id secret_string = jsonencode({ password = random_password.db.result }) }
EC2 — IMDSv1 Enabled (Default) High AWS
❌ Vulnerable Terraform
# BAD — default allows IMDSv1 (SSRF risk) resource "aws_instance" "app" { ami = data.aws_ami.ubuntu.id instance_type = "t3.medium" # No metadata_options = IMDSv1 allowed }
✅ Secure Terraform
# GOOD — enforce IMDSv2 resource "aws_instance" "app" { ami = data.aws_ami.ubuntu.id instance_type = "t3.medium" metadata_options { http_endpoint = "enabled" http_tokens = "required" # IMDSv2 only http_put_response_hop_limit = 1 # Prevent SSRF hops } } # Enforce IMDSv2 as account default resource "aws_ec2_instance_metadata_defaults" "main" { http_tokens = "required" }

🔍 IaC Scanning Tools

tfsec
Fast Terraform-specific scanner. 300+ checks across AWS, Azure, GCP. Outputs findings with severity, rule ID, and fix suggestion.
brew install tfsec
tfsec . --format json
Checkov
Multi-IaC scanner (Terraform, CloudFormation, Kubernetes, Helm, Bicep). 1000+ policies. CIS benchmark mapped. Integrates with CI/CD.
pip install checkov
checkov -d . --framework terraform
Terrascan
Detect compliance and security violations in IaC. Rego policy engine. Supports Terraform, Helm, Kustomize.
brew install terrascan
terrascan scan -t aws
KICS (Checkmarx)
Keeping Infrastructure as Code Secure. Open-source. Scans Terraform, CloudFormation, Docker, Kubernetes. 2000+ queries.
docker run -v $(pwd):/path checkmarx/kics:latest scan -p /path
Trivy (IaC mode)
Aqua Security tool. Scans IaC alongside container images — one tool for multiple surface areas.
trivy config --severity HIGH,CRITICAL .
Infracost
Not a security scanner but critical: shows cost impact of IaC changes. Prevents expensive misconfigs (open IMDS, unlimited autoscaling).
infracost diff --path .

Running tfsec with custom rules

#!/bin/bash # Install tfsec brew install tfsec # Mac # or: curl -sL https://github.com/aquasecurity/tfsec/releases/latest/download/tfsec-linux-amd64 -o tfsec && chmod +x tfsec # Basic scan tfsec . # Scan with JSON output for CI/CD tfsec . --format json > tfsec-results.json # Only show HIGH and CRITICAL tfsec . --minimum-severity HIGH # Scan specific cloud tfsec . --include-ignored-checks # Include ignored checks in output # Custom checks (YAML format) cat > .tfsec/custom-check.yaml << 'EOF' checks: - code: CUS001 description: "S3 bucket must have versioning enabled" impact: "Data loss risk without versioning" resolution: "Enable versioning on all S3 buckets" requiredTypes: - resource requiredLabels: - aws_s3_bucket severity: HIGH matchSpec: name: versioning action: isPresent errorMessage: "S3 bucket does not have versioning configured" relatedLinks: - "https://docs.aws.amazon.com/AmazonS3/latest/userguide/Versioning.html" EOF tfsec . --custom-check-dir .tfsec

✅ Secure Terraform Module Patterns

Sensitive variable handling

# variables.tf — mark secrets as sensitive variable "db_password" { type = string description = "Database master password" sensitive = true # Redacted from plan/apply output and state } variable "api_key" { type = string sensitive = true } # Never use default values for sensitive vars # Force explicit input every time

Remote state with encryption

terraform { backend "s3" { bucket = "company-terraform-state" key = "prod/terraform.tfstate" region = "ap-south-1" encrypt = true # Encrypt state at rest kms_key_id = "arn:aws:kms:ap-south-1:ACCOUNT:key/KEY_ID" dynamodb_table = "terraform-locks" # State locking # Never store state locally — always remote # Never store state in unencrypted S3 } } # State bucket must have: # - Block all public access # - Versioning enabled (recover accidental deletes) # - MFA delete enabled # - Server-side encryption (SSE-KMS) # - Access logs enabled

Prevent destructive operations in production

resource "aws_db_instance" "prod" { # ... lifecycle { prevent_destroy = true # terraform destroy will fail ignore_changes = [ password, # Managed outside Terraform after creation ] } } resource "aws_s3_bucket" "prod_data" { bucket = "company-prod-data" lifecycle { prevent_destroy = true } } # Use terraform workspace to separate environments # terraform workspace new prod # terraform workspace select prod

🔄 CI/CD Security Gate — GitHub Actions

name: Terraform Security Scan on: pull_request: branches: [main] paths: ['terraform/**','*.tf'] jobs: security-scan: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: tfsec — Terraform security scan uses: aquasecurity/tfsec-action@v1.0.0 with: working_directory: terraform/ minimum_severity: HIGH soft_fail: false # Fail PR on HIGH/CRITICAL findings - name: Checkov — Multi-framework scan uses: bridgecrewio/checkov-action@v12 with: directory: terraform/ framework: terraform check: CKV_AWS_*,CKV_AZURE_*,CKV_GCP_* soft_fail: false - name: Trivy — IaC config scan uses: aquasecurity/trivy-action@master with: scan-type: config scan-ref: terraform/ severity: HIGH,CRITICAL exit-code: '1' # Fail on findings - name: Check for secrets in Terraform files uses: trufflesecurity/trufflehog@main with: path: ./ extra_args: --only-verified - name: Terraform plan with OPA policy check run: | terraform init terraform plan -out=tfplan terraform show -json tfplan > tfplan.json # Run OPA against plan opa eval --data opa-policies/ --input tfplan.json data.terraform.deny

OPA policy to block public S3 buckets in plan

package terraform import future.keywords.contains import future.keywords.if # Deny if any S3 bucket has public access enabled deny contains msg if { r := input.resource_changes[_] r.type == "aws_s3_bucket_public_access_block" r.change.after.block_public_acls == false msg := sprintf("S3 bucket '%s' has public ACLs not blocked", [r.address]) } deny contains msg if { r := input.resource_changes[_] r.type == "aws_security_group_rule" r.change.after.cidr_blocks[_] == "0.0.0.0/0" r.change.after.from_port <= 22 r.change.after.to_port >= 22 msg := sprintf("Security group rule '%s' allows SSH from internet", [r.address]) } deny contains msg if { r := input.resource_changes[_] r.type == "aws_db_instance" r.change.after.publicly_accessible == true msg := sprintf("RDS instance '%s' is publicly accessible", [r.address]) }