Infrastructure as Code Security Scanning: Terraform, CloudFormation, and Kubernetes Manifests

A misconfigured S3 bucket in a Terraform file is not a theoretical risk. It is a production S3 bucket with public access enabled the moment someone runs terraform apply. The same misconfiguration that took thirty seconds to write will persist indefinitely unless something catches it — and that something has to be automated, because manual review at the scale modern teams deploy is not realistic.

This is the core promise of IaC security scanning: catch misconfigurations in code, before they become misconfigurations in infrastructure. This post covers the threat landscape, the most dangerous misconfiguration patterns, the tooling ecosystem, and how to integrate scanning into CI/CD pipelines effectively.

Why IaC Security Is Different from Runtime Security

Traditional security scanning focuses on running systems — vulnerability scanners probe live endpoints, DAST tools send payloads to web applications, agents monitor runtime behavior. IaC scanning works against the source of truth that generates those running systems.

This creates a fundamentally different risk profile. A single misconfigured resource block in a Terraform module that gets called across twenty environments deploys the same misconfiguration twenty times simultaneously. Runtime detection catches it after the fact, per environment, one at a time. IaC scanning catches it once, before any of those environments exist.

The other factor is blast radius. Organizations that have adopted IaC typically manage hundreds or thousands of resources from a relatively small number of template files. A permissive IAM policy in a shared module propagates to every resource that module creates. The leverage cuts both ways — fix it in code, fix it everywhere.

The IaC Landscape: What You Are Scanning

IaC security tooling has to cover a fragmented ecosystem. The major formats you will encounter in practice:

Terraform (HCL)

HashiCorp Configuration Language is the dominant multi-cloud IaC format. Terraform files describe desired state across AWS, GCP, Azure, and dozens of other providers. The provider ecosystem is vast, which means the attack surface for misconfigurations is also vast. Terraform state files are a separate concern — they frequently contain secrets and should be treated as sensitive artifacts.

CloudFormation (JSON/YAML)

AWS-native IaC. CloudFormation templates are deeply integrated with AWS services, including CDK (which compiles down to CloudFormation). Because CloudFormation is AWS-specific, tooling for it tends to have very precise knowledge of AWS resource properties and their security implications.

Pulumi

Pulumi uses general-purpose languages (TypeScript, Python, Go, C#) to define infrastructure. This makes it harder to scan statically — you are parsing code, not declarative config. Checkov has Pulumi support via its Python SDK parsing, but coverage is less complete than for HCL or YAML formats.

Kubernetes Manifests and Helm Charts

Kubernetes YAML manifests define Pod specifications, Deployments, StatefulSets, and other workload resources. Helm charts are templated versions of these manifests. The security surface here is different from cloud IaC — the concerns are around container privileges, network policies, RBAC, and resource constraints rather than cloud resource configurations.

Ansible

Ansible sits at the intersection of IaC and configuration management. Playbooks can provision cloud resources and configure OS-level settings. Checkov covers some Ansible patterns, particularly around AWS module usage.

Common IaC Security Misconfigurations

These are the patterns that appear repeatedly in IaC audits. Most are simple errors — defaults that were not overridden, arguments that were omitted because the resource deployed without them.

S3 Buckets

S3 remains one of the most common sources of cloud data exposure. Three misconfiguration patterns account for most incidents:

Public access not blocked. AWS provides a block public access setting at both the account and bucket level. Terraform's aws_s3_bucket_public_access_block resource should always accompany bucket definitions unless you have an explicit reason for public access (static website hosting, public assets). Omitting it relies on the account-level default, which may not be set.

# Misconfigured — no public access block
resource "aws_s3_bucket" "data" {
  bucket = "company-data-prod"
}

# Correct
resource "aws_s3_bucket" "data" {
  bucket = "company-data-prod"
}

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
}

No server-side encryption. S3 buckets are not encrypted at rest by default in older configurations. The aws_s3_bucket_server_side_encryption_configuration resource should be explicit, with preference for SSE-KMS over SSE-S3 where data sensitivity warrants it.

No versioning. Versioning is not a security control per se, but it is a recovery control that matters when ransomware or accidental deletion occurs. Most compliance frameworks require it for data buckets.

Security Groups

Overly permissive security groups are endemic. The pattern is usually pragmatic — someone opens a wide ingress rule to debug a connectivity problem and never closes it. IaC scanning catches these before they normalize in production.

# This will flag as critical in every IaC scanner
resource "aws_security_group_rule" "ssh" {
  type        = "ingress"
  from_port   = 22
  to_port     = 22
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]  # Open SSH to the world
  security_group_id = aws_security_group.bastion.id
}

