Terraform Providers: Allowlist First, Then Lock
Malware reached Terraform providers on the public registry this month. Days earlier, a fake job interview repo turned up naming a provider on a lookalike host in its lock file and its supplied state. The lock pins whatever came first, so the machine running init has to decide what may install.
On 22 September Aikido Security reported two malicious providers on HashiCorp's public Terraform Registry: gocommunity-io/dockerd and kreuzwenker/docker, the second one letter off the real kreuzwerker/docker and its 56 million downloads. Aikido says it is the first time it has seen malware distributed through Terraform providers. The Hacker News put the download counts at 222 and 1,449 on 23 September.
By 27 September both names returned 404 from registry.terraform.io, and neither report says who removed them. Nothing suggests the OpenTofu registry ever listed them. The downloads were few, and the payload stays inert unless two variables hash to one fixed value, which Aikido reads as a targeted attack. The attackers' Slack channel still logged 18 hosts checking in. Two more Terraform cases surfaced around the same time, each through a different door.
Three doors into Terraform came to light in September
- A one-letter typo. Aikido found hidden entry points in the providers' container code that activate only when the SHA-256 of the
containerNameandnetworkIDvariables, joined, equals one fixed value. A match decrypts a bundled Go payload and starts it withgo run; that malware polls a contract on the Arbitrum Sepolia testnet every 3 seconds and Slack every 10 for orders. Aikido found the same malware in two Go modules and says it overlaps with Graphalgo, which ReversingLabs documented in February and The Hacker News says was attributed to North Korean actors. - An interview repo. On 18 September SentinelOne described fake job interview repos whose lock files name a provider on lookalike hosts such as
registry.hashicorp-terraform[.]io, and attributed the activity to North Korea's TraderTraitor. On the one MacBook it examined, macOS backdoors were on disk before the repo was cloned, so it cannot say how they arrived. No report links this to the registry providers. The Hacker News says Graphalgo recruits developers with fake job offers and npm or PyPI coding tasks, but neither it nor Aikido says how the providers reached anyone. - A registry that changed underneath. On 31 August, for about 14 hours, Coder's module registry served some users modified modules built to collect credentials: secrets on the provisioner, and users' SSH keys and tokens during workspace builds. Coder said in an advisory on 1 September that an attacker had got into its Cloudflare setup.
A lock file trusts whatever came first
A provider is a separate program that runs as you. In our test on Terraform 1.16.4, init downloaded providers without starting any. validate started every provider the configuration names, and plan started those plus any the state names, with your environment variables, ~/.aws files and SSH agent in reach. Aikido does not say which command fires its payload, so assume anything after init can.
.terraform.lock.hcl records each provider's full address, hostname included, with its version and h1 and zh hashes. A download that does not match stops init. A provider with no entry is added, which is trust on first use. -lockfile=readonly only catches it after the fact: on 1.16.4 it downloaded and installed the new provider, then failed with “Provider dependency changes detected”. That at least stops a CI job before plan starts it.
The interview lure sits in that gap. In the novacart_interview repo SentinelOne links to, the first commit's lock file lists registry.hashicorp-terraform[.]io/hashicorp/awsbeta 1.0.0, while the configuration never asks for awsbeta and keeps state in an S3 bucket the interviewers provided. SentinelOne says init with that lock file in place downloads and runs the provider. In our test a lock entry alone did nothing: init dropped it, since neither configuration nor state needed it, and never contacted the host. State does the work: a note the candidate added says the supplied state was written with the lookalike, and init installs every provider the state names. Even init -backend=false read a terraform.tfstate committed to the repo and went looking for the lookalike.
A repo that arrives whole has no lock diff to review, though reading its lock file can still catch the host. The novacart candidate did, and also moved to a fresh state key, noting that the supplied one “forces the malicious provider just to read state”. Deleting the lock entry alone would not have helped. A policy check on required_providers never sees a provider only the state asks for.
Only the machine running init can refuse
Terraform's CLI configuration lives on the machine, not in the repo, and its provider_installation block decides where providers may come from. With a direct block and an include list, any other address fails at init with “was not found in any of the search locations”, and no request is sent for it. That held for both the typo and a state-only lookalike. The block also switches off a default: without it, Terraform installs whatever a repo ships under terraform.d/plugins, for any hostname, with no request at all. A filesystem_mirror, or a network_mirror you fill yourself rather than a pull-through proxy, serves only packages you have copied in. OpenTofu reads the same block, but its short addresses resolve to registry.opentofu.org, so list that host there. Here it is in a GitLab plan job:
# .gitlab-ci.yml: plan runs here, never on a laptop holding admin keys
tf-plan:
image:
name: hashicorp/terraform:1.16.4
entrypoint: [""]
# IDENTITY: a token minted for this job, no stored keys
id_tokens:
AWS_OIDC_TOKEN:
aud: https://gitlab.com
variables:
AWS_ROLE_ARN: arn:aws:iam::111122223333:role/tf-plan-readonly
AWS_WEB_IDENTITY_TOKEN_FILE: /tmp/web-identity-token
TF_CLI_CONFIG_FILE: /tmp/ci.tfrc
script:
- printf '%s' "$AWS_OIDC_TOKEN" > "$AWS_WEB_IDENTITY_TOKEN_FILE"
# SOURCES: the runner decides what may install, whatever the repo names
- |
cat > "$TF_CLI_CONFIG_FILE" <<'EOF'
provider_installation {
direct {
include = [
"registry.terraform.io/hashicorp/*",
"registry.terraform.io/kreuzwerker/docker"
]
}
}
EOF
# LOCK: fail rather than add a provider the committed lock file lacks
- terraform init -input=false -lockfile=readonly
# PLAN: a read-only role cannot write the state lock, so do not take it
- terraform plan -input=false -lock=false -out=plan.tfplan
Name third-party providers exactly; registry.terraform.io/*/* would let the typo straight back in. Signatures will not help: community providers are signed by their own publishers, typosquatters included. Bake the file into the runner image or a protected CI template so a merge request cannot edit it, and put the same block in ~/.terraformrc on laptops (~/.tofurc for OpenTofu).
Modules get none of this
The lock file tracks providers only and provider_installation ignores modules, so neither would have caught Coder's. A registry version is only as good as the registry serving it. Pin modules to a full commit SHA with ?ref= on a git source, or vendor them.
Watch init resolve four addresses
Press Run init below. One repo names four providers: hashicorp/aws and kreuzwerker/docker, the kreuzwenker typo, and the awsbeta lookalike that only its lock file and state mention. Run it with Terraform's defaults against the registry as it stood in early September, then with the job's allowlist, and see what gets requested, what installs and what plan then starts.
Plan on a role that can only read
The job stores no keys. GitLab mints a token for each job, and the AWS provider and S3 backend read AWS_ROLE_ARN and AWS_WEB_IDENTITY_TOKEN_FILE and assume the role themselves. Every provider can read that token too, so the plan role gets read access only and apply gets its own role, tied to the protected branch. Read-only still exposes state and its secrets, but a stolen session cannot create a user or open a port, and it expires.
- Treat take-home Terraform as untrusted code. Use a throwaway VM with no credentials, and never a backend or state you did not create.
- Close the way out. Aikido's sample talked to Slack and a blockchain; a runner that reaches only your mirror and cloud APIs reaches neither. The
directblock above also needsregistry.terraform.io,releases.hashicorp.comand, forkreuzwerker/docker,github.com; a mirror removes all three. The agent sandbox guide covers the proxy. - Check this week. Search lock files, state (
terraform providerslists what it requires),.terraform/providers, plugin caches andgo.sumforkreuzwenker,gocommunity,gogets.devand thehashicorp-awsandhashicorp-terraformhosts. On a hit, Aikido advises isolating the machine, rotating every credential it touched, cloud keys first, and reimaging it.
Takeaways
- September brought three Terraform supply chain cases: a typosquatted registry provider, an interview lure whose lock file and supplied state name a lookalike provider, and a module registry serving modified code.
- By default
terraform initinstalls every provider the configuration or the state names;planruns each one as you, andvalidateruns those the configuration names. - The lock file is trust on first use: it stops a changed package but not a new provider, and
-lockfile=readonlyfails only after the download. - A
provider_installationallowlist on the machine runninginitrefuses any provider outside the list, whether the configuration, lock file or state names it, without contacting its host. - Plan in CI on a short-lived read-only role, treat take-home Terraform as untrusted code, and on a hit isolate, rotate every credential and reimage.
Could a repo choose your providers for you?
We put a provider allowlist or mirror on every machine that runs init, move plan onto short-lived read-only roles, and check your lock files and plugin caches for this month's names.
Lock down provider installs