Policy as Code and Static Analysis
The idea in one sentence
Section titled “The idea in one sentence”Static analysis tools like tflint and a checkov-family scanner catch known-bad patterns in raw HCL before plan ever runs, while policy-as-code tools like Sentinel and Open Policy Agent evaluate the actual computed plan against organizational rules, and a production pipeline needs both layers, not just one.
Static analysis: fast, cheap checks before plan runs
Section titled “Static analysis: fast, cheap checks before plan runs”terraform validate only confirms that your HCL is syntactically legal and internally consistent — it has no opinion on whether a configuration is a good idea. Two extra tools fill that gap, and both run in seconds against the raw configuration, with no Azure API call and no state involved:
tflintcatches provider-specific mistakes thatvalidatehas no way to know about, becausevalidatedoes not understand whatazurerm_linux_virtual_machinearguments actually mean to Azure — only that the HCL parses. Thetflint-ruleset-azurermplugin adds Azure-aware rules on top of the core linter: an invalid VM size string, a deprecated argument that still parses but no longer does anything, or a naming pattern that violates Azure’s own resource-naming restrictions.- A security and misconfiguration scanner in the
checkov/tfsec-successor family pattern-matches the HCL itself against a library of known-bad configurations, entirely independent of what is actually changing in this run. A storage account withallow_nested_items_to_be_public = true, a managed disk with no encryption argument set, a network security group rule that opens0.0.0.0/0to a management port — these are all findings the scanner can raise by reading the.tffiles alone, beforeinitorplanever touches Azure.
# tflint — Azure-aware static analysis, no Azure API call requiredtflint --init # installs the tflint-ruleset-azurerm plugintflint
# example finding# Warning: "Standard_D999_v99" is an invalid VM size (azurerm_invalid_instance_type)# checkov — security and misconfiguration scanning against raw HCLcheckov -d .
# example finding# CKV_AZURE_35: "Ensure default network access rule for Storage Accounts is set to deny"# FAILED for resource: azurerm_storage_account.mainBoth of these run before terraform plan in a CI pipeline, and both should fail the pipeline fast on a real problem — there is no reason to wait for a multi-minute plan against live Azure state just to discover a storage account is misconfigured to allow public blob access.
jobs: static-analysis: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: terraform-linters/setup-tflint@v4 - run: tflint --init - run: tflint --recursive - name: Run checkov uses: bridgecrewio/checkov-action@master with: directory: .Policy as code: evaluating the plan itself
Section titled “Policy as code: evaluating the plan itself”Static analysis only ever looks at the HCL as written — it has no idea what the actual, computed change will do to your Azure subscription. Policy as code closes that gap by evaluating the machine-readable output of terraform plan (or terragrunt run --all plan) against a written policy, and blocking apply if the plan violates it.
Two tools dominate this layer:
- Sentinel is HCP Terraform’s built-in policy engine. Policies are written in Sentinel’s own language, attached to a workspace, and run automatically against every plan HCP Terraform produces — a plan that fails a
hard-mandatorypolicy simply cannot be applied. - Open Policy Agent (OPA), usually paired with
conftestfor Terraform, is the provider-agnostic, open-source alternative. Because it runs against a JSON representation of the plan (terraform show -json tfplan), it works in any CI system, with or without HCP Terraform — a GitHub Actions job can runconftestagainst the plan JSON exactly like it runstflintagainst the HCL.
# produce a plan, then convert it to JSON for policy evaluationterraform plan -out=tfplanterraform show -json tfplan > tfplan.json
# conftest evaluates the plan JSON against Rego policies in ./policyconftest test tfplan.json --policy ./policy
# example policy failure# FAIL - tfplan.json - main - Storage account "app-data" allows public blob access# an example Rego policy conftest evaluates against the plan JSONpackage main
deny[msg] { resource := input.resource_changes[_] resource.type == "azurerm_storage_account" resource.change.after.allow_nested_items_to_be_public == true msg := sprintf("Storage account %s allows public blob access", [resource.name])}The shared idea behind Sentinel and OPA is the same regardless of which one a team uses: a written rule such as “no storage account may allow public blob access” or “every resource must carry a cost-center tag” is evaluated against a specific plan, and a plan that violates the rule is blocked from being applied — the violation is caught after plan computes the change, but strictly before apply is allowed to run.
Two complementary layers, not one redundant check
Section titled “Two complementary layers, not one redundant check”It is tempting to treat tflint/checkov and Sentinel/OPA as two ways of doing the same thing, but they check fundamentally different things:
- Static analysis (
tflint,checkov) evaluates the code itself, independent of what is actually changing in this run. It can tell you a resource block is misconfigured even if that resource block never actually gets applied in this particular plan. - Policy as code (Sentinel, OPA) evaluates the planned change — it can reference real, computed values that only exist once a plan has actually been run, such as “this specific plan replaces a production database” or “the total monthly cost this plan adds exceeds a threshold,” neither of which is knowable by reading the raw HCL alone.
A rule like “block any plan that destroys a resource tagged environment = production” is a clear example of something only policy-as-code can enforce — it depends entirely on what a specific plan actually proposes to do, not on any pattern visible in the HCL by itself. Running both layers means known-bad patterns are rejected in seconds before plan ever starts, and organization-specific rules about the actual proposed change are still enforced right before apply — neither layer makes the other redundant.
flowchart LR hcl["HCL configuration"] -->|fast static checks| lint["tflint + checkov"] lint -->|passes| plan["terraform plan"] plan -->|plan JSON| policy["Sentinel or OPA / conftest"] policy -->|passes| apply["terraform apply"] lint -->|fails| block1["Pipeline blocked"] policy -->|fails| block2["Pipeline blocked"]