§ CM

An Unreviewed Terraform Plan Can Execute Contributor-Controlled Code. The External Data Source Runs During Planning, Not Only Apply

A contributor opens a pull request that adds one data "external" block, your pipeline runs terraform plan automatically to post a diff comment, and — if that plan runs in a privileged context — the block’s program executes on the runner before anyone has looked at the change. The exposure isn’t a Terraform vulnerability and it isn’t unconditional: it exists when a CI workflow initializes and plans contributor-controlled configuration with credentials, state, or a trusted runner in reach, before a trust boundary is established. GitHub, for its part, normally withholds repository secrets from public fork pull requests, downgrades their GITHUB_TOKEN to read-only, and can require maintainer approval before the workflow runs — though private repositories can be configured to send write tokens and secrets to fork workflows. The high-risk shape is a workflow that actually fetches and runs PR-controlled Terraform in a privileged context — and that surface narrowed recently: as of a June 2026 actions/checkout change, enforced across the backported v2–v6 releases on July 20, checkout refuses by default to check out a fork PR’s head or merge ref under pull_request_target or workflow_run. What’s still exposed: a job that opts out with allow-unsafe-pr-checkout: true, pins actions/checkout@v1 or a pre-protection SHA/minor/patch, or pulls the PR outside the action (a raw git/gh fetch or a downloaded artifact, which the checkout protection can’t reach); a privileged self-hosted runner with reachable state or credentials; or a private-repository setting that sends write tokens or secrets to fork workflows. The protection is fork-specific — same-repo PRs and the plain pull_request event are unchanged — and pull_request_target still runs the trusted base-branch workflow, so a permissions: block alone can’t lift a fork-PR token above read-only. Alex Kaskasoli’s writeup laid the mechanism out cleanly years ago and it stays relevant: automated Terraform planning is everywhere, and GitHub itself warns that untrusted workflow code can permanently compromise a self-hosted runner. Whether the exposed population has actually grown isn’t something measured here.

TL;DR

  • terraform plan is not a passive parser: when a data source’s arguments are known at plan time, a data "external" block launches a local program and a data "http" block issues an outbound request (if a required argument stays unknown until apply, Terraform defers that read). So an unreviewed PR planned in a privileged context can become contributor-controlled code execution or outbound data transfer before anyone reviews it.
  • It is conditional, not “gated by nothing.” GitHub withholds secrets from public fork PRs and issues a read-only token, and current actions/checkout refuses fork head/merge checkout under pull_request_target by default (since July 20, 2026). The defect is planning untrusted config before a trust boundary — a job that bypasses that checkout protection or fetches the PR another way, a privileged self-hosted runner with reachable state or credentials, or a private-repo setting that sends write tokens or secrets to fork workflows.
  • The blast radius is whatever the runner process can reach: files, network, the Terraform values passed into the malicious expression, and any credential exposed to that context. Backend and provider credentials may be separate; full state-file theft depends on backend design, not on every plan handing over the whole state.
  • Detect at the host, not in the HCL diff: alert on an unexpected executable anywhere in terraform/tofu‘s ancestry — with attention to children of terraform-provider-external, because the attacker’s program is a grandchild of Terraform, not a direct child — and combine it with process-attributed egress. Lineage won’t catch a malicious provider that acts inside its own process.
  • The load-bearing fix is credential separation: fork-PR plans get no path to production state or credentials, ideally no stateful plan at all until an approval or trusted-branch transition. Back it with provider-source allowlisting (separate from denying external/http), controlled provider installation, egress control, and ephemeral runners.

The reason this keeps biting teams that otherwise have their supply-chain story together: it doesn’t look like the supply-chain attacks everyone drills for. No dependency gets merged. No image gets pushed. The malicious code lives in a branch that never touches your protected trunk, runs once during a speculative plan, exfiltrates whatever the runner can reach, and the PR gets closed. Your merge-gate controls — CODEOWNERS, required reviews, branch protection — sit entirely downstream of the moment the code already ran.

What actually executes at plan

Split the Terraform lifecycle by when code runs, because that split is the entire threat model and most people have it wrong.

