Policy, security, and cost gates¶
Checks run at two moments, and they are not interchangeable. On commit, the pre-commit hooks check the source. On plan, three gates check the generated plan, where the Azure Verified Modules have expanded into real resources.
On commit: hooks and the pre-commit job¶
.pre-commit-config.yaml runs formatting, tflint, checkov, and the repository
invariants in .github/scripts/check-repo-invariants.sh. Wire it up once with
pre-commit install; the PR caller's pre-commit job runs the identical hooks
on every pull request and gates the dispatch, so --no-verify buys nothing:
a failed lint stops the plan and leaves the required merge gate uncreated.
The invariants catch what the linters cannot: a generated backend.tf,
providers.tf, or context.tf staged by hand (they carry the resolved tenant
and subscription ids), Terraform state, a terragrunt.stack.hcl, and a
directory holding Terraform but no terragrunt.hcl. The last two are the
silent failures: discovery skips them, so the PR plans nothing, every gate
passes with nothing to gate, and it merges green having deployed nothing. The
check also warns, without blocking, about a unit with no
.terraform.lock.hcl; see
Versions and upgrades.
checkov at this layer sees only the module calls, not what the modules expand to. The scan that matters is the plan-time one below, where it reads the real resources out of the plan JSON.
On plan: the three gates¶
Every plan, and every drift plan, runs three checks against the Terraform plan; an apply does not run them again on the plan it re-takes (engine v6.0.0). Findings appear in the PR run comment and in drift issues, under "Validation checks". That panel is status-aware: collapsed with a check mark when every gate is clean, and auto-expanded with a warning when a gate denies, warns, errors, or the run fails, so a finding is never left hidden behind a click.
| Gate | Tool | Checks | Config |
|---|---|---|---|
| Policy | conftest | policy/*.rego against the plan JSON |
$TFPR_ENGINE_DIR/scripts/conftest-gate.sh |
| Security | checkov | built-in checks against the plan JSON | $TFPR_ENGINE_DIR/scripts/checkov-gate.sh |
| Cost | infracost | monthly cost of the plan | $TFPR_ENGINE_DIR/scripts/infracost-gate.sh |
The gate scripts live in the engine, not in the customer repo. The post_plan
hooks in projects.yml run them from $TFPR_ENGINE_DIR, the engine checkout the
reusable workflow provides (or the workspace in vendored mode).
conftest and checkov are baked into the self-hosted runner image, so there
TFPR_EXTRA_TOOLS only adds infracost. On a GitHub-hosted posture, set
TFPR_EXTRA_TOOLS to conftest checkov infracost to install all three per run.
infracost is a static binary in either case.
Each gate also writes a small JSON result for the engine, and since v6.4.0
the per-unit check shows one line per gate from it: checkov and conftest
pass, fail, skipped and warning counts, and the infracost monthly total.
The counts come from the tools' JSON output, never from the rendered
markdown. A custom post_plan hook can add its own line by writing
$TFPR_RESULTS/<name>.json:
status is pass, fail, skipped or error; the counts,
monthly_cost and currency are optional.
Report-only by default¶
All three gates start quiet, so they surface findings without blocking a merge:
- conftest runs whatever matches
policy/*.rego, and the repository ships nothing matching, so the gate reports "No policies found" and passes. It is quiet by absence, not advisory by design: an active policy'sdenyrules block, itswarnrules do not. - checkov runs with soft-fail, so it reports but never fails the build.
- infracost is informational. The hook always exits zero and never gates.
This lets you turn the gates on against an existing tenant without a wall of findings blocking every PR on day one.
Enforcing a gate¶
Enforce deliberately, after reviewing the report-only findings.
- conftest. Nothing is active until you add a
.regofile topolicy/. The repository shipspolicy/tags.rego.example, a starter that denies untagged resource groups; activate it withgit mv policy/tags.rego.example policy/tags.rego, after reading what it denies. A deny fails the gate and blocks the PR. The example also documents the load-bearing plan-JSON patterns: match the right resource type,change.actionsis an array (test membership, never index zero),change.afteris null on a destroy (guard it, or the deny fails open), and an empty string counts as a present tag value. - checkov. Drop the soft-fail from the checkov hook in
projects.yml, or scope it withCHECKOV_HARD_FAIL_ON(check IDs) once findings are triaged. - infracost. Cost stays informational. Use it to review the monthly delta a PR introduces.
Writing a policy¶
conftest policies are Rego in package main. A warn rule surfaces a finding
without gating; a deny rule blocks. Run against terraform show -json, so you
match on the plan structure:
package main
import rego.v1
deny contains msg if {
resource := input.resource_changes[_]
resource.type == "azurerm_storage_account"
"create" in resource.change.actions
not resource.change.after.tags.owner
msg := sprintf("%s must have an owner tag", [resource.address])
}
Check which provider actually creates the resource
Matching on an azurerm_* type is the most common way to write a rule that
silently does nothing. Many Azure Verified Modules declare azapi_resource
instead, so a rule keyed on the azurerm type matches no resource, finds no
violation, and reports a clean gate. It looks identical to a policy that
passed.
Azure/avm-res-resources-resourcegroup is one of them. A rule written
against azurerm_resource_group reported no violations on a resource group
that carried no tags at all.
Confirm the type before trusting a rule. In the plan JSON an azapi resource
has type set to azapi_resource and the ARM type in change.after.type,
so match both shapes:
is_resource_group(resource) if resource.type == "azurerm_resource_group"
is_resource_group(resource) if {
resource.type == "azapi_resource"
startswith(resource.change.after.type, "Microsoft.Resources/resourceGroups@")
}
Test a new rule against a plan you know should fail it, before trusting a
pass. conftest test --policy policy --namespace main <plan.json> runs the
same check locally that the gate runs in CI.
Cost gate data residency¶
infracost sends per-resource region and SKU to its hosted pricing API to look up prices. The Terraform code itself does not leave. This is an accepted trade-off: the self-hosted pricing API that would avoid the lookup is behind a paid Infracost plan.
Upload of the breakdown itself is off, and since engine v1.10.0 the engine is what
turns it off. The gate exports INFRACOST_ENABLE_CLOUD,
INFRACOST_ENABLE_CLOUD_UPLOAD, and INFRACOST_ENABLE_DASHBOARD as false on
every run. Left unset, infracost infers the answer from the Infracost Cloud
setting on whichever organization owns the API key, which in a customer's own
account is not NRIT's to control. That matters because the breakdown runs inside
the consumer's checkout, so its JSON carries the commit sha and the author's name,
email, and message. Setting the three per run is what keeps the accepted position,
region and SKU only, true regardless of the remote default.
Use the legacy infracost line (the static-key model). Get a static API key from the
Infracost dashboard, set it as the INFRACOST_API_KEY secret, and add infracost
to TFPR_EXTRA_TOOLS. Until the key is set, the gate notes it and skips.