# Also watch for:
# - Port 3389 (RDP) open to 0.0.0.0/0
# - Port 3306/5432 (MySQL/PostgreSQL) open externally
# - All-traffic ingress: from_port=0, to_port=0, protocol="-1"

The common violations: SSH (22) or RDP (3389) open to the internet, database ports (3306, 5432, 27017) accessible from outside the VPC, and catch-all rules that open all traffic on all ports.

IAM Policies

IAM misconfigurations are among the most dangerous because they determine what an attacker can do after gaining initial access. The least-privilege principle is well understood but frequently violated in practice.

Wildcard actions and resources. "Action": "*" or "Resource": "*" in a policy statement grants far more than is typically needed. These appear in early-stage projects where speed matters more than correctness, then persist.

No MFA enforcement for sensitive operations. IAM policies that allow iam:*, s3:DeleteBucket, or destructive operations without an MFA condition create risk. A compromised access key without MFA enforcement has full destructive capability.

Admin roles attached to EC2 instances. An EC2 instance with an admin IAM role is a lateral movement shortcut. If an attacker achieves code execution on the instance, they inherit the role's permissions via the metadata service.

# Dangerous: admin policy on an instance role
resource "aws_iam_role_policy_attachment" "admin" {
  role       = aws_iam_role.ec2_role.name
  policy_arn = "arn:aws:iam::aws:policy/AdministratorAccess"
}

RDS Instances

Four RDS settings cause recurring issues: publicly_accessible = true exposes the database endpoint to the internet; storage_encrypted = false leaves data at rest unencrypted; omitting deletion_protection = true allows the database to be deleted without additional confirmation; and disabling automated backups removes recovery options entirely.

None of these require sophisticated exploitation. A publicly accessible RDS instance with a weak password is a direct path to data exfiltration.

Kubernetes Manifests

Kubernetes security misconfigurations are well-documented but still common, particularly in teams that are newer to container orchestration.

Privileged containers. securityContext.privileged: true gives a container near-root access to the host. Combined with a container escape vulnerability, this is a complete host compromise. There are very few legitimate use cases for privileged containers in production workloads.

Running as root. Containers that run as UID 0 have root inside the container. If a container escape occurs, the attacker is root on the process, which significantly reduces the work needed to escalate further. runAsNonRoot: true and runAsUser with a non-zero UID should be standard in pod security contexts.

hostPath mounts. Mounting host filesystem paths into containers breaks container isolation. /var/run/docker.sock is the classic example — any container with access to the Docker socket can create new privileged containers, effectively owning the node.

No resource limits. Missing resources.limits in container specs enables denial-of-service through resource exhaustion. A single misbehaving or compromised container can starve co-located workloads.

# Misconfigured pod spec
spec:
  containers:
  - name: app
    image: myapp:latest
    securityContext:
      privileged: true        # Never in prod
      runAsUser: 0            # Running as root
    volumeMounts:
    - mountPath: /host
      name: host-root         # hostPath mount
  volumes:
  - name: host-root
    hostPath:
      path: /

# Better
spec:
  containers:
  - name: app
    image: myapp:latest
    securityContext:
      runAsNonRoot: true
      runAsUser: 1000
      allowPrivilegeEscalation: false
      readOnlyRootFilesystem: true
      capabilities:
        drop: ["ALL"]
    resources:
      limits:
        cpu: "500m"
        memory: "256Mi"
      requests:
        cpu: "100m"
        memory: "128Mi"

Tools for IaC Scanning

The IaC scanning tooling landscape has consolidated significantly. A handful of tools cover most of the surface area, and the practical question is which combination gives you the right coverage without creating alert fatigue.

tfsec / Trivy (Terraform)

tfsec was the original dedicated Terraform security scanner and remains widely used. It has been merged into the Trivy project under Aqua Security, which now provides IaC scanning as part of its broader image and filesystem scanning capability. Running Trivy against a Terraform directory is straightforward:

trivy config ./terraform/
trivy config --severity HIGH,CRITICAL ./terraform/

Trivy's IaC mode covers Terraform, CloudFormation, Dockerfile, and Kubernetes manifests in a single tool, which reduces the number of tools you need to maintain in your pipeline.

cfn-nag and cfn-lint (CloudFormation)

cfn-lint is AWS's own CloudFormation linter. It validates template syntax, resource property types, and basic structural issues. It is not a security tool per se, but catching syntax errors early means security-focused tools operate on valid templates.

cfn-nag is the security-focused companion. It scans for insecure patterns: permissive security groups, unencrypted resources, missing logging configurations, and wildcard IAM policies. For teams primarily using CloudFormation, cfn-nag plus cfn-lint is a solid baseline.

cfn_nag_scan --input-path template.yaml
cfn-lint template.yaml

Checkov (Multi-Framework)