terraform init downloads provider plugins and fetches modules. terraform plan evaluates the configuration and reads data sources — but not unconditionally. HashiCorp documents that Terraform defers a data-source read until apply when the data source’s arguments depend on values that aren’t known during planning; only data sources whose arguments are known at plan time are read then. Provider plugins are launched during the run as local subprocesses (they’re go-plugin gRPC servers, spawned by Terraform core). terraform apply is where provisioners like local-exec fire.

Here’s the part people lean on: provisioners run at apply, and apply is usually VCS-gated behind a merge. True. So local-exec in a fork PR is comparatively contained. But two HashiCorp-maintained provider data sources — installed from the registry like any other provider, not built into Terraform Core — materially expand the plan-time attack surface:

  • data "external" runs a local program during plan and parses its stdout as JSON. That’s the clean code-execution primitive: the program runs with the privileges of the Terraform process, and its arguments can carry interpolated values.
  • data "http" issues an outbound request at plan time. That’s an exfiltration primitive, not local code execution — enough to beacon out or ship an interpolated value into a URL or query string, but it doesn’t launch an attacker-supplied executable the way external does.

Then there’s the provider plugin itself, a distinct and quieter boundary. A malicious or typosquatted provider referenced in the PR gets pulled by init and launched by the run, and it executes your code’s worst day inside its own binary — no external block required, nothing suspicious in the HCL beyond a required_providers entry pointing at the wrong namespace. It doesn’t need to shell out to a child process to read the environment or open a socket; it’s already executable code running locally. Mercari’s team documented a related wrinkle on the provisioner side: the plan output for a provisioner built from variable interpolation doesn’t include the resolved command string. So the generated plan is not a complete code-review surface — it can suppress sensitive detail, and it’s no substitute for reviewing the HCL, referenced scripts, provider-source changes, module sources, and lockfile changes in the actual diff.

And modules are worse off than providers on provenance. HashiCorp’s dependency lock file (.terraform.lock.hcl) tracks providers with checksums; it does not record remote module selections or checksums at all. boostsecurity’s 2023 registry research put numbers on it — analyzing a registry of more than 3,000 providers and 13,000 modules at the time, they identified several hundred modules whose publishing workflows looked susceptible to pull-request injection, and noted the quiet exfil path where a module pulls the generic http provider to ship values out. That research described the state at the time, and HashiCorp has since hardened the public side of it. Its HCSEC-2024-04 bulletin — the work completed January 2024, disclosed February 15, crediting the same Boost researcher — binds each published module version to an immutable Git commit SHA at publication and ties registry authentication and module publication to immutable GitHub profile and repository IDs rather than mutable names. So an exact public-Registry module version no longer carries the mutable-tag or repository-hijacking weakness the historical research described. The lockfile gap itself is unchanged — Terraform still records no module selections or checksums — so a version range can still pull a newly published release, and direct-VCS or other module sources have to be pinned and vetted on their own provenance.

Which is the other thing the plan can reach. State.

What the runner can hand over

The blast radius of plan-time code execution is whatever the runner process can touch. On a self-hosted GitHub Actions runner or a Terraform Cloud/Enterprise agent, that usually means the operating-system context Terraform inherited plus whatever credentials the workflow exposed to it. Three buckets are worth naming.

Cloud credentials, first — but note which credentials. A Terraform deployment can authenticate its backend (S3, Azure Blob, GCS, or a remote backend) with one identity and assume a separate role for provider API calls, so there is no single “plan role” that automatically holds both. If the runner authenticates by EC2 instance profile, the program can reach 169.254.169.254 and pull the role — and IMDSv2 does not stop it. IMDSv2 blocks tokenless IMDSv1-style requests, but arbitrary local code can just issue the required PUT to mint its own metadata token and then retrieve the instance-profile credentials; token enforcement doesn’t distinguish a malicious local process from a legitimate one. Against an untrusted local plan the real controls are disabling IMDS, removing the instance profile from the runner, or minimizing that profile’s permissions; treat IMDSv2 and a hop limit of 1 as defense-in-depth against SSRF, open-proxy paths, and access across extra network hops (some container setups), not against code already executing on the box. If you’ve moved to OIDC federation, the job doesn’t have a minted cloud credential lying in the environment — it has a request capability: with id-token: write, GitHub exposes ACTIONS_ID_TOKEN_REQUEST_URL and ACTIONS_ID_TOKEN_REQUEST_TOKEN, and code has to request the JWT and exchange it (via sts:AssumeRoleWithWebIdentity) to get credentials. That distinction matters for what an arbitrary subprocess can actually steal versus mint on the spot.

