---
key: "iac-review"
title: "Review infrastructure changes as access changes"
url: "https://softwaresecurity.com/domains/runtime/cloud-posture/iac-security/"
api: "https://softwaresecurity.com/api/v1/articles/iac-review.json"
domain: "runtime"
topic: "Runtime, cloud \u0026 APIs"
type: "Guide"
identifiers: ["CWE-732","CWE-312","A02:2025","V13.2.2","V13.3.1","V13.3.2"]
tags: ["Secrets management","Cloud security","Infrastructure as code"]
stages: ["operate"]
roles: ["developer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Review infrastructure changes as access changes

Inspect the effective permissions, exposure, sensitive state, and recovery implications of a proposed infrastructure change.

Canonical URL: https://softwaresecurity.com/domains/runtime/cloud-posture/iac-security/

## 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.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Policy input | `terraform show -json plan.bin` for the exact plan that will be applied | — | Terraform: JSON output format |
| Pipeline gate | `conftest test --policy policy plan.json`; any failure stops the apply | — | conftest documentation |
| Secrets passed to resources | Ephemeral variables into write-only arguments (`*_wo` with `*_wo_version`) | — | HashiCorp: managing sensitive Terraform data |
| Plan and state files | Treated as secret-bearing: encrypted storage, restricted readers, bounded retention | — | HashiCorp: managing sensitive Terraform data |

## 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.

## 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

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

Before you start:

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

Time: About 27 minutes: 7 to read and 20 for the exercise

## 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.

```hcl
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.

```rego
# 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.

```hcl
# 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.

| Case | Expected result | What it establishes |
| --- | --- | --- |
| Production `PutObject` `north/new.csv` | Allow | Required upload still works |
| Production `GetObject` `north/new.csv` | Deny under the stated role contract | Write-only role is actually narrow |
| Production `PutObject` `south/new.csv` | Deny | Tenant prefix is enforced |
| Staging acquires production role | Deny | Trust policy is separately restricted |
| Unrelated source connects to database | Deny | Unrequested 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 material

```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. Identify independent defects

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

Worked answer: 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. Review the claimed repair

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

Worked answer: 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. Bind approval to execution

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

Worked answer: 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. Handle retained credentials

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

Worked answer: 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.

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.

Scope: 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.

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [AWS IAM security best practices](https://docs.aws.amazon.com/IAM/latest/UserGuide/best-practices.html)
- [HashiCorp: managing sensitive Terraform data](https://developer.hashicorp.com/terraform/language/manage-sensitive-data)
- [Terraform: JSON output format](https://developer.hashicorp.com/terraform/internals/json-format)
- [conftest documentation](https://www.conftest.dev/)
- [Open Policy Agent: policy language](https://www.openpolicyagent.org/docs/policy-language)
- [HCP Terraform: policy enforcement with Sentinel and OPA](https://developer.hashicorp.com/terraform/cloud-docs/policy-enforcement)