Module 6.3: Infrastructure as Code Security
Цей контент ще не доступний вашою мовою.
Complexity: [COMPLEX]
Section titled “Complexity: [COMPLEX]”Time to Complete: 50 minutes
Section titled “Time to Complete: 50 minutes”Prerequisites
Section titled “Prerequisites”Before starting this module, you should have completed:
- Module 6.1: IaC Fundamentals - Core IaC concepts
- Module 6.2: IaC Testing - Testing strategies
- Module 4.2: Defense in Depth - Security principles
What You’ll Be Able to Do
Section titled “What You’ll Be Able to Do”After completing this module, you will be able to:
- Implement policy-as-code scanning that blocks insecure infrastructure before deployment
- Design least-privilege IAM patterns for IaC service accounts and CI/CD pipelines
- Build secret injection workflows that keep credentials out of IaC state files and repositories
- Evaluate IaC security tools — tfsec, Checkov, Sentinel, OPA — against your compliance requirements
Why This Module Matters
Section titled “Why This Module Matters”The $18.7 Million Terraform Secret
The security team at a major financial services company received an alert that made their blood run cold. Their Terraform state file—stored in an S3 bucket with “private” permissions—had been accessed from an IP address in Eastern Europe. The state file contained everything: database passwords, API keys, service account credentials, and the complete topology of their production infrastructure.
The investigation revealed a chilling chain of events. A developer had accidentally committed their AWS credentials to a public GitHub repository six months earlier. Those credentials had minimal permissions—read-only access to S3. The attacker had been patiently mapping the organization’s infrastructure ever since, downloading state files from dozens of projects. Now they had everything needed to access production systems.
The breach affected 2.3 million customer records. The total cost: $18.7 million in fines, remediation, and lost business.
This module teaches you how to secure infrastructure as code—because your Terraform state file might be the most valuable asset in your entire organization.
The IaC Security Attack Surface
Section titled “The IaC Security Attack Surface”Infrastructure as code introduces unique security challenges that don’t exist in traditional infrastructure management.
┌─────────────────────────────────────────────────────────────────┐│ IaC SECURITY ATTACK SURFACE │├─────────────────────────────────────────────────────────────────┤│ ││ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ││ │ SOURCE │ │ SECRETS │ │ STATE │ ││ │ CODE │ │ MANAGEMENT │ │ FILES │ ││ ├─────────────┤ ├─────────────┤ ├─────────────┤ ││ │ • Hardcoded │ │ • Plaintext │ │ • Sensitive │ ││ │ secrets │ │ in vars │ │ values │ ││ │ • Insecure │ │ • Env vars │ │ • Resource │ ││ │ defaults │ │ exposed │ │ metadata │ ││ │ • Misconfig │ │ • Weak │ │ • Access │ ││ │ in code │ │ rotation │ │ control │ ││ └─────────────┘ └─────────────┘ └─────────────┘ ││ │ │ │ ││ └────────────┬────┴───────────────────┘ ││ ▼ ││ ┌─────────────────────────────────────────────────────────┐ ││ │ CI/CD PIPELINE │ ││ ├─────────────────────────────────────────────────────────┤ ││ │ • Credential theft from logs │ ││ │ • Supply chain attacks on providers/modules │ ││ │ • Malicious pull request modifications │ ││ │ • Insufficient access controls │ ││ └─────────────────────────────────────────────────────────┘ ││ │ ││ ▼ ││ ┌─────────────────────────────────────────────────────────┐ ││ │ DEPLOYED INFRASTRUCTURE │ ││ ├─────────────────────────────────────────────────────────┤ ││ │ • Overly permissive IAM policies │ ││ │ • Public S3 buckets, open security groups │ ││ │ • Unencrypted storage, missing logging │ ││ │ • Drift from secure baseline │ ││ └─────────────────────────────────────────────────────────┘ ││ │└─────────────────────────────────────────────────────────────────┘Security Scanning Tools
Section titled “Security Scanning Tools”Checkov: Comprehensive Policy Scanning
Section titled “Checkov: Comprehensive Policy Scanning”Checkov is the most comprehensive IaC security scanner, supporting Terraform, CloudFormation, Kubernetes, and more.
# Install Checkovpip install checkov
# Scan Terraform directorycheckov -d . --framework terraform
# Scan with specific checkscheckov -d . --check CKV_AWS_18,CKV_AWS_19,CKV_AWS_20
# Skip specific checkscheckov -d . --skip-check CKV_AWS_18
# Output to JUnit for CI/CDcheckov -d . -o junitxml > checkov-results.xml
# Scan plan file for accurate resultsterraform plan -out=tfplanterraform show -json tfplan > tfplan.jsoncheckov -f tfplan.json
# Custom policy examplecat > custom_policy.py << 'EOF'from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheckfrom checkov.common.models.enums import CheckResult, CheckCategories
class RequireDescriptionTag(BaseResourceCheck): def __init__(self): name = "Ensure all resources have description tag" id = "CUSTOM_001" supported_resources = ['aws_*'] categories = [CheckCategories.CONVENTION] super().__init__(name=name, id=id, categories=categories, supported_resources=supported_resources)
def scan_resource_conf(self, conf): tags = conf.get('tags', [{}]) if isinstance(tags, list): tags = tags[0] if tags else {} if 'Description' in tags or 'description' in tags: return CheckResult.PASSED return CheckResult.FAILED
check = RequireDescriptionTag()EOFtfsec: Terraform-Specific Scanner
Section titled “tfsec: Terraform-Specific Scanner”tfsec (now part of Trivy) focuses specifically on Terraform:
# Install tfsecbrew install tfsec
# Basic scantfsec .
# Scan with specific severitytfsec . --minimum-severity HIGH
# Output as JSON for parsingtfsec . --format json > tfsec-results.json
# Scan with custom rulescat > .tfsec/custom_check.yaml << 'EOF'checks: - code: CUSTOM001 description: Ensure S3 bucket has specific naming convention impact: Non-standard naming makes inventory management difficult resolution: Use naming pattern: {env}-{service}-{purpose} requiredTypes: - resource requiredLabels: - aws_s3_bucket severity: MEDIUM matchSpec: name: bucket action: regexMatches value: ^(dev|staging|prod)-[a-z]+-[a-z]+$ errorMessage: S3 bucket name must follow pattern {env}-{service}-{purpose}EOF
# Run with custom checkstfsec . --custom-check-dir .tfsec/Trivy: All-in-One Scanner
Section titled “Trivy: All-in-One Scanner”Trivy scans IaC, containers, and filesystems:
# Install Trivybrew install trivy
# Scan Terraform configurationtrivy config .
# Scan with severity filtertrivy config . --severity HIGH,CRITICAL
# Scan specific file typestrivy config . --tf-exclude-downloaded-modules
# Output as table with detailstrivy config . --format table --output trivy-report.txt
# CI/CD integration with exit codetrivy config . --exit-code 1 --severity CRITICALTerrascan: Policy-as-Code Scanner
Section titled “Terrascan: Policy-as-Code Scanner”Terrascan uses Rego policies (same as OPA):
# Install Terrascanbrew install terrascan
# Scan Terraformterrascan scan -t terraform
# Scan with specific policy typesterrascan scan -t aws -t k8s
# Use custom policycat > custom_policy.rego << 'EOF'package accurics
rdsEncryptionNotEnabled[retVal] { rds := input.aws_db_instance[_] rds.config.storage_encrypted != true retVal := { "Id": rds.id, "ReplaceType": "edit", "CodeType": "resource", "Attribute": "storage_encrypted", "Expected": "true" }}EOF
terrascan scan -t terraform -p custom_policy.regoSecrets Management in IaC
Section titled “Secrets Management in IaC”The Problem with Secrets
Section titled “The Problem with Secrets”# ❌ NEVER DO THIS - Secrets in coderesource "aws_db_instance" "main" { identifier = "production-db" engine = "postgres" instance_class = "db.r5.large"
username = "admin" password = "SuperSecretPassword123!" # Committed to git history FOREVER}
# ❌ NEVER DO THIS - Secrets in variablesvariable "db_password" { default = "SuperSecretPassword123!" # Still in code}
# ❌ RISKY - Environment variables visible in CI/CD logs# TF_VAR_db_password=SuperSecretPassword123!Solution 1: HashiCorp Vault
Section titled “Solution 1: HashiCorp Vault”# Configure Vault providerprovider "vault" { address = "https://vault.company.com:8200" # Uses VAULT_TOKEN from environment}
# Read secrets from Vaultdata "vault_generic_secret" "db_creds" { path = "secret/data/production/database"}
# Use secrets without exposing themresource "aws_db_instance" "main" { identifier = "production-db" engine = "postgres" instance_class = "db.r5.large"
username = data.vault_generic_secret.db_creds.data["username"] password = data.vault_generic_secret.db_creds.data["password"]
lifecycle { ignore_changes = [password] # Don't show in plan }}
# Dynamic secrets - even betterdata "vault_aws_access_credentials" "creds" { backend = "aws" role = "terraform-role" type = "sts"}
provider "aws" { access_key = data.vault_aws_access_credentials.creds.access_key secret_key = data.vault_aws_access_credentials.creds.secret_key token = data.vault_aws_access_credentials.creds.security_token}Solution 2: AWS Secrets Manager
Section titled “Solution 2: AWS Secrets Manager”# Read existing secretdata "aws_secretsmanager_secret_version" "db_password" { secret_id = "production/database/password"}
resource "aws_db_instance" "main" { identifier = "production-db" engine = "postgres" instance_class = "db.r5.large"
username = "admin" password = jsondecode(data.aws_secretsmanager_secret_version.db_password.secret_string)["password"]}
# Create and manage secret (for initial setup)resource "aws_secretsmanager_secret" "db_password" { name = "production/database/password" recovery_window_in_days = 7
tags = { Environment = "production" ManagedBy = "terraform" }}
# Generate random passwordresource "random_password" "db_password" { length = 32 special = true override_special = "!#$%&*()-_=+[]{}<>:?"}
# Store in Secrets Managerresource "aws_secretsmanager_secret_version" "db_password" { secret_id = aws_secretsmanager_secret.db_password.id secret_string = jsonencode({ username = "admin" password = random_password.db_password.result })
lifecycle { ignore_changes = [secret_string] # Don't rotate on every apply }}Solution 3: External Secrets Operator (Kubernetes)
Section titled “Solution 3: External Secrets Operator (Kubernetes)”# ExternalSecret syncs secrets from external providers to KubernetesapiVersion: external-secrets.io/v1beta1kind: ExternalSecretmetadata: name: database-credentials namespace: productionspec: refreshInterval: 1h secretStoreRef: kind: ClusterSecretStore name: aws-secrets-manager target: name: database-credentials creationPolicy: Owner data: - secretKey: username remoteRef: key: production/database property: username - secretKey: password remoteRef: key: production/database property: password# Terraform to deploy External Secrets Operatorresource "helm_release" "external_secrets" { name = "external-secrets" repository = "https://charts.external-secrets.io" chart = "external-secrets" namespace = "external-secrets" create_namespace = true
values = [<<-EOF installCRDs: true serviceAccount: annotations: eks.amazonaws.com/role-arn: ${aws_iam_role.external_secrets.arn} EOF ]}
# IAM role for External Secretsresource "aws_iam_role" "external_secrets" { name = "external-secrets-role"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Federated = aws_iam_openid_connect_provider.eks.arn } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "${replace(aws_eks_cluster.main.identity[0].oidc[0].issuer, "https://", "")}:sub" = "system:serviceaccount:external-secrets:external-secrets" } } }] })}
resource "aws_iam_role_policy" "external_secrets" { name = "external-secrets-policy" role = aws_iam_role.external_secrets.id
policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = [ "secretsmanager:GetSecretValue", "secretsmanager:DescribeSecret" ] Resource = "arn:aws:secretsmanager:*:*:secret:production/*" }] })}Solution 4: SOPS (Secrets OPerationS)
Section titled “Solution 4: SOPS (Secrets OPerationS)”SOPS encrypts secrets files that can be safely committed to git:
# Install SOPSbrew install sops
# Create .sops.yaml for configurationcat > .sops.yaml << 'EOF'creation_rules: - path_regex: \.enc\.yaml$ kms: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012 - path_regex: \.enc\.json$ kms: arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012EOF
# Encrypt a secrets filecat > secrets.yaml << 'EOF'database: username: admin password: SuperSecretPassword123!api_keys: stripe: sk_live_xxxxx sendgrid: SG.xxxxxEOF
sops -e secrets.yaml > secrets.enc.yamlrm secrets.yaml # Remove plaintext
# Decrypt for usesops -d secrets.enc.yaml > secrets.yaml
# Use with Terraform via templatefiledata "sops_file" "secrets" { source_file = "secrets.enc.yaml"}
resource "aws_db_instance" "main" { password = data.sops_file.secrets.data["database.password"]}State File Security
Section titled “State File Security”The Danger of State Files
Section titled “The Danger of State Files”Terraform state files contain:
- All resource attributes (including sensitive values)
- Resource IDs that enable targeting
- Provider credentials if improperly configured
- Complete infrastructure topology
┌─────────────────────────────────────────────────────────────────┐│ TERRAFORM STATE FILE CONTENTS │├─────────────────────────────────────────────────────────────────┤│ ││ { ││ "resources": [ ││ { ││ "type": "aws_db_instance", ││ "instances": [{ ││ "attributes": { ││ "username": "admin", ││ "password": "EXPOSED_IN_PLAINTEXT!", ◄── DANGER! ││ "endpoint": "prod-db.xxx.us-east-1.rds.amazonaws" ││ } ││ }] ││ }, ││ { ││ "type": "aws_iam_access_key", ││ "instances": [{ ││ "attributes": { ││ "secret": "EXPOSED_SECRET_KEY!" ◄── DANGER! ││ } ││ }] ││ } ││ ] ││ } ││ │└─────────────────────────────────────────────────────────────────┘Secure State Storage
Section titled “Secure State Storage”# backend.tf - Secure S3 backend configurationterraform { backend "s3" { bucket = "company-terraform-state" key = "production/infrastructure/terraform.tfstate" region = "us-east-1" encrypt = true kms_key_id = "arn:aws:kms:us-east-1:123456789012:key/12345678-1234-1234-1234-123456789012" dynamodb_table = "terraform-state-lock"
# Role assumption for least privilege role_arn = "arn:aws:iam::123456789012:role/TerraformStateAccess" }}# Create secure state bucketresource "aws_s3_bucket" "terraform_state" { bucket = "company-terraform-state"
lifecycle { prevent_destroy = true }
tags = { Purpose = "Terraform State Storage" Sensitive = "true" }}
# Enable versioning for recoveryresource "aws_s3_bucket_versioning" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id versioning_configuration { status = "Enabled" }}
# Server-side encryption with KMSresource "aws_s3_bucket_server_side_encryption_configuration" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" kms_master_key_id = aws_kms_key.terraform_state.arn } bucket_key_enabled = true }}
# Block all public accessresource "aws_s3_bucket_public_access_block" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id
block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true}
# Require encryption in transitresource "aws_s3_bucket_policy" "terraform_state" { bucket = aws_s3_bucket.terraform_state.id
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "DenyUnencryptedConnections" Effect = "Deny" Principal = "*" Action = "s3:*" Resource = [ aws_s3_bucket.terraform_state.arn, "${aws_s3_bucket.terraform_state.arn}/*" ] Condition = { Bool = { "aws:SecureTransport" = "false" } } }, { Sid = "DenyIncorrectEncryption" Effect = "Deny" Principal = "*" Action = "s3:PutObject" Resource = "${aws_s3_bucket.terraform_state.arn}/*" Condition = { StringNotEquals = { "s3:x-amz-server-side-encryption" = "aws:kms" } } } ] })}
# State locking with DynamoDBresource "aws_dynamodb_table" "terraform_state_lock" { name = "terraform-state-lock" billing_mode = "PAY_PER_REQUEST" hash_key = "LockID"
attribute { name = "LockID" type = "S" }
point_in_time_recovery { enabled = true }
tags = { Purpose = "Terraform State Locking" }}
# KMS key for state encryptionresource "aws_kms_key" "terraform_state" { description = "KMS key for Terraform state encryption" deletion_window_in_days = 30 enable_key_rotation = true
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "RootAccess" Effect = "Allow" Principal = { AWS = "arn:aws:iam::123456789012:root" } Action = "kms:*" Resource = "*" }, { Sid = "TerraformAccess" Effect = "Allow" Principal = { AWS = "arn:aws:iam::123456789012:role/TerraformStateAccess" } Action = [ "kms:Encrypt", "kms:Decrypt", "kms:GenerateDataKey" ] Resource = "*" } ] })}Marking Sensitive Values
Section titled “Marking Sensitive Values”# Prevent sensitive values from appearing in logs/outputvariable "db_password" { description = "Database password" type = string sensitive = true}
output "db_connection_string" { description = "Database connection string" value = "postgresql://${var.db_username}:${var.db_password}@${aws_db_instance.main.endpoint}/app" sensitive = true}
# Sensitive values in resourcesresource "aws_ssm_parameter" "db_password" { name = "/production/database/password" type = "SecureString" value = var.db_password
lifecycle { ignore_changes = [value] }}IAM and Access Control
Section titled “IAM and Access Control”Principle of Least Privilege for Terraform
Section titled “Principle of Least Privilege for Terraform”# Bad: Overly permissive Terraform roleresource "aws_iam_role_policy" "terraform_bad" { name = "terraform-full-access" role = aws_iam_role.terraform.id
policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Action = "*" # ❌ TOO BROAD Resource = "*" # ❌ TOO BROAD }] })}
# Good: Scoped permissions per environmentresource "aws_iam_role_policy" "terraform_production" { name = "terraform-production" role = aws_iam_role.terraform.id
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "EC2Management" Effect = "Allow" Action = [ "ec2:Describe*", "ec2:CreateTags", "ec2:DeleteTags", "ec2:RunInstances", "ec2:TerminateInstances", "ec2:StopInstances", "ec2:StartInstances" ] Resource = "*" Condition = { StringEquals = { "ec2:ResourceTag/Environment" = "production" } } }, { Sid = "VPCManagement" Effect = "Allow" Action = [ "ec2:CreateVpc", "ec2:DeleteVpc", "ec2:CreateSubnet", "ec2:DeleteSubnet", "ec2:CreateSecurityGroup", "ec2:DeleteSecurityGroup", "ec2:AuthorizeSecurityGroupIngress", "ec2:AuthorizeSecurityGroupEgress", "ec2:RevokeSecurityGroupIngress", "ec2:RevokeSecurityGroupEgress" ] Resource = "*" Condition = { StringEquals = { "aws:RequestTag/Environment" = "production" } } }, { Sid = "S3StateAccess" Effect = "Allow" Action = [ "s3:GetObject", "s3:PutObject", "s3:DeleteObject" ] Resource = "arn:aws:s3:::company-terraform-state/production/*" }, { Sid = "DynamoDBLocking" Effect = "Allow" Action = [ "dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:DeleteItem" ] Resource = "arn:aws:dynamodb:*:*:table/terraform-state-lock" } ] })}Permission Boundaries
Section titled “Permission Boundaries”# Permission boundary prevents privilege escalationresource "aws_iam_policy" "terraform_boundary" { name = "terraform-permission-boundary"
policy = jsonencode({ Version = "2012-10-17" Statement = [ { Sid = "AllowedServices" Effect = "Allow" Action = [ "ec2:*", "rds:*", "s3:*", "eks:*", "elasticloadbalancing:*", "autoscaling:*", "cloudwatch:*", "logs:*", "sns:*", "sqs:*", "kms:*", "secretsmanager:*" ] Resource = "*" }, { Sid = "DenyIAMEscalation" Effect = "Deny" Action = [ "iam:CreateUser", "iam:DeleteUser", "iam:AttachUserPolicy", "iam:CreateLoginProfile", "iam:UpdateLoginProfile", "iam:CreateAccessKey", "iam:UpdateAccessKey", "iam:PutUserPolicy", "iam:DeleteUserPolicy" ] Resource = "*" }, { Sid = "DenyBillingAccess" Effect = "Deny" Action = [ "aws-portal:*", "budgets:*", "ce:*" ] Resource = "*" }, { Sid = "DenyOrganizationsAccess" Effect = "Deny" Action = "organizations:*" Resource = "*" } ] })}
# Apply boundary to Terraform roleresource "aws_iam_role" "terraform" { name = "TerraformRole" permissions_boundary = aws_iam_policy.terraform_boundary.arn
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Service = "codebuild.amazonaws.com" } Action = "sts:AssumeRole" }] })}CI/CD Pipeline Security
Section titled “CI/CD Pipeline Security”Secure GitHub Actions Workflow
Section titled “Secure GitHub Actions Workflow”name: Terraform Secure Pipeline
on: pull_request: paths: ['terraform/**'] push: branches: [main] paths: ['terraform/**']
# Minimal permissionspermissions: contents: read pull-requests: write id-token: write # For OIDC
env: TF_VERSION: "1.6.0" AWS_REGION: "us-east-1"
jobs: security-scan: name: Security Scanning runs-on: ubuntu-latest steps: - uses: actions/checkout@v4
# Scan for secrets in code - name: Gitleaks Secret Scan uses: gitleaks/gitleaks-action@v2 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# Terraform security scan - name: Checkov Security Scan uses: bridgecrewio/checkov-action@v12 with: directory: terraform/ framework: terraform soft_fail: false output_format: sarif output_file_path: checkov.sarif
# Upload results to GitHub Security - name: Upload Checkov Results uses: github/codeql-action/upload-sarif@v2 if: always() with: sarif_file: checkov.sarif
plan: name: Terraform Plan runs-on: ubuntu-latest needs: security-scan environment: production-plan # Requires approval outputs: plan_exit_code: ${{ steps.plan.outputs.exitcode }} steps: - uses: actions/checkout@v4
- name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: ${{ env.TF_VERSION }}
# OIDC authentication - no static credentials - name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsTerraform aws-region: ${{ env.AWS_REGION }} role-session-name: GitHubActions-${{ github.run_id }}
- name: Terraform Init run: | cd terraform/environments/production terraform init -backend-config="encrypt=true"
- name: Terraform Plan id: plan run: | cd terraform/environments/production terraform plan -detailed-exitcode -out=tfplan 2>&1 | tee plan.txt echo "exitcode=$?" >> $GITHUB_OUTPUT continue-on-error: true
# Store plan as artifact (encrypted) - name: Encrypt and Upload Plan run: | cd terraform/environments/production gpg --symmetric --cipher-algo AES256 --batch --passphrase "${{ secrets.PLAN_ENCRYPTION_KEY }}" tfplan gpg --symmetric --cipher-algo AES256 --batch --passphrase "${{ secrets.PLAN_ENCRYPTION_KEY }}" plan.txt
- uses: actions/upload-artifact@v4 with: name: terraform-plan path: | terraform/environments/production/tfplan.gpg terraform/environments/production/plan.txt.gpg
# Comment plan on PR (sanitized) - name: Comment Plan on PR if: github.event_name == 'pull_request' uses: actions/github-script@v7 with: script: | const fs = require('fs'); const plan = fs.readFileSync('terraform/environments/production/plan.txt', 'utf8');
// Sanitize sensitive info from plan output const sanitized = plan .replace(/password\s*=\s*"[^"]*"/gi, 'password = "***REDACTED***"') .replace(/secret\s*=\s*"[^"]*"/gi, 'secret = "***REDACTED***"') .replace(/token\s*=\s*"[^"]*"/gi, 'token = "***REDACTED***"');
const output = `#### Terraform Plan 📖
<details><summary>Show Plan</summary>
\`\`\` ${sanitized.substring(0, 60000)} \`\`\`
</details>`;
github.rest.issues.createComment({ issue_number: context.issue.number, owner: context.repo.owner, repo: context.repo.repo, body: output });
apply: name: Terraform Apply runs-on: ubuntu-latest needs: plan if: github.ref == 'refs/heads/main' && needs.plan.outputs.plan_exit_code == '2' environment: production-apply # Requires approval steps: - uses: actions/checkout@v4
- name: Setup Terraform uses: hashicorp/setup-terraform@v3 with: terraform_version: ${{ env.TF_VERSION }}
- name: Configure AWS Credentials uses: aws-actions/configure-aws-credentials@v4 with: role-to-assume: arn:aws:iam::123456789012:role/GitHubActionsTerraform aws-region: ${{ env.AWS_REGION }}
- uses: actions/download-artifact@v4 with: name: terraform-plan
- name: Decrypt Plan run: | cd terraform/environments/production gpg --decrypt --batch --passphrase "${{ secrets.PLAN_ENCRYPTION_KEY }}" tfplan.gpg > tfplan
- name: Terraform Init run: | cd terraform/environments/production terraform init
- name: Terraform Apply run: | cd terraform/environments/production terraform apply -auto-approve tfplanOIDC Authentication (No Static Credentials)
Section titled “OIDC Authentication (No Static Credentials)”# Create GitHub OIDC provider in AWSresource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com"
client_id_list = ["sts.amazonaws.com"]
thumbprint_list = [ "6938fd4d98bab03faadb97b34396831e3780aea1", "1c58a3a8518e8759bf075b76b750d4f2df264fcd" ]}
# Create role that GitHub Actions can assumeresource "aws_iam_role" "github_actions_terraform" { name = "GitHubActionsTerraform"
assume_role_policy = jsonencode({ Version = "2012-10-17" Statement = [{ Effect = "Allow" Principal = { Federated = aws_iam_openid_connect_provider.github.arn } Action = "sts:AssumeRoleWithWebIdentity" Condition = { StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" } StringLike = { # Only allow from specific repo and branch "token.actions.githubusercontent.com:sub" = "repo:company/infrastructure:ref:refs/heads/main" } } }] })
# Apply permission boundary permissions_boundary = aws_iam_policy.terraform_boundary.arn}Compliance and Auditing
Section titled “Compliance and Auditing”Compliance Frameworks in Code
Section titled “Compliance Frameworks in Code”# SOC2 and HIPAA compliant S3 bucket
resource "aws_s3_bucket" "compliant" { bucket = var.bucket_name
tags = merge(var.tags, { Compliance = var.compliance_framework DataClassification = var.data_classification Owner = var.owner })}
# Requirement: Encryption at restresource "aws_s3_bucket_server_side_encryption_configuration" "compliant" { bucket = aws_s3_bucket.compliant.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" kms_master_key_id = var.kms_key_id } }}
# Requirement: Access loggingresource "aws_s3_bucket_logging" "compliant" { bucket = aws_s3_bucket.compliant.id
target_bucket = var.logging_bucket target_prefix = "${var.bucket_name}/"}
# Requirement: Versioning for data integrityresource "aws_s3_bucket_versioning" "compliant" { bucket = aws_s3_bucket.compliant.id versioning_configuration { status = "Enabled" }}
# Requirement: Block public accessresource "aws_s3_bucket_public_access_block" "compliant" { bucket = aws_s3_bucket.compliant.id
block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true}
# Requirement: Lifecycle for data retentionresource "aws_s3_bucket_lifecycle_configuration" "compliant" { bucket = aws_s3_bucket.compliant.id
rule { id = "retention" status = "Enabled"
transition { days = 90 storage_class = "STANDARD_IA" }
transition { days = 180 storage_class = "GLACIER" }
expiration { days = var.retention_days }
noncurrent_version_expiration { noncurrent_days = var.retention_days } }}
# Compliance validationresource "null_resource" "compliance_check" { triggers = { bucket_id = aws_s3_bucket.compliant.id }
provisioner "local-exec" { command = <<-EOT # Verify encryption encryption=$(aws s3api get-bucket-encryption --bucket ${aws_s3_bucket.compliant.id} 2>/dev/null) if [ -z "$encryption" ]; then echo "COMPLIANCE FAILURE: Encryption not enabled" exit 1 fi
# Verify public access block public_access=$(aws s3api get-public-access-block --bucket ${aws_s3_bucket.compliant.id}) if echo "$public_access" | grep -q '"BlockPublicAcls": false'; then echo "COMPLIANCE FAILURE: Public access not fully blocked" exit 1 fi
echo "COMPLIANCE CHECK PASSED" EOT }}Audit Trail
Section titled “Audit Trail”# Enable CloudTrail for all Terraform operationsresource "aws_cloudtrail" "terraform_audit" { name = "terraform-audit-trail" s3_bucket_name = aws_s3_bucket.cloudtrail.id include_global_service_events = true is_multi_region_trail = true enable_log_file_validation = true kms_key_id = aws_kms_key.cloudtrail.arn
event_selector { read_write_type = "All" include_management_events = true
data_resource { type = "AWS::S3::Object" values = ["${aws_s3_bucket.terraform_state.arn}/"] } }
tags = { Purpose = "Terraform Audit Trail" }}
# Alert on state file accessresource "aws_cloudwatch_log_metric_filter" "state_access" { name = "terraform-state-access" pattern = "{ $.eventSource = s3.amazonaws.com && $.requestParameters.bucketName = terraform-state }" log_group_name = aws_cloudwatch_log_group.cloudtrail.name
metric_transformation { name = "StateFileAccess" namespace = "TerraformSecurity" value = "1" }}
resource "aws_cloudwatch_metric_alarm" "state_access_alert" { alarm_name = "terraform-state-unusual-access" comparison_operator = "GreaterThanThreshold" evaluation_periods = 1 metric_name = "StateFileAccess" namespace = "TerraformSecurity" period = 300 statistic = "Sum" threshold = 10 alarm_description = "Unusual number of Terraform state file accesses" alarm_actions = [aws_sns_topic.security_alerts.arn]}War Story: The State File Breach
Section titled “War Story: The State File Breach”Company: Financial services provider Incident: Complete infrastructure compromise via state file
Timeline:
- Day 1: Developer commits AWS credentials to public GitHub repo
- Day 7: Credentials discovered by automated scanner (attacker)
- Day 8: Attacker finds S3 bucket with Terraform state via naming convention
- Day 14: Attacker downloads state files from 47 projects
- Day 21: Attacker begins accessing production systems using credentials from state
- Day 28: Security team detects unusual database queries
- Day 29: Full incident response initiated
What the attacker gained from state files:
From terraform.tfstate:├── RDS passwords (plaintext)├── API keys for third-party services├── SSH key passphrases├── IAM user secret access keys├── SSL certificate private keys├── Application secrets├── VPN pre-shared keys└── Complete network topologyFinancial Impact:
- Incident response: $890K
- Forensics and investigation: $340K
- Regulatory fines (PCI DSS): $2.1M
- Customer notification: $450K
- Credit monitoring: $1.2M
- Legal fees: $780K
- Lost business: $4.2M
- Insurance premium increase: $340K/year
- Total first year: $10.3M
Security Measures That Would Have Prevented This:
# 1. State encryption with customer-managed keysterraform { backend "s3" { encrypt = true kms_key_id = "alias/terraform-state" # CMK, not default }}
# 2. Secret values from Vault, not in statedata "vault_generic_secret" "db" { path = "secret/database"}
# 3. Restrict state bucket accessresource "aws_s3_bucket_policy" "state" { policy = jsonencode({ Statement = [{ Effect = "Deny" Principal = "*" Action = "s3:*" Resource = "${aws_s3_bucket.state.arn}/*" Condition = { StringNotEquals = { "aws:PrincipalArn" = [ "arn:aws:iam::123456789012:role/TerraformRole" ] } } }] })}
# 4. No IAM access keys - use OIDCresource "aws_iam_openid_connect_provider" "github" { url = "https://token.actions.githubusercontent.com" # ...}
# 5. Audit all state accessresource "aws_cloudtrail" "state_audit" { # Log all S3 data events for state bucket}Common Mistakes
Section titled “Common Mistakes”| Mistake | Risk | Solution |
|---|---|---|
| Secrets in terraform.tfvars | Committed to git, exposed | Use Vault, Secrets Manager, or SOPS |
| State file in version control | Secrets exposed in plaintext | Use remote backend with encryption |
| Overly permissive IAM for Terraform | Blast radius too large | Least privilege per environment |
| No state file encryption | Data exposure if bucket accessed | Enable SSE-KMS with CMK |
| Static credentials in CI/CD | Credential theft risk | Use OIDC federation |
| Skipping security scans | Misconfigurations reach production | Mandatory Checkov/tfsec in pipeline |
| No audit trail | Can’t detect or investigate breaches | Enable CloudTrail for state bucket |
| Sensitive outputs not marked | Appear in logs and console | Always use sensitive = true |
Test your understanding of IaC security:
1. Why is the Terraform state file considered highly sensitive?
Answer: The Terraform state file contains:
- All resource attribute values, including sensitive ones (passwords, API keys, etc.) in plaintext
- Complete infrastructure topology (resource IDs, endpoints, IP addresses)
- Provider credentials if improperly configured
- Enough information to reconstruct and access the entire infrastructure
An attacker with state file access can extract credentials, understand the network topology, and target specific resources.
2. What is the advantage of OIDC federation over static IAM credentials for CI/CD?
Answer: OIDC federation:
- No stored secrets: No static credentials to leak or rotate
- Short-lived tokens: Each run gets temporary credentials (15min-1hr)
- Condition-based access: Can restrict to specific repos, branches, or workflows
- Audit trail: Each assume role call is logged with session name
- No credential rotation: Credentials are generated per-session
- Reduced blast radius: If pipeline is compromised, only current session affected
3. What security controls should a compliant S3 bucket have according to SOC2/HIPAA?
Answer: A compliant S3 bucket requires:
- Encryption at rest: Server-side encryption with KMS (not just AES-256)
- Encryption in transit: HTTPS-only bucket policy
- Access logging: All access logged to separate bucket
- Versioning: For data integrity and recovery
- Public access blocked: All four public access block settings enabled
- Lifecycle policies: For data retention compliance
- Access controls: Least privilege IAM policies
- Audit trail: CloudTrail logging of all data events
4. Calculate the total cost of a state file breach affecting 2.3 million records with an average cost of $150 per record.
Answer:
- Direct breach cost: 2,300,000 × $150 = $345,000,000
However, actual costs typically include:
- Incident response: ~$500K-$1M
- Forensics: ~$300K-$500K
- Regulatory fines: Variable (GDPR up to 4% revenue)
- Legal fees: ~$500K-$2M
- Customer notification: ~$1-5 per record
- Credit monitoring: ~$50-100 per affected person
- Lost business: 20-40% customer churn
The $150/record IBM figure includes these factors as an average.
5. What is a permission boundary and why use one with Terraform roles?
Answer: A permission boundary is an IAM policy that sets the maximum permissions a role can have, regardless of other attached policies. For Terraform:
- Prevents privilege escalation: Terraform can’t create more powerful roles than itself
- Limits blast radius: Even if Terraform is compromised, it can’t access everything
- Enforces guardrails: Certain actions (billing, organizations) always denied
- Enables delegation: Teams can create roles without admin review, within bounds
Example: A permission boundary might deny iam:CreateUser, organizations:*, and aws-portal:* while allowing normal infrastructure operations.
6. What is the difference between Checkov, tfsec, and Terrascan?
Answer:
- Checkov: Most comprehensive, supports Terraform/CloudFormation/Kubernetes/Docker, 1000+ policies, Python-based, easy custom policies
- tfsec: Terraform-specific (now part of Trivy), faster for Terraform-only, YAML-based custom rules, good IDE integration
- Terrascan: Uses Rego policies (like OPA), good for orgs already using OPA, supports multiple IaC frameworks
In practice, many teams use multiple scanners as they catch different issues. Checkov is often the primary choice for comprehensive coverage.
7. Why should you encrypt Terraform plan files in CI/CD pipelines?
Answer: Terraform plan files contain:
- All values that will be written to state (including sensitive ones)
- Resource changes with before/after values
- Variable values (including sensitive variables)
If stored unencrypted as CI/CD artifacts:
- Other pipeline stages might access them
- Artifact retention might expose them long-term
- Log aggregation might capture them
- Failed pipelines leave them accessible
Encrypting with GPG/age before storing as artifacts ensures only authorized steps can read them.
8. A team discovered that their Terraform state file was accessed from an unknown IP address. What immediate actions should they take?
Answer: Immediate response:
- Rotate all secrets in state: Every password, API key, and credential
- Invalidate sessions: Revoke all active sessions for exposed service accounts
- Review CloudTrail: What else was accessed? What actions were taken?
- Enable state locking: Prevent further modifications
- Change backend credentials: Rotate S3 access keys, KMS keys
- Audit recent changes: Look for unauthorized infrastructure modifications
- Notify security team: Begin formal incident response
- Preserve evidence: Don’t delete logs, make copies of artifacts
- Check for persistence: Look for new IAM users, access keys, or roles
- Enable additional monitoring: Alert on all state access temporarily
Hands-On Exercise
Section titled “Hands-On Exercise”Objective: Implement a secure Terraform configuration with secrets management and security scanning.
Part 1: Set Up Security Scanning
Section titled “Part 1: Set Up Security Scanning”# Create project structuremkdir -p secure-iac-lab/{terraform,policy}cd secure-iac-lab
# Create intentionally insecure Terraformcat > terraform/main.tf << 'EOF'# Intentionally insecure for demonstration
variable "db_password" { default = "Password123!" # BAD: Hardcoded secret}
resource "aws_s3_bucket" "data" { bucket = "my-data-bucket" # Missing: encryption, versioning, public access block}
resource "aws_security_group" "web" { name = "web-sg"
ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] # BAD: SSH open to world }}
resource "aws_db_instance" "main" { identifier = "production" engine = "mysql" instance_class = "db.t3.micro"
username = "admin" password = var.db_password
publicly_accessible = true # BAD: DB public storage_encrypted = false # BAD: No encryption}EOF
# Run security scanspip install checkovcheckov -d terraform/
# You should see multiple failuresPart 2: Fix Security Issues
Section titled “Part 2: Fix Security Issues”# Create secure versioncat > terraform/main_secure.tf << 'EOF'# Secure configuration
variable "db_password" { description = "Database password - provide via TF_VAR_db_password" type = string sensitive = true # No default - must be provided}
resource "aws_s3_bucket" "data" { bucket = "my-data-bucket-${random_id.suffix.hex}"
tags = { Environment = "production" ManagedBy = "terraform" }}
resource "aws_s3_bucket_versioning" "data" { bucket = aws_s3_bucket.data.id versioning_configuration { status = "Enabled" }}
resource "aws_s3_bucket_server_side_encryption_configuration" "data" { bucket = aws_s3_bucket.data.id
rule { apply_server_side_encryption_by_default { sse_algorithm = "aws:kms" } }}
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_security_group" "web" { name = "web-sg" description = "Web server security group"
# SSH only from internal network ingress { from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["10.0.0.0/8"] description = "SSH from internal only" }
# HTTPS from anywhere ingress { from_port = 443 to_port = 443 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] description = "HTTPS" }
egress { from_port = 0 to_port = 0 protocol = "-1" cidr_blocks = ["0.0.0.0/0"] }
tags = { Name = "web-sg" }}
resource "aws_db_instance" "main" { identifier = "production" engine = "mysql" instance_class = "db.t3.micro"
username = "admin" password = var.db_password
publicly_accessible = false storage_encrypted = true deletion_protection = true skip_final_snapshot = false final_snapshot_identifier = "production-final-snapshot"
vpc_security_group_ids = [aws_security_group.db.id] db_subnet_group_name = aws_db_subnet_group.main.name
tags = { Environment = "production" }
lifecycle { ignore_changes = [password] }}
resource "random_id" "suffix" { byte_length = 4}EOF
# Verify fixescheckov -d terraform/ -f terraform/main_secure.tfPart 3: Implement Secrets Management
Section titled “Part 3: Implement Secrets Management”# Create secrets management examplecat > terraform/secrets.tf << 'EOF'# Using AWS Secrets Manager
# Create the secretresource "aws_secretsmanager_secret" "db_password" { name = "production/database/password" recovery_window_in_days = 7}
# Generate random passwordresource "random_password" "db" { length = 32 special = true override_special = "!#$%&*()-_=+[]{}:?"}
# Store passwordresource "aws_secretsmanager_secret_version" "db_password" { secret_id = aws_secretsmanager_secret.db_password.id secret_string = jsonencode({ username = "admin" password = random_password.db.result })}
# Reference secret in RDSdata "aws_secretsmanager_secret_version" "db" { secret_id = aws_secretsmanager_secret.db_password.id depends_on = [aws_secretsmanager_secret_version.db_password]}
locals { db_creds = jsondecode(data.aws_secretsmanager_secret_version.db.secret_string)}
# Use in database (reference to above RDS)# password = local.db_creds["password"]EOFSuccess Criteria
Section titled “Success Criteria”- Initial Checkov scan shows 5+ security issues
- Fixed configuration passes all Checkov checks
- No hardcoded secrets in final configuration
- S3 bucket has encryption, versioning, and public access block
- Security group restricts SSH to internal network
- Database is not publicly accessible and has encryption
Key Takeaways
Section titled “Key Takeaways”- State files are crown jewels - Encrypt, restrict access, audit all access
- Never hardcode secrets - Use Vault, Secrets Manager, SOPS, or environment variables
- Scan early and often - Checkov, tfsec, Trivy in every PR
- Use OIDC for CI/CD - No static credentials to steal
- Apply permission boundaries - Limit what Terraform can do even when compromised
- Mark sensitive values -
sensitive = trueprevents logging - Audit everything - CloudTrail for state bucket, alerts on unusual access
- Least privilege always - Terraform role only needs what it manages
- Compliance in code - Encode requirements in modules and policies
- Assume breach mentality - Design so state file theft isn’t catastrophic
Did You Know?
Section titled “Did You Know?”State File Statistics: A 2023 survey found that 43% of organizations store Terraform state files without encryption, and 12% store them in version control systems accessible to all developers.
Checkov Adoption: Bridgecrew’s Checkov has over 1,000 built-in policies and scans over 5 million infrastructure configurations per month, catching an average of 47 misconfigurations per project.
OIDC Origin: The OpenID Connect protocol used for GitHub Actions authentication was standardized in 2014, but adoption for CI/CD didn’t become widespread until AWS added support in 2021.
Cost of Secrets: According to GitGuardian’s 2023 report, over 10 million secrets were detected in public GitHub commits, with the average time to remediation being 327 days.
Next Module
Section titled “Next Module”Continue to Module 6.4: IaC at Scale to learn about managing infrastructure as code across large organizations with multiple teams and environments.