State access, second — conditional, not automatic. Terraform must read state to plan, and it commonly holds state in memory during an operation; saved plan files can also carry sensitive values. A data "external" program directly receives whatever values are passed into its query. But whether it can pull the entire remote state object depends on backend design and whether backend credentials or local state artifacts are reachable from the subprocess. When they are, the exposure is real: state stores resource attributes in cleartext, and sensitive = true only redacts CLI and plan output — the value is still recorded in state. Complete state-file theft is possible when the runner’s accessible credentials can retrieve the backend object; it is not a guaranteed property of every backend architecture.

Third, CI credentials — again, verify the path rather than assume it. The process may reach GITHUB_TOKEN, registry creds, or action inputs, but GITHUB_TOKEN is exposed through secrets.GITHUB_TOKEN / the github.token context, not automatically injected into every subprocess environment unless the workflow passes it. boostsecurity’s gomplate finding — a README-formatting workflow that ran a postExec hook and coughed up the GITHUB_TOKEN — is the same class of problem wearing a different hat: the token was reachable because the workflow made it so. Restrict its permissions and don’t hand it to a plan that runs untrusted config.

Where the detection lives

Not in the HCL diff, and not primarily in CloudTrail. It lives on the runner, at the host level, because the tell is a process that should not exist — but you have to model the process tree correctly or the rule misses the canonical case.

A legitimate terraform plan spawns provider plugins as child processes. When data "external" runs a program, the tree is terraformterraform-provider-external → the program: Terraform core launches the provider plugin over go-plugin, and the External provider then launches the configured program with Go’s exec.CommandContext. So the attacker-controlled program is normally a grandchild of Terraform, not a direct child. A rule that alerts on “non-terraform-provider-* child of terraform” misses it. Alert instead on an unapproved executable with terraform or tofu anywhere in its ancestor chain, with particular attention to children of terraform-provider-external.

Instrument the runners with Falco (or auditd feeding your pipeline; Sysmon for Linux works too), and mind two field traps that will silently break the rule:

  • Match ancestry with proc.aname (an ancestor lookup — proc.aname[2] is the grandparent), not proc.pname (direct parent only).
  • Do not exclude provider plugins by matching terraform-provider- against proc.name. Falco documents proc.name as the kernel task->comm, truncated to 16 characters — and terraform-provider- is 19, so a prefix match against a value physically capped at ~15 chars can never fire, classifying every legitimate plugin as anomalous. Use a full executable-path field (proc.exepath / proc.aexepath, or the equivalent your Falco config emits), not proc.exe (that’s argv[0], which an attacker can spoof).

Don’t lift that ancestry test straight into a raw log search — Falco’s JSON only carries the fields the triggering rule emits, so a generic evt.type=execve query isn’t self-contained. Implement it as a Falco rule (or feed raw auditd/eBPF process-execution telemetry) covering both execve and execveat — the stock spawned_process macro does — that explicitly emits proc.exepath, proc.pexepath, and the indexed ancestry fields you actually need (e.g. proc.aexepath[2] / proc.aname[2]), then have Splunk key on the dedicated rule name:

index=ci_runner sourcetype=falco:json rule="Unexpected descendant of terraform"
| stats count values(proc.cmdline) values(proc.exepath) values(proc.aexepath) by agent.hostname, k8s.pod.name

Exclude legitimate provider plugins only by a trusted installation root plus an approved path, package checksum, or file hash — the plugins live under .terraform/providers/ or your plugin cache. Do not suppress every executable whose basename starts with terraform-provider-: the External data source runs whatever program path the HCL specifies, so an attacker can drop a payload named terraform-provider-anything and a basename exclusion waves it straight through.

