Scanning the plan, not just the code

Why HCL-only scanning misses things the plan JSON reveals.

Intermediate22 min · lesson 6 of 12

A mail-merge letter is a template full of blanks: `Dear [NAME], your approved credit limit is [LIMIT]`. You can proofread that template for an hour and still not know that tonight's merge run is about to tell forty thousand customers their limit is a million pounds. The blanks get filled from a spreadsheet nobody opened.

Terraform configuration has the same shape. Your `.tf` files are the template. The values that decide whether a firewall rule is sensible or a standing invitation usually arrive later: from a variables file chosen at run time, an environment variable your build system sets, an input handed down into a module, or a lookup against your live cloud account. A scanner that reads only the template is proofreading blanks.

HCL (HashiCorp Configuration Language) is the language `.tf` files are written in. Point Checkov or Trivy at a directory and it parses that text, builds a model of every resource, and tests each one against a rule library. That is fast, needs no cloud access, and catches plenty. It also has a ceiling it cannot climb, because it can only reason about values it can find in the files in front of it. The plan is the other half of the picture. `terraform plan` resolves variables, module inputs, and data source lookups into concrete values, then writes the result to a file. Scan that file and you are reading the merged letter instead of the template.

A clean file that opens SSH to the internet

main.tf
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
provider "aws" {
region = "eu-west-1"
access_key = "mock"
secret_key = "mock"
skip_credentials_validation = true
skip_requesting_account_id = true
skip_metadata_api_check = true
}
resource "aws_security_group" "bastion" {
name = "bastion-ssh"
description = "SSH access to the bastion host"
vpc_id = "vpc-0abc123def4567890"
ingress {
description = "SSH from approved networks"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = var.admin_cidrs
}
egress {
description = "HTTPS to the package mirror"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["10.0.0.0/8"]
}
}
resource "aws_network_interface" "bastion" {
subnet_id = "subnet-0abc123def4567890"
security_groups = [aws_security_group.bastion.id]
}
variables.tf
variable "admin_cidrs" {
description = "Networks allowed to SSH to the bastion"
type = list(string)
default = ["10.0.0.0/8"]
}
prod.tfvars
admin_cidrs = ["0.0.0.0/0"]

Read those three files the way a reviewer reads them at five o'clock on a Friday. The default looks like the corporate network, the rule carries a tidy description, and nothing in `main.tf` contains a suspicious string. The dangerous value sits in `prod.tfvars`, widened during a 2 a.m. incident eighteen months ago and never put back. In CIDR (Classless Inter-Domain Routing) notation, `0.0.0.0/0` means every address on the internet, and port 22 is SSH (Secure Shell), the remote login port. Applied, this security group offers a login prompt to every credential-stuffing bot on the planet, and internet-wide scanners tend to find a fresh SSH port within minutes. The mock keys and `skip_*` flags in the provider block let you run every command in this lesson without an AWS (Amazon Web Services) account; a real pipeline authenticates with a read-only role instead.

terminal
$ checkov -d . --framework terraform --compact --quiet
$ echo "exit status: $?"
output
terraform scan results:
Passed checks: 8, Failed checks: 0, Skipped checks: 0
exit status: 0

Eight checks, zero failures, exit status 0. Any gate wired to that exit code waves the change through. Checkov is not being careless here, and this part deserves precision: it does evaluate the variables it can see. Change the default in `variables.tf` to `["0.0.0.0/0"]` and the very same command fails `CKV_AWS_24` immediately. What it never opened was `prod.tfvars`. Terraform loads `terraform.tfvars`, `terraform.tfvars.json`, and anything matching `*.auto.tfvars` or `*.auto.tfvars.json` on its own, and nothing else. Every other filename is chosen at run time with `-var-file`, so a scanner walking the directory has no way to know which one production uses.

Make Terraform resolve the values for you

