Back to Blog

Language: English

Showing Terraform Plan on PRs Without Leaking

Running Terraform plan on same-repo PRs hands cloud read permissions to untrusted input. This post traces how a plan-commenting workflow hardened its boundary, starting from emitting nothing sensitive and widening what may be shown stage by stage.

Running Terraform plan against a PR diff amounts to handing cloud read permissions to untrusted input. plan performs refresh, so it reads live current values from the real environment. Connecting to the state bucket requires its settings, and TF_VAR_* environment variables ride along. A PR can also carry modifications to workflows and scripts alongside the Terraform config.

Run as-is, plan dumps raw output, state contents, and Secret values into PR comments and logs. This post uses our setup for running plan on same-repo PRs and commenting the result to trace how the boundary between what gets emitted and what stays hidden hardened stage by stage. The idea is close to the trusted workflow in the Preview QA environment; with plan, even failure diagnostics fall within the output design.

Drawing the trust boundary

Decide first whom to trust. People able to open PRs against the same repository count as trusted contributors, and the Terraform config and environment files at their head may execute. Untrusted code arrives as fork PRs and passes only static IaC checks requiring no credentials.

The entry pipeline started in this shape:

PR(same-repo・open・head SHA 不変を GitHub API で検証)
  → trusted checkout(develop 固定)の workflow 定義 / backend / provider lockfile
  → PR head の Terraform config と環境ファイルで plan
  → 結果を PR コメントへ upsert

The router workflow skips checking out the PR code. After confirming through the GitHub API that the PR is open, belongs to the same repository, and still points at an unchanged head SHA, it calls a worker pinned to develop. The worker sources its backend, provider lockfile, and Terraform version from the trusted checkout, so PR-side rewrites of any of these fail closed.

The service account running plan is Planner-only, separate from the deployer used for apply. WIF received a Planner-dedicated provider as well, pinning token issuance to the repository and to the workflow path and ref. The deployer’s mappings and permissions stay untouched. Running plan and posting comments split into distinct permissions. Neither side touches raw plan output, logs, artifacts, or GitHub Actions Secrets.

Permissions granted to Planner

Each verified need added a read permission to Planner’s service account.

PermissionReason for adding
Project Viewerplan with refresh reads the current value of every resource
objectViewer on the state bucketretrieving remote state
accessor on the dotenv key Secretdecrypting environment files; scoped to the individual Secret, not a project-wide accessor
Service Usage project rolerunning plan with refresh enabled
roles/iam.securityReviewerplan with refresh reads the IAM policy of every resource; a read-only getIamPolicy permission

securityReviewer joined last, after digging into the cause of a failure (next section). A contract test fixes the role set plan requires and mechanically checks that no write-capable role slips in. Switching to -refresh=false as a workaround was ruled out.

What failure prints

As soon as the pipeline ran, plan began failing. Failure output then became the design problem. Terraform’s error details mix in variable values and resource names; printing stdout/stderr as on success would leak them.

The first safeguard sorted failures into fixed messages.

Plan failed: init       ← terraform init の失敗        (イメージ)
Plan failed: dotenvx    ← 環境ファイル準備の失敗
Plan failed: plan       ← plan 実行の失敗

Secrets, plan contents, and state print nothing. The display changes only in the failure’s error title; successful comments stay as before. The classifier received equal care: pre-validation of dotenvx decryption launches /usr/bin/true on the trusted runner, nothing more. Executables from the PR never run, even for verification.

Fixed messages stop short of the cause, so we layered interim diagnostics on top:

  • The JSON diagnostic summary served cross-checking inside the workflow alone; no PR comment, log, or GitHub output received it
  • If the summary settled nothing, Cloud Audit Logs were queried only on exit 1, narrowed to Planner’s principal plus the plan’s start and end times
  • Audit queries ran at most three times on a fixed filter, 20 seconds apart
  • The sanitizer permitted four fields; we emitted at most five entries spanning serviceName / methodName / status.code
  • Raw audit logs reached no output, artifact, or comment
  • Failed queries and unclassifiable outcomes returned a dedicated category fail-closed

We removed these diagnostics once the cause was confirmed; they existed as interim tooling. IAM was the cause. plan with refresh reads every resource’s IAM policy, and the runs died for lack of getIamPolicy permission.

After removal, we decided the failure-output policy afresh. Under the approved trust model, failure output may be visible to people viewing the PR. Raw stdout/stderr therefore now reaches the GitHub Actions log on failures alone. Random tokens bracket the output so the runner won’t parse it as workflow commands. Success emits no raw output, and nothing archives stderr.

Showing the diff against base

Success comments initially held exact add / change / destroy counts. Counts avoid exposing content yet make poor reading, so we moved the comment to a diff against base and simplified the whole workflow in the same move.

The base commit and the PR’s merge result are checked out individually, and two plans per stack run in parallel. terraform show renders both to text and a diff is taken. Environmental drift appears identically in both plans, cancels inside the diff, and changes originating from the PR alone survive. Posting gathers into one sticky comment collapsed per stack. Large diffs truncate explicitly line by line; the full text goes to the workflow log. A paths filter narrowed invocation: previously plan ran on every PR, unrelated changes included.

Simplification tore out the fork-PR defenses: the router and reusable worker split, head SHA re-resolution via the GitHub API, backend definition verification against develop, base64 job-output handoff with a separate comment job, stop-commands, and stale run guards. Fork PRs against an internal repository cannot mint OIDC tokens and need only static checks, so the sole surviving guard executes same-repo PRs exclusively.

Token issuance conditions got attention too. A token issued by a pull_request trigger carries job_workflow_ref pointing at refs/pull/<n>/merge, mismatching an attribute condition that pins ref to develop. Provider conditions changed to pin on the workflow path alone.

Comment updating changed method as well. Editing an existing comment leaves it parked at its original spot in the timeline. Posting anew and deleting the old comment afterward brings the latest plan to the tail of the timeline on every update. Comparing head SHA before and after posting prevents older runs from publishing, and deletion targets bot comments carrying markers older than ours alone. A failed new post preserves the existing comment, and a 404 arising from racing deletes counts as settled and tolerable.

Rebuilding it in another repository

Porting the same mechanism to another repository exposed two gaps distorting the displayed diff.

One gap missed plan targets. The trusted-side planner covered only Terraform roots present in the base checkout. Roots newly added by the PR fell outside planning, and the comment kept reporting “no changes” while nobody examined the added roots. The display reported health while review was missing. The repair compares a root existing on one side only against an empty set and skips only roots absent from both sides.

The other gap concerned var-file paths. Pairing terraform -chdir with a relative -var-file= makes Terraform re-resolve variable files under the root directory. Present tfvars turn unreadable and plan fails. Scripts normalize the checkout path to a physical absolute path at their entry point, and each plan receives its tfvars by absolute path within its own checkout.

Sequencing that limits what gets shown

The mechanism took shape by widening what it may emit stage by stage while holding the opening condition of not emitting everything. Success output advanced from counts to a diff against base. Failure output advanced from fixed messages through minimal-field audit queries to raw log publication within approved limits. Because each stage fixed its exclusions first, interim diagnostics could come out as soon as the cause was confirmed.

The plan-on-PR mechanism also forms the foundation of the no-change verification described in Rearchitecting Terraform Without Touching State.