Infrastructure as Code Security: Hunting Bugs in Terraform with tfsec and Checkov

Containers & DevSecOps
Time it takes to read this article 6 minutes.

Disclaimer: This article is for education and authorized testing only. Only scan, modify, or attack infrastructure and code repositories you own or have explicit written permission to assess. Misusing leaked credentials or pivoting through misconfigured cloud infrastructure without authorization is illegal.

Introduction / Overview

Infrastructure as Code (IaC) turned infrastructure into text files. That is great for reproducibility, and great for attackers, because now a single careless commit can hand over an entire cloud account. Terraform is the dominant IaC tool, and it concentrates two of the most valuable things an attacker wants: credentials (in state) and a blueprint of the entire environment (in the .tf and state files).

This article walks both sides. Offensively, we look at where secrets leak, how IAM over-permissioning becomes lateral movement, and what configuration drift exposes. Defensively, we wire up tfsec and Checkov into a pipeline that catches these before they ship. The two halves carry equal weight, because finding a misconfiguration you cannot fix is just paperwork.

How it works / Background

Terraform reads .tf configuration, builds a dependency graph, and reconciles it against a state file (terraform.tfstate). The state file is JSON and it stores the actual values of every resource and data source, including resolved secrets: database passwords, generated private keys, IAM access keys, and any sensitive output. Marking an output sensitive = true only hides it from CLI display; it is still plaintext in state.

Three recurring weaknesses:

  • State file secrets: state contains plaintext credentials. If it lands in a public S3 bucket, a Git repo, or a misconfigured backend, it is game over.
  • Drift: the live environment diverges from code (someone clicks in the console). Drift means your security review of the .tf files no longer reflects reality, and emergency console changes are rarely least-privilege.
  • Least privilege violations: Action: "*" on Resource: "*" is fast to write and a perfect pivot for an attacker who lands on a role.

tfsec and Checkov are static analyzers that parse HCL (and tfsec also evaluates with the trivy engine now) and flag these patterns against rule packs mapped to CIS benchmarks and provider best practices.

Prerequisites / Lab setup

You need Terraform, the two scanners, and a deliberately broken module. Install on macOS/Linux:

# tfsec is now distributed under Trivy, but the standalone binary still works
brew install tfsec
# Checkov is a Python tool
pipx install checkov
# Terraform itself
brew install terraform

tfsec --version
checkov --version
terraform version
Bash

Create a vulnerable module to scan:

# main.tf — intentionally insecure, lab only
provider "aws" {
  region = "us-east-1"
}

resource "aws_s3_bucket" "logs" {
  bucket = "yunolay-lab-logs"
}

# No encryption, no public access block, world-readable ACL
resource "aws_s3_bucket_acl" "logs" {
  bucket = aws_s3_bucket.logs.id
  acl    = "public-read"
}

resource "aws_db_instance" "db" {
  engine            = "postgres"
  instance_class    = "db.t3.micro"
  allocated_storage = 20
  username          = "admin"
  password          = "SuperSecret123!" # hardcoded -> ends up in state
  publicly_accessible = true
  storage_encrypted = false
}

resource "aws_iam_policy" "admin" {
  name   = "lab-admin"
  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{ Effect = "Allow", Action = "*", Resource = "*" }]
  })
}
HCL

Walkthrough / PoC

1. Static scan with tfsec

tfsec . --format lovely
Bash

tfsec will flag the unencrypted bucket (aws-s3-enable-bucket-encryption), the missing public access block (aws-s3-block-public-acls), the public ACL, the unencrypted RDS instance (aws-rds-encrypt-instance-storage-data), and publicly_accessible. Output is rule-ID keyed so you can suppress or triage precisely.

2. Static scan with Checkov

Checkov has a deeper rule set and supports a graph-based engine that follows variable references:

checkov -d . --compact --quiet
Bash

Look for checks like CKV_AWS_16 (RDS encryption), CKV_AWS_17 (RDS not public), CKV_AWS_19/CKV_AWS_145 (S3 encryption), and CKV2_AWS_6 (S3 public access block). To fail a pipeline only on hard findings:

checkov -d . --check CKV_AWS_16,CKV_AWS_17 --soft-fail-on LOW
Bash

3. The state file — the real prize

This is the part attackers care about. Run a plan/apply against a throwaway account, then inspect state:

terraform init && terraform apply -auto-approve
# Secrets are sitting in plaintext:
grep -i password terraform.tfstate
terraform show -json | jq '.values.root_module.resources[] | select(.type=="aws_db_instance") | .values.password'
Bash

