Terraform now ships as the default provisioning layer for most cloud-native teams, which means every mistake it encodes gets deployed identically, at scale, every time someone runs terraform apply. That consistency cuts both ways: a single wrong default in a shared module can quietly open the same hole across dozens of environments before anyone notices. The pattern isn't hypothetical. In 2019, a misconfigured web application firewall in Capital One's AWS environment let attacker Paige Thompson pivot through the EC2 instance metadata service and pull data from more than 700 S3 buckets, exposing roughly 106 million customer records; Capital One paid an $80 million OCC penalty in 2020 and a $190 million class-action settlement, and Thompson was convicted in June 2022. That breach predates most teams' current IaC tooling, but the underlying failure mode — an overly permissive resource nobody explicitly locked down — is exactly what static IaC scanning exists to catch before apply ever runs. This post walks through the misconfiguration classes that show up most often in Terraform: public storage, open security groups, missing encryption, and a couple of state-file and IAM patterns that don't get enough attention.
Why do S3 buckets end up public even though AWS defaults to private?
S3 buckets end up public because Terraform's aws_s3_bucket_public_access_block resource doesn't inherit AWS's account-level default — it has its own arguments, and each one defaults to false unless you explicitly set it to true. AWS made Block Public Access and disabled ACLs the default for newly created buckets in April 2023 — across the console, CLI, SDKs, and CloudFormation alike — but that change didn't touch existing buckets, and Terraform code that predates it, or that never declares the access-block resource at all, still deploys open. A common broken pattern looks like this:
resource "aws_s3_bucket" "logs" { bucket = "app-logs" }
with no accompanying access-block resource at all — the bucket itself has no ACL set, but nothing stops a later aws_s3_bucket_acl resource or a bucket policy from granting public-read. The fix is to declare the block explicitly:
resource "aws_s3_bucket_public_access_block" "logs" { bucket = aws_s3_bucket.logs.id block_public_acls = true block_public_policy = true ignore_public_acls = true restrict_public_buckets = true }
Static analyzers like Checkov and tfsec flag the missing resource under checks such as CKV_AWS_53 through CKV_AWS_56 precisely because Terraform's silence is a security decision, not a safe default.
Why are open security groups still the top finding in every cloud scan?
Open security groups remain the single most-cited finding across Checkov, tfsec, and Trivy scans because 0.0.0.0/0 ingress rules are easy to write, easy to copy from a tutorial, and easy to forget. A typical offending block:
resource "aws_security_group_rule" "ssh" { type = "ingress" from_port = 22 to_port = 22 protocol = "tcp" cidr_blocks = ["0.0.0.0/0"] security_group_id = aws_security_group.app.id }
This exposes SSH (port 22) or RDP (port 3389) to the entire internet, and it's specifically what Checkov's CKV_AWS_24 and CKV_AWS_25 rules exist to catch. The safer pattern scopes cidr_blocks to a bastion host's IP, a VPN CIDR range, or a company office range, and pushes broader access behind AWS Systems Manager Session Manager instead of a public SSH listener. Because security groups are declarative and additive, a single overly broad rule buried in a shared module can silently widen access for every environment that imports it — which is why rule-based scanning needs to run on every plan, not just at initial module authorship.
Why doesn't Terraform encrypt storage and databases by default?
Terraform doesn't enable encryption at rest by default because the underlying AWS resources themselves don't require it — encryption is opt-in at the API level, and Terraform's provider simply mirrors that. aws_ebs_volume, aws_db_instance, and aws_s3_bucket all deploy unencrypted unless you add the specific argument or companion resource: encrypted = true on an EBS volume, storage_encrypted = true on an RDS instance, and a full aws_s3_bucket_server_side_encryption_configuration block for S3, since bucket-level encryption isn't a single boolean flag on the bucket resource itself. A minimal compliant RDS declaration looks like:
resource "aws_db_instance" "primary" { engine = "postgres" instance_class = "db.t3.medium" allocated_storage = 100 storage_encrypted = true kms_key_id = aws_kms_key.rds.arn }
Omitting storage_encrypted doesn't produce an error — terraform plan succeeds silently, and the database provisions unencrypted. That silent-success behavior is exactly why encryption checks belong in a policy gate that runs before merge, not in a compliance audit that runs months after the resource is already live.
What risk does the Terraform state file itself introduce?
The state file is a risk of its own because terraform.tfstate stores the full resource attributes Terraform tracks — including, for many resource types, values that started out as secrets. A aws_db_instance password passed via variable, a generated TLS private key, or an API token set as a resource argument all land in plaintext inside state unless you're using a provider or resource specifically designed to avoid it. This becomes an incident, not just a hygiene issue, when state is stored on a local disk that gets committed to version control, or in an S3 backend without server-side encryption and tight bucket policies. The standard mitigation is an encrypted remote backend — an S3 bucket with encrypt = true, versioning, and a restrictive bucket policy, paired with DynamoDB state locking — plus routing genuine secrets through a secrets manager and referencing them by ARN rather than passing raw values through Terraform variables in the first place.
How Safeguard helps
Safeguard treats infrastructure-as-code as a first-class finding type — iac — inside its unified findings model, alongside SAST, SCA, secrets, and container scanning, rather than as a bolt-on report you have to reconcile separately. That means a public S3 bucket, an open security group, or an unencrypted RDS instance shows up in the same Findings tab as everything else touching that project, filterable by the same Scanner dropdown, and correlatable against related signals — for instance, an iac finding that opens a security group to a component your SAST or DAST scan has already flagged as internet-facing gets prioritized together rather than triaged twice. Because the check runs against the Terraform source itself, it catches the public-bucket and missing-encryption patterns described above before apply ever provisions the resource, at the point in the workflow where a one-line fix in a pull request is cheaper than an incident response call afterward.