In short

Evaluate infrastructure policy in three places: the source, the plan you are about to apply and the live account. Make each rule return pass, fail or unassessable, and hold the release when a value is unknown until apply. Write exceptions with the exact resource, source, owner and expiry, and let them lapse. Run a refresh-only plan on a schedule to detect drift, then decide deliberately whether to revert, reconcile or approve each change.

  • A value that is unknown at plan time is neither safe nor unsafe; report it as unassessable and check the live resource after apply.
  • conftest with --fail-on-warn gives three exit codes: 0 pass, 1 warnings only (unassessable), 2 failures; without the flag an unassessable plan exits 0.
  • An exception that names the resource, source and expiry can expire on its own; a boolean flag on a repository cannot.
  • terraform plan -refresh-only -detailed-exitcode exits 2 when live infrastructure differs from state, and 1 when the check itself failed.
  • A clean source scan and a drift alert can both be true: they examined different representations.

Verify it

  1. Run the policy against a plan with all four cases and expect A to pass, B and D to fail and C to be reported as unassessable.
  2. Run conftest without --fail-on-warn on a plan whose only finding is unassessable and confirm the pipeline would have released it; then add the flag.
  3. Change the exception expiry to the future and confirm D passes, then back to the past and confirm it fails without any code change.
  4. Change a live rule out of band in a test account and confirm the scheduled refresh-only plan exits 2 and names the resource.
  5. Break the drift job's credentials and confirm exit 1 raises an alert instead of being read as no drift.
Applies to
  • Terraform 1.16.5 (-refresh-only needs 0.15.4 or later)
  • conftest 0.70.0

Infrastructure checks and drift response. Plan-time checks cannot by themselves validate a live account.

Part of
Learning path: Runtime and cloud defense (step 6 of 13). Workflow: Fix a finding
Learning goals and prerequisitesAbout 26 minutes: 7 to read and 19 for the exercise

What you will learn

  • Write a three-outcome policy decision instead of treating unknown values as safe
  • Match an exception to the capability and revision it actually covers
  • Reconstruct a drift incident across source, plan and live state

Before you start

Platform engineers who write infrastructure policy, review exceptions and respond to drift alerts

  • Read both intended infrastructure and observed deployed configuration.

Refresh first: Review infrastructure changes as access changes

Go to the guided practice

Three representations can disagree

Infrastructure source expresses intent, a plan describes a proposed change, and deployed resources represent current behavior. Defaults, provider changes, admission mutations and manual edits can make them differ. A scanner finding in source is useful, but it is not a complete picture of effective access or the final public endpoint.

Choose a policy tied to a specific failure: an application database must not accept public ingress; an export bucket must not be public; or a workload role must not gain policy-administration rights. Define exceptions in the same terms as the rule so reviewers can see exactly which capability is being permitted. The infrastructure review guide at /domains/runtime/cloud-posture/iac-security/ covers the questions such rules encode.

Worked policy: public database ingress with unknown values

The requirement is precise: database D must accept TCP/5432 only from the application security group. The policy evaluates the proposed effective sources, not a resource name containing the word private. A source containing 0.0.0.0/0 is a definite violation. A source whose value is unknown at plan time is neither evidence of compliance nor evidence of public exposure. Return unassessable, and require the missing value or a later controlled verification before releasing the resource.

An exception is a scoped decision, not a Boolean attached to a repository. Exception E7 permits one temporary maintenance source, sg-maint, for D until 2026-10-07T18:00Z. It does not permit the Internet, a different database, a different port, or use after expiry. Comparing only exception existence would transform a narrow maintenance approval into a broad bypass. Include the exact resource, action, source, owner and expiry in the decision record.

After deployment, source S4 and plan P4 can remain unchanged while a console edit changes live rule L5. A source scan then truthfully passes its input but cannot establish the deployed property. The response needs the live configuration, author of the change, associated incident/change record and an actual reachability check from the allowed and forbidden sources. If a legitimate emergency change was made, reconcile it deliberately; do not let an automated rollback overwrite incident containment without coordination.

The Open Policy Agent (OPA) Rego policy below implements the rule and its three outcomes for conftest: no message is a pass, deny is a fail and warn is unassessable. Exceptions arrive as data with an RFC 3339 expiry that is compared with the evaluation time. Run against a Terraform plan containing all four cases, it passed A, failed B for 0.0.0.0/0, failed D because E7 had expired, and reported C as unassessable because the security group it references is created by the same plan. With the expiry moved into the future, D passed.

Fixedpolicy/db_ingress.regoRego
# Database D accepts TCP 5432 only from the application security group or an unexpired
# exception. No message means pass, deny means fail, warn means unassessable: hold the release.
package main

app_sg := "sg-0a00000000000000a"

