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-warngives 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-exitcodeexits 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.
| Setting | Value | Note | Source |
|---|---|---|---|
| Policy gate on the plan | conftest test --fail-on-warn on terraform show -json | Treat exit 1 as hold and exit 2 as reject. | conftest documentation |
| Unknown values | Unassessable: hold the release and verify after apply | — | Terraform: JSON output format |
| Exceptions | Resource, port, source, owner and an RFC 3339 expiry, checked at evaluation time | — | Open Policy Agent: policy language |
| Drift check | terraform plan -refresh-only -detailed-exitcode on a schedule, with exit 1 alerting as a failed check | — | Terraform resource drift |
- 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.
- Run conftest without
--fail-on-warnon a plan whose only finding is unassessable and confirm the pipeline would have released it; then add the flag. - Change the exception expiry to the future and confirm D passes, then back to the past and confirm it fails without any code change.
- Change a live rule out of band in a test account and confirm the scheduled refresh-only plan exits 2 and names the resource.
- 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 prerequisites
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 practiceInfrastructure 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.
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.
# 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)
}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.
# 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
}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.
#!/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"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.
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.
These outcomes belong to the worked example, and the policy run above produced each of them against a real plan.
| Case | Expected result | What it establishes |
|---|---|---|
| A | Pass the stated source rule | Required access is retained |
| B | Fail | Explicit public source is prohibited |
| C | Unassessable; hold release | Unknown is not converted to safe |
| D | Fail at the stated time | Expired 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
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. 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
UNKNOWNcould contain an extra source. D fails because E7 expired one hour before evaluation.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. 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. 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-appconnection, a forbidden external-source result, and the reconciled source/exception decision. Another S4 source scan alone cannot close deployed drift.
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.
Reference information
- SDLC phase
- ReleaseMaintenance