Scanning the plan, not just the code
Why HCL-only scanning misses things the plan JSON reveals.
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
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 = trueskip_requesting_account_id = trueskip_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 = 22to_port = 22protocol = "tcp"cidr_blocks = var.admin_cidrs}egress {description = "HTTPS to the package mirror"from_port = 443to_port = 443protocol = "tcp"cidr_blocks = ["10.0.0.0/8"]}}resource "aws_network_interface" "bastion" {subnet_id = "subnet-0abc123def4567890"security_groups = [aws_security_group.bastion.id]}
variable "admin_cidrs" {description = "Networks allowed to SSH to the bastion"type = list(string)default = ["10.0.0.0/8"]}
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.
$ checkov -d . --framework terraform --compact --quiet$ echo "exit status: $?"
terraform scan results:Passed checks: 8, Failed checks: 0, Skipped checks: 0exit 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
$ terraform plan -input=false -var-file=prod.tfvars -out=tfplan
Terraform used the selected providers to generate the following executionplan. Resource actions are indicated with the following symbols:+ createTerraform 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: tfplanTo 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.
$ terraform show -json tfplan > plan.json$ jq '.planned_values.root_module.resources[]| select(.type == "aws_security_group") | .values.ingress' plan.json
[{"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
$ checkov -f plan.json --framework terraform_plan --compact --quiet$ echo "exit status: $?"
terraform_plan scan results:Passed checks: 7, Failed checks: 1, Skipped checks: 0Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group.bastionFile: /plan.json:0-0Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-securityexit 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.
$ checkov -f plan.json --framework terraform_plan \--repo-root-for-plan-enrichment . --compact --quiet$ echo "exit status: $?"
terraform_plan scan results:Passed checks: 7, Failed checks: 1, Skipped checks: 0Check: CKV_AWS_24: "Ensure no security groups allow ingress from 0.0.0.0:0 to port 22"FAILED for resource: aws_security_group.bastionFile: main.tf:19-39Guide: https://docs.prismacloud.io/en/enterprise-edition/policy-reference/aws-policies/aws-networking-policies/networking-1-port-securityexit 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.
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.
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.
name: terraformon: [push, pull_request]permissions:contents: readid-token: write # needed to request the OIDC tokenjobs:scan-hcl: # every push: fast, no cloud access at allruns-on: ubuntu-lateststeps:- uses: actions/checkout@v7- uses: bridgecrewio/checkov-action@v12with:directory: .framework: terraformscan-plan: # pull requests only: needs a read-only roleif: github.event_name == 'pull_request'runs-on: ubuntu-lateststeps:- uses: actions/checkout@v7- uses: aws-actions/configure-aws-credentials@v6with:role-to-assume: arn:aws:iam::111122223333:role/terraform-plan-readonlyaws-region: eu-west-1- uses: hashicorp/setup-terraform@v4with: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 jobwith:file: plan.jsonframework: terraform_planrepo_root_for_plan_enrichment: .- name: Shred the planif: 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.