db_ingress contains rc if {
	some rc in input.resource_changes
	rc.type == "aws_vpc_security_group_ingress_rule"
	some action in rc.change.actions
	action in {"create", "update"}
	rc.change.after.from_port <= 5432
	rc.change.after.to_port >= 5432
}

deny contains msg if {
	some rc in db_ingress
	some cidr in [rc.change.after.cidr_ipv4, rc.change.after.cidr_ipv6]
	cidr != null
	msg := sprintf("FAIL %s: port 5432 open to %s", [rc.address, cidr])
}

deny contains msg if {
	some rc in db_ingress
	source := rc.change.after.referenced_security_group_id
	source != null
	source != app_sg
	not excepted(rc.change.after.security_group_id, source)
	msg := sprintf("FAIL %s: %s is not the app group or an unexpired exception", [rc.address, source])
}

warn contains msg if {
	some rc in db_ingress
	rc.change.after_unknown.referenced_security_group_id
	msg := sprintf("UNASSESSABLE %s: source unknown until apply; verify the live rule", [rc.address])
}

excepted(group, source) if {
	some exception in data.exceptions
	exception.security_group_id == group
	exception.source == source
	time.now_ns() < time.parse_rfc3339_ns(exception.expires)
}

Build policies around insecure and secure fixtures

Use the supported test and policy interfaces of the selected IaC tool and version. For Terraform, record provider constraints, lockfile and whether a test creates real resources or uses mocks. Validate planned values where possible, including unknown values that cannot yet prove compliance. Treat unassessable input as an explicit result rather than silently converting it to a pass.

For each rule, keep a clearly insecure fixture, a required secure configuration and an intentional exception. Include alternate encodings or resource types that can express the same exposure. A rule matching only one familiar resource name may miss a public path created through another service. Review policy changes with their fixtures just as application changes are reviewed with regressions.

The fixture below expresses the four cases as Terraform, and its plan JSON is the policy's test input; case C references a security group the same plan creates, so its ID is genuinely unknown. Pass the exceptions with --data and run with --fail-on-warn: conftest then exits 0 when every rule passes, 1 when the only findings are warnings and 2 when anything fails. Without the flag, a plan whose only finding is unassessable exits 0 and would be released. For modules, terraform test (Terraform 1.6 and later) runs assertions on plans, and since 1.7 it can mock providers, so policy fixtures can run without cloud credentials.

Testcases/main.tfHCL
# Four candidate ingress rules for database D (TCP 5432), one per case in the guide.
locals {
  db_sg  = "sg-0d00000000000000d"
  app_sg = "sg-0a00000000000000a"
  maint  = "sg-0e00000000000000e"
}

resource "aws_vpc_security_group_ingress_rule" "case_a_app" {
  security_group_id            = local.db_sg
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
  referenced_security_group_id = local.app_sg
}

resource "aws_vpc_security_group_ingress_rule" "case_b_public" {
  security_group_id = local.db_sg
  ip_protocol       = "tcp"
  from_port         = 5432
  to_port           = 5432
  cidr_ipv4         = "0.0.0.0/0"
}

resource "aws_security_group" "diagnostics" { # created in the same plan, so its ID is unknown
  name   = "diagnostics"
  vpc_id = "vpc-0123456789abcdef0"
}

resource "aws_vpc_security_group_ingress_rule" "case_c_unknown" {
  security_group_id            = local.db_sg
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
  referenced_security_group_id = aws_security_group.diagnostics.id
}

resource "aws_vpc_security_group_ingress_rule" "case_d_maintenance" {
  security_group_id            = local.db_sg
  ip_protocol                  = "tcp"
  from_port                    = 5432
  to_port                      = 5432
  referenced_security_group_id = local.maint
}

Bind approval to what is applied

Protect plans and state because they can contain credentials, identifiers and sensitive resource attributes. Keep review artifacts out of unrestricted logs. Bind deployment approval to the reviewed revision and plan according to the tool's supported workflow; regenerating a materially different plan after approval must trigger another decision.

After deployment, verify the relevant access property using the actual service; the cloud provider baselines guide at /domains/runtime/cloud-posture/cloud-provider-baselines/ lists read-only checks for live accounts. Monitor drift for changes to permissions, network exposure, encryption/key access, audit configuration and deletion protection. Decide whether to reconcile source, revert the change or approve an exception. Blind automatic reversion can disrupt incident response or overwrite a legitimate emergency change, so ownership and escalation matter.

The script below is a scheduled drift check. terraform plan -refresh-only -detailed-exitcode reads the live resources without proposing changes to them, exits 0 when they match the state and 2 when something changed outside Terraform, and the plan JSON lists the drifted resources under resource_drift. In a test, editing the managed resource out of band produced exit 2 and named it; restoring it produced exit 0. Exit 1 means the check could not run and must alert, because silence is not evidence of no drift.

