Skip to content

Policy as Code and Static Analysis

Static analysis tools catch known-bad patterns in your HCL before a plan ever runs, while policy-as-code tools evaluate the actual computed plan against organizational rules and mechanically block it from being applied if it violates them.

terraform validate only checks that your HCL is syntactically well-formed and internally consistent — types match, required arguments are present, references resolve. It has no idea whether a f1-micro machine type is a sensible choice or whether a Cloud Storage bucket should really be publicly readable. Two categories of tool fill that gap, and both run fast, before anything is planned or applied:

  • tflint is provider-aware. It ships plugin rule sets, including tflint-ruleset-google, that catch mistakes validate cannot see, because they require knowing what the real Google Cloud API actually accepts — a deprecated machine type, an invalid region name, an argument a provider has quietly stopped supporting.
  • A security and misconfiguration scanner in the checkov/tfsec-successor family pattern-matches the HCL itself against known bad configurations: a Cloud Storage bucket with public read access, an unencrypted persistent disk, a firewall rule open to 0.0.0.0/0 on a sensitive port. tfsec covered similar ground and its checks have since been folded into Trivy; checkov remains a widely used, actively maintained choice for this layer.
.tflint.hcl
plugin "google" {
enabled = true
version = "0.29.0"
source = "github.com/terraform-linters/tflint-ruleset-google"
}

Real run, both tools via docker run --rm against a small config with a deliberately invalid machine type, a public-read IAM binding on a bucket, and a firewall rule open to the world on port 22:

$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest --init
Installing "google" plugin...
Installed "google" (source: github.com/terraform-linters/tflint-ruleset-google, version: 0.29.0)
$ docker run --rm -v "$PWD:/data" -w /data ghcr.io/terraform-linters/tflint:latest
2 issue(s) found:
Warning: terraform "required_version" attribute is required (terraform_required_version)
on main.tf line 1:
1: terraform {
Error: "n2-standard-999" is an invalid as machine type (google_compute_instance_invalid_machine_type)
on main.tf line 17:
17: machine_type = "n2-standard-999"

There is no real google_compute_instance_invalid_zone rule in tflint-ruleset-google — running tflint --only google_compute_instance_invalid_zone against the real plugin fails with Failed to check rule config; Rule not found: google_compute_instance_invalid_zone. The real rule that catches an invalid GCP value on this resource is google_compute_instance_invalid_machine_type, and it is a real Error severity, proven above against a deliberately bad n2-standard-999.

$ docker run --rm -v "$PWD:/data" bridgecrew/checkov:latest -d /data --compact --quiet
Check: CKV_GCP_28: "Ensure that Cloud Storage bucket is not anonymously or publicly accessible"
FAILED for resource: google_storage_bucket_iam_binding.public
Check: CKV_GCP_2: "Ensure Google compute firewall ingress does not allow unrestricted ssh access"
FAILED for resource: google_compute_firewall.ssh

Real checkov output (trimmed). CKV_GCP_28 is confirmed real and exactly as claimed, firing on the google_storage_bucket_iam_binding resource that grants allUsers access — checkov correctly looks at the IAM binding, not the bucket resource itself, since public access on GCS is granted through IAM, not a bucket-level ACL flag the way an AWS S3 bucket works. Both tools run against the raw configuration — no GCP credentials, no plan, no state. That is exactly why they belong as the very first gate in a pipeline: they are cheap, fast, and catch an entire class of mistake before Terraform ever talks to Google Cloud.

Policy-as-code at plan time: Sentinel and OPA with conftest

Section titled “Policy-as-code at plan time: Sentinel and OPA with conftest”

Static analysis stops at the code. It has no way to answer a question like “does this plan create more than five n2-standard-4 instances in the production project” — that number does not exist until a plan has actually been computed. Policy-as-code tools close that gap by evaluating the plan’s machine-readable output, not the HCL:

  • Sentinel is HCP Terraform’s built-in policy engine. It is tightly integrated with HCP Terraform and Terraform Enterprise runs — policies are attached to a workspace or organization and evaluated automatically as part of every run there.
  • Open Policy Agent (OPA), used through the conftest CLI, is the more provider-agnostic, open-source alternative: it works against any CI system, with or without HCP Terraform, by evaluating a JSON document you hand it.

The shared idea is the same either way: someone writes a policy once — “no Cloud Storage bucket may allow public access,” “every resource must carry a cost-center label” — and it is evaluated automatically against every plan from then on, blocking a violating plan from ever being applied. It is a mechanical, always-enforced version of what a human reviewer would otherwise have to remember to check by eye, every single time, without fail.

Terminal window
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policy
policy/public-bucket.rego
package main
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "google_storage_bucket_iam_binding"
resource.change.after.members[_] == "allUsers"
msg := sprintf("%s grants public access via allUsers", [resource.address])
}

