jan-karel.com
Home / Security Measures / Cloud Security / Infrastructure as Code Security

Infrastructure as Code Security

Infrastructure as Code Security

Infrastructure as Code Security

Policy In Code, Not In Hope

In the cloud, consistency is crucial: policy in code, minimal permissions, and visibility into drift.

For Infrastructure as Code Security, success depends on policy-as-code and controls that continuously run alongside the delivery chain.

This way you maintain speed in the cloud, without security depending on manual luck.

Immediate measures (15 minutes)

Why this matters

The core of Infrastructure as Code Security is risk reduction in practice. Technical context supports the choice of measures, but implementation and assurance are central.

Why IaC Security

Infrastructure as Code has fundamentally changed the way we manage infrastructure. Instead of manually configuring servers, we describe our environment in declarative code. That brings advantages — version control, repeatability, auditability — but also new risks.

Infrastructure drift occurs when the actual state of your infrastructure deviates from what is in your code, usually through manual console changes outside the IaC process. Every manual change is a potential misconfiguration that does not go through your security pipeline.

Misconfiguration is attack vector number 1 in the cloud. Not zero-days, not advanced exploits — just misconfigured resources. IaC offers the ability to systematically prevent these misconfigurations, but only if the IaC code itself is secure.

Shift-left security means finding security flaws before they reach production — already in the IDE, in the pull request, or in the CI/CD pipeline, long before terraform apply runs.

Terraform Security

State file protection

The Terraform state file contains the complete mapping between code and infrastructure, including sensitive values such as passwords and access keys — in plain text.

# Secure remote backend configuration with S3 + DynamoDB
terraform {
  backend "s3" {
    bucket         = "company-terraform-state"
    key            = "production/network/terraform.tfstate"
    region         = "eu-west-1"
    encrypt        = true
    kms_key_id     = "arn:aws:kms:eu-west-1:111111111111:key/abcd-1234"
    dynamodb_table = "terraform-state-lock"
  }
}
Measure Implementation Priority
Remote backend S3, GCS, Azure Blob or Terraform Cloud Critical
Encryption at rest SSE-KMS (S3), CMEK (GCS) Critical
Locking DynamoDB (AWS), native (GCS, Azure) Critical
Access control IAM policies, bucket policies Critical
Versioning Enable S3 bucket versioning High
State file not in git Always update .gitignore Critical

Sensitive values and provider credentials

# Mark variables as sensitive
variable "db_password" {
  type      = string
  sensitive = true  # Prevents display in CLI output, NOT in state file
}

# WRONG: credentials hardcoded
provider "aws" {
  access_key = "AKIAIOSFODNN7EXAMPLE"       # NEVER DO THIS
  secret_key = "wJalrXUtnFEMI/K7MDENG/bPx"  # NEVER DO THIS
}

# CORRECT: credentials from the environment
provider "aws" {
  region = "eu-west-1"
  # Via AWS_ACCESS_KEY_ID env vars, credential file, instance profile or OIDC
}

Module pinning

# WRONG: without version pinning (supply chain risk)
module "vpc" {
  source = "terraform-aws-modules/vpc/aws"
}

# CORRECT: specific version + lock file
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.5.1"
  # Commit .terraform.lock.hcl in git for reproducible builds
}

# CORRECT: Git module with specific tag
module "custom" {
  source = "git::https://github.com/company/tf-modules.git//vpc?ref=v2.1.0"
}

CloudFormation Security

NoEcho parameters and Secrets Manager

AWSTemplateFormatVersion: '2010-09-09'
Parameters:
  DatabasePassword:
    Type: String
    NoEcho: true
    MinLength: 16

Resources:
  # Better: let Secrets Manager generate the password
  DatabaseSecret:
    Type: AWS::SecretsManager::Secret
    Properties:
      Name: /app/production/db-password
      GenerateSecretString:
        PasswordLength: 32
        ExcludeCharacters: '"@/\'

Stack policies

{
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "Update:Modify",
      "Principal": "*",
      "Resource": "*"
    },
    {
      "Effect": "Deny",
      "Action": ["Update:Replace", "Update:Delete"],
      "Principal": "*",
      "Resource": "LogicalResourceId/ProductionDatabase"
    }
  ]
}