Configurationcheck-drift.shShell
#!/usr/bin/env bash
# Scheduled drift check. Exit 0: no drift. Exit 2: the live infrastructure differs from state.
# Exit 1: the check itself failed, which must not be mistaken for "no drift".
set -uo pipefail
terraform plan -refresh-only -detailed-exitcode -input=false -no-color -out=drift.plan > drift.txt
status=$?
if [ "$status" -eq 2 ]; then
  # Which resources changed outside Terraform, for the owner to triage (never auto-apply)
  terraform show -json drift.plan | jq -c '.resource_drift[] | {address, actions: .change.actions}'
fi
exit "$status"

Practice a drift decision before an incident

In a disposable environment, introduce one controlled change and observe whether the detection path identifies its security consequence. Confirm the alert reaches an accountable owner and that the chosen remediation restores the expected access behavior. Keep evidence of the before state, decision and after check. Re-run rules when providers or modules change; a passing historic plan cannot establish the safety of a new provider interpretation.

Diagnose incomplete fixes

Testing only the forbidden fixture proves that a rule can reject something, not that it recognizes all representations of the exposure. Include required access, IPv4/IPv6 public sources where supported, alternate rule resources and unresolved values. Document normalization and provider semantics; a string comparison against one literal does not cover every network configuration.

Drift reports may include harmless tags and critical permission changes together. Prioritize by changed capability rather than treating every difference as equal. Preserve the approved and observed revisions with their collection time; a screenshot without those identifiers cannot show whether the subsequent repair actually persisted.

Check the result against concrete cases

These outcomes belong to the worked example, and the policy run above produced each of them against a real plan.

Check the result against concrete cases
CaseExpected resultWhat it establishes
APass the stated source ruleRequired access is retained
BFailExplicit public source is prohibited
CUnassessable; hold releaseUnknown is not converted to safe
DFail at the stated timeExpired exception no longer grants the extra source

Guided practice

Evaluate the four supplied cases, then diagnose the supplied deployment timeline. No cloud account or Terraform installation is needed.

Supplied scenario material
Plain text
POLICY D/5432 sources = {sg-app}; no other sources.
E7: additionally permit sg-maint for D/5432 until 2026-10-07T18:00Z.
All cases evaluated at 2026-10-07T19:00Z.
A: {sg-app}. B: {sg-app, 0.0.0.0/0}. C: {sg-app, UNKNOWN}. D: {sg-app, sg-maint}, cites E7.
10:00 source S4 and plan P4 permit sg-app only.
10:10 P4 deploys; live check L4 matches.
11:00 console actor ops-12 adds 0.0.0.0/0 to D/5432.
11:05 source scan of S4 passes.
11:07 drift collector records L5 with the public source.
11:10 incident INC9 says a diagnostic host needed access, but grants no public-ingress exception.
  1. 1. Classify the policy cases

    Assign pass, fail or unassessable to A–D and name the decisive value.

    Expected result and reasoning

    A passes. B fails for the public source. C is unassessable because UNKNOWN could contain an extra source. D fails because E7 expired one hour before evaluation.

  2. 2. Explain the apparently conflicting results

    Decide whether the 11:05 source pass disproves the 11:07 finding.

    Expected result and reasoning

    No. The scan evaluated S4, while drift evaluated L5. Both reports can be correct. The exposure was introduced after the approved deployment through a different control path.

  3. 3. Choose a bounded repair

    Use INC9 to decide what should happen to the public source and what evidence is still needed.

    Expected result and reasoning

    INC9 does not authorize public access. Coordinate with the incident owner to remove the public source or replace it with an explicitly approved narrow diagnostic source. Identify ops-12’s authority and reason; do not infer malicious intent from the change alone.

  4. 4. Prove closure at the right layer

    Specify a final record that would support closing this finding.

    Expected result and reasoning

    Retain the repaired live rule revision and observation time, an allowed sg-app connection, a forbidden external-source result, and the reconciled source/exception decision. Another S4 source scan alone cannot close deployed drift.

Your deliverable: Pass, fail or unassessable for A to D with the decisive value, an explanation of the 11:05 and 11:07 results, a bounded repair and the closing evidence.

The cases and timeline are supplied; the policy, fixture plan and drift script on this page reproduce the same outcomes with real tools.

Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.

No draft saved on this device yet.

Sources & further reading

The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.

  1. Terraform tests
  2. Terraform resource drift
  3. Terraform sensitive data
  4. Terraform: plan -refresh-only and detailed exit codes
  5. Terraform: JSON output format
  6. conftest documentation
  7. Open Policy Agent: policy language

Reference information

Download this guide as Markdown