terminal
$ terraform plan -input=false -var-file=prod.tfvars -out=tfplan
output
Terraform used the selected providers to generate the following execution
plan. Resource actions are indicated with the following symbols:
+ create
Terraform will perform the following actions:
# aws_network_interface.bastion will be created
+ resource "aws_network_interface" "bastion" {
# (computed attributes trimmed for this lesson)
}
# aws_security_group.bastion will be created
+ resource "aws_security_group" "bastion" {
+ arn = (known after apply)
+ description = "SSH access to the bastion host"
# (egress trimmed for this lesson)
+ id = (known after apply)
+ ingress = [
+ {
+ cidr_blocks = [
+ "0.0.0.0/0",
]
+ description = "SSH from approved networks"
+ from_port = 22
+ ipv6_cidr_blocks = []
+ prefix_list_ids = []
+ protocol = "tcp"
+ security_groups = []
+ self = false
+ to_port = 22
},
]
+ name = "bastion-ssh"
+ name_prefix = (known after apply)
+ owner_id = (known after apply)
+ region = "eu-west-1"
+ revoke_rules_on_delete = false
+ tags_all = (known after apply)
+ vpc_id = "vpc-0abc123def4567890"
}
Plan: 2 to add, 0 to change, 0 to destroy.
Saved the plan to: tfplan
To perform exactly these actions, run the following command to apply:
terraform apply "tfplan"

The resolved value is already sitting there in the open: `cidr_blocks = ["0.0.0.0/0"]`. A human skimming a 900-line plan will scroll straight past it, and no rule engine can parse that layout anyway. Convert the saved plan to JSON (JavaScript Object Notation), which every scanner understands.

terminal
$ terraform show -json tfplan > plan.json
$ jq '.planned_values.root_module.resources[]
| select(.type == "aws_security_group") | .values.ingress' plan.json
output
[
{
"cidr_blocks": [
"0.0.0.0/0"
],
"description": "SSH from approved networks",
"from_port": 22,
"ipv6_cidr_blocks": [],
"prefix_list_ids": [],
"protocol": "tcp",
"security_groups": [],
"self": false,
"to_port": 22
}
]

Run `terraform show` from the same initialized directory that produced the plan. The binary plan file does not carry provider schemas inside it, so `show` loads them from `.terraform/`. Copy `tfplan` to another folder and you get `Error: Failed to load plugin schemas` instead of output. Four parts of `plan.json` are worth knowing by name. `planned_values` holds the final attributes of every resource, which is what most rules read. `resource_changes` holds a before-and-after pair per resource plus the action (create, update, delete), which lets a policy care about deletions specifically. `variables` holds the resolved inputs. `configuration` holds the raw expressions, still unresolved, for tools that want to compare the two.

Same scanner, same commit, different verdict

terminal
$ checkov -f plan.json --framework terraform_plan --compact --quiet
$ echo "exit status: $?"
output
terraform_plan scan results:
Passed checks: 7, Failed checks: 1, Skipped checks: 0
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group.bastion
File: /plan.json:0-0
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-security
exit status: 1

Nothing in the repository changed between those two runs. The same eight checks ran both times, `CKV_AWS_24` among them, and it passed on the first run because the value it needed did not exist yet. Now the exit status is 1, the only thing a pipeline gate actually reads. One irritation remains. The finding points at `/plan.json:0-0`, a file the developer never wrote and a line number that means nothing. Hand Checkov the source directory alongside the plan and it maps the resource back to where it lives.

terminal
$ checkov -f plan.json --framework terraform_plan \
--repo-root-for-plan-enrichment . --compact --quiet
$ echo "exit status: $?"
output
terraform_plan scan results:
Passed checks: 7, Failed checks: 1, Skipped checks: 0
Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"
FAILED for resource: aws_security_group.bastion
File: main.tf:19-39
Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-security
exit status: 1

`main.tf:19-39` is a range somebody can open and edit. The verdict still comes from the resolved value in the plan, but the address now points at the code that produced it, and that is the version of this feedback developers act on. Trivy reaches the same place from another direction: it absorbed tfsec and carries those Terraform rules under `trivy config`, and it reads the binary plan snapshot directly, so `trivy config tfplan` needs no `terraform show` step at all. Watch one flag there. `trivy config` returns 0 even when it prints HIGH findings, unless you pass `--exit-code 1`, so a pipeline missing that flag is running a scanner that can never fail a build.

