IaC Supply Chain

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.

Cloud X Ops TeamDevOps & AI Integration
September 27, 2026
12 min read

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 containerName and networkID variables, joined, equals one fixed value. A match decrypts a bundled Go payload and starts it with go 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
# .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.

terraform init · runner cli config
hashicorp/aws
required_providerslockstate
registry.terraform.iono request
kreuzwerker/docker
required_providerslockstate
registry.terraform.iono request
kreuzwenker/docker
required_providerslockstate
registry.terraform.iono request
registry.hashicorp-terraform[.]io/hashicorp/awsbeta v1.0.0
required_providerslockstate
registry.hashicorp-terraform[.]iono request
terraform plan
env in reachAWS_ROLE_ARNAWS_WEB_IDENTITY_TOKEN_FILE
hashicorp/aws
kreuzwerker/docker
kreuzwenker/docker
hashicorp/awsbeta
$ awaiting run · press Run init

Defaults install all four and plan starts them; the allowlist refuses the typo and the lookalike without sending a request for either, and init stops.

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 direct block above also needs registry.terraform.io, releases.hashicorp.com and, for kreuzwerker/docker, github.com; a mirror removes all three. The agent sandbox guide covers the proxy.
  • Check this week. Search lock files, state (terraform providers lists what it requires), .terraform/providers, plugin caches and go.sum for kreuzwenker, gocommunity, gogets.dev and the hashicorp-aws and hashicorp-terraform hosts. On a hit, Aikido advises isolating the machine, rotating every credential it touched, cloud keys first, and reimaging it.
A lock file keeps your first choice, right or wrong.

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 init installs every provider the configuration or the state names; plan runs each one as you, and validate runs those the configuration names.
  • The lock file is trust on first use: it stops a changed package but not a new provider, and -lockfile=readonly fails only after the download.
  • A provider_installation allowlist on the machine running init refuses 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
providers.sh
SECURE
cloudxops@iac:~$ ./audit-providers.sh ./infra
# Reading CLI config, lock files and state...
[OK] every provider matches the allowlist
[INFO] state names no provider outside it
[READY] plan runs on a read-only role
$ ▋
Provider trust