Checkov by Bridgecrew (now Prisma Cloud) is the most comprehensive multi-framework IaC scanner. It covers Terraform, CloudFormation, Kubernetes, Helm, ARM templates, Dockerfile, and Ansible. The check library is extensive — over 1,000 built-in policies — and custom checks can be written in Python or YAML.

# Scan Terraform
checkov -d ./terraform --framework terraform

# Scan Kubernetes manifests
checkov -d ./k8s --framework kubernetes

# Scan everything in a directory
checkov -d . --quiet --compact

# Output as JUnit XML for CI integration
checkov -d . -o junitxml > checkov-results.xml

Checkov's YAML-based custom check format is accessible for teams that do not want to write Python:

metadata:
  name: "Ensure S3 bucket has versioning enabled"
  id: "CKV2_CUSTOM_S3_001"
  category: "GENERAL_SECURITY"
definition:
  and:
  - cond_type: attribute
    resource_types: [aws_s3_bucket_versioning]
    attribute: versioning_configuration.0.status
    operator: equals
    value: "Enabled"

OPA / Conftest (Custom Policies)

Open Policy Agent with Conftest is the right tool when you need policies that go beyond what built-in scanners cover — organization-specific naming conventions, mandatory tagging requirements, approved AMI lists, or custom compliance controls.

Conftest reads Terraform plan JSON, Kubernetes manifests, Dockerfile, and other structured formats, then evaluates them against Rego policies. The learning curve for Rego is real, but the expressiveness is worth it for complex policies.

# Convert Terraform plan to JSON first
terraform plan -out=tfplan
terraform show -json tfplan > tfplan.json

# Run conftest against the plan
conftest test tfplan.json --policy ./policies/

A Rego policy enforcing that all S3 buckets must have specific tags:

package terraform.aws.s3

import future.keywords.in

required_tags := {"Environment", "Owner", "CostCenter"}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_s3_bucket"
  resource.change.actions[_] in ["create", "update"]

  provided_tags := {k | resource.change.after.tags[k]}
  missing := required_tags - provided_tags
  count(missing) > 0

  msg := sprintf(
    "S3 bucket '%s' is missing required tags: %v",
    [resource.address, missing]
  )
}

kube-bench and kube-linter (Kubernetes)

kube-linter is a static analysis tool for Kubernetes manifests that checks for security and reliability issues before deployment. It integrates naturally into CI pipelines and covers the misconfiguration patterns described above.

kube-linter lint ./k8s/
kube-linter lint deployment.yaml --config .kube-linter.yaml

kube-bench is different — it runs against a live cluster and checks node and control plane configuration against the CIS Kubernetes Benchmark. It is a runtime tool, not a static analysis tool, but it belongs in any Kubernetes security posture assessment.

IaC Scanning Tool Comparison

Tool Frameworks Custom Policies Best For
Trivy (config) Terraform, CF, K8s, Dockerfile Rego Unified pipeline tool, already using Trivy for images
Checkov Terraform, CF, K8s, Helm, ARM, Ansible Python or YAML Broadest framework coverage, policy-as-code
cfn-nag CloudFormation Ruby AWS-native teams with deep CF usage
Conftest + OPA Any structured format Rego (required) Complex org-specific policy enforcement
kube-linter Kubernetes, Helm YAML checks Kubernetes-focused teams, lightweight integration

Integrating IaC Scanning into CI/CD

A scanner that runs manually is a scanner that gets skipped. IaC scanning needs to be wired into your pipeline at multiple points to be effective.

Pre-Commit Hooks

The fastest feedback loop is at commit time, before code even reaches the remote. The pre-commit framework supports Checkov and Trivy natively:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/bridgecrewio/checkov
    rev: 3.2.0
    hooks:
      - id: checkov
        args: ['--framework', 'terraform', '--quiet']

  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.92.0
    hooks:
      - id: terraform_tfsec

Pre-commit hooks catch the obvious failures immediately and reduce noise in PR reviews. They should not be the only gate — developers can bypass them — but they are an effective first filter.

Pull Request Blocking

The critical gate is the PR pipeline. IaC scan failures on HIGH and CRITICAL severity should block merge. This is where you enforce the policy rather than just advise on it.

# GitHub Actions example
name: IaC Security Scan

on:
  pull_request:
    paths:
      - 'terraform/**'
      - 'kubernetes/**'
      - '**.yaml'

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

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@v12
        with:
          directory: .
          soft_fail: false          # Block on failures
          framework: terraform,kubernetes
          output_format: sarif
          output_file_path: checkov.sarif

      - name: Upload SARIF
        uses: github/codeql-action/upload-sarif@v3
        if: always()
        with:
          sarif_file: checkov.sarif