plan.json is a secrets file, treat it like state
Everything the plan resolved lands in that JSON as cleartext, including values you declared `sensitive = true`. Pass a database password in through `TF_VAR_db_password` and `jq '.variables' plan.json` prints it straight back at you. That flag governs display, not storage; it encrypts nothing. The human-readable plan is barely better, because a value masked as `(sensitive value)` under `tags` reappears in full under `tags_all`. Anyone who can download either file gets your production secrets and a complete map of what you are about to build. So: never publish `tfplan` or `plan.json` as a build artifact people can fetch, never paste them into a pull request comment, keep them out of the job log, and delete both in a cleanup step that runs even when the job fails. A scanner reading the file inside the runner is fine. A copy sitting in an artifact bucket for 90 days is an incident waiting to be found.

What HCL scanning can never resolve

That security group is one instance of a general gap. A directory-walking scanner is blind to anything supplied with `-var-file` or `-var` at run time, or through a `TF_VAR_` environment variable your build system injects. It is blind to `data` source lookups, so an AMI (Amazon Machine Image) picked by a filter, or a subnet's real address range, is unknown to it. It is blind to `terraform_remote_state`, where the networking team's output quietly becomes your input. It is blind to computed values: a `for_each` over a map built at run time, a `merge()` of shared tags, a conditional keyed on `var.environment`. And it is blind to context, since a module can look perfectly safe in isolation while the call site is the thing that makes it dangerous.

Part of that gap closes cheaply. `checkov -d . --var-file prod.tfvars` renders the file and fails the SSH check, and Trivy offers `--tf-vars` for the same purpose. Both are worth wiring up. Neither helps with data sources, remote state, or attributes the provider computes, and both depend on somebody remembering every variables file for every environment, forever. The plan closes the gap by construction, because resolving values is the entire job of a plan.

Two scans, two different jobs
Every commit: seconds, no cloud credentials
checkov -d .
parses HCL, resolves only what is in the directory
trivy config .
same idea, overlapping but different rule set
Catches the obvious
hardcoded 0.0.0.0/0, unencrypted bucket, logging switched off
On the pull request: minutes, read-only cloud role
terraform plan -out=tfplan
needs credentials, refreshes against real infrastructure
terraform show -json
tfplan to plan.json, secrets included, handle with care
Scan the plan
catches resolved values: tfvars, data sources, remote state
Gate on the exit code
non-zero blocks the merge, then delete the plan files
Same rule libraries, different inputs. The commit scan keeps feedback fast; the plan scan is the one that knows what will actually be built.

The trade-off, stated honestly

Scanning the plan costs two things. It needs cloud credentials, because `terraform plan` refreshes state and calls provider APIs (application programming interfaces) to read what exists right now. And it is slower: parsing HCL takes a second or two, while a real plan over a few hundred resources takes tens of seconds to several minutes, plus `terraform init` and provider downloads on a clean runner. Neither cost justifies skipping the plan scan, and neither is comfortable on every keystroke. So most teams split the difference and run the HCL scan on every push, the plan scan on the pull request.

.github/workflows/tf.yml
name: terraform
on: [push, pull_request]
permissions:
contents: read
id-token: write # needed to request the OIDC token
jobs:
scan-hcl: # every push: fast, no cloud access at all
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: bridgecrewio/checkov-action@v12
with:
directory: .
framework: terraform
scan-plan: # pull requests only: needs a read-only role
if: github.event_name == 'pull_request'
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: aws-actions/configure-aws-credentials@v6
with:
role-to-assume: arn:aws:iam::111122223333:role/terraform-plan-readonly
aws-region: eu-west-1
- uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- run: terraform init -input=false
- run: terraform plan -input=false -var-file=prod.tfvars -out=tfplan
- run: terraform show -json tfplan > plan.json
- uses: bridgecrewio/checkov-action@v12 # non-zero exit fails the job
with:
file: plan.json
framework: terraform_plan
repo_root_for_plan_enrichment: .
- name: Shred the plan
if: always()
run: rm -f tfplan plan.json

