Skip to content
ExponaBLOG
TERRAFORM, IAC & CLOUD AUTOMATION · DAY 048 / 100

Security scanning Terraform code: lessons from production

Find misconfigurations early.

Expona Admin7 min read

Security scanning Terraform code: lessons from production

We've all pushed infrastructure code that looked clean until it landed in production and exposed a database bucket to the internet. Security scanning Terraform code catches these misconfigurations before they become incidents—and once you've had to scramble to restrict access at 2 AM, you'll never skip this step again. Let's walk through the tools and practices that protect your cloud infrastructure from day one.

Why scanning matters: the cost of missing it

In our experience across e-commerce and SaaS platforms, the majority of cloud breaches stem from misconfigurations rather than zero-day exploits. Terraform code that provisions S3 buckets, security groups, IAM policies, and databases can embed security debt before a single resource launches. Checkov and Trivy exist to catch these problems during code review, not during an audit or breach notification.

The difference between scanning and not scanning is the difference between catching an open RDS port in a pull request versus discovering it in your AWS Config compliance report three months later. We've seen teams go from zero security tooling to comprehensive scanning in weeks, and the ROI is immediate: fewer late-night fixes, faster deployments because reviewers trust the code, and a measurable reduction in security findings.

Checkov and Trivy: scanning HCL at scale

Checkov is a static code analysis tool built specifically for infrastructure as code. It understands HCL, CloudFormation, Kubernetes manifests, and dozens of other formats. Trivy, developed by Aqua Security, started as a container vulnerability scanner but now includes filesystem and IaC scanning. For Terraform, both are excellent—Checkov tends to have more depth for HCL-specific checks, while Trivy is lighter and faster for quick scans.

Here's how we typically integrate Checkov into a pipeline:

hcl
#!/bin/bash
# Run Checkov on all Terraform files
checkov -d . \
  --framework terraform \
  --quiet \
  --compact \
  --exit-code 1

# Exit code 1 = failures found, fail the pipeline
# Exit code 0 = all passed

In practice, we run this in CI on every pull request. If Checkov detects a violation, the build fails and the developer sees immediate feedback. For example, if someone creates an S3 bucket without versioning or encryption, Checkov will flag it before the code review even starts.

Trivy offers a similar workflow but with a slightly different interface:

hcl
trivy config . \
  --format sarif \
  --output trivy-results.sarif \
  --exit-code 1 \
  --severity HIGH,CRITICAL

We prefer Checkov for Terraform-heavy organizations because its rule set is deeper and its false-positive rate is lower. But if you're scanning multiple infrastructure formats and container images in one pipeline, Trivy's unified approach saves operational overhead.

Catching unencrypted storage and open ports

The two most common misconfigurations we catch in scanning are unencrypted storage and overly permissive network rules. Let's walk through real examples.

Unencrypted storage

S3 buckets without encryption-at-rest, RDS databases without encryption, and EBS volumes without encryption are low-hanging security fruit. Checkov rule CKV_AWS_21 flags S3 buckets without encryption. Here's vulnerable code:

hcl
resource "aws_s3_bucket" "customer_data" {
  bucket = "my-customer-data-${var.environment}"
}

# Checkov finds this and fails the check:
# CKV_AWS_21: Ensure S3 bucket has versioning enabled
# (Also missing encryption!)

The fix is straightforward:

hcl
resource "aws_s3_bucket" "customer_data" {
  bucket = "my-customer-data-${var.environment}"
}

resource "aws_s3_bucket_versioning" "customer_data" {
  bucket = aws_s3_bucket.customer_data.id
  versioning_configuration {
    status = "Enabled"
  }
}

resource "aws_s3_bucket_server_side_encryption_configuration" "customer_data" {
  bucket = aws_s3_bucket.customer_data.id

  rule {
    apply_server_side_encryption_by_default {
      sse_algorithm = "AES256"
    }
  }
}

Checkov catches this before deployment. Multiply this across 50 resources in a module, and the scanner saves hours of manual review.

Open ports and permissive security groups

Security groups that allow all traffic (0.0.0.0/0) on database ports are equally common and equally dangerous. Checkov rule CKV_AWS_24 flags this:

hcl
resource "aws_security_group" "database" {
  ingress {
    from_port   = 5432
    to_port     = 5432
    protocol    = "tcp"
    cidr_blocks = ["0.0.0.0/0"]  # Open to the world!
  }
}