Uploading results as SARIF to GitHub's code scanning integration surfaces findings inline on the PR diff, which dramatically improves developer experience compared to reading raw scanner output.

Terraform Plan Scanning

Scanning Terraform source files is fast and works pre-apply, but it misses dynamic values that only appear after terraform plan resolves provider data. For comprehensive coverage, scan the plan output as well:

- name: Terraform Plan
  run: terraform plan -out=tfplan

- name: Convert plan to JSON
  run: terraform show -json tfplan > tfplan.json

- name: Checkov plan scan
  run: checkov -f tfplan.json --framework terraform_plan

Severity Thresholds

Blocking on every finding, including LOW severity, creates developer friction that leads to scanner bypass and policy exceptions. A practical threshold:

This threshold should evolve. Start permissive to establish baseline, then tighten as the team matures and the backlog of existing findings gets addressed.

Writing Custom OPA/Rego Policies

Built-in scanner checks cover generic best practices. Organization-specific requirements need custom policies. OPA's Rego language is purpose-built for this.

A practical example: enforcing that all EC2 instances use approved AMI IDs (a control relevant to compliance programs that require hardened, pre-approved base images):

package terraform.aws.ec2

import future.keywords.in

# Approved AMI list — maintain this in your policy repo
approved_amis := {
  "ami-0abcdef1234567890",  # Amazon Linux 2023 hardened
  "ami-0fedcba9876543210",  # Ubuntu 22.04 CIS hardened
}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_instance"
  resource.change.actions[_] in ["create", "update"]

  ami := resource.change.after.ami
  not ami in approved_amis

  msg := sprintf(
    "EC2 instance '%s' uses unapproved AMI '%s'. Approved AMIs: %v",
    [resource.address, ami, approved_amis]
  )
}

Another common pattern — enforcing that no security group rule allows ingress from the public internet on sensitive ports:

package terraform.aws.networking

sensitive_ports := {22, 3389, 3306, 5432, 6379, 27017}

deny[msg] {
  resource := input.resource_changes[_]
  resource.type == "aws_security_group_rule"
  resource.change.after.type == "ingress"

  cidr := resource.change.after.cidr_blocks[_]
  cidr in {"0.0.0.0/0", "::/0"}

  port := resource.change.after.from_port
  port in sensitive_ports

  msg := sprintf(
    "Security group rule '%s' exposes sensitive port %d to %s",
    [resource.address, port, cidr]
  )
}

Rego policies can be tested with opa test, which supports unit tests against mock input data. This makes policy-as-code a first-class engineering practice rather than an ad-hoc collection of scripts.

IaC Scanning vs Runtime Scanning: Complementary, Not Either/Or

A question that comes up frequently: if you are scanning IaC thoroughly, do you still need runtime security tools? The answer is yes, and the distinction matters.

IaC scanning catches misconfigurations in the declared state of your infrastructure — what you intended to deploy. It does not catch:

Runtime tools — CSPM (Cloud Security Posture Management) platforms, container runtime security (Falco, Sysdig), and web application scanners — complement IaC scanning by covering what actually exists versus what was declared.

The operational model that works: IaC scanning as a preventive control (shift-left, catches misconfigs before deployment), CSPM as a detective control (continuously checks what is actually deployed), and runtime security as a responsive control (detects and responds to active threats). All three layers are necessary.

Getting Started: A Practical Baseline

If you are standing up IaC scanning from scratch, a reasonable starting point that provides coverage without overwhelming the team:

  1. Install Checkov as a pre-commit hook and in your CI pipeline. Configure it to fail on HIGH and CRITICAL for Terraform and Kubernetes. Use --soft-fail initially to get a baseline without immediately blocking everyone.
  2. Add Trivy config scanning to your image scanning pipeline if you are already running Trivy for container images — it is a one-line addition.
  3. Triage your existing findings before enabling blocking. Run Checkov against your current IaC, categorize findings, and address or accept-risk the existing backlog. Enabling blocking on a codebase full of existing violations just means the pipeline stays broken.
  4. Write two or three custom Conftest policies for your most important organizational controls. This builds team familiarity with Rego and establishes the pattern before you need complex policies.
  5. Review thresholds quarterly. As the team gets comfortable with the tooling and the backlog shrinks, tighten the thresholds and expand coverage.

IaC security scanning is one of the highest-leverage security investments a DevSecOps team can make — the cost per finding caught pre-production is orders of magnitude lower than the cost of remediating a misconfigured production environment. The tooling is mature, the integration patterns are well understood, and the failure modes of not doing it are well documented in public breach reports.

Ironimo uses the same tool suite professional pentesters rely on — Kali Linux, automated workflows, real exploit techniques. No black-box scanner noise.

Start free scan

← Back to blog