Safeguard
Engineering

Terraform Module Supply Chain Security

The dependency lockfile everyone commits only covers providers — your modules float free. Pinning, provenance, and the code-execution paths hiding inside terraform plan.

Jonas Meyer
Site Reliability Engineer
6 min read

Terraform has a supply chain blind spot most teams never notice: .terraform.lock.hcl locks provider versions and hashes, but modules — the code you actually compose infrastructure from — are not covered by any lockfile and will float to whatever the source ref resolves to at init time. Combine that with the fact that terraform plan can execute arbitrary code via providers and external data sources, and your IaC pipeline is running third-party software with cloud-admin credentials on every run. The controls exist; they're just not defaults.

Modules float. Pin them yourself.

A module block sourced from the public registry with a version range, or from git with a branch ref, re-resolves on every terraform init -upgrade:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "~> 5.0"        # floats within 5.x
}

module "internal_net" {
  source = "git::https://git.example.com/infra/net.git?ref=main"  # floats with main
}

Neither of those is pinned in any meaningful sense, and neither is recorded in the lockfile. The hardened forms:

module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.8.1"          # exact registry version
}

module "internal_net" {
  source = "git::https://git.example.com/infra/net.git?ref=9f2c1a7e4b..."  # commit SHA
}

Registry versions are immutable once published, so an exact version is a real pin. For git sources, only a full commit SHA is immutable — tags can be moved, branches obviously move. This is the same argument as SHA-pinning GitHub Actions, and the same objection applies (update toil), with the same answer: Renovate understands both Terraform registry constraints and git refs, and will PR the bumps with changelogs attached.

The provider lockfile: use it properly or it lies to you

.terraform.lock.hcl records each provider's exact version plus h1: and zh: hashes. Two operational mistakes neuter it. First, not committing it — then every environment re-resolves and you've locked nothing. Second, generating it on one platform only: hashes recorded on your Apple Silicon laptop don't include the linux_amd64 archives CI downloads, so either init fails or someone "fixes" it with -upgrade in the pipeline, which defeats the lock. Generate hashes for every platform you run:

terraform providers lock \
  -platform=linux_amd64 \
  -platform=darwin_arm64 \
  -platform=darwin_amd64

Then make CI enforce rather than repair:

terraform init -lockfile=readonly

Providers on the public registry are GPG-signed and verified at download — HashiCorp-signed, partner-signed, or community-signed with the tier shown on the registry page. The signature proves publisher identity; the lockfile proves you got identical bytes to last time. You want both, because a signed malicious provider is still signed.

Where code execution actually happens

People model Terraform as declarative and therefore inert. Three counterexamples that all run with the pipeline's cloud credentials:

  • Providers are native binaries. A malicious or compromised provider executes arbitrary code the moment terraform init completes and plan begins. It also sees every value flowing through its resources, credentials included.
  • The external data source runs at plan time. data "external" executes a local program during terraform plan — the operation everyone runs automatically on pull requests, including PRs from forks in some setups. A module that quietly includes one gets code execution before any human approves an apply.
  • Provisioners (local-exec) run at apply time, which at least sits behind an approval gate in most pipelines, but still executes module-authored commands on your runner.

Practical containment: run plan and apply on ephemeral runners with the minimum cloud role, scope registry access through an allowlist (Artifactory and Terraform Enterprise can both proxy and filter the public registry), and grep incoming modules for external, local-exec, and http data sources as a review gate. That grep is crude and effective — vendored modules that legitimately need those constructs are rare and worth knowing about by name.

Typosquats, namespaces and the registry

The public registry namespace is flat per-author (namespace/name/provider), and lookalike namespaces are cheap: the difference between terraform-aws-modules (the real, 1.5-billion-download org) and a plausible variant is one hyphen nobody reads. The registry shows download counts, source repos, and verification badges — teach reviewers to check the source repository link, not just the module name, before first adoption of any public module.

Note also that misconfiguration scanners don't cover any of this. Checkov, tfsec, and Trivy audit what your HCL declares (open security groups, unencrypted buckets); they don't validate where modules came from or whether a provider binary matches its signature — our comparison of Terraform scanners goes into the coverage split. Supply chain coverage for IaC means inventorying module and provider origins the same way an SCA tool inventories libraries; Safeguard treats module sources and provider hashes as first-class dependencies for exactly this reason.

A pipeline checklist

ControlMechanismCatches
Module pinningExact registry versions / git commit SHAsMoved tags, hijacked branches
Provider lockingCommitted multi-platform .terraform.lock.hcl + -lockfile=readonlySubstituted provider binaries
Registry allowlistProxy registry (Artifactory / TFE)Typosquats, unvetted namespaces
Plan-time exec reviewGrep for external, local-exec, http in new modulesPlan-time code execution
Runner blast radiusEphemeral runners, least-privilege cloud rolesEverything above, partially
Update hygieneRenovate for module and provider bumpsPin rot

None of these require replacing your workflow. The lockfile discipline and module pinning are a week of cleanup on most estates; the registry proxy is the only one needing new infrastructure, and it's usually a checkbox on an artifact server you already run.

Frequently asked questions

Does .terraform.lock.hcl protect my modules?

No — it records provider versions and hashes only. Modules are re-fetched from their declared sources at init, so module integrity depends entirely on how you pin the source: exact versions for registry modules, full commit SHAs for git sources.

Is terraform plan safe to run on untrusted pull requests?

Not by default. Plan executes provider binaries and any external data sources in the configuration, so a malicious PR can achieve code execution on your runner with its cloud credentials before any approval. Run PR plans on isolated, least-privilege runners, or gate plan behind a first review.

Should we mirror the Terraform registry internally?

If you're subject to change control or have more than a handful of teams, yes. A proxying private registry gives you an allowlist, availability independence, and an audit log of exactly which module versions entered the estate — the same reasons you proxy npm or Maven Central.

How do commit-SHA pins stay current without constant toil?

Renovate (and Dependabot for registry modules) automates the bumps: it PRs updated SHAs and versions with release notes, your CI runs plan against the change, and humans review the diff. Pinning without automated updates decays into staleness, which is its own risk.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.