IaC scanning
Cloud breaches are usually misconfigurations, not exploits — a bucket left public, a security group open to 0.0.0.0/0. IaC scanning reads the Terraform plan and fails the pipeline before apply, so the bad config never reaches the cloud.
Someone merges a Terraform change to add a CDN logging bucket. It’s three lines. The reviewer skims the diff, sees no acl = "public-read", approves it. What the diff doesn’t show is the module default: this S3 module ships with public access not blocked, and the org has no account-level BlockPublicAccess. terraform apply runs in the deploy job, the bucket goes live, and four hours later it’s indexed by a scanner-as-a-service that crawls cloud ranges for open buckets. No CVE, no exploit, no attacker skill — just a default the human eye couldn’t see in a diff. This is the modal cloud breach, and it is exactly what a pre-apply scan catches.
By the end of this lesson you’ll know how IaC scanning reads a Terraform plan to catch cloud misconfig before apply, why the pipeline gate beats post-deploy detection, and where the gate’s blind spots are.
Why misconfiguration, not exploits, is the cloud threat
The picture most engineers carry of a “cloud breach” is an attacker chaining a clever exploit. The reality is duller and far more common: a resource was configured to be open, and someone found it. An S3 bucket whose policy allows Principal: "*". A security group with 0.0.0.0/0 on port 22 or 5432. An RDS instance with publicly_accessible = true. A KMS-less, unencrypted volume holding PII. None of these is a software flaw — the cloud did exactly what the config told it to. The defender’s job is therefore not “patch faster”; it’s prevent the wrong config from ever being applied.
Infrastructure-as-Code is what makes that prevention possible. When every bucket, security group, and IAM policy is declared in Terraform and goes through a pull request, the configuration becomes a reviewable, testable artifact before it touches the cloud. The catch: humans are bad at reading config diffs for security. The dangerous setting is usually an absence — a missing aws_s3_bucket_public_access_block, an unspecified encryption block that falls back to a weak default — and absences don’t appear in a diff. That’s the gap a scanner fills: it evaluates the effective configuration, including module defaults the diff never showed, against a library of known-bad patterns.
Scan the plan, not just the files
There are two places to scan, and the difference is the whole game. Static scanning of .tf source (the fast path) parses HCL and flags obvious bad literals — a hardcoded cidr_blocks = ["0.0.0.0/0"], acl = "public-read" written in plain text. It’s instant and needs no cloud credentials, but it’s blind to anything computed: a variable, a module default, a for_each, a value pulled from a remote data source.
Scanning the Terraform plan is the senior move. You run terraform plan -out=tfplan && terraform show -json tfplan > plan.json, then point the scanner at plan.json. The plan is the resolved end state — every variable substituted, every module expanded, every default materialized — so the scanner sees the bucket as it will actually exist, public-access-block and all. This is what catches the Hook’s bug: the source diff was clean, but the planned bucket has no public-access block, and the plan makes that visible. The cost is that producing a plan needs read access to provider state, so it runs slightly later and with credentials — a real tradeoff, not a free upgrade.
Policy-as-code: the rules are the gate
A scanner is only as good as its policy. The first layer is a vendor’s built-in ruleset — tfsec, Checkov, Trivy, or Terrascan ship hundreds of curated checks (“S3 bucket should block public access”, “security group should not allow ingress from 0.0.0.0/0 to port 22”, “RDS storage must be encrypted”). That gets you the OWASP/CIS baseline for free on day one.
The layer that separates teams is custom policy-as-code — your own rules, version-controlled next to the infra, expressed in a policy engine (Open Policy Agent’s Rego, Checkov’s Python/YAML custom checks, or HashiCorp Sentinel). This is where org-specific invariants live: “every resource must carry a cost-center tag,” “no IAM policy may grant *:*,” “all data buckets must use our specific KMS key.” The point of policy-as-code is that the rule is itself a reviewed, tested, diffable artifact — a security requirement encoded once and enforced on every PR forever, instead of living in a wiki page a reviewer is supposed to remember. That’s the same shift-left discipline as a misconfig: turn a thing humans forget into a thing the pipeline enforces.
| Misconfig in Terraform | Why it’s dangerous | What the scan flags | The fix in HCL |
|---|---|---|---|
S3 bucket, no public_access_block | Bucket can be made world-readable; data leaks | HIGH — missing block (module default) | aws_s3_bucket_public_access_block all true |
SG ingress 0.0.0.0/0 → 22 | SSH open to the internet; brute-force / scan | HIGH — wide-open ingress | Scope cidr_blocks to a bastion / VPN |
RDS publicly_accessible = true | Database reachable from the public internet | HIGH — public DB | Set false; reach it via private subnet |
EBS / RDS no encryption block | PII at rest unencrypted; compliance fail | MEDIUM — encryption disabled | storage_encrypted = true + KMS key |
IAM policy with ”:“ | Compromised role = full account takeover | HIGH — wildcard privilege | Least-privilege action + resource ARNs |
▸Why this works
Why gate at the pipeline instead of just running a Cloud Security Posture Management tool (CSPM) that scans the live account? Because CSPM detects the bucket after it’s public — the window between apply and the next CSPM scan is real exposure, and that window is exactly when crawlers find it. The pipeline gate is prevention: the misconfig is rejected before it exists. The two aren’t rivals — CSPM is your safety net for drift, manual console changes, and resources created outside Terraform — but the cheapest misconfig to fix is the one that never reached the cloud, and only the pre-apply gate gives you that.
The gate’s blind spots — and where it bites
A pre-apply scan is necessary, not sufficient, and a senior knows its limits. It only sees what Terraform manages: a bucket a teammate created by hand in the console, or a setting changed live after apply (drift), is invisible to it. It catches known bad patterns, so a novel misconfig with no rule is a clean pass. And it has a sharp operational edge — false positives. Turn on every rule at HIGH and block on all of them, and the first week is a wall of findings on legitimate exceptions (a genuinely public marketing-asset bucket, a port open by design). The failure mode here is alert fatigue: developers learn to slap a blanket # tfsec:ignore on everything, and the gate becomes theater. The senior practice is to gate hard only on a curated high-severity set, route mediums to warnings, and require every suppression to carry an inline justification and an owner — so an exception is a recorded decision, not a silent escape hatch.
Your team adds IaC scanning. A new S3 logging bucket passes the source-file scan but is actually public because the module default leaves public-access unblocked. Pick the approach that catches this class of bug as a reliable pre-deploy gate.
Why does a senior scan the `terraform plan` JSON rather than only the `.tf` source files?
Your team turns on every rule at HIGH and blocks the pipeline on all of them. Within a week developers are adding blanket ignore comments everywhere. What went wrong, and what's the senior fix?
Order the pre-apply IaC scanning gate from pull request to the cloud:
- 1 Developer opens a PR with a Terraform change
- 2 CI runs `terraform plan -out=tfplan`
- 3 Export the resolved plan to JSON (`terraform show -json`)
- 4 Policy-as-code scanner checks plan.json against built-in + custom rules
- 5 Fail on high-severity findings → merge/apply is blocked; pass → `apply` runs
- 01Explain why a senior scans the resolved Terraform plan instead of only the .tf source, and what that costs.
- 02What is policy-as-code, why gate at the pipeline instead of relying on a CSPM that scans the live account, and where are the gate's blind spots?
Most cloud breaches aren’t exploits — they’re misconfigurations the cloud applied exactly as told: a public S3 bucket, a security group open to 0.0.0.0/0 on SSH, a publicly-accessible database, an unencrypted volume of PII. Infrastructure-as-Code makes prevention possible by turning every resource into a reviewable artifact before it touches the cloud, but humans can’t reliably spot the danger because it’s usually an absence — a missing public-access block, an unset encryption flag — that never shows up in a diff. IaC scanning closes that gap: scan the resolved terraform plan JSON (not just the .tf source, which is blind to module defaults and computed values) against a vendor baseline plus custom policy-as-code, and fail the pipeline before apply so the bad config never reaches the account. Policy-as-code makes each org invariant a tested, diffable rule enforced on every PR instead of a wiki line a reviewer must remember. The gate has limits — it only sees Terraform-managed resources, only catches known patterns, and over-gating breeds the alert fatigue that turns it into theater — so pair it with a CSPM safety net for drift, gate hard only on a curated high-severity set, and make every suppression an owned, justified decision. Now when you read a three-line Terraform diff that adds a bucket, your first question is: what does the plan say this resource actually looks like once the module defaults resolve?
Practice
Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.
Something unclear?
Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.