Skip to main content
RunBook Academy

TerraformXVI · Plan Review and Saved PlansProduction Terraform

Reading the Plan: A Disciplined Walk

Intermediate⏱ ~14 minbash

What you'll learn

  • Explain why a static reading of the plan must be augmented by automated scanners in CI
  • Compare the four families of HCL scanner (tflint, tfsec, checkov, trivy) and the questions each answers
  • Configure a severity gate that fails the pipeline on the right findings and passes on the rest
  • Suppress false positives with evidence rather than blanket ignores
  • Schedule the audit cadence that catches the findings a PR-time scanner missed

Prerequisites

Verified against Terraform CLI 1.9.x · OpenTofu 1.7.x · HCL 2.0 · bpg/proxmox provider 0.66+ · hashicorp/local provider 2.5+ · hashicorp/null provider 3.2+ · hashicorp/random provider 3.6+ · hashicorp/http provider 3.4+ · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · 2026-08-13

Not yet marked complete on this device.

A human reading the symbols is necessary. It is not sufficient. The symbols tell you what the plan will do; they do not tell you whether the configuration that generated them is wrong. Catching a security-group rule that opens port 22 to 0.0.0.0/0 is the job of a scanner, not the job of the reviewer squinting at 200 lines of ~ arrows.

The disciplined walk through the plan has two passes. The first pass is the human read of the action tuples and resource addresses. The second pass is the automated read of the configuration by a set of scanners that look for patterns the human cannot catch reliably in every PR: default encryptions, public CIDRs, instance types against policy, IAM action widenings. This lesson is the second pass.

The four scanners and what each one does

The HCL scanner ecosystem is large. Most production pipelines settle on four tools because they cover different ground and rarely duplicate findings.

ScannerQuestion it answersSource
tflint“Is this HCL valid and idiomatic for the target provider?”terraform-linters/tflint
tfsec“Does this configuration violate a security best practice?”aquasecurity/tfsec
checkov“Does this configuration meet policy and compliance rules?”bridgecrewio/checkov
trivy“Are there misconfigurations and exposed secrets in this repository?”aquasecurity/trivy

Each tool returns a structured finding list (file, line, rule ID, severity, description). Each tool has its own severity scale. The trick is deciding what to gate the pipeline on.

tflint: provider-aware linting

tflint parses the HCL and runs rules against it. Most rules are provider-specific. The AWS plugin checks that instance_type values are valid; the AWS plugin checks that cidr_blocks are not RFC1918 ranges used in unintended ways; the AWS plugin checks IAM action patterns against a denylist. The Google, Azure, and Kubernetes plugins do the same for their respective providers.

tflint --init
tflint --recursive --format junit > tflint.junit.xml
1 issue(s) found:

Warning: "instance_type" is not a valid value [from google]
  on main.tf line 12
   9: resource "google_compute_instance" "web" {
  12:   machine_type = "n2-standard-not-a-real-size"

The structured format (junit) is what the CI pipeline parses. The exit code is non-zero on warnings by default; use --max-warnings=N to control the gate or set it via .tflint.hcl.

tfsec: security best practices

tfsec (and its successor trivy config) checks the configuration against a security ruleset. The default ruleset covers the common OWASP Top 10 for cloud: public S3 buckets, security groups with 0.0.0.0/0 ingress on sensitive ports, unencrypted storage, IAM policies with wildcards, missing logging, hard-coded credentials.

tfsec --format json --soft-fail-on-warnings \
      --exclude-checks GEN001,GEN002 \
      --out tfsec.json
{
  "results": [
    {
      "rule_id": "AWS017",
      "long_id": "AWS017",
      "severity": "ERROR",
      "description": "Cross-region networking is denied by AWS Config",
      "filename": "network/main.tf",
      "start_line": 87
    }
  ]
}

The ruleset is opinionated. The severity (HIGH / MEDIUM / LOW) is the scanner’s; the gate is yours to set.

checkov: policy and compliance

checkov runs an OPA-based policy engine over the configuration. The default ruleset includes CIS, PCI, and HIPAA-derived checks. Checkov’s primary value over tfsec is the policy framework: rules can be written as Rego policies in-house and consumed alongside the public ruleset.

checkov -d . --framework terraform \
       --output json --soft-fail \
       --skip-check CKV_AWS_8,CKV_AWS_18 \
       > checkov.json
Passed checks: 47
Failed checks: 3
Skipped checks: 2

Resource: aws_security_group_rule.open_ssh
File: /network/sg.tf:24
Check: CKV_AWS_24
Guide: https://docs.bridgecrew.io/aws/...

The --skip-check flag is the standard suppress mechanism; the skip directive is recorded in the scan output so the audit trail shows which rules were waived and where.

trivy: misconfiguration and secrets

trivy config combines misconfiguration scanning (a superset of many tfsec checks) with secret scanning. The secret scanning is the differentiator: trivy will fail if the configuration or any text file in the repository contains what looks like an AWS access key, a GitHub token, or a generic high-entropy string.

trivy config --format sarif \
             --output trivy.sarif \
             --severity HIGH,CRITICAL \
             .
network/sg.tf (terraform)
============================
HIGH: Security group rule allows ingress from public internet
ID: AVD-AWS-0007
File: network/sg.tf
Line: 24

SARIF is the standard format. Many CI systems surface SARIF findings in code review directly, which makes the reviewer’s job easier.

The right severity gate

Not every finding should fail the pipeline. Not every finding should pass. The gate is a deliberate configuration that names the severity levels that block the merge.

Severity    Gate behaviour
--------    ------------------------------------------
CRITICAL    Always fails the pipeline. Production-block.
HIGH        Fails the pipeline. Production-block.
MEDIUM      Fails the pipeline on production-bound PRs;
            allowed on internal workspaces with a
            documented exception.
LOW         Warning only. Logged, surfaced in the PR
            comment, not gate.
INFO        Diagnostic. Logged only.

CRITICAL findings are the ones that are unambiguously broken: a public S3 bucket, a * action in an IAM policy, a secret in the configuration. HIGH findings are the ones that are almost always wrong but have an occasional legitimate use: a security group opening 0.0.0.0/0 on port 80 for a load balancer, an unencrypted EBS volume on a non-sensitive workload.

# Gate policy in CI
trivy config --severity CRITICAL,HIGH --exit-code 1 .
tfsec   --soft-fail-on-warnings --exit-code 0 .
tflint  --max-warnings 0 ; [ $? -le 1 ] || exit 1
checkov --check HIGH,CRITICAL --soft-fail .

CRITICAL and HIGH fail. MEDIUM and below pass with a log. The exception process is documented: MEDIUM findings on internal workspaces require a comment in the PR that names the rule and the compensating control.

Suppressing false positives

Every scanner has rules that fire on legitimate patterns. The right way to suppress is with evidence: a comment at the resource, a justification in the PR, and a review by someone with the authority to grant the exception.

resource "aws_security_group_rule" "public_http" {
  type              = "ingress"
  from_port         = 80
  to_port           = 80
  protocol          = "tcp"
  cidr_blocks       = ["0.0.0.0/0"]
  security_group_id = aws_security_group.alb.id
  # tfsec:ignore:AWS017
  # checkov:skip=CKV_AWS_24:public HTTP entry point for ALB; WAF in front
  description       = "ALB entry, HTTP only, WAF inspected"
}

The suppression is not a blanket ignore. It is at the resource level. It names the rule being suppressed and the reason. The reason must be short, must point to a compensating control, and must be reviewable.

Three rules for suppressing:

  1. Suppress at the resource, not the file or the directory. A file-wide ignore hides other findings on resources that were not considered.
  2. Name the rule. # tflint:ignore=aws_instance_invalid_type is the right shape; # lint ignore is not.
  3. Justify in the comment. “WAF in front” is a justification. “this is fine” is not.

A suppression with no justification is a suppression that the audit cadence will flag.

The audit cadence

PR-time scanning catches findings in the configuration that was added. It does not catch findings that exist in the cloud and were never in the configuration. It does not catch findings from a scanner version that came out after the last scan. The audit cadence catches what the PR-time scan misses.

Frequency   Check
----------  -------------------------------------------------------
Per-run     tflint, tfsec, checkov, trivy (PR-time)
Daily       trivy fs on the live state file in the backend
            (catches secrets committed to state by old code)
Weekly      trivy config against the merged main branch
Monthly     Manual review of all suppressions;
            the suppression list is the deferred debt
Quarterly   Ruleset update; bump scanner versions,
            review new rules, decide which to adopt

The daily state-file scan is the one most teams skip. A secret that was committed to the state file by an old apply is not caught by PR-time configuration scan. A trivy fs against the live state file is the catch.

# Daily state-file scan
aws s3 cp s3://runbook-tfstate/prod/terraform.tfstate /tmp/state
trivy fs --severity CRITICAL,HIGH --secret-config trivy-secret.yaml /tmp/state
rm -f /tmp/state

The state file is sensitive; it is downloaded to an ephemeral location on a runner, scanned, and the copy is deleted. The scan output is the artifact; the state content is not.

Validating the pipeline

tflint --recursive --format junit > tflint.xml
tfsec   --format json                 > tfsec.json
checkov -d . --framework terraform --output json > checkov.json
trivy   config --format sarif         > trivy.sarif .
tflint:  1 warning, 1/1 plugins loaded
tfsec:   2 ERROR, 1 WARNING, 0 LOW
checkov: passed 47, failed 3, skipped 2
trivy:   2 HIGH, 1 MEDIUM (1 secret)

Run all four. Compare the failure counts across PRs. A PR that introduces 0 CRITICAL, 0 HIGH, but 4 MEDIUM findings is a candidate for the MEDIUM-exception process; it is not a candidate for blanket approval. The scanner output is the receipt.

Production failure modes

  1. The gate was set at CRITICAL only. HIGH and MEDIUM findings pass. A public S3 bucket pattern slips through on an MEDIUM finding. The fix is to set the gate at HIGH for all paths into production.

  2. A blanket # tfsec:ignore was added at the top of every file. A new resource added later is not scanned. The fix is to attach the ignore directive to the resource, not the file.

  3. The scanner runs against terraform plan -out output but the plan file is an old binary format. The new scanner version does not parse it. The fix is to run the scanner against the source HCL (and against terraform show -json tfplan for the change-level scan), not against the binary plan file.

  4. A suppression was added without a justification. The monthly review surfaces the suppression. The auditor flags the resource. The fix is to require a reason on every suppression at PR time.

  5. The scanner version was pinned, then the upstream version published a critical rule that the pinned version lacks. Drift in scanner versions is drift in security posture. The fix is to update the scanner version monthly as a routine and to review the new rules before pinning.

  6. The state-file scan was deleted during a runner cleanup and the secret stayed in /tmp/state. A subsequent build artifact contained the live state. The fix is to scan in a memory-only mount and never write the state file to durable storage.

Security and performance

Security: scanner output is itself a sensitive artifact. A scanner finding that lists the unencrypted bucket is the same information an attacker needs to find the unencrypted bucket. Store scan output with the same retention as the plan artifact.

Performance: four scanners per PR is roughly 10 to 30 seconds of additional CI time on a small-to-medium estate. On a large estate with thousands of resources, the scanners run against the source HCL, not the live configuration, and stay under a minute. The performance cost is small; the security benefit is large.

Production guidance

  • Run all four scanners on every PR. Different tools catch different findings; the union is the protection.
  • Set the gate at HIGH by default. CRITICAL is too lax; MEDIUM is too strict.
  • Require a justification on every suppression. The justification is the audit trail.
  • Scan the state file daily. The state file is where old secrets go.
  • Bump scanner versions monthly. A scanner that does not get updated is a scanner that is not catching new finding classes.
  • Output all scan results in SARIF or junit. The PR comment is the reviewer’s interface; structured output surfaces the right lines.

What comes next

The next lesson is the saved plan artifact: how to serialise a plan to a file, how to ship the file from the plan stage to the apply stage, and why a plan file is itself a sensitive artefact.

Verification

trivy config --severity CRITICAL,HIGH --exit-code 1 \
             --format sarif --output trivy.sarif .
tfsec --exit-code 1 --format json --out tfsec.json .
tflint --recursive --max-warnings 0 .
checkov -d . --check HIGH,CRITICAL --soft-fail .
trivy: 0 HIGH, 0 CRITICAL → exit 0
tfsec: 1 HIGH  → exit 1 (gate failed)
tflint: 0 warnings → exit 0
checkov: 4 HIGH, 0 CRITICAL → exit 0 (logged)

A CI run with one HIGH finding from tfsec and zero elsewhere is a CI run that the gate stopped. The finding is in the PR; the suppression, if legitimate, is added with a reason; the second run passes.

Knowledge check · 7 questions

  1. Q1. Which of the four scanners covers misconfiguration AND secret scanning in a single tool?

  2. Q2. What severity gate is the right production default for a Terraform PR pipeline?

  3. Q3. It is appropriate to add a single file-level ignore directive at the top of every Terraform file to disable scanner noise.

  4. Q4. Where should a scanner suppression carry its justification?

  5. Q5. Which of the following are valid reasons to run a daily scanner against the state file rather than only against the source configuration? (Select all that apply.)

  6. Q6. PR-time scanning catches findings in changes. What category of finding does the daily state-file scan catch that PR-time scanning cannot?

  7. Q7. A team adds # tfsec:ignore:AWS017 at the top of network/sg.tf to silence a recurring finding on the ALB's public ingress rule. A junior engineer adds a new resource to the same file in a PR. What is the failure mode?

Passing score: 75%. Answers are checked in this browser.