# Checkov fails: CKV_AWS_24: Restrict ingress RDP access

The fix scopes access to your VPC or a specific bastion host:

hcl
resource "aws_security_group" "database" {
  vpc_id = var.vpc_id

  ingress {
    from_port       = 5432
    to_port         = 5432
    protocol        = "tcp"
    security_groups = [aws_security_group.app_servers.id]
  }
}

In a real platform, we'd use a database security group that only allows traffic from application servers. Scanning catches the overly permissive rule in CI, not when a customer's data is exposed.

Suppressing findings with justifications

Not every Checkov violation is a real problem. Sometimes you intentionally allow something for operational reasons—a public S3 bucket for static assets, for example. Suppressing with justification is how we keep scanning actionable rather than noisy.

Checkov supports suppression via comments in HCL:

hcl
resource "aws_s3_bucket" "public_assets" {
  bucket = "my-public-assets-${var.environment}"
  #checkov:skip=CKV_AWS_21:Assets are public CDN content, versioning not required
  #checkov:skip=CKV_AWS_145:Encryption not needed for public static files
}

resource "aws_s3_bucket_public_access_block" "public_assets" {
  bucket = aws_s3_bucket.public_assets.id

  block_public_acls       = false
  block_public_policy     = false
  ignore_public_acls      = false
  restrict_public_buckets = false
  #checkov:skip=CKV_AWS_56:Intentionally public bucket for CDN distribution
}

The key is the justification. Anyone reading the code—your future self included—understands why the rule was suppressed. We also maintain a team policy: suppression requires a JIRA ticket or PR comment explaining the business reason. This prevents "skip everything" culture.

For organization-wide suppression (e.g., all environments must encrypt RDS), use a Checkov policy file:

hcl
apiVersion: v1
metadata:
  name: Require RDS encryption
  description: All RDS instances must use encryption at rest
specs:
  - CKV_AWS_16
  - CKV_AWS_162
enforcement: hard

Running scans on every pull request

Scanning is only effective if it runs automatically and blocks merges when violations are found. We implement this as a GitHub Actions workflow (or equivalent in GitLab CI, Jenkins, etc.):

hcl
name: Terraform Security Scan

on: [pull_request]

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

      - name: Run Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: infrastructure/
          framework: terraform
          quiet: true
          compact: true
          soft_fail: false  # Hard fail on violations
          output_format: sarif
          output_file_path: reports/checkov.sarif

      - name: Upload to GitHub Security Tab
        if: always()
        uses: github/codeql-action/upload-sarif@v2
        with:
          sarif_file: reports/checkov.sarif

This workflow runs on every pull request, uploads results to GitHub's Security tab so reviewers see violations inline, and fails the check if violations are found. Developers can't merge until Checkov passes (or violations are explicitly suppressed with justification).

We typically run scans in two modes: on pull requests (strict, blocks merge) and on main branch nightly (informational, sends report to Slack). The nightly scan catches any drift or new vulnerability patterns published since the last commit.

Common mistakes

  • Suppressing too broadly. Using soft_fail: true in CI means violations don't block merges, making scanning pointless. Keep it strict.
  • Never reviewing suppression comments. Suppressions accumulate like technical debt. Audit them quarterly and remove obsolete ones.
  • Ignoring policy-as-code extensions. Checkov can enforce custom policies (e.g., "all resources must have a cost center tag"). Many teams miss this and resort to manual review instead.
  • Running only on PRs, not production code. If your main branch has unscanned code, you're already exposed. Scan on merge and continuously monitor production infrastructure.
  • Using outdated rule sets. Checkov and Trivy release new checks frequently. Run checkov --update or update your container image weekly to catch emerging patterns.

Key takeaways

  • Security scanning Terraform code catches misconfigurations early, before they reach production or audit findings.
  • Checkov excels at HCL scanning; Trivy offers lighter scanning for mixed infrastructure and container artifacts.
  • Focus scanning on high-impact violations: unencrypted storage, open ports, overly permissive IAM, missing monitoring and logging.
  • Suppress violations only with explicit justifications; review suppressions regularly to prevent accumulation.
  • Integrate scanning into CI/CD to block merges; pair with nightly scans on main to catch drift and new vulnerabilities.
  • Treat scanning as continuous: update rule sets weekly, audit findings weekly, and measure violation trends over time.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles