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:
- CRITICAL: Block merge, require security team sign-off to override
- HIGH: Block merge, developer can resolve or document accepted risk with justification
- MEDIUM: Warn in PR, tracked as technical debt, does not block
- LOW / INFO: Logged, not surfaced in PR review
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:
- Configuration drift: resources that were modified directly in the AWS console or via API calls outside of Terraform, leaving actual state diverged from declared state
- Dynamic misconfigurations: security groups modified by Lambda functions, IAM policies attached at runtime, bucket policies set by application code
- Post-deployment vulnerabilities: CVEs in application dependencies, OS-level vulnerabilities in running containers, web application vulnerabilities
- Behavioral anomalies: unusual API call patterns, data exfiltration attempts, lateral movement
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:
- 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-failinitially to get a baseline without immediately blocking everyone. - Add Trivy config scanning to your image scanning pipeline if you are already running Trivy for container images — it is a one-line addition.
- 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.
- 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.
- 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.