Lineage monitoring has a hard limit worth stating plainly: it catches External-provider programs and providers that shell out to helper processes, but it does not detect a malicious provider that does its collection and exfiltration inside its correctly named terraform-provider-* process — that provider never launches a child. So pair lineage with trusted provider-source enforcement, hash verification, and process-attributed egress.

That egress control is the second detection, and it’s why VPC Flow Logs alone aren’t enough. During a plan window the runner legitimately talks to registry.terraform.io, releases.hashicorp.com, your module source hosts, and your cloud provider’s API endpoints — a hostname-shaped allowlist. But those endpoints resolve to shifting, shared address ranges, and an L3/L4 flow record can’t attribute a connection to the initiating process, so it can’t distinguish a malicious provider’s beacon from legitimate Terraform traffic. Enforce and monitor egress through a host sensor, a DNS-aware control, or an authenticated egress proxy that can tie a connection to the process and hostname; treat VPC Flow Logs as IP-level corroboration, not the allowlist itself.

CloudTrail is the backstop, and there’s a prerequisite most people miss. The plan role calling sts:GetCallerIdentity is common — the AWS provider generates it during credential and account validation, so it’s weak as a standalone signal, and it isn’t even guaranteed every run (provider options like skip_credentials_validation / skip_requesting_account_id skip it). A spike in GetObject against the state bucket that doesn’t line up with plan cadence is worth alerting on — but S3 object-level reads are data events, and CloudTrail does not log data events by default. Enable S3 data events on the state bucket (or an equivalent object-access log) before you rely on that detection, then alert on abnormal principals, source networks, object keys, regions, or read frequency. Treat it as corroboration for a host-level hit.

The first week of tuning

The process-lineage rule will surface benign activity immediately, and separating it from an attack is the work — but tune it from measurement, not from an assumed volume.

The tempting mistake is to assume a couple hundred plans a day yields “a handful of patterns, then near silence.” Alert volume is environment-dependent, and provider credential helpers, internal providers, and approved External programs can all move the baseline. So run it in audit mode first: measure the actual descendant processes and process-attributed network activity, identify the approved External programs and provider helpers, then allowlist by exact executable path, hash, arguments, parent relationship, and destination — never by silencing a whole rule.

Watch the direction of ancestry, because the obvious false-positive sources sit on the wrong side of the tree from what you’d guess. Terragrunt is ordinarily an ancestor and orchestrator of Terraform — it launches terraform as a child — and its before_hook / after_hook blocks are executed by Terragrunt, so they’re children of the terragrunt process, not descendants of terraform. A rule scoped to descendants of terraform shouldn’t match them at all; tune from observed ancestry and executable paths, and monitor Terragrunt hooks separately, because they’re their own arbitrary-command surface. The same correction applies to tfenv, the setup-terraform action, and Makefile wrappers: those prepare or launch Terraform, they don’t normally appear as unexpected children of it. What you will legitimately see are a few real external data sources — the sops-into-Terraform pattern, a script that resolves an AMI ID — and those you allowlist by specific program path and arguments, not by turning off external-source detection.

The egress rule’s false positives come from provider API calls to endpoints you didn’t allowlist (a new region, a new service, a partner SaaS provider) and module fetches over Git/HTTPS to hosts you forgot to enumerate. Both are additive fixes, and both quiet down after the first pass.

Fix the exposure, not just the alert

Detection tells you it happened. These change whether it can.

The load-bearing control is credential and state separation: a fork PR must never plan with a path to production or to production state. For genuinely untrusted contributions the stronger pattern is to run terraform fmt, static linting, and policy checks with no credentials — and to run terraform validate only in a disposable, network-restricted sandbox with a trusted lockfile and controlled provider installation, because validation requires installed providers and starts the provider processes to fetch resource and data-source schemas and run provider-side validation — the same plugin-execution boundary as plan. Ordinary terraform init, by contrast, locates, downloads, verifies, and installs the provider packages but doesn’t itself launch those binaries. Then require an approval or a trusted-branch transition before any stateful plan runs. Give the speculative stage a read-only, non-prod identity nowhere near the real state bucket. That is the biggest blast-radius reduction available — but it is not a shrug. Successful code execution is still a security event: even a read-only, non-prod context can expose configuration, secrets, source, CI metadata, reachable internal services, and token-request capabilities.

