Security

Infrastructure as Code Security: Scanning Terraform and CloudFormation

How to shift left on cloud security by scanning Terraform and CloudFormation with tfsec, Checkov, and Bridgecrew before misconfigurations reach production.

March 9, 20265 min readShipSafer Team

Infrastructure as Code Security: Scanning Terraform and CloudFormation

Infrastructure as Code (IaC) has transformed how cloud infrastructure is provisioned — enabling repeatable, version-controlled deployments. But it has also introduced a new class of security risk: a single misconfigured Terraform module can instantiate hundreds of insecure resources across dozens of environments simultaneously.

Traditional security tools scan resources after they are deployed. IaC security scanning examines configuration files before terraform apply runs — catching misconfigurations at the pull request stage, where they are easiest and cheapest to fix.

What IaC Security Scanning Detects

IaC scanners analyze infrastructure configuration for security policy violations. Common findings include:

  • Public S3 bucketsaws_s3_bucket without aws_s3_bucket_public_access_block
  • Unrestricted security groupscidr_blocks = ["0.0.0.0/0"] on sensitive ports
  • Unencrypted storage — EBS volumes, RDS instances, S3 buckets without KMS encryption
  • Missing logging — CloudTrail disabled, VPC flow logs absent, S3 access logging off
  • Weak TLS — Load balancers accepting TLS 1.0 or 1.1
  • Overly permissive IAM — Wildcard actions ("Action": ["*"]) on policies
  • Exposed secrets — API keys or passwords hardcoded in resource tags or variables

tfsec: Fast Terraform Scanning

tfsec (by Aqua Security) is a static analysis tool for Terraform that checks for security misconfigurations. It is fast, easy to install, and integrates natively with GitHub Actions.

Installation:

# Homebrew
brew install tfsec

# Or via binary
curl -s https://raw.githubusercontent.com/aquasecurity/tfsec/master/scripts/install_linux.sh | bash

Basic usage:

# Scan a directory
tfsec ./terraform

# Scan with specific severity threshold
tfsec ./terraform --minimum-severity HIGH

# Output in JSON for CI/CD processing
tfsec ./terraform --format json --out results.json

Sample findings:

Result #1 HIGH
───────────────────────────────────────────────────
  ID        aws-s3-no-public-buckets
  Impact    The contents of the bucket may be read publicly
  Resource  aws_s3_bucket.data (terraform/s3.tf:5)

  5  resource "aws_s3_bucket" "data" {
  6    bucket = "my-data-bucket"
  7  }

  # Missing: aws_s3_bucket_public_access_block

GitHub Actions integration:

- name: tfsec
  uses: aquasecurity/tfsec-action@v1.0.0
  with:
    working_directory: terraform/
    minimum_severity: HIGH
    github_token: ${{ secrets.GITHUB_TOKEN }}
    soft_fail: false

tfsec comments inline on pull requests, showing exactly which line contains the misconfiguration.

Checkov: Multi-Framework IaC Scanner

Checkov (by Bridgecrew/Prisma Cloud) supports Terraform, CloudFormation, Kubernetes manifests, Dockerfile, Helm charts, and ARM templates — making it the right choice for polyglot infrastructure.

Installation:

pip install checkov
# or
brew install checkov

Scanning Terraform:

checkov -d ./terraform \
  --framework terraform \
  --check CKV_AWS_*,CKV2_AWS_* \
  --output junitxml \
  --output-file-path results.xml

Scanning CloudFormation:

checkov -f cloudformation/stack.yaml \
  --framework cloudformation

Writing custom checks:

Checkov supports Python-based custom checks for organization-specific policies:

from checkov.terraform.checks.resource.base_resource_check import BaseResourceCheck
from checkov.common.models.enums import CheckResult, CheckCategories