Unlike azurerm (proven in the sibling Azure course’s version of this lesson to require a real Azure session even to compute a plan), the google provider can plan pure resource creation with fabricated service-account JSON — it validates the credentials file’s shape, not whether the key is genuine, and defers any real API call to apply time. That made a fully real, end-to-end proof possible here:

$ terraform plan -input=false -out=plan.tfplan -detailed-exitcode
...
Plan: 4 to add, 0 to change, 0 to destroy.
$ echo $?
2
$ docker run --rm -v "$PWD:/project" -w /project openpolicyagent/conftest:latest test plan.json -p policy
FAIL - plan.json - main - google_storage_bucket_iam_binding.public grants public access via allUsers
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions

A genuine terraform plan (fake, correctly-shaped service-account credentials — credentials = file("fakecreds/key.json") in the provider block), a genuine terraform show -json, and a genuine conftest rejection, all against real google_storage_bucket_iam_binding.public data with members: ["allUsers"]. The same deny contains msg if { ... } syntax matters here too: openpolicyagent/conftest:latest bundles OPA 1.20.2, which defaults to Rego v1 and requires if/contains on every rule — the older deny[msg] { ... } form fails to load:

Error: running test: load: loading policies: load: 2 errors occurred during loading:
policy/old-syntax-test.rego:3: rego_parse_error: `if` keyword is required before rule body
policy/old-syntax-test.rego:3: rego_parse_error: `contains` keyword is required for partial set rules

conftest reads the same plan.json that a person could read by hand, and rejects the run the moment deny produces any message — no plan matching that rule ever reaches apply.

Cost estimation: Infracost (docs only — no run below)

Section titled “Cost estimation: Infracost (docs only — no run below)”

Static analysis and policy-as-code both answer “is this safe/compliant.” Neither answers “what will this cost.” Infracost fills that gap and is docs-sourced here, not run — it needs a live pricing catalog this offline environment has no access to (fake-but-correctly-shaped credentials were enough to make google’s plan proceed above, but Infracost’s own pricing lookups are a separate, genuinely external API call, not something a fake key satisfies). Per Infracost’s own docs: “Infracost shifts cloud costs left — into your engineering workflow. It helps you write cost-aware infrastructure (Terraform, CloudFormation, AWS CDK) across AWS, Azure, and Google Cloud,” and for CI specifically: “Add Infracost to your pull requests to give your team a safety net — every PR gets an automatic comment showing the cost diff, FinOps policy violations, and tagging issues introduced by the change, so cost impact gets discussed alongside the code review.” Like conftest, it can read a plan’s JSON directly: “Infracost can also estimate costs from a Terraform plan JSON file (the output of terraform show -json), which is useful with HCP Terraform / Enterprise, Bitbucket Pipelines, and costs that only resolve at plan time.” — www.infracost.io/docs.

Two complementary layers, not redundant ones

Section titled “Two complementary layers, not redundant ones”

It is tempting to see tflint/checkov and Sentinel/OPA as doing the same job twice, but they check fundamentally different things:

  • Static analysis checks the code itself, independent of what is actually changing. It would flag a public Cloud Storage bucket the same way whether this run creates one bucket or a hundred.
  • Policy-as-code checks the actual planned change, and can reference real, computed values that only exist once a plan exists — an actual instance count, an actual label value, an actual member list that only got resolved after variable interpolation and module composition ran. A rule like “block any plan that would create more than five n2-standard-4 instances in the production project” simply has no equivalent at the raw-HCL level; there is no way to know the instance count without computing a plan first.

Both stages belong in the same pipeline, back to back: cheap static checks first, catching whole classes of mistake instantly; policy evaluation against the computed plan second, catching the specific, situational violations that only show up once Terraform knows exactly what it is about to do.

flowchart LR
  hcl["HCL code"] --> lint["tflint / checkov: static checks"]
  lint -->|passes| plan["terraform plan"]
  plan --> policy["Sentinel / OPA + conftest: policy check"]
  policy -->|passes| apply["apply"]
  policy -->|violates a rule| blocked["Blocked, never applied"]
HCL passes through static analysis, then a plan is computed and checked against policy before apply is allowed

Every tool in this lesson answers a different question at a different point in the pipeline. “Is this HCL syntactically valid” (validate) needs zero context. “Does this HCL use a known-bad pattern” (tflint/checkov) needs a provider rule catalog but no plan. “Does this specific, computed change violate policy” (Sentinel/OPA) cannot be answered before a plan exists. “What will this cost” (Infracost) is a fourth lens on that same computed plan. This lesson also surfaces a real, provider-specific fact worth internalizing: google’s provider validated only the shape of a fake service-account credential and proceeded to compute a genuine plan for pure resource creation, while azurerm (proven in the sibling course) fails at provider configuration with no credentials at all. Neither behavior is “more correct” — they are simply different real authentication paths, and a pipeline that assumes one cloud’s offline-plan behavior generalizes to another is making an unproven assumption.