StackSets guardrails: use SERVICE_MANAGED permission model with auto-deployment and set FailureTolerancePercentage=0 so that a single failure stops the rollout.

Policy as Code

Framework Language Platform Integration
Open Policy Agent (OPA) Rego Multi-cloud Conftest, Terraform, K8s
HashiCorp Sentinel Sentinel Terraform Enterprise/Cloud Native
AWS SCP JSON AWS Organizations Native
Azure Policy JSON Azure Native
Google Org Policies YAML/JSON GCP Native

Rego example: block public S3 buckets

package terraform.s3

deny[msg] {
    resource := input.resource.aws_s3_bucket[name]
    resource.acl == "public-read"
    msg := sprintf("S3 bucket '%s' has a public ACL.", [name])
}

deny[msg] {
    resource := input.resource.aws_s3_bucket_public_access_block[name]
    resource.block_public_acls != true
    msg := sprintf("S3 bucket '%s' does not block public ACLs.", [name])
}
# Evaluate with Conftest
conftest test --policy policy/ tfplan.json

Sentinel example: mandatory encryption

import "tfplan/v2" as tfplan

mandatory_encryption = rule {
    all tfplan.resource_changes as _, rc {
        rc.type is "aws_ebs_volume" implies
            rc.change.after.encrypted is true
    }
}

main = rule { mandatory_encryption }

Static analysis tools

Tool Supported languages CI/CD integration Notable features
tfsec Terraform (HCL) GitHub Actions, GitLab CI, Jenkins Fast, good Terraform coverage
Checkov TF, CFN, K8s, ARM, Helm All major CI/CD platforms Broadest language support
KICS 15+ IaC languages GitHub Actions, GitLab CI Docker, Ansible, Pulumi
Terrascan TF, K8s, Helm, Dockerfiles GitHub Actions, ArgoCD OPA-based
Snyk IaC TF, CFN, K8s, ARM All major platforms Fix suggestions, IDE plugins

Pipeline integration (GitHub Actions)

name: IaC Security Scan
on:
  pull_request:
    paths: ['terraform/**']

jobs:
  iac-security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: tfsec scan
        uses: aquasecurity/tfsec-action@v1.0.3
        with:
          working_directory: terraform/
          soft_fail: false

      - name: Checkov scan
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: terraform/
          framework: terraform
          soft_fail: false

Drift Detection

Terraform plan as a detection tool

# Detect drift without applying
terraform plan -detailed-exitcode
# Exit codes: 0 = no drift, 1 = error, 2 = drift detected
# GitHub Actions: daily drift detection
name: Drift Detection
on:
  schedule:
    - cron: '0 6 * * *'

jobs:
  detect-drift:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3

      - name: Detect Drift
        run: |
          terraform init
          terraform plan -detailed-exitcode -no-color
        working-directory: terraform/production
        continue-on-error: true
      # On exit code 2: notification to Slack/Teams

Remediation

Strategy When to use Command
Import Manage a manually created resource terraform import aws_instance.web i-123456
Remove from state No longer manage resource via Terraform terraform state rm aws_instance.legacy
Force apply Terraform is the source of truth terraform apply

Secrets in IaC

Secrets do not belong in IaC code or state files. Yet this is one of the most common mistakes: deleting a secret from the current version does not remove it from the git history.

# Option 1: HashiCorp Vault
data "vault_generic_secret" "database" {
  path = "secret/production/database"
}

resource "aws_db_instance" "main" {
  username = data.vault_generic_secret.database.data["username"]
  password = data.vault_generic_secret.database.data["password"]
}

# Option 2: AWS Secrets Manager
data "aws_secretsmanager_secret_version" "db_creds" {
  secret_id = "production/database/credentials"
}

# Option 3: Environment variables
# export TF_VAR_db_password="secret"
# Terraform automatically reads TF_VAR_* variables

Pre-commit hooks and .gitignore

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/gitleaks/gitleaks
    rev: v8.18.0
    hooks:
      - id: gitleaks
# Terraform state and secrets
*.tfstate
*.tfstate.*
*.tfvars
*.tfvars.json
!example.tfvars
.terraform/
crash.log

CI/CD pipeline for IaC

A secure IaC pipeline follows the principle: plan in the pull request, apply only after approval.