class MandatoryTagsCheck(BaseResourceCheck):
    def __init__(self):
        name = "All AWS resources must have mandatory tags"
        id = "CKV_CUSTOM_001"
        supported_resources = ["aws_instance", "aws_rds_cluster", "aws_s3_bucket"]
        categories = [CheckCategories.GENERAL_SECURITY]
        super().__init__(name=name, id=id, categories=categories,
                         supported_resources=supported_resources)

    def scan_resource_conf(self, conf):
        tags = conf.get("tags", [{}])[0]
        required_tags = {"Environment", "Team", "CostCenter"}

        if isinstance(tags, dict) and required_tags.issubset(tags.keys()):
            return CheckResult.PASSED
        return CheckResult.FAILED

scanner = MandatoryTagsCheck()

GitHub Actions with SARIF output (shows in Security tab):

- name: Run Checkov
  uses: bridgecrewio/checkov-action@master
  with:
    directory: terraform/
    framework: terraform
    output_format: sarif
    output_file_path: checkov-results.sarif
    soft_fail: false
    check: CKV_AWS_*,CKV_K8S_*
    skip_check: CKV_AWS_144  # S3 cross-region replication — not required in our setup

- name: Upload SARIF
  uses: github/codeql-action/upload-sarif@v3
  with:
    sarif_file: checkov-results.sarif

Addressing Common Misconfigurations

Fix 1: S3 Public Access Block

# BEFORE — missing public access block
resource "aws_s3_bucket" "data" {
  bucket = "my-data-bucket"
}

# AFTER — explicitly block all public access
resource "aws_s3_bucket" "data" {
  bucket = "my-data-bucket"
}

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     = "aws:kms"
      kms_master_key_id = aws_kms_key.s3_key.arn
    }
  }
}

Fix 2: Restrictive Security Groups

# BEFORE — open to world
resource "aws_security_group_rule" "app_ingress" {
  type        = "ingress"
  from_port   = 443
  to_port     = 443
  protocol    = "tcp"
  cidr_blocks = ["0.0.0.0/0"]
  security_group_id = aws_security_group.app.id
}

# AFTER — only allow from ALB security group
resource "aws_security_group_rule" "app_ingress" {
  type                     = "ingress"
  from_port                = 443
  to_port                  = 443
  protocol                 = "tcp"
  source_security_group_id = aws_security_group.alb.id
  security_group_id        = aws_security_group.app.id
}

Fix 3: Encrypted RDS

resource "aws_db_instance" "main" {
  identifier        = "main-db"
  engine            = "postgres"
  engine_version    = "15.4"
  instance_class    = "db.t3.medium"
  allocated_storage = 20

  # Encryption
  storage_encrypted = true
  kms_key_id        = aws_kms_key.rds.arn

  # Logging
  enabled_cloudwatch_logs_exports = ["postgresql", "upgrade"]

  # Backup
  backup_retention_period = 7
  deletion_protection     = true
}

Managing Findings at Scale

In a large organization with many Terraform modules, an initial scan may return hundreds of findings. A structured approach:

  1. Baseline scan — Run the scanner and categorize all findings as pre-existing. Create tickets for each HIGH and CRITICAL finding.

  2. Block new misconfigurations — Configure CI to fail on any NEW HIGH+ findings introduced in the PR. Do not fail on pre-existing issues (use --baseline flag in Checkov).

  3. Risk acceptance workflow — For findings that are intentionally accepted (e.g., a public S3 bucket for a CDN), add an inline suppression with a justification comment:

resource "aws_s3_bucket" "cdn" {
  bucket = "my-public-cdn-bucket"
  #checkov:skip=CKV_AWS_144:Cross-region replication not required for CDN bucket
  #checkov:skip=CKV2_AWS_62:Public access intentional — serves static assets
}
  1. Remediation sprints — Schedule dedicated time each quarter to reduce the pre-existing findings backlog.

  2. Policy as code review — Review scanner rules quarterly. Disable rules that do not apply to your environment; add custom rules for organization-specific policies.

IaC security scanning is most valuable when it is invisible friction — developers see the feedback inline in their PR, fix it in minutes, and move on. Policies that require security team approval for every finding quickly become bottlenecks. Automate the easy checks; reserve human review for complex risk decisions.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.