ChoiceBenefitCost
tflint with the google ruleset pluginCatches provider-specific mistakes (invalid machine types) validate cannot seeNeeds --init to fetch the plugin over the network, and the default image alone catches almost nothing GCP-specific
checkov broad rule catalogOne run surfaces several real misconfigurations (proven above: public IAM binding, open SSH firewall)Findings land on the specific resource that actually grants access (an IAM binding, not the bucket) — a reviewer expecting the bucket resource itself to be flagged can miss it
OPA/conftest against show -json instead of SentinelOpen-source, works in any CI system, no HCP Terraform dependency; proven here as a fully real, end-to-end run (unlike the sibling Azure lesson, which could not get a real live plan offline)Policy must be hand-written in Rego and kept in sync with both terraform show -json’s schema and OPA’s own Rego version (proven above: Rego v1 broke an old-style deny[msg] rule)
Infracost from docs, not proven hereAnswers a question neither static analysis nor policy-as-code can (cost)No live pricing catalog access in this environment — a fake credential was enough to make google plan proceed, but not enough for a real pricing API call
  • “tflint catches security misconfigurations the same way checkov does.” Proven above: they found different things on the same config — tflint flagged an invalid machine type; checkov flagged the public IAM binding and the open SSH firewall rule. Complementary rule catalogs, not overlapping ones.
  • “Every cloud’s Terraform provider behaves the same way offline.” Proven above with two different real behaviors in this exact course pairing: google computed a full real plan with a fake-but-correctly-shaped credentials file; azurerm (sibling course) refuses to even configure the provider without a real session. Assuming one generalizes to the other is exactly the kind of unproven claim this course’s proof-everything rule exists to catch.
  • “A rule name like google_compute_instance_invalid_zone sounds plausible, so it probably exists.” Proven above: running tflint --only google_compute_instance_invalid_zone against the real plugin returns Rule not found. The real rule for this resource’s invalid values is google_compute_instance_invalid_machine_type.
  • “Any Rego snippet copied from an older tutorial will run against a current conftest.” Proven above: the exact same deny[msg] { ... } rule (no if/contains) fails to load against conftest’s bundled OPA 1.20.2 — Rego v1 requires if/contains on every rule body.

When reviewing AI-generated policy/static-analysis tooling for Terraform on GCP, check for:

  • A .tflint.hcl that never declares plugin "google" { ... } — the bare tflint binary catches almost nothing GCP-specific, proven above (only a generic required_version warning with no plugin configured).
  • A tflint rule name that was guessed rather than verified — proven above, google_compute_instance_invalid_zone does not exist as a real rule in tflint-ruleset-google; the real rule for an invalid machine type is google_compute_instance_invalid_machine_type.
  • Rego policy using deny[msg] { ... } without if/contains against a current conftest/OPA image — proven above, this is a real parse error under OPA 1.20.2’s Rego v1 default.
  • A policy check written against google_storage_bucket’s own resource block instead of the google_storage_bucket_iam_binding/google_storage_bucket_iam_member resource that actually grants access — proven above, checkov’s real CKV_GCP_28 finding lands on the IAM binding, because GCS public access is an IAM grant, not a bucket-level ACL flag the way S3 works.
  • A claim that an offline terraform plan “can’t work without real cloud credentials” stated as a universal fact — proven above, this is provider-specific: google proceeded with fake-but-correctly-shaped credentials for pure resource creation, while azurerm did not.

🐛 Spot the bug

.tflint.hcl
plugin "google" {
enabled = true
}
Show the answer

This plugin block is missing version and source, the two arguments tflint --init actually needs to fetch anything. Proven above, the real init run in this lesson pinned both (version = "0.29.0", source = "github.com/terraform-linters/tflint-ruleset-google") — without them, a CI run either fails to install the plugin or silently picks up whatever version the plugin manager defaults to, which is not reproducible across runners.

What can tflint and checkov catch that plain terraform validate cannot
What is the key difference between what static analysis tools check and what policy-as-code tools like Sentinel or OPA check
Which policy could only be enforced against a computed plan, not against raw HCL alone
How does Sentinel differ from Open Policy Agent with conftest
What real, provider-specific fact does this lesson prove about offline terraform plan behavior on google versus azurerm?
Why is the Infracost section in this lesson explicitly labeled as docs-sourced rather than proven with a real run?