Scanning plan vs code
Catch what only appears at plan.
A recipe card tells you what somebody wrote down. It does not tell you what actually went in the oven, because the cook swapped margarine for butter and doubled the sugar. Terraform source has the same gap. Variables carry a default that production quietly overrides, modules bury a setting three directories down, and the tfvars file (a file of variable values) you only use in prod never appears in the .tf files a source scanner reads. Scanning raw HCL (HashiCorp Configuration Language, the syntax your .tf files are written in) catches the obvious mistakes early. Scanning the plan catches what will actually be created once every variable, local and module output has resolved.
The sequence is short. Run terraform plan -out, then terraform show -json, then checkov -f tfplan.json --framework terraform_plan. That JSON (JavaScript Object Notation, a plain text data format) has every value filled in, which makes it the last honest gate before apply, the step that creates real infrastructure. Source scans stay cheap enough to run on every commit. Plan scans tell you what your variables and modules really produce. One catch to hold onto: suppressions do not work the same way on a plan, because inline #checkov:skip comments never carry over.
Get the plan into JSON
Run init, then plan with -out to save the binary plan file, then show -json to flatten it into text. The JSON carries a planned_values section with every attribute resolved: the public ACL (access control list, the setting that decides who may read a bucket) that a module set for you, the encryption flag driven by a variable, security group rules a count loop merged together. Checkov's terraform_plan framework reads that structure. It never opens your .tf files at all.
$ terraform init$ terraform plan -out=tfplan.bin$ terraform show -json tfplan.bin > tfplan.json$ checkov -f tfplan.json --framework terraform_plan --compact
terraform_plan scan results:Passed checks: 156, Failed checks: 2, Skipped checks: 0Check: CKV_AWS_20: "S3 Bucket has public access block"FAILED for resource: aws_s3_bucket.uploadsFile: tfplan.json: aws_s3_bucket.uploadsCheck: CKV2_AWS_6: "Ensure S3 bucket does not allow public access"FAILED for resource: aws_s3_bucket.uploads
What the plan sees and the source hides
Say a module sets acl = var.is_public ? "public-read" : "private". A source scan of that module sees a ternary and cannot tell you which branch wins. The plan JSON shows acl: "public-read", flat out, because the production tfvars set is_public = true. Encryption defaults that live inside a module, tags merged from three places, dynamic blocks that only take shape at plan time: all of it lands here. That is why regulated teams put the plan scan directly in front of apply.
$ checkov -f tfplan.json --framework terraform_plan -o json | jq '.results.failed_checks[] | {id: .check_id, resource: .resource, file: .file_path}'
{"id": "CKV_AWS_20","resource": "aws_s3_bucket.uploads","file": "tfplan.json"}
Running both scans
Each scan buys you something different. Source is fast, needs no cloud credentials for pure static modules, and hands developers a file and line number they can click. Plan needs an init and a plan run (often with credentials, because data sources go and look things up in the account), returns the authoritative resolved config, and ignores inline #checkov:skip completely. So run source on every pull request for speed, and run plan on the merge queue or the pre-apply stage, where the gate actually matters.
# PR: fast source feedbackcheckov -d . --framework terraform --compact# Merge queue: authoritative plan gatecheckov -f tfplan.json --framework terraform_plan --baseline .checkov.baseline
terraform scan results:Passed checks: 812, Failed checks: 37terraform_plan scan results:Passed checks: 156, Failed checks: 2# plan may fail checks that source missed — treat plan failures as blocking
Wiring it into the pipeline
The steps run in this order: check out the code, terraform init, plan with -out, show -json, upload tfplan.json as a build artefact, then checkov -f. Store one plan per commit so the findings map to the exact diff being merged. Add --baseline when a legacy repo throws plan-only findings the team already accepted, so brand-new risk still blocks the merge while old debt does not.
$ terraform plan -out=tfplan.bin -input=false$ terraform show -json tfplan.bin > tfplan.json$ checkov -f tfplan.json --framework terraform_plan -o sarif --output-file-path results
Wrote SARIF output to results/results_sarif.sarifterraform_plan scan results:Passed checks: 156, Failed checks: 2
Credentials and plan scope
With some providers, terraform plan has to read the account to resolve data sources, so plan scanning inherits that requirement. Give the job a read-only CI role (continuous integration, the automated pipeline that builds and checks every change), scoped to the workspace being planned and nothing wider. Remember that a plan only describes the changes it was asked about. A destroy-only plan produces a different shape of resources, so make sure your pipeline plans the same workspace your developers apply.
$ AWS_PROFILE=ci-readonly terraform plan -out=tfplan.bin -input=false$ checkov -f tfplan.json --framework terraform_plan --compact --quiet
terraform_plan scan results:Passed checks: 156, Failed checks: 2
Inline #checkov:skip comments live in source .tf files and nowhere else. A plan scan never sees them, so a finding you accepted in a comment last month still fails the plan gate today. If the waiver genuinely stands, mirror it in .checkov.yaml under skip-check so both surfaces say the same thing. Skip that step and you will lose afternoons to CI failures nobody can explain.
Keep tfplan.json as a build artefact, keyed to the commit SHA (the unique fingerprint git gives every commit). Security can then re-run checkov -f against that exact file later without re-planning anything. Plans go stale the moment the code moves, so findings have to come from the plan built on the same merge commit the pipeline will apply. When the scan job and the apply job drift apart, usually because someone injected different tfvars, you get the classic complaint: "Checkov passed in CI but prod was wrong."
Estates built out of many modules gain the most from this. Child module settings get merged at plan time, and that is where a public ACL or a missing encryption flag finally becomes visible, especially when the modules live in a private git repo that Checkov never downloaded during a source scan. Pair --download-external-modules on the source scan with a plan scan on the merge queue and you cover both angles, without doubling the runtime of every pull request job.
terraform plan -out captures one exact set of planned changes and nothing else. Scanning yesterday's plan JSON from a different branch is worse than not scanning at all, because it gates a delta nobody is merging. Plan from the merge commit SHA, scan that artefact, and attach the SARIF (Static Analysis Results Interchange Format, the file format scanners use to hand findings to a code host like GitHub) to the same pull request. Destroy plans and replace-only workspaces change which resources appear at all, so write down which workspace each pipeline job plans.
Plan JSON can hold values you would rather nobody read. If a pipeline step echoes the file, those values land in the build log where anyone with repo access can scroll to them, so treat tfplan.json as credentials-adjacent data when you set artefact retention. checkov -f reads the JSON locally and does not redact what Terraform marked sensitive, which leaves log scrubbing on your side of the line.
When a developer asks why CI flagged a bucket their local scan called clean, this is your answer. Raw .tf leaves variables, locals and module outputs unresolved, so a bucket made public by a variable is invisible to a code-only scan. The fix is the three commands you already have: terraform plan -out, terraform show -json, then checkov -f tfplan.json --framework terraform_plan. The plan holds resolved values, which is to say the thing that will actually be created.
Plan scanning is not free, and that is the whole reason nobody runs it on every push. init pulls down providers, plan itself takes minutes on a large workspace, and it often wants credentials to get there. A source scan costs seconds and gives back a file and line a developer can jump straight to. Mature pipelines split the work along exactly that line: source on every commit for feedback, plan as the gating step before apply. And because plan JSON ignores inline skips, anything you have agreed to waive needs to sit in .checkov.yaml before the gate runs.
Full plan-scan command sequence
Here is the whole pre-merge sequence in one place: init, plan with -input=false and -out, show -json, then checkov -f with the terraform_plan framework, plus --baseline if the repo carries plan-only debt the team has already accepted. Save tfplan.json as an artefact. While debugging you can re-run Checkov against that saved file instead of re-planning, as long as the plan is hours old at most. Older than that and it is describing code nobody has any more.
$ terraform init -input=false$ terraform plan -out=tfplan.bin -input=false$ terraform show -json tfplan.bin > tfplan.json$ checkov -f tfplan.json --framework terraform_plan --baseline .checkov.baseline -o sarif --output-file-path results
terraform_plan scan results:Passed checks: 156, Failed checks: 1Wrote SARIF output to results/results_sarif.sarif
Do not read the two failure counts as if they measured the same thing. The source run covers 812 checks across every .tf file in the directory. The plan run covers 156, because a plan only contains the resources it is about to touch. Different denominators, different meaning. What matters is which findings show up on the plan side and not the source side, since those are the ones your variables and modules created: the public ACL that came from a module, the encryption flag a var switched off.
If you inherit somebody else's pipeline, four things tell you it is wired correctly. terraform init and terraform plan -out=tfplan.bin run before anything scans. terraform show -json tfplan.bin > tfplan.json produces the file. The gate step is checkov -f tfplan.json --framework terraform_plan --baseline .checkov.baseline. And tfplan.json is kept per commit SHA so an auditor can replay the scan. Missing that last one is the difference between showing your working and asking to be believed.
Three ways this goes wrong, roughly in the order teams hit them. A waiver that only exists as an inline comment, so the plan gate keeps failing on something already accepted, fixed by syncing skip-check in .checkov.yaml. A pipeline step that prints the plan file, leaving sensitive values in a build log until somebody scrubs it. A source-only setup with modules in a private repo, reporting clean because it never read them, which --download-external-modules on the source scan and a plan scan on the queue cover between them.
Try this
Generate a plan JSON once, scan it with the terraform_plan framework, then scan the same directory as plain source. Put the two failure counts side by side and go hunting for the findings that exist only on the plan side. Those are the ones your variables and modules put there.
$ terraform init -input=false$ terraform plan -out=tfplan.bin -input=false$ terraform show -json tfplan.bin > tfplan.json$ checkov -d . --framework terraform --compact --quiet$ checkov -f tfplan.json --framework terraform_plan --compact --quiet
terraform scan results:Passed checks: 812, Failed checks: 37terraform_plan scan results:Passed checks: 156, Failed checks: 2Check: CKV_AWS_20: "S3 Bucket has public access block"FAILED for resource: aws_s3_bucket.uploads# plan surfaced a module var that source never showed as public
Takeaway
Source scan is the smoke test. Plan scan is the pre-apply truth. Variables, module outputs and dynamic blocks keep risk out of sight right up until the plan JSON spells the values out. The price is time and credentials, since plan needs init and providers and runs slower, which is why most teams keep source on every push and plan on the merge queue.
Before you switch the gate on, move any inline #checkov:skip you actually rely on into .checkov.yaml, because the plan scan will not honour a comment. Next comes the baseline (cv-baseline), which parks a legacy repo's existing plan failures in a file instead of blocking every merge, while brand-new risk still stops the pipeline.