In short

Review the plan, not only the diff, and review it as four separate questions: who can obtain each identity, what each identity can do, what becomes reachable from the network, and where secrets end up. Encode the recurring rules as policy, for example Rego run with conftest against terraform show -json, and fail the pipeline on violations. Apply exactly the approved plan, then test the deployed access, and keep secrets out of state with ephemeral values and write-only arguments.

  • Trust, permission, network exposure and artifact handling are independent findings; fixing one says nothing about the others.
  • Policy as code turns the review into a pipeline gate: a 45-line Rego policy caught all three access defects in R17 and passed R18.
  • sensitive = true only hides a value in the terminal; in a test it still appeared in clear text in plan.json and the saved plan file.
  • Terraform 1.11 write-only arguments, fed by ephemeral variables, keep a secret out of plan and state entirely.
  • An approved plan is evidence only for itself: a later plan from a different lock file needs its own review.

Verify it

  1. Run terraform show -json plan.bin > plan.json and conftest test --policy policy plan.json in CI and confirm a failing rule stops the apply.
  2. Search the saved plan and plan.json for a canary secret and expect no match where write-only arguments are used.
  3. Apply only the plan file that was reviewed (terraform apply plan.bin), and record its hash with the approval.
  4. After apply, test the role from production and staging, one allowed write, a forbidden read and a neighboring prefix.
  5. Check that the database port is not reachable from outside the VPC.
Applies to
  • Terraform 1.16.5 tested; ephemeral values need 1.10 and write-only arguments 1.11 or later
  • conftest 0.70.0 (OPA Rego v1 syntax)
  • AWS provider 6.68.0

Infrastructure change review. A clean source diff cannot establish deployed state or all effective permissions.

Part of
Workflow: Fix a finding
Learning goals and prerequisitesAbout 27 minutes: 7 to read and 20 for the exercise

What you will learn

  • Translate a feature requirement into a precise access tuple
  • Separate a plan approval from deployed-access evidence
  • Identify secrets that remain exposed in state artifacts

Before you start

Engineers who write or approve Terraform changes, and the platform teams who run the pipeline that applies them

  • Read an infrastructure diff and identify identities, resources, and network exposure.

Refresh first: Review cloud permissions and role trust together

Go to the guided practice

Review the planned result

Use this checklist for cloud infrastructure changes; the state-handling notes specifically concern Terraform. Provider features and resource behavior vary, so inspect the plan using the exact tool and provider versions your deployment supports. Review both the configuration and the proposed effective resources.

Start with the intended capability. Which service needs access to which resource, for what operation, from which identity and network path? A syntactically valid configuration can still grant more authority or exposure than the feature requires.

Worked review: an export feature changes three access boundaries

The feature is deliberately small: a production report worker writes a finished CSV to one tenant prefix. It does not browse the bucket or read other tenants’ exports. Represent this requirement as principal, operation, resource and conditions. A role named report-writer is only a label; the role’s effective actions and the identities that can acquire it determine its authority. Start with the requirement before reading the proposed implementation so the implementation does not quietly redefine the need.