Three things in that file earn their keep. The `id-token: write` permission lets the job ask GitHub for an OIDC (OpenID Connect) token, a short signed statement of who is running, and trade it for temporary credentials on `terraform-plan-readonly`, so no long-lived AWS key is stored anywhere. The trust policy on that role is what makes the arrangement safe, and it is the part people skip: pin the subject claim to your own repository and event, `"token.actions.githubusercontent.com:sub": "repo:my-org/my-repo:pull_request"`, and pin the audience claim to `sts.amazonaws.com`. Leave a wildcard in that subject and a workflow in somebody else's GitHub account can assume your role. The `Shred the plan` step then carries `if: always()`, which removes the secret-bearing files even when the scan fails, which is exactly the run where you were least likely to remember them.

Read-only is not quite literal, and this trips people up on the first attempt. `terraform plan` takes a lock on the state backend while it runs, so the plan role needs write permission on whatever holds that lock, even though it changes no infrastructure. On the Amazon S3 (Simple Storage Service) backend with `use_lockfile = true`, the lock is a small `.tflock` object written next to your state file. Older setups keep a DynamoDB item instead, through `dynamodb_table`, which HashiCorp now documents as deprecated and due for removal in a future minor version. Grant that one permission narrowly and keep every create, update, and delete away from the plan role. The apply job runs later, under a different role, once the gates have passed. Two flags trim the job further: `-refresh=false` skips reading current state from the provider, which is faster and needs fewer read permissions but plans against a possibly stale picture, and `-lock=false` drops the lock altogether, which is safe only when nothing else can be applying at the same moment.

Verify the gate the way you verify a smoke alarm, by setting it off on purpose. Open a branch that sets `admin_cidrs = ["0.0.0.0/0"]`, push it, and watch the pull request go red with `CKV_AWS_24` named in the output. If it goes green, something is swallowing the exit code, usually `soft_fail`, a stray `|| true`, or a `continue-on-error` somebody added to get an urgent release out. Run that test again every time you touch the workflow file, because a gate nobody has fired is decoration.

Quick check
01Your HCL scan passes and your plan scan fails on the same commit. What is the most likely explanation?
Incorrect — Checkov's `terraform` and `terraform_plan` frameworks drew on the same rules here, and the identical eight checks ran in both passes, `CKV_AWS_24` included. The rules did not change, the input did.
Correct — The plan is the merged document: variables, module inputs, and data source lookups are resolved into real values, so the rule finally has something to test.
Incorrect — A malformed plan makes the scanner error out with a parse failure, not report a named policy violation against a specific resource.
Incorrect — Credentials are needed to produce the plan, but a credential failure stops `terraform plan` before any scan begins, so you get no plan.json and no findings at all.
02Terraform loads a fixed set of variables files on its own, and a directory-walking Checkov run only ever sees those. Which of these filenames does Terraform load automatically, with no `-var-file` flag?
Incorrect — any name outside the auto-loaded set must be passed with `-var-file`, which is exactly why the dangerous value in the lesson's `prod.tfvars` went unseen.
Correct — Terraform auto-loads anything matching `*.auto.tfvars` (and `*.auto.tfvars.json`), so this file is read with no flag at all.
Incorrect — a plain `.tfvars` name that is not `terraform.tfvars` is only loaded when named on the command line.
Incorrect — only `terraform.tfvars.json` is auto-loaded by that exact name; a differently named `.tfvars.json` in a subfolder is not.
03On the CI runner you produce `tfplan`, then copy just that one file to a fresh, empty directory and run `terraform show -json tfplan > plan.json` there. It fails with `Error: Failed to load plugin schemas`. Why?
Incorrect — copying does not damage the file; the failure is about missing provider schemas, not corruption.
Incorrect — reading a saved plan is fully offline; the values were already resolved when the plan was created.
Correct — the binary plan does not carry schemas inside it, so `show` must run from the initialized directory that produced it.
Incorrect — a plan file has no built-in expiry and does not go stale sitting on disk.

Related