Control Illustrative family What it does here
Separate read-only, non-prod plan identity; no prod creds/state on fork-PR plans AC-6 The load-bearing blast-radius control
Approve provider sources outside the untrusted branch; controlled install; deny external/http/provisioners on PR branches via Conftest/Rego CM-7 Two boundaries: provider trust and the plan-time primitives
Provider-source allowlist + -lockfile=readonly against a trusted lockfile; vendored/immutable modules SR-3, SR-4, SR-11 Closes typosquat and mutable-module gaps
Falco/auditd descendant-process + process-attributed egress monitoring SI-4 The detection above
Egress control (host sensor / DNS / authenticated proxy) from the runner SC-7 Blocks http beacon/exfil with process attribution
OIDC short-lived federation over static keys IA-5 Shrinks the credential’s useful life and pre-mint window
Ephemeral/write-only values; scoped backend permissions SC-28 Keeps selected secrets out of state entirely

Treat those NIST SP 800-53 Rev. 5 families as illustrative and tailor them to your control implementation — a single technical measure doesn’t by itself satisfy a control.

Two of those rows carry nuance worth spelling out. First, provider trust is a separate boundary from denying external/http. A Rego policy that rejects external, http, and provisioner blocks on PR branches is good defense in depth, but a malicious or compromised provider executes and communicates directly without declaring any of them — so you also need to approve provider source addresses and versions outside the untrusted branch, install from a controlled filesystem_mirror or network_mirror without a direct-registry fallback, and treat provider or lockfile changes in a PR as an explicit trust decision. Pinning a version and accepting a PR-supplied lockfile doesn’t establish trust: the lockfile is trust-on-first-use for a newly selected provider — its checksums verify previously selected packages, not the trustworthiness of a new one.

Second, on state secrecy: sensitive = true redacts presentation but leaves the value in state, so “anything a resource emitted as a secret sits in cleartext” is now too categorical. Terraform 1.10+ supports ephemeral values and 1.11+ supports provider-defined write-only arguments, both of which keep selected values out of state entirely — the directly relevant modern mitigation. And encryption isn’t a substitute for keeping code out of the trust context: OpenTofu’s state encryption protects state and plan artifacts at rest, but its own docs say it “does not and cannot protect the sensitive values in the state file from the person running the tofu command” — the operation must decrypt state to run, so code executing as part of that authorized operation can still steal values explicitly handed to it, readable local artifacts, or reachable backend and key-provider credentials — though access to the entire decrypted state isn’t automatically inherited by every child process; that still depends on process, filesystem, backend, and credential boundaries. With stock Terraform, state protection depends on the backend and its access controls: S3 server-side encryption protects the stored object but does nothing against an identity that’s authorized to GetObject. Use secret references, ephemeral/write-only values, minimal backend permissions, and single-job ephemeral runners so a foothold can’t persist on a reused runner filesystem or process environment — but restrict the durable resources the job can reach (caches, artifacts, container registries, repositories, cloud control planes, state backends) separately, because malicious code can establish persistence outside the runner before it’s destroyed. GitHub treats a self-hosted runner exposed to untrusted workflow code as potentially, persistently compromised.

terraform plan on unreviewed code is code execution on your infrastructure’s control plane — conditional on the context you run it in, which is exactly the point. Treat the plan step as a privileged execution boundary, put a real trust boundary in front of it for untrusted contributions, monitor the runner like the sensitive host it is, and stop giving a job that runs whatever a stranger typed into a branch anything worth stealing.

Sources


This post was engineered and validated through a multi-agent AI workflow — drafted, adversarially reviewed by several independent models, checked against primary sources, and given a human review before publishing. See an inaccuracy, or found this useful? Leave a comment below — corrections and feedback are read and shape what comes next.

Leave a comment