In the supplied plan, s3:* on exports/* grants many operations across every prefix. Replacing it with PutObject on exports/north/* removes the documented read and neighboring-tenant paths. That is only the permissions half of the review. If a staging runner may assume the production role, staging still gains the narrower production capability. Review and repair the role acquisition condition separately. An environment string in a variable is not an enforcement condition unless it reaches the identity provider and trust policy correctly.

The network change is independent again. Opening a database security group to 0.0.0.0/0 is not required for writing a CSV to object storage. Ask for the dependency that needs the ingress; if none exists, remove it. A successful upload test cannot excuse unrelated database exposure. Finally, a sensitive Terraform variable may be redacted in terminal output while its value remains in plan/state. Treat those artifacts as a distinct secret-bearing data flow with an owner, retention and access controls.

Close the review in two stages. Before deployment, accept only the intended plan revision and lockfile, with the excess changes removed. After deployment, establish the actual principal and test one allowed write plus forbidden read, neighboring prefix and staging assumption. A plan can be correct while a separate resource policy or manual change still grants access; only the combined evidence supports the operational conclusion.

The Terraform below is the R17 proposal. It shows three of the four defects in code: a trust condition that accepts every environment through StringLike, s3:* on every export object, and PostgreSQL open to the internet. The fourth, a plan file readable by anyone with build-log access that carries a database password, is a pipeline and state problem covered below.

Vulnerabler17/main.tfHCL
resource "aws_iam_role" "report_worker" {
  name = "report-worker"
  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Action    = "sts:AssumeRoleWithWebIdentity"
      Principal = { Federated = "arn:aws:iam::111122223333:oidc-provider/token.actions.githubusercontent.com" }
      Condition = {
        StringEquals = { "token.actions.githubusercontent.com:aud" = "sts.amazonaws.com" }
        StringLike   = { "token.actions.githubusercontent.com:sub" = "repo:company/reports:environment:*" }
      }
    }]
  })
}

resource "aws_iam_role_policy" "report_worker" {
  role = aws_iam_role.report_worker.id
  policy = jsonencode({
    Version   = "2012-10-17"
    Statement = [{ Effect = "Allow", Action = "s3:*", Resource = "arn:aws:s3:::exports/*" }]
  })
}

resource "aws_vpc_security_group_ingress_rule" "db" {
  security_group_id = "sg-0123456789abcdef0"
  ip_protocol       = "tcp"
  from_port         = 5432
  to_port           = 5432
  cidr_ipv4         = "0.0.0.0/0"
}

Trace access and data paths

  • Identity: prefer appropriate workload roles and temporary credentials; inspect trust relationships as carefully as permission policies, as the cloud IAM trust guide at /domains/runtime/cloud-posture/cloud-iam-trust/ shows.
  • Permission: examine wildcard actions/resources, cross-account access, policy conditions, administrative privileges, and who can change the controls themselves.
  • Network: explain every new public endpoint or broad ingress rule. Confirm whether private naming or an internal load balancer actually enforces the required boundary.
  • Data: identify storage exposure, encryption/key access, backup behavior, deletion protection, and where audit evidence goes.
  • Operations: identify the deployer, the approved plan, drift handling, rollback constraints, and the owner of any deliberate exception.

Encode the review as policy and run it on the plan

Rules that every change must meet belong in a policy the pipeline runs on the plan JSON, so a reviewer spends time on the judgment calls. The Rego policy below, run with conftest, checks the three access questions: wildcard actions in permission policies, a GitHub OIDC subject matched with anything other than StringEquals, and ports other than 80 and 443 opened to 0.0.0.0/0 or ::/0. On the plan for R17 it reported three failures, one per defect; on the plan for R18 all three rules passed.

The same approach works with other engines: HCP Terraform and Terraform Enterprise run Sentinel or OPA policies on every plan, and scanners such as Checkov and Trivy ship hundreds of ready-made checks. A policy only sees what is in the plan, so the trust path through the pipeline itself and existing resources outside the configuration still need review.

Configurationpolicy/review.regoRego
# Review rules for a Terraform plan: terraform show -json plan.bin > plan.json
package main

changes contains rc if {
	some rc in input.resource_changes
	some action in rc.change.actions
	action in {"create", "update"}
}

# 1. Permission policies must not allow wildcard actions
deny contains msg if {
	some rc in changes
	rc.type in {"aws_iam_role_policy", "aws_iam_policy"}
	some statement in as_array(json.unmarshal(rc.change.after.policy).Statement)
	statement.Effect == "Allow"
	some action in as_array(statement.Action)
	contains(action, "*")
	msg := sprintf("%s: Allow with wildcard action %q", [rc.address, action])
}

# 2. A GitHub OIDC trust must pin one exact subject with StringEquals
deny contains msg if {
	some rc in changes
	rc.type == "aws_iam_role"
	some statement in as_array(json.unmarshal(rc.change.after.assume_role_policy).Statement)
	some operator, conditions in statement.Condition
	some key, value in conditions
	endswith(key, ":sub")
	operator != "StringEquals"
	msg := sprintf("%s: %s on %s matches %v; use StringEquals", [rc.address, operator, key, value])
}

# 3. Nothing but the public web ports may be opened to the whole internet
deny contains msg if {
	some rc in changes
	rc.type == "aws_vpc_security_group_ingress_rule"
	some cidr in {rc.change.after.cidr_ipv4, rc.change.after.cidr_ipv6}
	cidr in {"0.0.0.0/0", "::/0"}
	not rc.change.after.from_port in {80, 443}
	msg := sprintf("%s: port %v open to %s", [rc.address, rc.change.after.from_port, cidr])
}

as_array(x) := x if is_array(x)

as_array(x) := [x] if not is_array(x)

Treat state and plans as sensitive artifacts

Terraform stores sensitive values in state and plan files; sensitive = true only redacts them from terminal and UI output. In a test with Terraform 1.16.5, a password passed through a sensitive variable printed as "(sensitive value)" in the plan output but appeared in clear text in terraform show -json and in the saved plan file. Since Terraform 1.10, variables can be ephemeral, and since 1.11 resources can take write-only arguments: the same password passed through an ephemeral variable to value_wo appeared in neither file. Use them where the provider supports them, and restrict access to, encrypt and limit the retention of state and plans everywhere else.

Do not paste complete plans into public tickets or unconstrained logging systems. Review the deployment pipeline’s artifact visibility and the identities able to read previous state versions.

Fixedsecrets.tfHCL
# Terraform 1.11+: an ephemeral variable passed to a write-only argument never reaches
# the plan or state. A plain `sensitive = true` variable is only hidden in the terminal.
variable "db_password" {
  type      = string
  sensitive = true
  ephemeral = true
}

resource "aws_ssm_parameter" "db_password" {
  name             = "/reports/db-password"
  type             = "SecureString"
  value_wo         = var.db_password
  value_wo_version = 1 # increase to write a new value
}

Verify after applying

In an authorized test environment, prove that the intended workload can perform its required action while an unrelated identity cannot. Check actual public reachability and effective policies after deployment; configuration review alone cannot establish the final state. The policy and drift guide at /domains/runtime/cloud-posture/iac-policy-drift/ covers checks after apply. Retain the approved plan, deployed revision, access-test results, and exception decisions. Repeat important checks when policies, providers, or deployment identities change.

Diagnose incomplete fixes

A wildcard replacement is not automatically least privilege. Bucket-level and object-level actions use different resource forms in AWS; copy the service’s documented action/resource relationships, not one ARN shape for every action. Multipart uploads or a particular client may need additional narrowly scoped operations. Establish that need through the supported client’s actual calls, rather than restoring s3:* after the first failure.

A clean infrastructure scan does not establish that a secret disappeared from older state versions or downloaded plans. Revoke any exposed credential and review readers and retained copies. Do not attempt to sanitize the problem by merely toggling sensitive. Conversely, never publish raw state as the evidence for a security review; record redacted identifiers and controlled artifact references.

Check the result against concrete cases

These outcomes belong to the worked example; the policy checks the plan-level rows, and the access rows need tests against the deployed account.

Check the result against concrete cases
CaseExpected resultWhat it establishes
Production PutObject north/new.csvAllowRequired upload still works
Production GetObject north/new.csvDeny under the stated role contractWrite-only role is actually narrow
Production PutObject south/new.csvDenyTenant prefix is enforced
Staging acquires production roleDenyTrust policy is separately restricted
Unrelated source connects to databaseDenyUnrequested ingress is removed

Guided practice

Review change R17 using only the supplied requirement and plan. Produce separate decisions for identity acquisition, object operations, network exposure and artifact handling.

Supplied scenario material
Plain text
R17 is the Terraform shown above; its plan file is readable by everyone with build-log access and carries a database password from a variable marked sensitive. Revision R18 removes database ingress, allows only production identity P, and grants PutObject on exports/north/*. Plan P18 was approved. Pipeline later creates plan P19 from a different provider lockfile and deploys P19. Observed tests: P writes north/a.csv; P cannot read it; staging assumes the role successfully. A shared log artifact contains the old plan with a database credential.
  1. 1. Identify independent defects

    List each original capability with its missing business justification. Do not combine trust and permission into one finding.

    Expected result and reasoning

    The four defects are staging-to-production acquisition, excessive object actions/prefixes, unrelated public database ingress, and broad access to secret-bearing plan artifacts. Each has a separate control and acceptance test.

  2. 2. Review the claimed repair

    Compare R18 and the observed staging test. Decide whether a narrow permission list closes the finding.

    Expected result and reasoning

    R18 appears to repair the documented intent, but staging still assumes the deployed role. Either another trust path remains or P19 differs. The acquisition finding stays open even though the production upload succeeds.

  3. 3. Bind approval to execution

    Explain the significance of approving P18 and deploying P19. Name the evidence needed next.

    Expected result and reasoning

    P18 approval is not approval of P19. Compare the exact plans and provider lockfiles, identify the deployed trust policy and all permission attachments, and rerun the intended principal/action/resource tests. Do not label P19 compliant from a P18 screenshot.

  4. 4. Handle retained credentials

    Decide what changes after discovering the old plan credential. Separate future artifact policy from response.

    Expected result and reasoning

    Restrict future plan access and retention, but also treat the already exposed credential as compromised: rotate/revoke it, review permitted readers and access history, and remove retained copies where policy allows. Redaction in the next build does not invalidate an old credential.

Your deliverable: Four separate findings for R17, a decision on whether R18 closes them, the evidence needed because P19 replaced P18, and the response to the exposed credential.

The review uses the supplied plans and observations; the Terraform, Rego policy and state test on this page show the same findings 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. AWS IAM security best practices
  2. HashiCorp: managing sensitive Terraform data
  3. Terraform: JSON output format
  4. conftest documentation
  5. Open Policy Agent: policy language
  6. HCP Terraform: policy enforcement with Sentinel and OPA

Reference information

ASVS 5.0.0 requirements
v5.0.0-13.2.2 · v5.0.0-13.3.1 · v5.0.0-13.3.2

Download this guide as Markdown