Overview
Infrastructure as code security means checking a proposed change before it is provisioned, not only auditing it afterward. Policy-as-code frameworks like HashiCorp Sentinel evaluate a Terraform plan and can block the run from proceeding to apply if a rule is violated. That timing is the point: an unsafe AWS bucket or Azure storage account is stopped before it exists. Static source scanning remains useful, but the plan shows the concrete resource changes after variables, data sources, and modules have been evaluated. Some values can still be unknown until apply, so a production policy must explicitly decide whether an unknown security-sensitive value fails closed or receives a narrowly documented exception.
Quick Reference
| Check point | Sees | Can prevent provisioning? |
|---|---|---|
| Source scanning (linting) | Raw Terraform files | Only obvious, statically-visible issues |
| Plan-based policy (Sentinel, OPA/Conftest) | Planned changes plus explicitly represented unknown values | Yes, blocks the run before apply |
| Post-apply audit (cloud config scanning) | Already-provisioned live resources | No, detective only, after the fact |
Syntax
# sentinel.hcl - attach a policy to every plan for this workspace
policy "no-public-s3-buckets" {
source = "./no-public-s3-buckets.sentinel"
enforcement_level = "hard-mandatory"
}Examples
AWS: prevent public S3 access
# no-public-s3-buckets.sentinel - evaluated against the plan, not the
# source, so it sees actually-resolved config. Checks the legacy inline
# acl argument, the modern separate aws_s3_bucket_acl resource, and
# public-access-block settings that would otherwise allow a public
# bucket policy through; it does not evaluate bucket policy documents
# themselves.
import "tfplan/v2" as tfplan
public_acl_buckets = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket" and rc.change.after.acl is "public-read"
}
public_acl_resources = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket_acl" and rc.change.after.acl is "public-read"
}
open_access_blocks = filter tfplan.resource_changes as _, rc {
rc.type is "aws_s3_bucket_public_access_block" and
(rc.change.after.block_public_acls is false or
rc.change.after.restrict_public_buckets is false)
}
main = rule {
length(public_acl_buckets) is 0 and
length(public_acl_resources) is 0 and
length(open_access_blocks) is 0
}Azure: prevent anonymous blob access and old TLS
# secure-azure-storage.sentinel
import "tfplan/v2" as tfplan
insecure_storage_accounts = filter tfplan.resource_changes as _, rc {
rc.mode is "managed" and
rc.type is "azurerm_storage_account" and
rc.change.actions is not ["delete"] and
(rc.change.after.allow_nested_items_to_be_public is true or
rc.change.after.min_tls_version is not "TLS1_2")
}
main = rule {
length(insecure_storage_accounts) is 0
}The Azure rule checks the planned AzureRM provider values, not a screenshot or an already-deployed account. In a real policy library, add explicit handling for after_unknown, unit-test create/update/delete plans, and expand the controls to match the organization's threat model. Public network access, for example, may be permitted only when a restrictive firewall is present, while higher-risk state or secrets storage may require a private endpoint.
Check the plan, not just the source
Static scanning of .tf files cannot see values resolved through runtime inputs, data sources, and module composition. Policy-as-code evaluates Terraform's best prediction of the resource changes about to be applied. Security-sensitive values that remain unknown must be handled explicitly rather than silently treated as compliant.
Policy Validation Workflow
Treat policy code like production application code:
- I run
terraform plan -out=tfplanwith representative inputs and modules. - Render the saved plan with
terraform show -json tfplanand verify the exact provider resource types, attribute names, action lists, and unknown values the policy receives. - Build fixtures for allowed and denied AWS/Azure creates, updates, replacements, imports, and deletes. A policy tested only against one happy-path create will fail unexpectedly during real lifecycle operations.
- I start in advisory mode long enough to measure false positives, then make critical controls mandatory with an owned exception process and expiration date.
- Preserve the reviewed plan and policy result so the applied artifact is the exact plan that passed enforcement.
Plan files and their JSON output can contain sensitive values. I keep them out of source control and restrict CI artifact access and retention.
Visual Diagram
Common Mistakes
- Relying only on static source scanning and assuming it catches everything a plan-based policy check would, missing runtime-resolved misconfigurations entirely.
- Writing policy-as-code rules but setting them to advisory rather than mandatory enforcement, so a violation is logged but never actually blocks the run.
- Discovering a misconfigured, publicly-exposed resource only via a post-apply cloud security scan, after it's already been live and potentially exploited.
- Treating policy-as-code as a one-time setup instead of updating rules as new resource types and misconfiguration classes are adopted.
- Copying an AWS-only rule set into a multi-cloud pipeline and assuming it protects Azure resources whose provider types and attributes are completely different.
- Ignoring unknown plan values or delete/replace actions, causing a policy to fail open on the riskiest values or block legitimate lifecycle operations.
Performance
- Plan-based policy evaluation adds a bounded check to the
plan-to-applyworkflow, proportional to the number of resources in the plan and the number of policies evaluated, not the size of the entire infrastructure. - Preventive, plan-stage checks are unambiguously cheaper than the alternative: remediating an already-provisioned, possibly-exploited misconfiguration costs far more than blocking it before it exists.
Best Practices
- Evaluate policy against the plan, not just the source, so runtime-resolved values are actually checked before anything is provisioned.
- Set genuinely important policies to mandatory (hard-blocking) enforcement, not advisory, or they function as a report nobody has to act on.
- Pair plan-based policy checks with post-apply cloud configuration scanning as defense in depth, not as the primary control.
- I keep policy rules current as new resource types are adopted; an IaC security process that never adds new checks quietly falls behind what's actually being provisioned.
- I test policies against sanitized plan fixtures for every supported provider and pin provider versions so schema changes are reviewed alongside policy changes.
- Link every exception to an owner, justification, narrow scope, and expiration date; an undocumented permanent bypass is not an exception process.