The SuperSecret123! password is right there. Now imagine terraform.tfstate committed to Git or synced to a public S3 bucket. A classic attacker workflow:

# Find leaked state in a repo's history (red team / authorized review)
git log --all --full-history -- "*.tfstate" "*.tfstate.backup"
# Pull credentials and outputs out of any state file found
jq -r '.resources[].instances[].attributes | select(.access_key != null) | .access_key, .secret_key' terraform.tfstate
Bash

4. Drift detection

Drift hides changes from your code review. Detect it with a refresh-only plan:

terraform plan -refresh-only -detailed-exitcode
# exit 0 = no drift, 2 = drift detected, 1 = error
Bash

If someone opened a security group to 0.0.0.0/0 in the console, this surfaces it. Pair it with a scheduled CI job so drift is caught in hours, not after a breach. See also our notes on cloud IAM privilege escalation for what an attacker does once that open path exists.

Mermaid diagram

Infrastructure as Code Security: Hunting Bugs in Terraform with tfsec and Checkov diagram 1

The diagram shows the secure path: scanning gates the PR, an encrypted remote backend protects state secrets, and scheduled drift detection loops any deviation back into review.

Detection & Defense (Blue Team)

Defense here is mostly about removing the attacker's two prizes (state secrets and over-permission) and closing the drift gap.

1. Never store secrets in state in the clear.

  • Use a remote backend with encryption at rest and TLS. For S3, enable SSE-KMS and block all public access:
terraform {
  backend "s3" {
    bucket         = "yunolay-tfstate"
    key            = "prod/terraform.tfstate"
    region         = "us-east-1"
    encrypt        = true
    kms_key_id     = "arn:aws:kms:us-east-1:111122223333:key/abcd"
    dynamodb_table = "tf-locks"   # state locking
  }
}
HCL
  • Pull secrets from a secrets manager at runtime (Vault, AWS Secrets Manager) via data sources or ephemeral resources (Terraform 1.10+ keeps ephemeral values out of state) rather than hardcoding.
  • Lock the state backend down: bucket policy denying non-TLS, and IAM restricting s3:GetObject on the state key to the CI role only.

2. Shift-left scanning, enforced in CI.
Run both tools as a required gate so nothing merges around them:

# .github/workflows/iac.yml
name: iac-scan
on: [pull_request]
jobs:
  scan:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: aquasecurity/tfsec-action@v1.0.0
      - name: Checkov
        uses: bridgecrewio/checkov-action@master
        with:
          directory: .
          soft_fail: false
YAML

Add a pre-commit hook so developers see findings before they push:

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/aquasecurity/tfsec
    rev: v1.28.13
    hooks: [{ id: tfsec }]
  - repo: https://github.com/bridgecrewio/checkov.git
    rev: 3.2.0
    hooks: [{ id: checkov }]
YAML

3. Enforce least privilege.

  • Replace Action: "*" with scoped actions. Use iam-policy-validator or Checkov's CKV_AWS_109/CKV_AWS_111 (no wildcard write/permissions actions) to block wildcards.
  • Generate minimal policies from observed CloudTrail activity with tools like AWS IAM Access Analyzer's policy generation, then commit the tightened policy back to Terraform.

4. Detect drift and tampering.

  • Schedule terraform plan -refresh-only -detailed-exitcode and alert on exit code 2.
  • Turn on CloudTrail + Config rules to detect console changes outside the pipeline. Map these to MITRE ATT&CK T1578 (Modify Cloud Compute Infrastructure) and T1098 (Account Manipulation) for detection engineering.
  • Scan repos and CI logs for leaked state and keys with gitleaks or trufflehog; treat any matched live credential as compromised and rotate immediately.

5. Provenance and integrity.

  • Restrict who can run apply. Use OIDC-federated CI roles, not long-lived keys, so there are fewer static secrets to steal. This dovetails with the controls in our CI/CD pipeline hardening writeup.

Conclusion

IaC security is not exotic. The same three issues recur: secrets sitting in state, IAM policies that are wildcards because wildcards are easy, and drift that quietly invalidates your last review. tfsec and Checkov catch most of the static problems for free; an encrypted remote backend with locking removes the biggest prize; and scheduled drift detection closes the loop between what you reviewed and what is actually running. Wire all of it into CI as a hard gate, and the attacker's easy wins disappear. For container-layer follow-up, see our container image scanning guide.

References

You Might Also Like

If you found this useful, these related deep-dives cover adjacent techniques and their defenses:

Comments

Copied title and URL