name: Terraform Pipeline
on:
  pull_request:
    paths: ['terraform/**']
  push:
    branches: [main]
    paths: ['terraform/**']

permissions:
  contents: read
  pull-requests: write
  id-token: write

jobs:
  validate:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform fmt -check -recursive
      - run: terraform init -backend=false && terraform validate
      - uses: aquasecurity/tfsec-action@v1.0.3

  plan:
    needs: validate
    if: github.event_name == 'pull_request'
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/TerraformPlanRole
          aws-region: eu-west-1
      - run: terraform init && terraform plan -no-color

  apply:
    needs: validate
    if: github.ref == 'refs/heads/main'
    runs-on: ubuntu-latest
    environment: production  # Requires manual approval
    steps:
      - uses: actions/checkout@v4
      - uses: aws-actions/configure-aws-credentials@v4
        with:
          role-to-assume: arn:aws:iam::111111111111:role/TerraformApplyRole
          aws-region: eu-west-1
      - run: terraform init && terraform apply -auto-approve

Branch protection rules: at least 1 approval, security scan must pass, no direct pushes to main.

Common Mistakes

Mistake Risk Solution
State file in git All secrets visible in version control Remote backend with encryption
Credentials in HCL/YAML Hardcoded secrets Environment variables or vault
acl = "public-read" on S3 Data breach Policy as Code blocking
Wildcard IAM policies (*) Privilege escalation Least privilege, tfsec rules
No module pinning Supply chain attack Version pinning + lock file
Local state file No locking/encryption/backup Enforce remote backend
apply without plan review Destructive changes Plan-in-PR workflow
No drift detection Changes go unnoticed Scheduled plan checks
.tfvars with secrets in git Passwords in plain text Vault, SOPS, or env vars
Security groups 0.0.0.0/0 Unrestricted network access Checkov/tfsec + SCP
No state locking State corruption DynamoDB or native locking
destroy without protection Entire environment gone prevent_destroy + stack policies

Checklist

# Measure Priority
1 Remote backend with encryption for state files Critical
2 No credentials in IaC code Critical
3 State locking enabled Critical
4 .gitignore for state files and .tfvars Critical
5 Pre-commit hooks for secret detection (gitleaks) High
6 Static analysis in CI/CD pipeline (tfsec/Checkov) High
7 Plan-in-PR, apply-after-approval workflow High
8 Module version pinning with lock file High
9 OIDC federation for CI/CD credentials High
10 Branch protection on IaC repositories High
11 Policy as Code (OPA/Sentinel/SCP) High
12 External secret management (Vault/Secrets Manager) High
13 Daily drift detection Medium
14 sensitive = true on sensitive variables and outputs Medium
15 prevent_destroy on critical resources Medium
16 Stack policies for CloudFormation Medium
17 State file access auditing Medium
18 Terraform version pinning (required_version) Medium

We automated our infrastructure. Fantastic. We also automated our misconfigurations, but at scale and with version control so we can admire them forever. The Terraform state file is a masterpiece of irony: a file designed to manage your infrastructure, but that simultaneously stores every secret in plain text as if it were 1999. In the old days, a mistake on a server could at most break that one server. Now a single git push to main — followed by an auto-apply pipeline that someone set up "for convenience" — can level an entire AWS account. We call that progress. The state file, that sacred document, is still stored locally by half of all teams or accidentally committed to git, complete with database passwords and API keys. And when you ask why there is no encryption on it, you hear: "That's on the backlog." Just like the rest of the security.

Summary

Infrastructure as Code is a powerful tool for making infrastructure manageable, repeatable, and auditable — but only if the code itself is secure. The core of IaC security rests on four pillars: protect your state files as if they are crown jewels (remote backend, encryption, locking), keep secrets out of your code and version control (vault, environment variables, SOPS), automatically scan your configurations for misconfigurations (tfsec, Checkov, Policy as Code), and set up a secure pipeline with plan review and approval gates. Drift detection prevents manual changes from undermining your security baseline. Without these measures, you are not just automating your infrastructure, but also your vulnerabilities.

In the next chapter, we cover secrets management: how to securely store, rotate, and distribute sensitive values across your cloud environment without them ending up in code, logs, or state files.

Op de hoogte blijven?

Ontvang maandelijks cybersecurity-inzichten in je inbox.

← Cloud Security ← Home